feat: mobile design (#1568)
* init Signed-off-by: Innei <i@innei.in> * feat: feed column Signed-off-by: Innei <i@innei.in> * feat: float bar Signed-off-by: Innei <tukon479@gmail.com> * feat: profile button Signed-off-by: Innei <tukon479@gmail.com> * chore: comment Signed-off-by: Innei <tukon479@gmail.com> * feat: layout Signed-off-by: Innei <tukon479@gmail.com> * feat: split mobile entry content Signed-off-by: Innei <tukon479@gmail.com> * feat: add history stack Signed-off-by: Innei <tukon479@gmail.com> * feat: return back Signed-off-by: Innei <tukon479@gmail.com> * fix: return back position Signed-off-by: Innei <tukon479@gmail.com> * feat: entry list header Signed-off-by: Innei <tukon479@gmail.com> * feat: sync selector Signed-off-by: Innei <tukon479@gmail.com> * feat: modal stack mobile Signed-off-by: Innei <tukon479@gmail.com> * fix: modal selector Signed-off-by: Innei <tukon479@gmail.com> * cleanup Signed-off-by: Innei <tukon479@gmail.com> * fix: sheet and optimize entry readers Signed-off-by: Innei <tukon479@gmail.com> * fix: scroller Signed-off-by: Innei <tukon479@gmail.com> * feat: entry contents Signed-off-by: Innei <tukon479@gmail.com> * feat: entry content Signed-off-by: Innei <tukon479@gmail.com> * update Signed-off-by: Innei <tukon479@gmail.com> * feat: user profile sheet Signed-off-by: Innei <tukon479@gmail.com> * fix: entry content header Signed-off-by: Innei <tukon479@gmail.com> * fix: entry header Signed-off-by: Innei <tukon479@gmail.com> * chore: auto-fix linting and formatting issues * fix: return back Signed-off-by: Innei <tukon479@gmail.com> * feat: image view Signed-off-by: Innei <tukon479@gmail.com> * feat: audio Signed-off-by: Innei <tukon479@gmail.com> * feat: inline player Signed-off-by: Innei <tukon479@gmail.com> * feat: pull to refresh Signed-off-by: Innei <tukon479@gmail.com> * cleanup Signed-off-by: Innei <tukon479@gmail.com> * update Signed-off-by: Innei <tukon479@gmail.com> * chore: auto-fix linting and formatting issues * chore: fix import, remove no support display * chore: auto-fix linting and formatting issues * feat: achievement modal in mobile Signed-off-by: Innei <tukon479@gmail.com> * fix: subview mobile Signed-off-by: Innei <tukon479@gmail.com> * feat: power Signed-off-by: Innei <tukon479@gmail.com> * feat: wallet mobile design Signed-off-by: Innei <tukon479@gmail.com> * feat: pwa support (#1575) Co-authored-by: hyoban <hyoban@users.noreply.github.com> * chore: auto-fix linting and formatting issues * fix import * discover * fix: border color Signed-off-by: Innei <tukon479@gmail.com> * fix: modal async Signed-off-by: Innei <tukon479@gmail.com> * feat: mobile setting Signed-off-by: Innei <tukon479@gmail.com> * fix: entry header width Signed-off-by: Innei <tukon479@gmail.com> * fix: remove unused hide filter * feat: settings Signed-off-by: Innei <tukon479@gmail.com> * feat: setting done Signed-off-by: Innei <tukon479@gmail.com> * action card * fix discover feed form * popular filter * inbox table * fix: picture masonry pull to refresh Signed-off-by: Innei <tukon479@gmail.com> * fix: scroll element Signed-off-by: Innei <tukon479@gmail.com> * fix: radio in mobile Signed-off-by: Innei <tukon479@gmail.com> * feat: to scientific notation Signed-off-by: Innei <tukon479@gmail.com> * fix: login Signed-off-by: Innei <tukon479@gmail.com> * ResponsiveSelect for action card * chore: remove theme color Signed-off-by: Innei <tukon479@gmail.com> * type check * type check * fix: setting import circular Signed-off-by: Innei <tukon479@gmail.com> * refactor: remove hijack pushState Signed-off-by: Innei <tukon479@gmail.com> * fix: float bar Signed-off-by: Innei <tukon479@gmail.com> * fix: scroll end trigger Signed-off-by: Innei <tukon479@gmail.com> * fix: dark theme color Signed-off-by: Innei <tukon479@gmail.com> * fix: overscroll-behavior only in destop Signed-off-by: Innei <tukon479@gmail.com> * fix: ux Signed-off-by: Innei <tukon479@gmail.com> * fix: color Signed-off-by: Innei <tukon479@gmail.com> * fix: list scroll area Signed-off-by: Innei <tukon479@gmail.com> * update Signed-off-by: Innei <tukon479@gmail.com> * merge * update * update favicon link * fix: reload prompt on mobile * test Signed-off-by: Innei <i@innei.in> * test Signed-off-by: Innei <i@innei.in> * fix: ios Signed-off-by: Innei <i@innei.in> * try patch precaching * try * try * build * try * try * feat: ctx menu on safari Signed-off-by: Innei <tukon479@gmail.com> * try * clear timeout Signed-off-by: Innei <tukon479@gmail.com> * try * fix * deny og * fix patch * pre cache exclude * feat: add all Signed-off-by: Innei <tukon479@gmail.com> * i18n Signed-off-by: Innei <tukon479@gmail.com> * feat: slide Signed-off-by: Innei <tukon479@gmail.com> * feat: ico Signed-off-by: Innei <tukon479@gmail.com> * fix: build Signed-off-by: Innei <tukon479@gmail.com> * test network first Signed-off-by: Innei <tukon479@gmail.com> * test Signed-off-by: Innei <tukon479@gmail.com> * fix: icon padding * test Signed-off-by: Innei <tukon479@gmail.com> * use react-ios-pwa-prompt * fix: icon path * update * fix: prompt Signed-off-by: Innei <tukon479@gmail.com> * feat: redesign toc Signed-off-by: Innei <tukon479@gmail.com> * ctx Signed-off-by: Innei <tukon479@gmail.com> * ready to prod Signed-off-by: Innei <tukon479@gmail.com> * feat: startup screen Signed-off-by: Innei <tukon479@gmail.com> * feat: floatbar Signed-off-by: Innei <tukon479@gmail.com> * fix: switch view Signed-off-by: Innei <tukon479@gmail.com> * fix: keep scroll map Signed-off-by: Innei <tukon479@gmail.com> * enable hoverOnlyWhenSupported * fix: count Signed-off-by: Innei <tukon479@gmail.com> * fix: hoverOnlyWhenSupported only in web Signed-off-by: Innei <tukon479@gmail.com> * fix Signed-off-by: Innei <tukon479@gmail.com> * chore: auto-fix linting and formatting issues --------- Signed-off-by: Innei <i@innei.in> Signed-off-by: Innei <tukon479@gmail.com> Co-authored-by: Innei <Innei@users.noreply.github.com> Co-authored-by: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Co-authored-by: hyoban <hyoban@users.noreply.github.com> Co-authored-by: lawvs <18554747+lawvs@users.noreply.github.com> Co-authored-by: lawvs <lawvs@users.noreply.github.com>
|
|
@ -20,4 +20,6 @@ vite.config.*.mjs
|
|||
|
||||
.generated
|
||||
.turbo
|
||||
|
||||
apps/renderer/dev-dist
|
||||
tsconfig.tsbuildinfo
|
||||
|
|
|
|||
|
|
@ -10,4 +10,17 @@ declare global {
|
|||
}
|
||||
}
|
||||
|
||||
declare module "virtual:pwa-register/react" {
|
||||
import type { Dispatch, SetStateAction } from "react"
|
||||
import type { RegisterSWOptions } from "vite-plugin-pwa/types"
|
||||
|
||||
export function useRegisterSW(options?: RegisterSWOptions): {
|
||||
needRefresh: [boolean, Dispatch<SetStateAction<boolean>>]
|
||||
offlineReady: [boolean, Dispatch<SetStateAction<boolean>>]
|
||||
updateServiceWorker: (reloadPage?: boolean) => Promise<void>
|
||||
}
|
||||
}
|
||||
|
||||
export {}
|
||||
|
||||
export { type RegisterSWOptions } from "vite-plugin-pwa/types"
|
||||
|
|
|
|||
|
|
@ -5,12 +5,15 @@
|
|||
<meta name="referrer" content="no-referrer" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=0"
|
||||
content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=0, viewport-fit=cover"
|
||||
/>
|
||||
|
||||
<meta name="theme-color" content="#ff5c00" />
|
||||
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#ffffff" />
|
||||
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#121212" />
|
||||
<!-- favicon -->
|
||||
<link rel="icon" href="/favicon.ico" type="image/x-icon" />
|
||||
<link rel="icon" href="/favicon.ico" sizes="48x48" />
|
||||
<link rel="icon" href="/favicon.svg" sizes="any" type="image/svg+xml" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon-180x180.png" />
|
||||
<title>Follow</title>
|
||||
|
||||
<meta property="og:type" content="website" />
|
||||
|
|
@ -58,6 +61,10 @@
|
|||
const isElectron = navigator.userAgent.includes("Electron")
|
||||
document.documentElement.dataset.buildType = isElectron ? "electron" : "web"
|
||||
</script>
|
||||
<script>
|
||||
const isMobile = window.innerWidth < 1024
|
||||
document.documentElement.dataset.viewport = isMobile ? "mobile" : "desktop"
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
@ -77,6 +84,9 @@
|
|||
inset: 0;
|
||||
z-index: 1000;
|
||||
}
|
||||
[data-viewport="mobile"] #app-skeleton {
|
||||
display: none;
|
||||
}
|
||||
[data-theme="light"] {
|
||||
--background: 0 0% 100%;
|
||||
}
|
||||
|
|
@ -89,6 +99,7 @@
|
|||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 16rem;
|
||||
flex-shrink: 0;
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
"scripts": {
|
||||
"build:web": "cd ../.. && pnpm build:web",
|
||||
"dev": "cd ../.. && pnpm dev:web",
|
||||
"generate-pwa-assets": "pwa-assets-generator public/icon.svg",
|
||||
"test": "vitest --typecheck",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
|
|
@ -82,6 +83,7 @@
|
|||
"react-hotkeys-hook": "4.6.1",
|
||||
"react-i18next": "^15.1.0",
|
||||
"react-intersection-observer": "9.13.1",
|
||||
"react-ios-pwa-prompt": "^2.0.6",
|
||||
"react-resizable-layout": "npm:@innei/react-resizable-layout@0.7.3-fork.1",
|
||||
"react-router-dom": "6.27.0",
|
||||
"react-selecto": "^1.26.3",
|
||||
|
|
@ -103,6 +105,7 @@
|
|||
"unified": "11.0.5",
|
||||
"unist-util-visit-parents": "^6.0.1",
|
||||
"use-context-selector": "2.0.0",
|
||||
"use-pull-to-refresh": "2.4.1",
|
||||
"use-sync-external-store": "1.2.2",
|
||||
"usehooks-ts": "3.1.0",
|
||||
"vfile": "6.0.3",
|
||||
|
|
@ -125,6 +128,7 @@
|
|||
"@types/node": "^22.8.7",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vite-pwa/assets-generator": "^0.2.6",
|
||||
"fake-indexeddb": "6.0.0",
|
||||
"happy-dom": "15.8.0",
|
||||
"react": "^18.3.1",
|
||||
|
|
|
|||
|
After Width: | Height: | Size: 539 B |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 564 B |
|
|
@ -1,16 +0,0 @@
|
|||
{
|
||||
"theme_color": "#ff5c00",
|
||||
"name": "Follow",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/icon-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 460 B |
|
|
@ -0,0 +1,54 @@
|
|||
import type { Preset } from "@vite-pwa/assets-generator/config"
|
||||
import { defineConfig } from "@vite-pwa/assets-generator/config"
|
||||
|
||||
const minimal2023Preset: Preset = {
|
||||
transparent: {
|
||||
sizes: [64, 192, 512],
|
||||
favicons: [[48, "favicon.ico"]],
|
||||
padding: 0.05,
|
||||
// rgba(255, 92, 0, 1)
|
||||
resizeOptions: {
|
||||
fit: "contain",
|
||||
background: {
|
||||
r: 255,
|
||||
g: 92,
|
||||
b: 0,
|
||||
alpha: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
maskable: {
|
||||
sizes: [512],
|
||||
padding: 0,
|
||||
resizeOptions: {
|
||||
fit: "contain",
|
||||
background: {
|
||||
r: 255,
|
||||
g: 92,
|
||||
b: 0,
|
||||
alpha: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
apple: {
|
||||
sizes: [180],
|
||||
padding: 0,
|
||||
resizeOptions: {
|
||||
fit: "contain",
|
||||
background: {
|
||||
r: 255,
|
||||
g: 92,
|
||||
b: 0,
|
||||
alpha: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
headLinkOptions: {
|
||||
preset: "2023",
|
||||
},
|
||||
preset: minimal2023Preset,
|
||||
images: ["public/logo.svg"],
|
||||
})
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import { isMobile } from "@follow/components/hooks/useMobile.js"
|
||||
import { IN_ELECTRON } from "@follow/shared/constants"
|
||||
import { cn, getOS } from "@follow/utils/utils"
|
||||
import { useEffect } from "react"
|
||||
|
|
@ -12,6 +13,7 @@ import { applyAfterReadyCallbacks } from "./initialize/queue"
|
|||
import { removeAppSkeleton } from "./lib/app"
|
||||
import { appLog } from "./lib/log"
|
||||
import { Titlebar } from "./modules/app/Titlebar"
|
||||
import { useRegisterFollowCommands } from "./modules/command/use-register-follow-commands"
|
||||
import { RootProviders } from "./providers/root-providers"
|
||||
import { handlers } from "./tipc"
|
||||
|
||||
|
|
@ -53,6 +55,7 @@ function App() {
|
|||
const AppLayer = () => {
|
||||
const appIsReady = useAppIsReady()
|
||||
|
||||
useRegisterFollowCommands()
|
||||
useEffect(() => {
|
||||
removeAppSkeleton()
|
||||
|
||||
|
|
@ -63,6 +66,17 @@ const AppLayer = () => {
|
|||
appLog("App is ready", `${doneTime}ms`)
|
||||
|
||||
applyAfterReadyCallbacks()
|
||||
|
||||
if (isMobile()) {
|
||||
const handler = (e: MouseEvent) => {
|
||||
e.preventDefault()
|
||||
}
|
||||
document.addEventListener("contextmenu", handler)
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("contextmenu", handler)
|
||||
}
|
||||
}
|
||||
}, [appIsReady])
|
||||
|
||||
return appIsReady ? <Outlet /> : <AppSkeleton />
|
||||
|
|
|
|||
|
|
@ -29,13 +29,15 @@ const playerInitialValue: PlayerAtomValue = {
|
|||
}
|
||||
|
||||
const jsonStorage = createJSONStorage<PlayerAtomValue>()
|
||||
let hydrationDone = false
|
||||
const patchedLocalStorage: SyncStorage<PlayerAtomValue> = {
|
||||
setItem: jsonStorage.setItem,
|
||||
getItem: (key, initialValue) => {
|
||||
const value = jsonStorage.getItem(key, initialValue)
|
||||
if (value) {
|
||||
if (value && !hydrationDone) {
|
||||
// patch status to `paused` when hydration
|
||||
value.status = "paused"
|
||||
hydrationDone = true
|
||||
}
|
||||
return value
|
||||
},
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ const createDefaultSettings = (): GeneralSettings => ({
|
|||
// App
|
||||
appLaunchOnStartup: false,
|
||||
language: "en",
|
||||
// mobile app
|
||||
startupScreen: "timeline",
|
||||
// Data control
|
||||
dataPersist: true,
|
||||
sendAnonymousData: true,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,83 @@
|
|||
import { useEffect } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { useRegisterSW } from "virtual:pwa-register/react"
|
||||
|
||||
// check for updates every hour
|
||||
const period = 60 * 60 * 1000
|
||||
|
||||
export function ReloadPrompt() {
|
||||
const {
|
||||
// offlineReady: [offlineReady, setOfflineReady],
|
||||
needRefresh: [needRefresh],
|
||||
updateServiceWorker,
|
||||
} = useRegisterSW({
|
||||
onRegisteredSW(swUrl, r) {
|
||||
if (period <= 0) return
|
||||
if (r?.active?.state === "activated") {
|
||||
registerPeriodicSync(period, swUrl, r)
|
||||
} else if (r?.installing) {
|
||||
r.installing.addEventListener("statechange", (e) => {
|
||||
const sw = e.target as ServiceWorker
|
||||
if (sw.state === "activated") registerPeriodicSync(period, swUrl, r)
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// const close = useCallback(() => {
|
||||
// setOfflineReady(false)
|
||||
// setNeedRefresh(false)
|
||||
// }, [setNeedRefresh, setOfflineReady])
|
||||
|
||||
// useEffect(() => {
|
||||
// if (offlineReady) {
|
||||
// toast.info("App is ready to work offline", {
|
||||
// action: {
|
||||
// label: "Close",
|
||||
// onClick: close,
|
||||
// },
|
||||
// duration: Infinity,
|
||||
// })
|
||||
// }
|
||||
// }, [offlineReady, close])
|
||||
|
||||
useEffect(() => {
|
||||
const isPwa = window.matchMedia("(display-mode: standalone)").matches
|
||||
if (!isPwa) return
|
||||
|
||||
if (needRefresh) {
|
||||
toast.info("New version available", {
|
||||
action: {
|
||||
label: "Refresh",
|
||||
onClick: () => {
|
||||
updateServiceWorker(true)
|
||||
},
|
||||
},
|
||||
duration: Infinity,
|
||||
})
|
||||
}
|
||||
}, [needRefresh, updateServiceWorker])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* This function will register a periodic sync check every hour, you can modify the interval as needed.
|
||||
*/
|
||||
function registerPeriodicSync(period: number, swUrl: string, r: ServiceWorkerRegistration) {
|
||||
if (period <= 0) return
|
||||
|
||||
setInterval(async () => {
|
||||
if ("onLine" in navigator && !navigator.onLine) return
|
||||
|
||||
const resp = await fetch(swUrl, {
|
||||
cache: "no-store",
|
||||
headers: {
|
||||
cache: "no-store",
|
||||
"cache-control": "no-cache",
|
||||
},
|
||||
})
|
||||
|
||||
if (resp?.status === 200) await r.update()
|
||||
}, period)
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
import { MotionButtonBase } from "@follow/components/ui/button/index.js"
|
||||
import { cn } from "@follow/utils/utils"
|
||||
|
||||
export const HeaderTopReturnBackButton: Component<{ to?: string }> = ({ className, to }) => (
|
||||
<MotionButtonBase
|
||||
onClick={() => window.history.returnBack(to)}
|
||||
className={cn("center size-8", className)}
|
||||
>
|
||||
<i className="i-mingcute-left-line size-6" />
|
||||
|
||||
<span className="sr-only">Back</span>
|
||||
</MotionButtonBase>
|
||||
)
|
||||
|
|
@ -63,7 +63,7 @@ const ContextMenuContent = React.forwardRef<
|
|||
ref={ref}
|
||||
className={cn(
|
||||
"z-[60] min-w-32 overflow-hidden rounded-md border border-border bg-theme-modal-background-opaque p-1 text-theme-foreground/90 shadow-lg dark:shadow-zinc-800/60",
|
||||
"text-xs",
|
||||
"text-xs motion-duration-150 motion-scale-in-75 lg:animate-none",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { MemoedDangerousHTMLStyle } from "@follow/components/common/MemoedDanger
|
|||
import katexStyle from "katex/dist/katex.min.css?raw"
|
||||
import { createElement, Fragment, memo, useEffect, useMemo, useRef, useState } from "react"
|
||||
|
||||
import { ENTRY_CONTENT_RENDER_CONTAINER_ID } from "~/constants/dom"
|
||||
import { parseHtml } from "~/lib/parse-html"
|
||||
import { useWrappedElementSize } from "~/providers/wrapped-element-provider"
|
||||
|
||||
|
|
@ -79,7 +80,15 @@ const HTMLImpl = <A extends keyof JSX.IntrinsicElements = "div">(props: HTMLProp
|
|||
<MediaContainerWidthProvider width={containerWidth}>
|
||||
<MediaInfoRecordProvider mediaInfo={mediaInfo}>
|
||||
<MemoedDangerousHTMLStyle>{katexStyle}</MemoedDangerousHTMLStyle>
|
||||
{createElement(as, { ...rest, ref: setRefElement }, markdownElement)}
|
||||
{createElement(
|
||||
as,
|
||||
{
|
||||
...rest,
|
||||
id: ENTRY_CONTENT_RENDER_CONTAINER_ID,
|
||||
ref: setRefElement,
|
||||
},
|
||||
markdownElement,
|
||||
)}
|
||||
</MediaInfoRecordProvider>
|
||||
</MediaContainerWidthProvider>
|
||||
{!!accessory && <Fragment key={shouldForceReMountKey}>{accessory}</Fragment>}
|
||||
|
|
|
|||
|
|
@ -1,38 +1,20 @@
|
|||
import { getViewport, useViewport } from "@follow/components/hooks/useViewport.js"
|
||||
import { useScrollViewElement } from "@follow/components/ui/scroll-area/hooks.js"
|
||||
import { getElementTop } from "@follow/utils/dom"
|
||||
import { springScrollToElement } from "@follow/utils/scroller"
|
||||
import { useViewport } from "@follow/components/hooks/useViewport.js"
|
||||
import { cn } from "@follow/utils/utils"
|
||||
import * as HoverCard from "@radix-ui/react-hover-card"
|
||||
import { throttle } from "es-toolkit/compat"
|
||||
import { AnimatePresence, m } from "framer-motion"
|
||||
import {
|
||||
memo,
|
||||
startTransition,
|
||||
useContext,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react"
|
||||
import { useEventCallback } from "usehooks-ts"
|
||||
import { memo, useContext, useEffect, useRef, useState } from "react"
|
||||
|
||||
import { useRealInWideMode } from "~/atoms/settings/ui"
|
||||
import {
|
||||
useGetWrappedElementPosition,
|
||||
useWrappedElementPosition,
|
||||
useWrappedElementSize,
|
||||
} from "~/providers/wrapped-element-provider"
|
||||
|
||||
import { MarkdownRenderContainerRefContext } from "../context"
|
||||
import { useScrollTracking, useTocItems } from "./hooks"
|
||||
import type { TocItemProps } from "./TocItem"
|
||||
import { TocItem } from "./TocItem"
|
||||
|
||||
type DebouncedFuncLeading<T extends (..._args: any[]) => any> = T & {
|
||||
cancel: () => void
|
||||
flush: () => void
|
||||
}
|
||||
export interface ITocItem {
|
||||
depth: number
|
||||
title: string
|
||||
|
|
@ -42,7 +24,7 @@ export interface ITocItem {
|
|||
$heading: HTMLHeadingElement
|
||||
}
|
||||
|
||||
interface TocProps {
|
||||
export interface TocProps {
|
||||
onItemClick?: (index: number, $el: HTMLElement | null, anchorId: string) => void
|
||||
}
|
||||
|
||||
|
|
@ -234,157 +216,6 @@ const MemoedItem = memo<TocItemProps>((props) => {
|
|||
})
|
||||
MemoedItem.displayName = "MemoedItem"
|
||||
|
||||
// Hooks
|
||||
const useTocItems = (markdownElement: HTMLElement | null) => {
|
||||
const $headings = useMemo(
|
||||
() =>
|
||||
(markdownElement?.querySelectorAll("h1, h2, h3, h4, h5, h6") || []) as HTMLHeadingElement[],
|
||||
[markdownElement],
|
||||
)
|
||||
|
||||
const toc: ITocItem[] = useMemo(
|
||||
() =>
|
||||
Array.from($headings).map((el, idx) => {
|
||||
const depth = +el.tagName.slice(1)
|
||||
const elClone = el.cloneNode(true) as HTMLElement
|
||||
const title = elClone.textContent || ""
|
||||
const index = idx
|
||||
|
||||
return {
|
||||
depth,
|
||||
index: Number.isNaN(index) ? -1 : index,
|
||||
title,
|
||||
anchorId: el.dataset.rid || "",
|
||||
$heading: el,
|
||||
}
|
||||
}),
|
||||
[$headings],
|
||||
)
|
||||
|
||||
const rootDepth = useMemo(
|
||||
() =>
|
||||
toc?.length
|
||||
? (toc.reduce(
|
||||
(d: number, cur) => Math.min(d, cur.depth),
|
||||
toc[0]?.depth || 0,
|
||||
) as any as number)
|
||||
: 0,
|
||||
[toc],
|
||||
)
|
||||
|
||||
return { toc, rootDepth }
|
||||
}
|
||||
|
||||
const useScrollTracking = (toc: ITocItem[], options: Pick<TocProps, "onItemClick">) => {
|
||||
const scrollContainerElement = useScrollViewElement()
|
||||
const [currentScrollRange, setCurrentScrollRange] = useState([-1, 0] as [number, number])
|
||||
const { h } = useWrappedElementSize()
|
||||
const getWrappedElPos = useGetWrappedElementPosition()
|
||||
|
||||
const headingRangeParser = () => {
|
||||
// calculate the range of data-container-top between each two headings
|
||||
const titleBetweenPositionTopRangeMap = [] as [number, number][]
|
||||
for (let i = 0; i < toc.length - 1; i++) {
|
||||
const { $heading } = toc[i]
|
||||
const $nextHeading = toc[i + 1].$heading
|
||||
|
||||
const headingTop =
|
||||
Number.parseInt($heading.dataset["containerTop"] || "0") || getElementTop($heading)
|
||||
if (!$heading.dataset) {
|
||||
// @ts-expect-error
|
||||
$heading.dataset["containerTop"] = headingTop.toString()
|
||||
}
|
||||
|
||||
const nextTop = getElementTop($nextHeading)
|
||||
if (!$nextHeading.dataset) {
|
||||
// @ts-expect-error
|
||||
$nextHeading.dataset["containerTop"] = nextTop.toString()
|
||||
}
|
||||
|
||||
titleBetweenPositionTopRangeMap.push([headingTop, nextTop])
|
||||
}
|
||||
return titleBetweenPositionTopRangeMap
|
||||
}
|
||||
|
||||
const [titleBetweenPositionTopRangeMap, setTitleBetweenPositionTopRangeMap] =
|
||||
useState(headingRangeParser)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
startTransition(() => {
|
||||
setTitleBetweenPositionTopRangeMap(headingRangeParser)
|
||||
})
|
||||
}, [toc, h])
|
||||
|
||||
const throttleCallerRef = useRef<DebouncedFuncLeading<() => void>>()
|
||||
|
||||
useEffect(() => {
|
||||
if (!scrollContainerElement) return
|
||||
|
||||
const handler = throttle(() => {
|
||||
const { y } = getWrappedElPos()
|
||||
const top = scrollContainerElement.scrollTop + y
|
||||
const winHeight = getViewport().h
|
||||
const deltaHeight = top >= winHeight ? winHeight : (top / winHeight) * winHeight
|
||||
|
||||
const actualTop = Math.floor(Math.max(0, top - y + deltaHeight)) || 0
|
||||
|
||||
// current top is in which range?
|
||||
const currentRangeIndex = titleBetweenPositionTopRangeMap.findIndex(
|
||||
([start, end]) => actualTop >= start && actualTop <= end,
|
||||
)
|
||||
const currentRange = titleBetweenPositionTopRangeMap[currentRangeIndex]
|
||||
|
||||
if (currentRange) {
|
||||
const [start, end] = currentRange
|
||||
|
||||
// current top is this range, the precent is ?
|
||||
const precent = (actualTop - start) / (end - start)
|
||||
|
||||
// position , precent
|
||||
setCurrentScrollRange([currentRangeIndex, precent])
|
||||
} else {
|
||||
const last = titleBetweenPositionTopRangeMap.at(-1) || [0, 0]
|
||||
|
||||
if (top + winHeight > last[1]) {
|
||||
setCurrentScrollRange([
|
||||
titleBetweenPositionTopRangeMap.length,
|
||||
1 - (last[1] - top) / winHeight,
|
||||
])
|
||||
} else {
|
||||
setCurrentScrollRange([-1, 1])
|
||||
}
|
||||
}
|
||||
}, 100)
|
||||
|
||||
throttleCallerRef.current = handler
|
||||
scrollContainerElement.addEventListener("scroll", handler)
|
||||
|
||||
return () => {
|
||||
scrollContainerElement.removeEventListener("scroll", handler)
|
||||
handler.cancel()
|
||||
}
|
||||
}, [getWrappedElPos, scrollContainerElement, titleBetweenPositionTopRangeMap])
|
||||
|
||||
const handleScrollTo = useEventCallback(
|
||||
(i: number, $el: HTMLElement | null, _anchorId: string) => {
|
||||
options.onItemClick?.(i, $el, _anchorId)
|
||||
if ($el) {
|
||||
const handle = () => {
|
||||
springScrollToElement($el, -100, scrollContainerElement!).then(() => {
|
||||
throttleCallerRef.current?.cancel()
|
||||
setTimeout(() => {
|
||||
setCurrentScrollRange([i, 1])
|
||||
}, 36)
|
||||
})
|
||||
}
|
||||
handle()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
return { currentScrollRange, handleScrollTo }
|
||||
}
|
||||
|
||||
// Types
|
||||
interface TocContainerProps {
|
||||
className?: string
|
||||
|
|
|
|||
|
|
@ -0,0 +1,198 @@
|
|||
import { getViewport } from "@follow/components/hooks/useViewport.js"
|
||||
import { useScrollViewElement } from "@follow/components/ui/scroll-area/hooks.js"
|
||||
import { getElementTop } from "@follow/utils/dom"
|
||||
import { springScrollToElement } from "@follow/utils/scroller"
|
||||
import { throttle } from "es-toolkit/compat"
|
||||
import {
|
||||
startTransition,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react"
|
||||
import { useEventCallback } from "usehooks-ts"
|
||||
|
||||
import {
|
||||
useGetWrappedElementPosition,
|
||||
useWrappedElementSize,
|
||||
} from "~/providers/wrapped-element-provider"
|
||||
|
||||
import type { ITocItem, TocProps } from "./Toc"
|
||||
|
||||
// Hooks
|
||||
export const useTocItems = (markdownElement: HTMLElement | null) => {
|
||||
const $headings = useMemo(
|
||||
() =>
|
||||
(markdownElement?.querySelectorAll("h1, h2, h3, h4, h5, h6") || []) as HTMLHeadingElement[],
|
||||
[markdownElement],
|
||||
)
|
||||
|
||||
const toc: ITocItem[] = useMemo(
|
||||
() =>
|
||||
Array.from($headings).map((el, idx) => {
|
||||
const depth = +el.tagName.slice(1)
|
||||
const elClone = el.cloneNode(true) as HTMLElement
|
||||
const title = elClone.textContent || ""
|
||||
const index = idx
|
||||
|
||||
return {
|
||||
depth,
|
||||
index: Number.isNaN(index) ? -1 : index,
|
||||
title,
|
||||
anchorId: el.dataset.rid || "",
|
||||
$heading: el,
|
||||
}
|
||||
}),
|
||||
[$headings],
|
||||
)
|
||||
|
||||
const rootDepth = useMemo(
|
||||
() =>
|
||||
toc?.length
|
||||
? (toc.reduce(
|
||||
(d: number, cur) => Math.min(d, cur.depth),
|
||||
toc[0]?.depth || 0,
|
||||
) as any as number)
|
||||
: 0,
|
||||
[toc],
|
||||
)
|
||||
|
||||
return { toc, rootDepth }
|
||||
}
|
||||
|
||||
type DebouncedFuncLeading<T extends (..._args: any[]) => any> = T & {
|
||||
cancel: () => void
|
||||
flush: () => void
|
||||
}
|
||||
|
||||
export const useScrollTracking = (
|
||||
toc: ITocItem[],
|
||||
options: Pick<TocProps, "onItemClick"> & {
|
||||
useWindowScroll?: boolean
|
||||
},
|
||||
) => {
|
||||
const _scrollContainerElement = useScrollViewElement()
|
||||
const scrollContainerElement = options.useWindowScroll ? document : _scrollContainerElement
|
||||
const [currentScrollRange, setCurrentScrollRange] = useState([-1, 0] as [number, number])
|
||||
const { h } = useWrappedElementSize()
|
||||
|
||||
const getWrappedElPos = useGetWrappedElementPosition()
|
||||
|
||||
const headingRangeParser = useCallback(() => {
|
||||
// calculate the range of data-container-top between each two headings
|
||||
const titleBetweenPositionTopRangeMap = [] as [number, number][]
|
||||
for (let i = 0; i < toc.length - 1; i++) {
|
||||
const { $heading } = toc[i]
|
||||
const $nextHeading = toc[i + 1].$heading
|
||||
|
||||
const headingTop =
|
||||
Number.parseInt($heading.dataset["containerTop"] || "0") || getElementTop($heading)
|
||||
if (!$heading.dataset) {
|
||||
// @ts-expect-error
|
||||
$heading.dataset["containerTop"] = headingTop.toString()
|
||||
}
|
||||
|
||||
const nextTop = getElementTop($nextHeading)
|
||||
if (!$nextHeading.dataset) {
|
||||
// @ts-expect-error
|
||||
$nextHeading.dataset["containerTop"] = nextTop.toString()
|
||||
}
|
||||
|
||||
titleBetweenPositionTopRangeMap.push([headingTop, nextTop])
|
||||
}
|
||||
return titleBetweenPositionTopRangeMap
|
||||
}, [toc])
|
||||
|
||||
const headingRangeParserEvent = useEventCallback(headingRangeParser)
|
||||
|
||||
const [titleBetweenPositionTopRangeMap, setTitleBetweenPositionTopRangeMap] =
|
||||
useState(headingRangeParser)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
startTransition(() => {
|
||||
setTitleBetweenPositionTopRangeMap(headingRangeParserEvent)
|
||||
})
|
||||
}, [toc, h, headingRangeParserEvent])
|
||||
|
||||
const throttleCallerRef = useRef<DebouncedFuncLeading<() => void>>()
|
||||
|
||||
useEffect(() => {
|
||||
if (!scrollContainerElement) return
|
||||
|
||||
const handler = throttle(() => {
|
||||
const { y } = getWrappedElPos()
|
||||
|
||||
const top =
|
||||
scrollContainerElement === document
|
||||
? document.documentElement.scrollTop + y
|
||||
: (scrollContainerElement as HTMLElement).scrollTop + y
|
||||
|
||||
const winHeight = getViewport().h
|
||||
const deltaHeight = top >= winHeight ? winHeight : (top / winHeight) * winHeight
|
||||
|
||||
const actualTop = Math.floor(Math.max(0, top - y + deltaHeight)) || 0
|
||||
|
||||
// current top is in which range?
|
||||
const currentRangeIndex = titleBetweenPositionTopRangeMap.findIndex(
|
||||
([start, end]) => actualTop >= start && actualTop <= end,
|
||||
)
|
||||
const currentRange = titleBetweenPositionTopRangeMap[currentRangeIndex]
|
||||
|
||||
if (currentRange) {
|
||||
const [start, end] = currentRange
|
||||
|
||||
// current top is this range, the precent is ?
|
||||
const precent = (actualTop - start) / (end - start)
|
||||
|
||||
// position , precent
|
||||
setCurrentScrollRange([currentRangeIndex, precent])
|
||||
} else {
|
||||
const last = titleBetweenPositionTopRangeMap.at(-1) || [0, 0]
|
||||
|
||||
if (top + winHeight > last[1]) {
|
||||
setCurrentScrollRange([
|
||||
titleBetweenPositionTopRangeMap.length,
|
||||
1 - (last[1] - top) / winHeight,
|
||||
])
|
||||
} else {
|
||||
setCurrentScrollRange([-1, 1])
|
||||
}
|
||||
}
|
||||
}, 100)
|
||||
|
||||
throttleCallerRef.current = handler
|
||||
scrollContainerElement.addEventListener("scroll", handler)
|
||||
|
||||
return () => {
|
||||
scrollContainerElement.removeEventListener("scroll", handler)
|
||||
handler.cancel()
|
||||
}
|
||||
}, [getWrappedElPos, scrollContainerElement, titleBetweenPositionTopRangeMap])
|
||||
|
||||
const handleScrollTo = useEventCallback(
|
||||
(i: number, $el: HTMLElement | null, _anchorId: string) => {
|
||||
options.onItemClick?.(i, $el, _anchorId)
|
||||
if ($el) {
|
||||
const handle = () => {
|
||||
springScrollToElement(
|
||||
$el,
|
||||
-100,
|
||||
scrollContainerElement === document
|
||||
? undefined
|
||||
: (scrollContainerElement as HTMLElement),
|
||||
).then(() => {
|
||||
throttleCallerRef.current?.cancel()
|
||||
setTimeout(() => {
|
||||
setCurrentScrollRange([i, 1])
|
||||
}, 36)
|
||||
})
|
||||
}
|
||||
handle()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
return { currentScrollRange, handleScrollTo }
|
||||
}
|
||||
|
|
@ -46,6 +46,7 @@ export type MediaProps = BaseProps &
|
|||
previewImageUrl?: string
|
||||
})
|
||||
)
|
||||
|
||||
const MediaImpl: FC<MediaProps> = ({
|
||||
className,
|
||||
proxy,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
import { isMobile } from "@follow/components/hooks/useMobile.js"
|
||||
import { useCallback } from "react"
|
||||
|
||||
import { replaceImgUrlIfNeed } from "~/lib/img-proxy"
|
||||
|
||||
import { PlainModal } from "../modal/stacked/custom-modal"
|
||||
import { useModalStack } from "../modal/stacked/hooks"
|
||||
import type { PreviewMediaProps } from "./preview-media"
|
||||
|
|
@ -9,6 +12,10 @@ export const usePreviewMedia = (children?: React.ReactNode) => {
|
|||
const { present } = useModalStack()
|
||||
return useCallback(
|
||||
(media: PreviewMediaProps[], initialIndex = 0) => {
|
||||
if (isMobile()) {
|
||||
window.open(replaceImgUrlIfNeed(media[initialIndex].url))
|
||||
return
|
||||
}
|
||||
present({
|
||||
content: () => (
|
||||
<div className="relative size-full">
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ const Wrapper: Component<{
|
|||
const { t } = useTranslation(["shortcuts", "common"])
|
||||
|
||||
return (
|
||||
<div className="center relative size-full px-20 pb-8 pt-10" onClick={dismiss}>
|
||||
<div className="center relative size-full py-12 lg:px-20 lg:pb-8 lg:pt-10" onClick={dismiss}>
|
||||
<m.div
|
||||
onFocusCapture={stopPropagation}
|
||||
initial={true}
|
||||
|
|
@ -426,9 +426,9 @@ const FallbackableImage: FC<
|
|||
resolutionY={32}
|
||||
className="!size-full"
|
||||
/>
|
||||
) : (
|
||||
) : isLoading ? (
|
||||
<i className="i-mgc-loading-3-cute-re size-8 animate-spin text-white/80" />
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
</TransformComponent>
|
||||
</TransformWrapper>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import { Button } from "@follow/components/ui/button/index.js"
|
||||
import { nextFrame } from "@follow/utils/dom"
|
||||
import type { DragControls } from "framer-motion"
|
||||
import { atom, useAtomValue } from "jotai"
|
||||
import type { ResizeCallback, ResizeStartCallback } from "re-resizable"
|
||||
import { useCallback, useContext, useId, useRef, useState } from "react"
|
||||
import { useContext, useId, useRef, useState } from "react"
|
||||
import { flushSync } from "react-dom"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { useContextSelector } from "use-context-selector"
|
||||
|
|
@ -22,49 +23,49 @@ export const useModalStack = (options?: ModalStackOptions) => {
|
|||
const currentCount = useRef(0)
|
||||
const { wrapper } = options || {}
|
||||
|
||||
const presentSync = (props: ModalProps & { id?: string }) => {
|
||||
const fallbackModelId = `${id}-${++currentCount.current}`
|
||||
const modalId = props.id ?? fallbackModelId
|
||||
|
||||
const currentStack = jotaiStore.get(modalStackAtom)
|
||||
|
||||
const existingModal = currentStack.find((item) => item.id === modalId)
|
||||
if (existingModal) {
|
||||
// Move to top
|
||||
jotaiStore.set(modalStackAtom, (p) => {
|
||||
const index = p.indexOf(existingModal)
|
||||
return [...p.slice(0, index), ...p.slice(index + 1), existingModal]
|
||||
})
|
||||
} else {
|
||||
// NOTE: The props of the Command Modal are immutable, so we'll just take the store value and inject it.
|
||||
// There is no need to inject `overlay` props, this is rendered responsively based on ui changes.
|
||||
const uiSettings = getUISettings()
|
||||
const modalConfig: Partial<ModalProps> = {
|
||||
draggable: uiSettings.modalDraggable,
|
||||
modal: true,
|
||||
}
|
||||
jotaiStore.set(modalStackAtom, (p) => {
|
||||
const modalProps: ModalProps = {
|
||||
...modalConfig,
|
||||
...props,
|
||||
|
||||
wrapper,
|
||||
}
|
||||
modalIdToPropsMap[modalId] = modalProps
|
||||
return p.concat({
|
||||
id: modalId,
|
||||
...modalProps,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return () => {
|
||||
jotaiStore.set(modalStackAtom, (p) => p.filter((item) => item.id !== modalId))
|
||||
}
|
||||
}
|
||||
return {
|
||||
present: useCallback(
|
||||
(props: ModalProps & { id?: string }) => {
|
||||
const fallbackModelId = `${id}-${++currentCount.current}`
|
||||
const modalId = props.id ?? fallbackModelId
|
||||
|
||||
const currentStack = jotaiStore.get(modalStackAtom)
|
||||
|
||||
const existingModal = currentStack.find((item) => item.id === modalId)
|
||||
if (existingModal) {
|
||||
// Move to top
|
||||
jotaiStore.set(modalStackAtom, (p) => {
|
||||
const index = p.indexOf(existingModal)
|
||||
return [...p.slice(0, index), ...p.slice(index + 1), existingModal]
|
||||
})
|
||||
} else {
|
||||
// NOTE: The props of the Command Modal are immutable, so we'll just take the store value and inject it.
|
||||
// There is no need to inject `overlay` props, this is rendered responsively based on ui changes.
|
||||
const uiSettings = getUISettings()
|
||||
const modalConfig: Partial<ModalProps> = {
|
||||
draggable: uiSettings.modalDraggable,
|
||||
modal: true,
|
||||
}
|
||||
jotaiStore.set(modalStackAtom, (p) => {
|
||||
const modalProps: ModalProps = {
|
||||
...modalConfig,
|
||||
...props,
|
||||
|
||||
wrapper,
|
||||
}
|
||||
modalIdToPropsMap[modalId] = modalProps
|
||||
return p.concat({
|
||||
id: modalId,
|
||||
...modalProps,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return () => {
|
||||
jotaiStore.set(modalStackAtom, (p) => p.filter((item) => item.id !== modalId))
|
||||
}
|
||||
},
|
||||
[id, wrapper],
|
||||
present: useEventCallback((props: ModalProps & { id?: string }) =>
|
||||
nextFrame(() => presentSync(props)),
|
||||
),
|
||||
|
||||
...actions,
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { modalMontionConfig } from "../constants"
|
|||
*/
|
||||
export const useModalAnimate = (isTop: boolean) => {
|
||||
const animateController = useAnimationControls()
|
||||
|
||||
useEffect(() => {
|
||||
nextFrame(() => {
|
||||
animateController.start(modalMontionConfig.animate)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,31 @@
|
|||
import { AnimatePresence } from "framer-motion"
|
||||
import { useAtomValue } from "jotai"
|
||||
|
||||
import { modalStackAtom } from "./atom"
|
||||
import { useModalStack } from "./hooks"
|
||||
import { ModalInternal } from "./modal"
|
||||
import { useModalStackCalculationAndEffect } from "./modal-stack.shared"
|
||||
|
||||
export const ModalStack = () => {
|
||||
const { present } = useModalStack()
|
||||
window.presentModal = present
|
||||
|
||||
const stack = useAtomValue(modalStackAtom)
|
||||
|
||||
const { topModalIndex, overlayOptions } = useModalStackCalculationAndEffect()
|
||||
|
||||
return (
|
||||
<AnimatePresence mode="popLayout">
|
||||
{stack.map((item, index) => (
|
||||
<ModalInternal
|
||||
key={item.id}
|
||||
item={item}
|
||||
index={index}
|
||||
isTop={index === topModalIndex}
|
||||
isBottom={index === 0}
|
||||
overlayOptions={overlayOptions}
|
||||
/>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { ModalStack } from "./modal-stack.desktop"
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
import { sheetStackAtom } from "@follow/components/ui/sheet/context.js"
|
||||
import type { SheetRef } from "@follow/components/ui/sheet/Sheet.js"
|
||||
import { PresentSheet } from "@follow/components/ui/sheet/Sheet.js"
|
||||
import { jotaiStore } from "@follow/utils/jotai"
|
||||
import { AnimatePresence } from "framer-motion"
|
||||
import { produce } from "immer"
|
||||
import { useAtomValue, useSetAtom } from "jotai"
|
||||
import { createElement, useMemo, useRef } from "react"
|
||||
import { useEventCallback } from "usehooks-ts"
|
||||
|
||||
import { modalStackAtom } from "./atom"
|
||||
import { MODAL_STACK_Z_INDEX } from "./constants"
|
||||
import type { CurrentModalContentProps, ModalActionsInternal } from "./context"
|
||||
import { CurrentModalContext } from "./context"
|
||||
import { useModalStack } from "./hooks"
|
||||
import { ModalInternal } from "./modal"
|
||||
import { useModalStackCalculationAndEffect } from "./modal-stack.shared"
|
||||
import type { ModalProps } from "./types"
|
||||
|
||||
export const ModalStack = () => {
|
||||
const { present } = useModalStack()
|
||||
window.presentModal = present
|
||||
|
||||
const stack = useAtomValue(modalStackAtom)
|
||||
|
||||
const { topModalIndex, overlayOptions } = useModalStackCalculationAndEffect()
|
||||
|
||||
return (
|
||||
<AnimatePresence mode="popLayout">
|
||||
{stack.map((item, index) => {
|
||||
const shouldUseModal = !!item.CustomModalComponent
|
||||
if (!shouldUseModal) return <ModalToSheet {...item} key={item.id} index={index} />
|
||||
return (
|
||||
<ModalInternal
|
||||
key={item.id}
|
||||
item={item}
|
||||
index={index}
|
||||
isTop={index === topModalIndex}
|
||||
isBottom={index === 0}
|
||||
overlayOptions={overlayOptions}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
|
||||
const ModalToSheet = (props: ModalProps & { index: number; id: string }) => {
|
||||
const sheetRef = useRef<SheetRef>(null)
|
||||
const drawerLength = useRef(jotaiStore.get(sheetStackAtom).length).current
|
||||
const { title, content, id } = props
|
||||
|
||||
const close = useEventCallback(() => {
|
||||
setStack((p) => p.filter((modal) => modal.id !== id))
|
||||
})
|
||||
|
||||
const setStack = useSetAtom(modalStackAtom)
|
||||
// TODO Compatible with other modal stack methods
|
||||
const ModalProps: ModalActionsInternal = useMemo(
|
||||
() => ({
|
||||
dismiss: () => {
|
||||
sheetRef.current?.dismiss()
|
||||
},
|
||||
getIndex: () => props.index,
|
||||
setClickOutSideToDismiss: (v) => {
|
||||
setStack((state) =>
|
||||
produce(state, (draft) => {
|
||||
const model = draft.find((modal) => modal.id === id)
|
||||
if (!model) return
|
||||
if (model.clickOutsideToDismiss === v) return
|
||||
model.clickOutsideToDismiss = v
|
||||
}),
|
||||
)
|
||||
},
|
||||
}),
|
||||
[id, props.index, setStack],
|
||||
)
|
||||
const modalContentRef = useRef<HTMLDivElement>(null)
|
||||
const ModalContextProps = useMemo<CurrentModalContentProps>(
|
||||
() => ({
|
||||
...ModalProps,
|
||||
ref: modalContentRef,
|
||||
}),
|
||||
[ModalProps],
|
||||
)
|
||||
|
||||
const finalChildren = (
|
||||
<CurrentModalContext.Provider value={ModalContextProps}>
|
||||
{createElement(content, ModalProps)}
|
||||
</CurrentModalContext.Provider>
|
||||
)
|
||||
|
||||
return (
|
||||
<PresentSheet
|
||||
ref={sheetRef}
|
||||
title={title}
|
||||
contentRef={modalContentRef}
|
||||
defaultOpen
|
||||
zIndex={MODAL_STACK_Z_INDEX + drawerLength}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setTimeout(() => {
|
||||
close()
|
||||
}, 1000)
|
||||
}
|
||||
}}
|
||||
content={finalChildren}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
import { useAtomValue } from "jotai"
|
||||
import { useEffect } from "react"
|
||||
|
||||
import { modalStackAtom } from "./atom"
|
||||
|
||||
export const useModalStackCalculationAndEffect = () => {
|
||||
const stack = useAtomValue(modalStackAtom)
|
||||
const topModalIndex = stack.findLastIndex((item) => item.modal)
|
||||
const overlayIndex = stack.findLastIndex((item) => item.overlay || item.modal)
|
||||
const overlayOptions = stack[overlayIndex]?.overlayOptions
|
||||
|
||||
const hasModalStack = stack.length > 0
|
||||
const topModalIsNotSetAsAModal = topModalIndex !== stack.length - 1
|
||||
|
||||
useEffect(() => {
|
||||
// NOTE: document.body is being used by radix's dismissable,
|
||||
// and using that will cause radix to get the value of `none` as the store value,
|
||||
// and then revert to `none` instead of `auto` after a modal dismiss.
|
||||
document.documentElement.style.pointerEvents =
|
||||
hasModalStack && !topModalIsNotSetAsAModal ? "none" : "auto"
|
||||
document.documentElement.dataset.hasModal = hasModalStack.toString()
|
||||
}, [hasModalStack, topModalIsNotSetAsAModal])
|
||||
|
||||
return {
|
||||
overlayOptions,
|
||||
topModalIndex,
|
||||
hasModalStack,
|
||||
topModalIsNotSetAsAModal,
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import { withResponsiveSyncComponent } from "@follow/components/utils/selector.js"
|
||||
|
||||
import { ModalStack as Desktop } from "./modal-stack.desktop"
|
||||
import { ModalStack as Mobile } from "./modal-stack.mobile"
|
||||
|
||||
export const ModalStack = withResponsiveSyncComponent(Desktop, Mobile)
|
||||
|
|
@ -1,11 +1,6 @@
|
|||
import { AnimatePresence } from "framer-motion"
|
||||
import { useAtomValue } from "jotai"
|
||||
import type { FC, PropsWithChildren } from "react"
|
||||
import { useEffect } from "react"
|
||||
|
||||
import { modalStackAtom } from "./atom"
|
||||
import { useModalStack } from "./hooks"
|
||||
import { ModalInternal } from "./modal"
|
||||
import { ModalStack } from "./modal-stack"
|
||||
import type { ModalProps } from "./types"
|
||||
|
||||
declare global {
|
||||
|
|
@ -20,40 +15,3 @@ export const ModalStackProvider: FC<PropsWithChildren> = ({ children }) => (
|
|||
<ModalStack />
|
||||
</>
|
||||
)
|
||||
|
||||
const ModalStack = () => {
|
||||
const { present } = useModalStack()
|
||||
window.presentModal = present
|
||||
|
||||
const stack = useAtomValue(modalStackAtom)
|
||||
|
||||
const topModalIndex = stack.findLastIndex((item) => item.modal)
|
||||
const overlayIndex = stack.findLastIndex((item) => item.overlay || item.modal)
|
||||
const overlayOptions = stack[overlayIndex]?.overlayOptions
|
||||
|
||||
const hasModalStack = stack.length > 0
|
||||
const topModalIsNotSetAsAModal = topModalIndex !== stack.length - 1
|
||||
|
||||
useEffect(() => {
|
||||
// NOTE: document.body is being used by radix's dismissable,
|
||||
// and using that will cause radix to get the value of `none` as the store value,
|
||||
// and then revert to `none` instead of `auto` after a modal dismiss.
|
||||
document.documentElement.style.pointerEvents =
|
||||
hasModalStack && !topModalIsNotSetAsAModal ? "none" : "auto"
|
||||
document.documentElement.dataset.hasModal = hasModalStack.toString()
|
||||
}, [hasModalStack, topModalIsNotSetAsAModal])
|
||||
return (
|
||||
<AnimatePresence mode="popLayout">
|
||||
{stack.map((item, index) => (
|
||||
<ModalInternal
|
||||
key={item.id}
|
||||
item={item}
|
||||
index={index}
|
||||
isTop={index === topModalIndex}
|
||||
isBottom={index === 0}
|
||||
overlayOptions={overlayOptions}
|
||||
/>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,163 @@
|
|||
import { clsx } from "clsx"
|
||||
import type { ReactNode } from "react"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
|
||||
import { ENTRY_COLUMN_LIST_SCROLLER_ID } from "~/constants/dom"
|
||||
|
||||
interface PullToRefreshProps {
|
||||
children: ReactNode
|
||||
onRefresh: () => Promise<any>
|
||||
className?: string
|
||||
scrollContainerSelector?: string
|
||||
}
|
||||
const THRESHOLD = 80
|
||||
const MAX_PULL_DISTANCE = 120
|
||||
|
||||
export function PullToRefresh({
|
||||
children,
|
||||
onRefresh,
|
||||
className,
|
||||
scrollContainerSelector = `#${ENTRY_COLUMN_LIST_SCROLLER_ID}`,
|
||||
}: PullToRefreshProps) {
|
||||
const [startY, setStartY] = useState(0)
|
||||
const [pulling, setPulling] = useState(false)
|
||||
const [pullDistance, setPullDistance] = useState(0)
|
||||
const [shouldAllowPull, setShouldAllowPull] = useState(false)
|
||||
|
||||
const [isRefreshing, setIsRefreshing] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!pulling) {
|
||||
setPullDistance(0)
|
||||
}
|
||||
}, [pulling])
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const contentRef = useRef<HTMLDivElement>(null)
|
||||
useEffect(() => {
|
||||
const element = containerRef.current
|
||||
if (!element) return
|
||||
|
||||
const touchStartHandler = (e: TouchEvent) => {
|
||||
const scrollContainer = contentRef.current?.querySelector(
|
||||
scrollContainerSelector,
|
||||
) as HTMLElement
|
||||
if (!scrollContainer) return
|
||||
|
||||
const touchY = e.touches[0].clientY
|
||||
setStartY(touchY)
|
||||
|
||||
if (scrollContainer.scrollTop <= 0) {
|
||||
setShouldAllowPull(true)
|
||||
} else {
|
||||
setShouldAllowPull(false)
|
||||
}
|
||||
}
|
||||
|
||||
const touchMoveHandler = (e: TouchEvent) => {
|
||||
if (!shouldAllowPull || isRefreshing) return
|
||||
|
||||
const y = e.touches[0].clientY
|
||||
const delta = y - startY
|
||||
|
||||
if (delta > 0) {
|
||||
e.preventDefault()
|
||||
setPulling(true)
|
||||
setPullDistance(Math.min(delta, MAX_PULL_DISTANCE))
|
||||
}
|
||||
}
|
||||
|
||||
const touchEndHandler = async () => {
|
||||
if (!pulling || isRefreshing) return
|
||||
|
||||
setPulling(false)
|
||||
|
||||
if (pullDistance >= THRESHOLD) {
|
||||
const promise = onRefresh()
|
||||
setIsRefreshing(true)
|
||||
|
||||
try {
|
||||
await promise
|
||||
} finally {
|
||||
setIsRefreshing(false)
|
||||
setPullDistance(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
element.addEventListener("touchstart", touchStartHandler, { passive: true })
|
||||
element.addEventListener("touchmove", touchMoveHandler, { passive: false })
|
||||
element.addEventListener("touchend", touchEndHandler, { passive: true })
|
||||
|
||||
return () => {
|
||||
element.removeEventListener("touchstart", touchStartHandler)
|
||||
element.removeEventListener("touchmove", touchMoveHandler)
|
||||
element.removeEventListener("touchend", touchEndHandler)
|
||||
}
|
||||
}, [
|
||||
startY,
|
||||
shouldAllowPull,
|
||||
pulling,
|
||||
pullDistance,
|
||||
onRefresh,
|
||||
scrollContainerSelector,
|
||||
isRefreshing,
|
||||
])
|
||||
|
||||
// 计算实际的下拉距离
|
||||
const actualPullDistance = isRefreshing
|
||||
? THRESHOLD // 刷新时保持在阈值位置
|
||||
: pullDistance
|
||||
|
||||
// 计算下拉进度 (0-1)
|
||||
const pullProgress = Math.max(Math.min(pullDistance / THRESHOLD - 0.2, 1), 0)
|
||||
const SIZE = 24
|
||||
const STROKE_WIDTH = 2
|
||||
const RADIUS = (SIZE - STROKE_WIDTH) / 2
|
||||
const CIRCUMFERENCE = 2 * Math.PI * RADIUS
|
||||
const strokeDashoffset = CIRCUMFERENCE * (1 - pullProgress)
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={clsx("relative touch-none", className)}>
|
||||
<div
|
||||
className={clsx(
|
||||
"absolute inset-x-0 flex items-center justify-center",
|
||||
actualPullDistance > 0 ? "opacity-100" : "opacity-0",
|
||||
!pulling && "duration-200",
|
||||
)}
|
||||
style={{
|
||||
transform: `translateY(${actualPullDistance - 60}px)`,
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width={SIZE}
|
||||
height={SIZE}
|
||||
viewBox={`0 0 ${SIZE} ${SIZE}`}
|
||||
className={clsx(isRefreshing ? "animate-spin" : "")}
|
||||
>
|
||||
<circle
|
||||
cx={SIZE / 2}
|
||||
cy={SIZE / 2}
|
||||
r={RADIUS}
|
||||
fill="none"
|
||||
strokeWidth={STROKE_WIDTH}
|
||||
className="stroke-zinc-500/70"
|
||||
strokeDasharray={CIRCUMFERENCE}
|
||||
strokeDashoffset={isRefreshing ? 20 : strokeDashoffset}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Content area */}
|
||||
<div
|
||||
ref={contentRef}
|
||||
className={clsx("h-full will-change-transform", !pulling ? "transition-transform" : "")}
|
||||
style={{
|
||||
transform: `translateY(${actualPullDistance}px)`,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
export const ENTRY_CONTENT_RENDER_CONTAINER_ID = "follow-entry-render"
|
||||
|
||||
export const LOGO_MOBILE_ID = "follow-logo-mobile"
|
||||
|
||||
export const ENTRY_COLUMN_LIST_SCROLLER_ID = "entry-column-scroller"
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import type { FeedViewType } from "@follow/constants"
|
||||
import type { ReactNode } from "react"
|
||||
import { useCallback, useMemo } from "react"
|
||||
|
||||
import {
|
||||
|
|
@ -13,6 +14,7 @@ import { shortcuts } from "~/constants/shortcuts"
|
|||
import { tipcClient } from "~/lib/client"
|
||||
import { COMMAND_ID } from "~/modules/command/commands/id"
|
||||
import { useGetCommand, useRunCommandFn } from "~/modules/command/hooks/use-command"
|
||||
import type { FollowCommandId } from "~/modules/command/types"
|
||||
import { useEntry } from "~/store/entry"
|
||||
import { useFeedById } from "~/store/feed"
|
||||
import { useInboxById } from "~/store/inbox"
|
||||
|
|
@ -55,6 +57,15 @@ export const useEntryReadabilityToggle = ({ id, url }: { id: string; url: string
|
|||
}
|
||||
}, [id, url])
|
||||
|
||||
export type EntryActionItem = {
|
||||
id: FollowCommandId
|
||||
name: string
|
||||
icon?: ReactNode
|
||||
active?: boolean
|
||||
shortcut?: string
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
export const useEntryActions = ({ entryId, view }: { entryId: string; view?: FeedViewType }) => {
|
||||
const entry = useEntry(entryId)
|
||||
const feed = useFeedById(entry?.feedId, (feed) => {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { getReadonlyRoute, getStableRouterNavigate } from "@follow/components/atoms/route.js"
|
||||
import { isMobile } from "@follow/components/hooks/useMobile.js"
|
||||
import { FeedViewType } from "@follow/constants"
|
||||
import { isUndefined } from "es-toolkit/compat"
|
||||
|
||||
|
|
@ -23,7 +24,8 @@ export type NavigateEntryOptions = Partial<{
|
|||
/**
|
||||
* @description a hook to navigate to `feedId`, `entryId`, add search for `view`, `level`
|
||||
*/
|
||||
// eslint-disable-next-line @eslint-react/hooks-extra/ensure-custom-hooks-using-other-hooks
|
||||
|
||||
// eslint-disable-next-line @eslint-react/hooks-extra/no-redundant-custom-hook, @eslint-react/hooks-extra/ensure-custom-hooks-using-other-hooks
|
||||
export const useNavigateEntry = () => navigateEntry
|
||||
|
||||
export const navigateEntry = (options: NavigateEntryOptions) => {
|
||||
|
|
@ -66,7 +68,14 @@ export const navigateEntry = (options: NavigateEntryOptions) => {
|
|||
})
|
||||
}
|
||||
|
||||
return getStableRouterNavigate()?.(
|
||||
`/feeds/${finalFeedId}/${entryId || ROUTE_ENTRY_PENDING}?${nextSearchParams.toString()}`,
|
||||
)
|
||||
let path = `/feeds`
|
||||
if (finalFeedId) {
|
||||
path += `/${finalFeedId}`
|
||||
}
|
||||
if (entryId) {
|
||||
path += `/${entryId}`
|
||||
} else {
|
||||
if (!isMobile()) path += `/${ROUTE_ENTRY_PENDING}`
|
||||
}
|
||||
return getStableRouterNavigate()?.(`${path}?${nextSearchParams.toString()}`)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,26 @@
|
|||
import { useLongPress } from "@follow/hooks"
|
||||
|
||||
interface UseContextMenuOptions {
|
||||
onContextMenu: (e: React.MouseEvent) => void
|
||||
onTouchStart?: (e: React.TouchEvent) => void
|
||||
onTouchMove?: (e: React.TouchEvent) => void
|
||||
onTouchEnd?: (e: React.TouchEvent) => void
|
||||
}
|
||||
|
||||
export const useContextMenu = ({
|
||||
onContextMenu,
|
||||
onTouchStart,
|
||||
onTouchMove,
|
||||
onTouchEnd,
|
||||
}: UseContextMenuOptions) => {
|
||||
const props = useLongPress({
|
||||
onLongPress: onContextMenu as any,
|
||||
onTouchStart,
|
||||
onTouchMove,
|
||||
onTouchEnd,
|
||||
})
|
||||
return {
|
||||
...props,
|
||||
onContextMenu,
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
import { nextFrame } from "@follow/utils/dom"
|
||||
import { jotaiStore } from "@follow/utils/jotai"
|
||||
import { atom } from "jotai"
|
||||
|
||||
import { router } from "~/router"
|
||||
|
||||
const historyAtom = atom<string[]>([])
|
||||
|
||||
declare global {
|
||||
interface History {
|
||||
stack: string[]
|
||||
|
||||
returnBack: (to?: string) => void
|
||||
|
||||
get isPop(): boolean
|
||||
}
|
||||
}
|
||||
|
||||
let __isPop = false
|
||||
const F = {} as { isPop: boolean }
|
||||
let resetTimer: any = null
|
||||
Object.defineProperty(F, "isPop", {
|
||||
get() {
|
||||
return __isPop
|
||||
},
|
||||
set(value) {
|
||||
if (!value) return
|
||||
|
||||
resetTimer && clearTimeout(resetTimer)
|
||||
resetTimer = setTimeout(() => {
|
||||
__isPop = false
|
||||
}, 1200)
|
||||
|
||||
__isPop = true
|
||||
},
|
||||
})
|
||||
|
||||
export const registerHistoryStack = () => {
|
||||
const onPopState = (e: PopStateEvent) => {
|
||||
F.isPop = true
|
||||
|
||||
const url = e.state?.url
|
||||
if (url) {
|
||||
jotaiStore.set(historyAtom, jotaiStore.get(historyAtom).slice(0, -1))
|
||||
}
|
||||
}
|
||||
window.addEventListener("popstate", onPopState)
|
||||
|
||||
const unsub = router.subscribe((e) => {
|
||||
const url = e.location.pathname + e.location.search
|
||||
jotaiStore.set(historyAtom, [...jotaiStore.get(historyAtom), url])
|
||||
})
|
||||
|
||||
Object.defineProperty(window.history, "stack", {
|
||||
get() {
|
||||
return jotaiStore.get(historyAtom)
|
||||
},
|
||||
enumerable: false,
|
||||
})
|
||||
|
||||
Object.defineProperty(window.history, "returnBack", {
|
||||
value: (to?: string) => {
|
||||
const stack = jotaiStore.get(historyAtom)
|
||||
F.isPop = true
|
||||
const last = stack.at(-1)
|
||||
|
||||
to = typeof to === "string" ? to : last
|
||||
|
||||
if (!last || last !== to) {
|
||||
window.router.navigate(to ?? "/")
|
||||
|
||||
nextFrame(() => {
|
||||
jotaiStore.set(historyAtom, [])
|
||||
})
|
||||
} else {
|
||||
window.history.back()
|
||||
}
|
||||
},
|
||||
enumerable: false,
|
||||
})
|
||||
|
||||
Object.defineProperty(window.history, "isPop", {
|
||||
get() {
|
||||
return F.isPop
|
||||
},
|
||||
enumerable: false,
|
||||
})
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("popstate", onPopState)
|
||||
|
||||
unsub()
|
||||
}
|
||||
}
|
||||
|
|
@ -17,6 +17,7 @@ import { subscribeNetworkStatus } from "../atoms/network"
|
|||
import { getGeneralSettings, subscribeShouldUseIndexedDB } from "../atoms/settings/general"
|
||||
import { appLog } from "../lib/log"
|
||||
import { initAnalytics } from "./analytics"
|
||||
import { registerHistoryStack } from "./history"
|
||||
import { hydrateDatabaseToStore, hydrateSettings, setHydrated } from "./hydrate"
|
||||
import { doMigration } from "./migrates"
|
||||
import { initSentry } from "./sentry"
|
||||
|
|
@ -42,7 +43,7 @@ export const initializeApp = async () => {
|
|||
appLog(`${APP_NAME}: Follow your favorites in one inbox`, repository.url)
|
||||
|
||||
if (isDev) {
|
||||
const favicon = await import("~/../public/favicon-dev.ico?url")
|
||||
const favicon = await import("/favicon-dev.ico?url")
|
||||
|
||||
const url = new URL(favicon.default, import.meta.url).href
|
||||
|
||||
|
|
@ -69,6 +70,7 @@ export const initializeApp = async () => {
|
|||
credentials: "include",
|
||||
})
|
||||
initializeDayjs()
|
||||
registerHistoryStack()
|
||||
|
||||
// Set Environment
|
||||
document.documentElement.dataset.buildType = isElectronBuild ? "electron" : "web"
|
||||
|
|
|
|||
|
|
@ -197,7 +197,7 @@ export const AchievementModalContent: FC = () => {
|
|||
return (
|
||||
<div className="relative flex w-full grow flex-col items-center">
|
||||
<LazyDotLottie
|
||||
className="mt-12 size-[100px]"
|
||||
className="mt-4 size-[100px] lg:mt-12"
|
||||
autoplay
|
||||
speed={2}
|
||||
src={absoluteachievementAnimationUri}
|
||||
|
|
@ -211,7 +211,10 @@ export const AchievementModalContent: FC = () => {
|
|||
<sup className="inline-block translate-y-1 text-xs">*</sup>
|
||||
</small>
|
||||
|
||||
<ScrollArea rootClassName="h-0 grow mt-10 w-[calc(100%+2rem)] -mx-4" viewportClassName="px-4">
|
||||
<ScrollArea
|
||||
rootClassName="lg:h-0 h-auto grow mt-10 w-[calc(100%+2rem)] -mx-4"
|
||||
viewportClassName="px-4"
|
||||
>
|
||||
<ul className="flex w-full flex-col gap-2">
|
||||
{isLoading ? (
|
||||
<div className="center pointer-events-none grow -translate-y-16">
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { isMobile } from "@follow/components/hooks/useMobile.js"
|
||||
import { useCallback } from "react"
|
||||
|
||||
import { SlideUpModal } from "~/components/ui/modal/stacked/custom-modal"
|
||||
|
|
@ -11,9 +12,9 @@ export const useAchievementModal = () => {
|
|||
return useCallback(() => {
|
||||
present({
|
||||
id: "achievement",
|
||||
title: "Achievements",
|
||||
title: isMobile() ? "" : "Achievements",
|
||||
content: AchievementModalContent,
|
||||
CustomModalComponent: SlideUpModal,
|
||||
CustomModalComponent: !isMobile() ? SlideUpModal : undefined,
|
||||
modalContainerClassName: "overflow-hidden",
|
||||
overlay: true,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ import { apiClient } from "~/lib/api-fetch"
|
|||
import { defineQuery } from "~/lib/defineQuery"
|
||||
import { FlatMarkAllReadButton } from "~/modules/entry-column/components/mark-all-button"
|
||||
import { StarIcon } from "~/modules/entry-column/star-icon"
|
||||
import { EntryContentRender } from "~/modules/entry-content"
|
||||
import { EntryContent } from "~/modules/entry-content"
|
||||
import { FeedIcon } from "~/modules/feed/feed-icon"
|
||||
import { Queries } from "~/queries"
|
||||
import { useEntry } from "~/store/entry"
|
||||
|
|
@ -404,7 +404,7 @@ const EntryToastPreview = ({ entryId }: { entryId: string }) => {
|
|||
|
||||
const EntryModalPreview = ({ entryId }: { entryId: string }) => (
|
||||
<Paper className="!p-0">
|
||||
<EntryContentRender
|
||||
<EntryContent
|
||||
className="h-auto [&_#entry-action-header-bar]:!bg-transparent"
|
||||
entryId={entryId}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,61 @@
|
|||
import { PanelSplitter } from "@follow/components/ui/divider/index.js"
|
||||
import { views } from "@follow/constants"
|
||||
import { cn, isSafari } from "@follow/utils/utils"
|
||||
import { useMemo, useRef } from "react"
|
||||
import { useResizable } from "react-resizable-layout"
|
||||
import { Outlet } from "react-router-dom"
|
||||
|
||||
import {
|
||||
getUISettings,
|
||||
setUISetting,
|
||||
useRealInWideMode,
|
||||
useUISettingKey,
|
||||
} from "~/atoms/settings/ui"
|
||||
import { useRouteParams } from "~/hooks/biz/useRouteParams"
|
||||
import { EntryColumn } from "~/modules/entry-column"
|
||||
import { AppLayoutGridContainerProvider } from "~/providers/app-grid-layout-container-provider"
|
||||
|
||||
export function CenterColumnDesktop() {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Memo this initial value to avoid re-render
|
||||
|
||||
const settingWideMode = useRealInWideMode()
|
||||
const entryColWidth = useMemo(() => getUISettings().entryColWidth, [])
|
||||
const { view } = useRouteParams()
|
||||
const inWideMode = (view ? views[view].wideMode : false) || settingWideMode
|
||||
const feedColumnWidth = useUISettingKey("feedColWidth")
|
||||
const { position, separatorProps, isDragging, separatorCursor } = useResizable({
|
||||
axis: "x",
|
||||
// FIXME: Less than this width causes grid images to overflow on safari
|
||||
min: isSafari() ? 356 : 300,
|
||||
max: Math.max((window.innerWidth - feedColumnWidth) / 2, 600),
|
||||
initial: entryColWidth,
|
||||
containerRef,
|
||||
onResizeEnd({ position }) {
|
||||
setUISetting("entryColWidth", position)
|
||||
// TODO: Remove this after useMeasure can get bounds in time
|
||||
window.dispatchEvent(new Event("resize"))
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative flex min-w-0 grow">
|
||||
<div
|
||||
className={cn("h-full shrink-0", inWideMode ? "flex-1" : "border-r", "will-change-[width]")}
|
||||
data-hide-in-print
|
||||
style={{
|
||||
width: position,
|
||||
}}
|
||||
>
|
||||
<AppLayoutGridContainerProvider>
|
||||
<EntryColumn />
|
||||
</AppLayoutGridContainerProvider>
|
||||
</div>
|
||||
{!inWideMode && (
|
||||
<PanelSplitter {...separatorProps} cursor={separatorCursor} isDragging={isDragging} />
|
||||
)}
|
||||
<Outlet />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
export { CenterColumnDesktop as CenterColumnLayout } from "./desktop"
|
||||
|
||||
export const MobileCenterColumnScreen = () => null
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
import { withResponsiveComponent } from "@follow/components/utils/selector.js"
|
||||
import { Outlet } from "react-router-dom"
|
||||
|
||||
const noop = () =>
|
||||
Promise.resolve({
|
||||
default: () => null,
|
||||
})
|
||||
export const CenterColumnLayout = withResponsiveComponent<object>(
|
||||
() =>
|
||||
import("~/modules/app-layout/entry-column/desktop").then((m) => ({
|
||||
default: m.CenterColumnDesktop,
|
||||
})),
|
||||
() =>
|
||||
Promise.resolve({
|
||||
default: Outlet,
|
||||
}),
|
||||
)
|
||||
|
||||
export const MobileCenterColumnScreen = withResponsiveComponent<object>(noop, () =>
|
||||
import("~/modules/app-layout/entry-column/mobile").then((m) => ({
|
||||
default: m.EntryColumnMobile,
|
||||
})),
|
||||
)
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
import { useEffect, useState } from "react"
|
||||
|
||||
import { useGeneralSettingKey } from "~/atoms/settings/general"
|
||||
import { ROUTE_FEED_PENDING } from "~/constants/app"
|
||||
import { ENTRY_COLUMN_LIST_SCROLLER_ID, LOGO_MOBILE_ID } from "~/constants/dom"
|
||||
import { navigateEntry } from "~/hooks/biz/useNavigateEntry"
|
||||
import { EntryColumn } from "~/modules/entry-column"
|
||||
|
||||
import { MobileFloatBar } from "../feed-column/float-bar.mobile"
|
||||
|
||||
export const EntryColumnMobile = () => {
|
||||
const isStartupTimeline = useGeneralSettingKey("startupScreen") === "timeline"
|
||||
|
||||
const [scrollContainer, setScrollContainer] = useState<null | HTMLDivElement>(null)
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setScrollContainer(
|
||||
document.querySelector(`#${ENTRY_COLUMN_LIST_SCROLLER_ID}`) as HTMLDivElement,
|
||||
)
|
||||
}, 1000)
|
||||
return () => clearTimeout(timer)
|
||||
}, [])
|
||||
return (
|
||||
<div className="flex h-screen min-w-0 grow">
|
||||
<EntryColumn />
|
||||
|
||||
{isStartupTimeline && (
|
||||
<MobileFloatBar
|
||||
className="!bottom-10"
|
||||
scrollContainer={scrollContainer}
|
||||
onLogoClick={() => {
|
||||
;(document.querySelector(`#${LOGO_MOBILE_ID}`) as HTMLElement)?.click()
|
||||
}}
|
||||
onViewChange={(view) => {
|
||||
navigateEntry({
|
||||
entryId: null,
|
||||
feedId: ROUTE_FEED_PENDING,
|
||||
view,
|
||||
})
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,134 @@
|
|||
import { FeedViewType, views } from "@follow/constants"
|
||||
import { cn } from "@follow/utils/utils"
|
||||
import { useWheel } from "@use-gesture/react"
|
||||
import { easeOut } from "framer-motion"
|
||||
import type { FC, PropsWithChildren } from "react"
|
||||
import { useState } from "react"
|
||||
import { useHotkeys } from "react-hotkeys-hook"
|
||||
import { useParams } from "react-router-dom"
|
||||
|
||||
import { useRealInWideMode } from "~/atoms/settings/ui"
|
||||
import { useFeedColumnShow, useFeedColumnTempShow } from "~/atoms/sidebar"
|
||||
import { m } from "~/components/common/Motion"
|
||||
import { FixedModalCloseButton } from "~/components/ui/modal/components/close"
|
||||
import { HotKeyScopeMap, ROUTE_ENTRY_PENDING, ROUTE_FEED_PENDING } from "~/constants"
|
||||
import { useNavigateEntry } from "~/hooks/biz/useNavigateEntry"
|
||||
import { useRouteParams, useRouteView } from "~/hooks/biz/useRouteParams"
|
||||
import { EntryPlaceholderDaily } from "~/modules/ai/ai-daily/EntryPlaceholderDaily"
|
||||
import { EntryContent } from "~/modules/entry-content"
|
||||
import { EntryPlaceholderLogo } from "~/modules/entry-content/components/EntryPlaceholderLogo"
|
||||
import { AppLayoutGridContainerProvider } from "~/providers/app-grid-layout-container-provider"
|
||||
|
||||
export const RightContentDesktop = () => {
|
||||
const { entryId } = useParams()
|
||||
const view = useRouteView()
|
||||
const navigate = useNavigateEntry()
|
||||
|
||||
const settingWideMode = useRealInWideMode()
|
||||
const realEntryId = entryId === ROUTE_ENTRY_PENDING ? "" : entryId
|
||||
const showEntryContent = !(views[view].wideMode || (settingWideMode && !realEntryId))
|
||||
const wideMode = !!(settingWideMode && realEntryId)
|
||||
const feedColumnTempShow = useFeedColumnTempShow()
|
||||
const feedColumnShow = useFeedColumnShow()
|
||||
const shouldHeaderPaddingLeft = feedColumnTempShow && !feedColumnShow && settingWideMode
|
||||
|
||||
useHotkeys(
|
||||
"Escape",
|
||||
() => {
|
||||
navigate({ entryId: null })
|
||||
},
|
||||
{
|
||||
enabled: showEntryContent && settingWideMode,
|
||||
scopes: HotKeyScopeMap.Home,
|
||||
preventDefault: true,
|
||||
},
|
||||
)
|
||||
|
||||
const { feedId } = useRouteParams()
|
||||
|
||||
if (!showEntryContent) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<AppLayoutGridContainerProvider>
|
||||
<EntryGridContainer showEntryContent={showEntryContent} wideMode={wideMode}>
|
||||
{wideMode && (
|
||||
<FixedModalCloseButton
|
||||
className="no-drag-region absolute left-4 top-4 z-10 macos:translate-y-margin-macos-traffic-light-y"
|
||||
onClick={() => navigate({ entryId: null })}
|
||||
/>
|
||||
)}
|
||||
{realEntryId ? (
|
||||
<EntryContent
|
||||
entryId={realEntryId}
|
||||
classNames={{
|
||||
header: shouldHeaderPaddingLeft
|
||||
? "ml-[calc(theme(width.feed-col)+theme(width.8))]"
|
||||
: wideMode
|
||||
? "ml-12"
|
||||
: "",
|
||||
}}
|
||||
/>
|
||||
) : !settingWideMode ? (
|
||||
<m.div
|
||||
className="center size-full flex-col"
|
||||
initial={{ opacity: 0.01, y: 300 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
>
|
||||
<EntryPlaceholderLogo />
|
||||
{feedId === ROUTE_FEED_PENDING && view === FeedViewType.Articles && (
|
||||
<EntryPlaceholderDaily view={view} />
|
||||
)}
|
||||
</m.div>
|
||||
) : null}
|
||||
</EntryGridContainer>
|
||||
</AppLayoutGridContainerProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const EntryGridContainer: FC<
|
||||
PropsWithChildren<{
|
||||
showEntryContent: boolean
|
||||
wideMode: boolean
|
||||
}>
|
||||
> = ({ children, showEntryContent, wideMode }) => {
|
||||
const [containerRef, setContainerRef] = useState<HTMLDivElement | null>()
|
||||
|
||||
const navigate = useNavigateEntry()
|
||||
useWheel(
|
||||
({ delta: [dex] }) => {
|
||||
if (dex < -50) {
|
||||
navigate({ entryId: null })
|
||||
}
|
||||
},
|
||||
{
|
||||
enabled: showEntryContent && wideMode,
|
||||
target: containerRef!,
|
||||
eventOptions: { capture: true },
|
||||
axis: "x",
|
||||
},
|
||||
)
|
||||
|
||||
if (wideMode) {
|
||||
return (
|
||||
<m.div
|
||||
ref={setContainerRef}
|
||||
// slide up
|
||||
initial={{ opacity: 0, y: 100 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 100, transition: { duration: 0.2, ease: easeOut } }}
|
||||
transition={{ duration: 0.2, type: "spring" }}
|
||||
className={cn("flex min-w-0 flex-1 flex-col", "absolute inset-0 z-10 bg-theme-background")}
|
||||
>
|
||||
{children}
|
||||
</m.div>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<div ref={setContainerRef} className="flex min-w-0 flex-1 flex-col">
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
export { RightContentDesktop as RightContentLayout } from "./desktop"
|
||||
|
||||
export const MobileRightContentScreen = () => null
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
import { withResponsiveComponent } from "@follow/components/utils/selector.js"
|
||||
|
||||
export const RightContentLayout = withResponsiveComponent<object>(
|
||||
() =>
|
||||
import("~/modules/app-layout/entry-content/desktop").then((m) => ({
|
||||
default: m.RightContentDesktop,
|
||||
})),
|
||||
() =>
|
||||
import("~/modules/app-layout/entry-content/mobile").then((m) => ({
|
||||
default: m.EntryContentMobile,
|
||||
})),
|
||||
)
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
import { ROUTE_ENTRY_PENDING } from "~/constants"
|
||||
import { useRouteParamsSelector } from "~/hooks/biz/useRouteParams"
|
||||
import { EntryContent } from "~/modules/entry-content"
|
||||
|
||||
import { EntryColumnMobile } from "../entry-column/mobile"
|
||||
|
||||
export const EntryContentMobile = () => {
|
||||
const { entryId } = useRouteParamsSelector((s) => ({
|
||||
entryId: s.entryId,
|
||||
}))
|
||||
|
||||
if (entryId === ROUTE_ENTRY_PENDING) {
|
||||
return <EntryColumnMobile />
|
||||
}
|
||||
return <EntryContent entryId={entryId!} />
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
import { useMobile } from "@follow/components/hooks/useMobile.js"
|
||||
import { repository } from "@pkg"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import { isWebBuild } from "~/constants"
|
||||
|
||||
export const FooterInfo = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const isMobile = useMobile()
|
||||
return (
|
||||
<div className="relative !mt-0">
|
||||
{APP_VERSION?.[0] === "0" && (
|
||||
<div className="pointer-events-none w-full py-3 text-center text-xs opacity-20">
|
||||
{t("beta_access")} {GIT_COMMIT_SHA ? `(${GIT_COMMIT_SHA.slice(0, 7).toUpperCase()})` : ""}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isWebBuild && !isMobile && (
|
||||
<div className="center absolute inset-y-0 right-2">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Download Desktop App"
|
||||
onClick={() => {
|
||||
window.open(`${repository.url}/releases`)
|
||||
}}
|
||||
className="center rounded-full border bg-background p-1.5 shadow-sm"
|
||||
>
|
||||
<i className="i-mgc-download-2-cute-re size-3.5 opacity-80" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,360 @@
|
|||
import type { DragEndEvent } from "@dnd-kit/core"
|
||||
import { DndContext, PointerSensor, pointerWithin, useSensor, useSensors } from "@dnd-kit/core"
|
||||
import { PanelSplitter } from "@follow/components/ui/divider/index.js"
|
||||
import { Kbd } from "@follow/components/ui/kbd/Kbd.js"
|
||||
import { RootPortal } from "@follow/components/ui/portal/index.jsx"
|
||||
import type { FeedViewType } from "@follow/constants"
|
||||
import { IN_ELECTRON } from "@follow/shared/constants"
|
||||
import { preventDefault } from "@follow/utils/dom"
|
||||
import { cn } from "@follow/utils/utils"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { throttle } from "es-toolkit/compat"
|
||||
import type { PropsWithChildren } from "react"
|
||||
import * as React from "react"
|
||||
import { forwardRef, lazy, Suspense, useEffect, useRef, useState } from "react"
|
||||
import { useHotkeys } from "react-hotkeys-hook"
|
||||
import { Trans } from "react-i18next"
|
||||
import { useResizable } from "react-resizable-layout"
|
||||
import { Outlet } from "react-router-dom"
|
||||
|
||||
import { setMainContainerElement, setRootContainerElement } from "~/atoms/dom"
|
||||
import { getIsZenMode, getUISettings, setUISetting, useUISettingKey } from "~/atoms/settings/ui"
|
||||
import {
|
||||
getFeedColumnTempShow,
|
||||
setFeedColumnShow,
|
||||
setFeedColumnTempShow,
|
||||
useFeedColumnShow,
|
||||
useFeedColumnTempShow,
|
||||
} from "~/atoms/sidebar"
|
||||
import { useLoginModalShow, useWhoami } from "~/atoms/user"
|
||||
import { AppErrorBoundary } from "~/components/common/AppErrorBoundary"
|
||||
import { ErrorComponentType } from "~/components/errors/enum"
|
||||
import { PlainModal } from "~/components/ui/modal/stacked/custom-modal"
|
||||
import { DeclarativeModal } from "~/components/ui/modal/stacked/declarative-modal"
|
||||
import { HotKeyScopeMap, isDev } from "~/constants"
|
||||
import { shortcuts } from "~/constants/shortcuts"
|
||||
import { useDailyTask } from "~/hooks/biz/useDailyTask"
|
||||
import { useBatchUpdateSubscription } from "~/hooks/biz/useSubscriptionActions"
|
||||
import { useAuthQuery, useI18n } from "~/hooks/common"
|
||||
import { EnvironmentIndicator } from "~/modules/app/EnvironmentIndicator"
|
||||
import { NetworkStatusIndicator } from "~/modules/app/NetworkStatusIndicator"
|
||||
import { LoginModalContent } from "~/modules/auth/LoginModalContent"
|
||||
import { DebugRegistry } from "~/modules/debug/registry"
|
||||
import { FeedColumn } from "~/modules/feed-column"
|
||||
import { useSelectedFeedIds } from "~/modules/feed-column/atom"
|
||||
import { AutoUpdater } from "~/modules/feed-column/auto-updater"
|
||||
import { useShortcutsModal } from "~/modules/modal/shortcuts"
|
||||
import { CmdF } from "~/modules/panel/cmdf"
|
||||
import { SearchCmdK } from "~/modules/panel/cmdk"
|
||||
import { CmdNTrigger } from "~/modules/panel/cmdn"
|
||||
import { CornerPlayer } from "~/modules/player/corner-player"
|
||||
import { AppNotificationContainer } from "~/modules/upgrade/lazy/index"
|
||||
import { AppLayoutGridContainerProvider } from "~/providers/app-grid-layout-container-provider"
|
||||
import { settings } from "~/queries/settings"
|
||||
|
||||
import { FooterInfo } from "./components/FooterInfo"
|
||||
|
||||
const LazyNewUserGuideModal = lazy(() =>
|
||||
import("~/modules/new-user-guide/modal").then((m) => ({ default: m.NewUserGuideModal })),
|
||||
)
|
||||
|
||||
const errorTypes = [
|
||||
ErrorComponentType.Page,
|
||||
ErrorComponentType.FeedFoundCanBeFollow,
|
||||
ErrorComponentType.FeedNotFound,
|
||||
] as ErrorComponentType[]
|
||||
|
||||
export function MainDestopLayout() {
|
||||
const isAuthFail = useLoginModalShow()
|
||||
const user = useWhoami()
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useDailyTask()
|
||||
|
||||
const { data: remoteSettings, isLoading } = useAuthQuery(settings.get(), {})
|
||||
const isNewUser = !isLoading && remoteSettings && Object.keys(remoteSettings.updated).length === 0
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 8,
|
||||
},
|
||||
}),
|
||||
)
|
||||
const [selectedIds, setSelectedIds] = useSelectedFeedIds()
|
||||
const { mutate } = useBatchUpdateSubscription()
|
||||
const handleDragEnd = React.useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
if (!event.over) {
|
||||
return
|
||||
}
|
||||
|
||||
const { category, view } = event.over.data.current as {
|
||||
category?: string | null
|
||||
view: FeedViewType
|
||||
}
|
||||
|
||||
mutate({ category, view, feedIdList: selectedIds })
|
||||
|
||||
setSelectedIds([])
|
||||
},
|
||||
[mutate, selectedIds, setSelectedIds],
|
||||
)
|
||||
|
||||
return (
|
||||
<RootContainer ref={containerRef}>
|
||||
{!import.meta.env.PROD && <EnvironmentIndicator />}
|
||||
|
||||
<Suspense>
|
||||
<AppNotificationContainer />
|
||||
</Suspense>
|
||||
<AppLayoutGridContainerProvider>
|
||||
<FeedResponsiveResizerContainer containerRef={containerRef}>
|
||||
<DndContext
|
||||
autoScroll={{ threshold: { x: 0, y: 0.2 } }}
|
||||
sensors={sensors}
|
||||
collisionDetection={pointerWithin}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<FeedColumn>
|
||||
<CornerPlayer />
|
||||
<FooterInfo />
|
||||
|
||||
{ELECTRON && <AutoUpdater />}
|
||||
|
||||
<NetworkStatusIndicator />
|
||||
</FeedColumn>
|
||||
</DndContext>
|
||||
</FeedResponsiveResizerContainer>
|
||||
</AppLayoutGridContainerProvider>
|
||||
|
||||
<main
|
||||
ref={setMainContainerElement}
|
||||
className="flex min-w-0 flex-1 bg-theme-background pt-[calc(var(--fo-window-padding-top)_-10px)] !outline-none"
|
||||
// NOTE: tabIndex for main element can get by `document.activeElement`
|
||||
tabIndex={-1}
|
||||
>
|
||||
<AppErrorBoundary errorType={errorTypes}>
|
||||
<Outlet />
|
||||
</AppErrorBoundary>
|
||||
</main>
|
||||
|
||||
{user && isNewUser && (
|
||||
<Suspense>
|
||||
<LazyNewUserGuideModal />
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{isAuthFail && !user && (
|
||||
<RootPortal>
|
||||
<DeclarativeModal
|
||||
id="login"
|
||||
CustomModalComponent={PlainModal}
|
||||
open
|
||||
overlay
|
||||
title="Login"
|
||||
canClose={false}
|
||||
clickOutsideToDismiss={false}
|
||||
>
|
||||
<LoginModalContent canClose={false} runtime={IN_ELECTRON ? "app" : "browser"} />
|
||||
</DeclarativeModal>
|
||||
</RootPortal>
|
||||
)}
|
||||
|
||||
<SearchCmdK />
|
||||
<CmdNTrigger />
|
||||
{ELECTRON && <CmdF />}
|
||||
</RootContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const RootContainer = forwardRef<HTMLDivElement, PropsWithChildren>(({ children }, ref) => {
|
||||
const feedColWidth = useUISettingKey("feedColWidth")
|
||||
|
||||
const [elementRef, _setElementRef] = useState<HTMLDivElement | null>(null)
|
||||
const setElementRef = React.useCallback((el: HTMLDivElement | null) => {
|
||||
_setElementRef(el)
|
||||
setRootContainerElement(el)
|
||||
}, [])
|
||||
React.useImperativeHandle(ref, () => elementRef!)
|
||||
return (
|
||||
<div
|
||||
ref={setElementRef}
|
||||
style={
|
||||
{
|
||||
"--fo-feed-col-w": `${feedColWidth}px`,
|
||||
} as any
|
||||
}
|
||||
className="relative z-0 flex h-screen overflow-hidden print:h-auto print:overflow-auto"
|
||||
onContextMenu={preventDefault}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
const FeedResponsiveResizerContainer = ({
|
||||
containerRef,
|
||||
children,
|
||||
}: {
|
||||
containerRef: React.RefObject<HTMLDivElement>
|
||||
} & PropsWithChildren) => {
|
||||
const { isDragging, position, separatorProps, separatorCursor } = useResizable({
|
||||
axis: "x",
|
||||
min: 256,
|
||||
max: 300,
|
||||
initial: getUISettings().feedColWidth,
|
||||
containerRef,
|
||||
|
||||
onResizeEnd({ position }) {
|
||||
setUISetting("feedColWidth", position)
|
||||
},
|
||||
})
|
||||
|
||||
const feedColumnShow = useFeedColumnShow()
|
||||
const feedColumnTempShow = useFeedColumnTempShow()
|
||||
|
||||
useEffect(() => {
|
||||
if (feedColumnShow) {
|
||||
setFeedColumnTempShow(false)
|
||||
return
|
||||
}
|
||||
const handler = throttle((e: MouseEvent) => {
|
||||
const mouseX = e.clientX
|
||||
const mouseY = e.clientY
|
||||
|
||||
const uiSettings = getUISettings()
|
||||
const feedColumnTempShow = getFeedColumnTempShow()
|
||||
const isInEntryContentWideMode = uiSettings.wideMode || getIsZenMode()
|
||||
if (mouseY < 200 && isInEntryContentWideMode) return
|
||||
const threshold = feedColumnTempShow ? uiSettings.feedColWidth : 100
|
||||
|
||||
if (mouseX < threshold) {
|
||||
setFeedColumnTempShow(true)
|
||||
} else {
|
||||
setFeedColumnTempShow(false)
|
||||
}
|
||||
}, 300)
|
||||
|
||||
document.addEventListener("mousemove", handler)
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handler)
|
||||
}
|
||||
}, [feedColumnShow])
|
||||
|
||||
useHotkeys(
|
||||
shortcuts.layout.toggleSidebar.key,
|
||||
() => {
|
||||
setFeedColumnShow(!feedColumnShow)
|
||||
},
|
||||
{
|
||||
scopes: HotKeyScopeMap.Home,
|
||||
},
|
||||
)
|
||||
const showShortcuts = useShortcutsModal()
|
||||
useHotkeys(shortcuts.layout.showShortcuts.key, showShortcuts)
|
||||
|
||||
const [delayShowSplitter, setDelayShowSplitter] = useState(feedColumnShow)
|
||||
|
||||
useEffect(() => {
|
||||
let timer: any
|
||||
if (feedColumnShow) {
|
||||
timer = setTimeout(() => {
|
||||
setDelayShowSplitter(true)
|
||||
}, 200)
|
||||
} else {
|
||||
setDelayShowSplitter(false)
|
||||
}
|
||||
|
||||
return () => {
|
||||
timer = clearTimeout(timer)
|
||||
}
|
||||
}, [feedColumnShow])
|
||||
const t = useI18n()
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
data-hide-in-print
|
||||
className={cn(
|
||||
"shrink-0 overflow-hidden",
|
||||
"absolute inset-y-0 z-[2]",
|
||||
feedColumnTempShow && !feedColumnShow && "shadow-drawer-to-right z-[12]",
|
||||
!feedColumnShow && !feedColumnTempShow ? "-translate-x-full delay-200" : "",
|
||||
!isDragging ? "duration-200" : "",
|
||||
)}
|
||||
style={{
|
||||
width: `${position}px`,
|
||||
// @ts-expect-error
|
||||
"--fo-feed-col-w": `${position}px`,
|
||||
}}
|
||||
>
|
||||
<Slot className={!feedColumnShow ? "!bg-native" : ""}>{children}</Slot>
|
||||
</div>
|
||||
|
||||
<div
|
||||
data-hide-in-print
|
||||
className={!isDragging ? "duration-200" : ""}
|
||||
style={{
|
||||
width: feedColumnShow ? `${position}px` : 0,
|
||||
}}
|
||||
/>
|
||||
|
||||
{delayShowSplitter && (
|
||||
<PanelSplitter
|
||||
isDragging={isDragging}
|
||||
cursor={separatorCursor}
|
||||
{...separatorProps}
|
||||
onDoubleClick={() => {
|
||||
setFeedColumnShow(false)
|
||||
}}
|
||||
tooltip={
|
||||
!isDragging && (
|
||||
<>
|
||||
<div>
|
||||
{/* <b>Drag</b> to resize */}
|
||||
<Trans t={t} i18nKey="resize.tooltip.drag_to_resize" components={{ b: <b /> }} />
|
||||
</div>
|
||||
<div className="center">
|
||||
<span>
|
||||
<Trans
|
||||
t={t}
|
||||
i18nKey="resize.tooltip.double_click_to_collapse"
|
||||
components={{ b: <b /> }}
|
||||
/>
|
||||
</span>{" "}
|
||||
<Kbd className="ml-1">{"["}</Kbd>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
if (isDev) {
|
||||
DebugRegistry.add("New User Guide", () => {
|
||||
import("~/modules/new-user-guide/guide-modal-content").then((m) => {
|
||||
window.presentModal({
|
||||
title: "New User Guide",
|
||||
content: ({ dismiss }) => (
|
||||
<m.GuideModalContent
|
||||
onClose={() => {
|
||||
dismiss()
|
||||
}}
|
||||
/>
|
||||
),
|
||||
|
||||
CustomModalComponent: PlainModal,
|
||||
modalContainerClassName: "flex items-center justify-center",
|
||||
|
||||
canClose: false,
|
||||
clickOutsideToDismiss: false,
|
||||
overlay: true,
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,157 @@
|
|||
import { Logo } from "@follow/components/icons/logo.js"
|
||||
import { MotionButtonBase } from "@follow/components/ui/button/index.js"
|
||||
import { DividerVertical } from "@follow/components/ui/divider/Divider.js"
|
||||
import { views } from "@follow/constants"
|
||||
import { stopPropagation } from "@follow/utils/dom"
|
||||
import clsx from "clsx"
|
||||
import { m, useAnimationControls } from "framer-motion"
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
|
||||
import { useAudioPlayerAtomSelector } from "~/atoms/player"
|
||||
import { useUISettingKey } from "~/atoms/settings/ui"
|
||||
import { useSidebarActiveView } from "~/atoms/sidebar"
|
||||
import { FeedIcon } from "~/modules/feed/feed-icon"
|
||||
import { CornerPlayer } from "~/modules/player/corner-player"
|
||||
import { useEntry } from "~/store/entry"
|
||||
import { useFeedById } from "~/store/feed"
|
||||
import { feedIconSelector } from "~/store/feed/selector"
|
||||
import { useUnreadByView } from "~/store/unread/hooks"
|
||||
|
||||
import { ProfileButton } from "../../user/ProfileButton"
|
||||
import styles from "./mobile.module.css"
|
||||
|
||||
export const MobileFloatBar = ({
|
||||
scrollContainer,
|
||||
className,
|
||||
onLogoClick,
|
||||
onViewChange,
|
||||
}: {
|
||||
scrollContainer: Nullable<HTMLDivElement>
|
||||
className?: string
|
||||
onLogoClick?: () => void
|
||||
onViewChange?: (view: number) => void
|
||||
}) => {
|
||||
const [isScrollDown, setIsScrollDown] = useState(false)
|
||||
const prevScrollY = useRef(0)
|
||||
useEffect(() => {
|
||||
if (!scrollContainer) return
|
||||
|
||||
const handler = () => {
|
||||
const currentY = scrollContainer.scrollTop
|
||||
|
||||
if (currentY < 30) return
|
||||
|
||||
const isScrollEnd =
|
||||
scrollContainer.scrollHeight - currentY - scrollContainer.clientHeight < 10
|
||||
|
||||
if (isScrollEnd) {
|
||||
setIsScrollDown(true)
|
||||
return
|
||||
}
|
||||
|
||||
setIsScrollDown(currentY > prevScrollY.current)
|
||||
prevScrollY.current = currentY
|
||||
}
|
||||
scrollContainer.addEventListener("scroll", handler)
|
||||
return () => scrollContainer.removeEventListener("scroll", handler)
|
||||
}, [scrollContainer])
|
||||
|
||||
const animateController = useAnimationControls()
|
||||
|
||||
useEffect(() => {
|
||||
if (isScrollDown) {
|
||||
animateController.start({
|
||||
translateY: "calc(100% + 40px + env(safe-area-inset-bottom))",
|
||||
transition: { type: "tween", duration: 0.1 },
|
||||
})
|
||||
} else {
|
||||
animateController.start({
|
||||
translateY: 0,
|
||||
})
|
||||
}
|
||||
}, [isScrollDown, animateController])
|
||||
|
||||
return (
|
||||
<m.div
|
||||
className={clsx(
|
||||
"absolute inset-x-0 flex h-10 justify-center will-change-transform bottom-safe-offset-8",
|
||||
className,
|
||||
)}
|
||||
transition={{ type: "spring" }}
|
||||
animate={animateController}
|
||||
>
|
||||
<div className={styles["float-bar"]}>
|
||||
<MotionButtonBase onClick={onLogoClick}>
|
||||
<Logo className="size-5 shrink-0" />
|
||||
</MotionButtonBase>
|
||||
<DividerVertical className="h-3/4 shrink-0" />
|
||||
<ViewTabs onViewChange={onViewChange} />
|
||||
<DividerVertical className="h-3/4 shrink-0" />
|
||||
<PlayerIcon />
|
||||
<ProfileButton />
|
||||
</div>
|
||||
</m.div>
|
||||
)
|
||||
}
|
||||
|
||||
const ViewTabs = ({ onViewChange }: { onViewChange?: (view: number) => void }) => {
|
||||
const [active, setActive] = useSidebarActiveView()
|
||||
const unreadByView = useUnreadByView()
|
||||
const showCount = useUISettingKey("sidebarShowUnreadCount")
|
||||
return (
|
||||
<div
|
||||
className="flex w-full shrink items-center justify-between gap-4 overflow-x-auto overflow-y-hidden text-xl text-theme-vibrancyFg"
|
||||
onClick={stopPropagation}
|
||||
>
|
||||
{views.map((item) => (
|
||||
<MotionButtonBase
|
||||
className={clsx(
|
||||
"center flex transition-colors duration-200",
|
||||
active === item.view && item.className,
|
||||
)}
|
||||
key={item.name}
|
||||
onClick={() => {
|
||||
setActive(item.view)
|
||||
onViewChange?.(item.view)
|
||||
}}
|
||||
>
|
||||
<div className="relative flex flex-col items-center">
|
||||
{item.icon}
|
||||
|
||||
{showCount && (
|
||||
<span className="mt-px text-[8px] leading-none">
|
||||
{unreadByView[item.view] > 99 ? "99+" : unreadByView[item.view] || "0"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</MotionButtonBase>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const PlayerIcon = () => {
|
||||
const { isPlaying, entryId } = useAudioPlayerAtomSelector(
|
||||
useCallback((state) => ({ isPlaying: state.status === "playing", entryId: state.entryId }), []),
|
||||
)
|
||||
const feedId = useEntry(entryId, (s) => s.feedId)
|
||||
const feed = useFeedById(feedId, feedIconSelector)
|
||||
const [isShowPlayer, setIsShowPlayer] = useState(false)
|
||||
if (!feed) return null
|
||||
if (!isPlaying) return null
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="mr-3 size-5 shrink-0"
|
||||
onClick={() => setIsShowPlayer((v) => !v)}
|
||||
>
|
||||
<FeedIcon feed={feed} />
|
||||
</button>
|
||||
|
||||
{isShowPlayer && (
|
||||
<CornerPlayer className="absolute bottom-12 left-0 w-full max-w-[350px] overflow-hidden rounded-r-lg" />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
export { MainDestopLayout as LeftSidebarLayout } from "./desktop"
|
||||
|
||||
export const MobileFeedScreen = () => null
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
import { Outlet } from "react-router-dom"
|
||||
|
||||
import { useDailyTask } from "~/hooks/biz/useDailyTask"
|
||||
|
||||
export const MobileRootLayout = () => {
|
||||
useDailyTask()
|
||||
return <Outlet />
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
import { withResponsiveComponent } from "@follow/components/utils/selector.js"
|
||||
|
||||
const noop = () =>
|
||||
Promise.resolve({
|
||||
default: () => null,
|
||||
})
|
||||
export const LeftSidebarLayout = withResponsiveComponent<object>(
|
||||
() =>
|
||||
import("~/modules/app-layout/feed-column/desktop").then((m) => ({
|
||||
default: m.MainDestopLayout,
|
||||
})),
|
||||
() =>
|
||||
import("~/modules/app-layout/feed-column/index.mobile").then((m) => ({
|
||||
default: m.MobileRootLayout,
|
||||
})),
|
||||
)
|
||||
|
||||
export const MobileFeedScreen = withResponsiveComponent<object>(noop, () =>
|
||||
import("~/modules/app-layout/feed-column/mobile").then((m) => ({
|
||||
default: m.FeedColumnMobile,
|
||||
})),
|
||||
)
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
.float-bar {
|
||||
@apply min-w-0 mx-4 rounded-full border bg-background dark:border-neutral-800 shadow-sm shadow-zinc-100 border-neutral-200 h-10 inline-flex items-center pl-4 pr-2;
|
||||
|
||||
box-shadow: 0px 8px 30px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .float-bar {
|
||||
box-shadow: 0px 8px 30px rgba(122, 122, 122, 0.2);
|
||||
}
|
||||
|
|
@ -0,0 +1,137 @@
|
|||
import { Logo } from "@follow/components/icons/logo.js"
|
||||
import { ActionButton } from "@follow/components/ui/button/index.js"
|
||||
import { RootPortal } from "@follow/components/ui/portal/index.js"
|
||||
import { views } from "@follow/constants"
|
||||
import { IN_ELECTRON } from "@follow/shared/constants"
|
||||
import { cn } from "@follow/utils/utils"
|
||||
import useEmblaCarousel from "embla-carousel-react"
|
||||
import type { FC } from "react"
|
||||
import { useEffect, useLayoutEffect, useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { Link } from "react-router-dom"
|
||||
|
||||
import { useSetSidebarActiveView, useSidebarActiveView } from "~/atoms/sidebar"
|
||||
import { useLoginModalShow, useWhoami } from "~/atoms/user"
|
||||
import { PlainModal } from "~/components/ui/modal/stacked/custom-modal"
|
||||
import { DeclarativeModal } from "~/components/ui/modal/stacked/declarative-modal"
|
||||
import { getRouteParams } from "~/hooks/biz/useRouteParams"
|
||||
import { LoginModalContent } from "~/modules/auth/LoginModalContent"
|
||||
|
||||
import { FeedList } from "../../feed-column/list"
|
||||
import { FooterInfo } from "./components/FooterInfo"
|
||||
import { MobileFloatBar } from "./float-bar.mobile"
|
||||
|
||||
export function FeedColumnMobile({ asWidget }: { asWidget?: boolean }) {
|
||||
const [active, setActive_] = useSidebarActiveView()
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (asWidget) return
|
||||
const { view } = getRouteParams()
|
||||
if (view !== undefined) {
|
||||
setActive_(view)
|
||||
}
|
||||
}, [asWidget, setActive_])
|
||||
|
||||
const [feedListScrollRef, setFeedListScrollRef] = useState<HTMLDivElement | null>()
|
||||
|
||||
const isAuthFail = useLoginModalShow()
|
||||
const user = useWhoami()
|
||||
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex flex-col space-y-3 bg-background",
|
||||
asWidget ? "grow" : "h-screen",
|
||||
)}
|
||||
>
|
||||
<div className="mt-4 flex items-center justify-between pl-6 pr-2">
|
||||
<span className="inline-flex items-center gap-3 text-lg font-bold">
|
||||
<Logo className="size-8 shrink-0" />
|
||||
{APP_NAME}
|
||||
</span>
|
||||
<div className="center inline-flex">
|
||||
<Link to="/discover" tabIndex={-1}>
|
||||
<ActionButton shortcut="Meta+T" tooltip={t("words.discover")}>
|
||||
<i className="i-mgc-add-cute-re size-5 text-theme-vibrancyFg" />
|
||||
</ActionButton>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className={"relative flex size-full h-0 grow"}>
|
||||
<SwipeWrapper active={active}>
|
||||
{views.map((item, index) => (
|
||||
<section key={item.name} className="size-full flex-none shrink-0 snap-center">
|
||||
<FeedList
|
||||
ref={setFeedListScrollRef}
|
||||
className="flex size-full flex-col text-sm"
|
||||
view={index}
|
||||
/>
|
||||
</section>
|
||||
))}
|
||||
</SwipeWrapper>
|
||||
</div>
|
||||
|
||||
{isAuthFail && !user && (
|
||||
<RootPortal>
|
||||
<DeclarativeModal
|
||||
id="login"
|
||||
CustomModalComponent={PlainModal}
|
||||
open
|
||||
overlay
|
||||
title="Login"
|
||||
canClose={false}
|
||||
clickOutsideToDismiss={false}
|
||||
>
|
||||
<LoginModalContent canClose={false} runtime={IN_ELECTRON ? "app" : "browser"} />
|
||||
</DeclarativeModal>
|
||||
</RootPortal>
|
||||
)}
|
||||
|
||||
<FooterInfo />
|
||||
<MobileFloatBar
|
||||
className={asWidget ? "!bottom-0" : undefined}
|
||||
scrollContainer={feedListScrollRef}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const SwipeWrapper: FC<{
|
||||
children: React.JSX.Element[]
|
||||
active: number
|
||||
className?: string
|
||||
}> = ({ children, active, className }) => {
|
||||
const setActive = useSetSidebarActiveView()
|
||||
|
||||
const [initialActive] = useState(active)
|
||||
const [emblaRef, emblaApi] = useEmblaCarousel(
|
||||
{
|
||||
loop: false,
|
||||
startIndex: initialActive,
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!emblaApi) return
|
||||
emblaApi.on("select", () => {
|
||||
setActive(emblaApi.selectedScrollSnap())
|
||||
})
|
||||
}, [emblaApi, setActive])
|
||||
|
||||
useEffect(() => {
|
||||
if (!emblaApi) return
|
||||
const currentSlideIndex = emblaApi.selectedScrollSnap()
|
||||
if (currentSlideIndex !== active) {
|
||||
emblaApi.scrollTo(active)
|
||||
}
|
||||
}, [active, emblaApi])
|
||||
|
||||
return (
|
||||
<div ref={emblaRef} className={cn("w-full overflow-hidden", className)}>
|
||||
<div className="flex size-full">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
import { getReadonlyRoute } from "@follow/components/atoms/route.js"
|
||||
import { MotionButtonBase } from "@follow/components/ui/button/index.js"
|
||||
import { ScrollArea } from "@follow/components/ui/scroll-area/index.js"
|
||||
import { springScrollTo } from "@follow/utils/scroller"
|
||||
import { cn, getOS } from "@follow/utils/utils"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { Outlet, useNavigate } from "react-router-dom"
|
||||
|
||||
import { getSidebarActiveView, setSidebarActiveView } from "~/atoms/sidebar"
|
||||
import { FABContainer, FABPortable } from "~/components/ui/fab"
|
||||
import { isElectronBuild } from "~/constants"
|
||||
|
||||
import { useSubViewTitleValue } from "./hooks"
|
||||
|
||||
export function SubviewLayout() {
|
||||
const navigate = useNavigate()
|
||||
const prevLocation = useRef(getReadonlyRoute().location).current
|
||||
const prevView = useRef(getSidebarActiveView()).current
|
||||
const title = useSubViewTitleValue()
|
||||
const [scrollRef, setRef] = useState(null as HTMLDivElement | null)
|
||||
const [isTitleSticky, setIsTitleSticky] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const $scroll = scrollRef
|
||||
|
||||
if (!$scroll) return
|
||||
|
||||
springScrollTo(0, $scroll)
|
||||
const handler = () => {
|
||||
setIsTitleSticky($scroll.scrollTop > 120)
|
||||
}
|
||||
$scroll.addEventListener("scroll", handler)
|
||||
return () => {
|
||||
$scroll.removeEventListener("scroll", handler)
|
||||
}
|
||||
}, [scrollRef])
|
||||
|
||||
const { t } = useTranslation()
|
||||
|
||||
// electron window has pt-[calc(var(--fo-window-padding-top)_-10px)]
|
||||
const isElectronWindows = isElectronBuild && getOS() === "Windows"
|
||||
|
||||
return (
|
||||
<div className="relative flex size-full">
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-x-0 top-0 z-10 p-4",
|
||||
"grid grid-cols-[1fr_auto_1fr] items-center gap-4",
|
||||
isTitleSticky && "group border-b bg-zinc-50/80 backdrop-blur-xl dark:bg-neutral-900/90",
|
||||
isTitleSticky && isElectronWindows && "-top-5",
|
||||
)}
|
||||
>
|
||||
<MotionButtonBase
|
||||
onClick={() => {
|
||||
navigate(prevLocation)
|
||||
|
||||
setSidebarActiveView(prevView)
|
||||
}}
|
||||
className="no-drag-region inline-flex items-center gap-1 duration-200 hover:text-accent"
|
||||
>
|
||||
<i className="i-mingcute-left-line" />
|
||||
<span className="text-sm font-medium">{t("words.back", { ns: "common" })}</span>
|
||||
</MotionButtonBase>
|
||||
<div>
|
||||
<div className="font-bold opacity-0 duration-200 group-[.group]:opacity-100">{title}</div>
|
||||
</div>
|
||||
<div />
|
||||
</div>
|
||||
|
||||
<ScrollArea.ScrollArea
|
||||
mask={false}
|
||||
flex
|
||||
ref={setRef}
|
||||
rootClassName="w-full"
|
||||
viewportClassName="pb-10 pt-28 [&>div]:items-center"
|
||||
>
|
||||
<Outlet />
|
||||
</ScrollArea.ScrollArea>
|
||||
|
||||
<FABContainer>
|
||||
<BackToTopFAB show={isTitleSticky} scrollRef={scrollRef} />
|
||||
</FABContainer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const BackToTopFAB = ({ show, scrollRef }: { show: boolean; scrollRef: any }) => {
|
||||
return (
|
||||
<FABPortable
|
||||
onClick={() => {
|
||||
springScrollTo(0, scrollRef)
|
||||
}}
|
||||
show={show}
|
||||
>
|
||||
<i className="i-mingcute-arow-to-up-line" />
|
||||
</FABPortable>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { SubviewLayout } from "./index.desktop"
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
import { cn } from "@follow/utils/utils"
|
||||
import { easeOut, m, useScroll, useTransform } from "framer-motion"
|
||||
import { Outlet } from "react-router-dom"
|
||||
|
||||
import { HeaderTopReturnBackButton } from "~/components/mobile/button"
|
||||
|
||||
import { useSubViewTitleValue } from "./hooks"
|
||||
|
||||
export const SubviewLayout = () => {
|
||||
const title = useSubViewTitleValue()
|
||||
|
||||
const scrollYProgress = useScroll()
|
||||
|
||||
const opacity = useTransform(scrollYProgress.scrollY, [0, 120], [0, 1], { ease: easeOut })
|
||||
const borderOpacity = useTransform(scrollYProgress.scrollY, [50, 120], [0, 1], { ease: easeOut })
|
||||
|
||||
return (
|
||||
<>
|
||||
<HeaderTopReturnBackButton className="fixed z-[11] m-3" />
|
||||
|
||||
<m.div
|
||||
className={cn(
|
||||
"sticky inset-x-0 top-0 z-10 p-4",
|
||||
"grid grid-cols-[1fr_auto_1fr] items-center gap-4",
|
||||
"bg-zinc-50/80 backdrop-blur-xl dark:bg-neutral-900/90",
|
||||
"border-b",
|
||||
)}
|
||||
style={{
|
||||
opacity,
|
||||
borderColor: useTransform(borderOpacity, (value) => `hsl(var(--border) / ${value})`),
|
||||
}}
|
||||
>
|
||||
<div />
|
||||
<div>
|
||||
<div className="font-bold">{title}</div>
|
||||
</div>
|
||||
<div />
|
||||
</m.div>
|
||||
|
||||
<Outlet />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import { withResponsiveSyncComponent } from "@follow/components/utils/selector.js"
|
||||
|
||||
import { SubviewLayout as SubviewLayoutDesktop } from "./index.desktop"
|
||||
import { SubviewLayout as SubviewLayoutMobile } from "./index.mobile"
|
||||
|
||||
export const SubviewLayout = withResponsiveSyncComponent(SubviewLayoutDesktop, SubviewLayoutMobile)
|
||||
|
|
@ -50,7 +50,7 @@ export const BoostModalContent = ({ feedId }: { feedId: string }) => {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-[80vw] max-w-[350px] flex-col gap-3">
|
||||
<div className="flex w-full flex-col gap-3 lg:w-[80vw] lg:max-w-[350px]">
|
||||
<div className="center flex flex-col gap-2">
|
||||
<FeedIcon className="mr-0" feed={feed} size={50} />
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { FeedViewType } from "@follow/constants"
|
||||
import { IN_ELECTRON } from "@follow/shared/constants"
|
||||
import { nextFrame } from "@follow/utils/dom"
|
||||
import { getOS } from "@follow/utils/utils"
|
||||
import { useMutation } from "@tanstack/react-query"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
|
@ -96,13 +95,11 @@ export const useRegisterEntryCommands = () => {
|
|||
// keyBinding: shortcuts.entry.tip.key,
|
||||
// when: !isInbox && feed?.ownerUserId !== whoami()?.id && !!populatedEntry,
|
||||
run: ({ userId, feedId, entryId }) => {
|
||||
nextFrame(() =>
|
||||
openTipModal({
|
||||
userId,
|
||||
feedId,
|
||||
entryId,
|
||||
}),
|
||||
)
|
||||
openTipModal({
|
||||
userId,
|
||||
feedId,
|
||||
entryId,
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -304,7 +304,7 @@ export const DiscoverFeedForm = ({
|
|||
)
|
||||
})}
|
||||
{routeParams && (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
{Object.entries(routeParams).map(([key, value]) => (
|
||||
<FormItem key={`${routeParamsKeyPrefix}${key}`} className="flex flex-col space-y-2">
|
||||
<FormLabel className="capitalize">{key}</FormLabel>
|
||||
|
|
@ -330,7 +330,7 @@ export const DiscoverFeedForm = ({
|
|||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"sticky bottom-0 -mt-4 mb-1 flex w-full translate-y-3 justify-end py-3",
|
||||
"sticky bottom-0 -mt-4 mb-1 flex w-full justify-end py-3",
|
||||
submitButtonClassName,
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { useMobile } from "@follow/components/hooks/useMobile.js"
|
||||
import { Button } from "@follow/components/ui/button/index.js"
|
||||
import { Card, CardContent, CardFooter, CardHeader } from "@follow/components/ui/card/index.jsx"
|
||||
import {
|
||||
|
|
@ -11,6 +12,7 @@ import {
|
|||
import { Input } from "@follow/components/ui/input/index.js"
|
||||
import { Radio } from "@follow/components/ui/radio-group/index.js"
|
||||
import { RadioGroup } from "@follow/components/ui/radio-group/RadioGroup.jsx"
|
||||
import { ResponsiveSelect } from "@follow/components/ui/select/responsive.js"
|
||||
import type { FeedViewType } from "@follow/constants"
|
||||
import { getBackgroundGradient } from "@follow/utils/color"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
|
|
@ -191,12 +193,14 @@ export function DiscoverForm({ type = "search" }: { type?: string }) {
|
|||
[form],
|
||||
)
|
||||
|
||||
const isMobile = useMobile()
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="w-[540px] space-y-8"
|
||||
className="w-full max-w-[540px] space-y-8"
|
||||
data-testid="discover-form"
|
||||
>
|
||||
<FormField
|
||||
|
|
@ -221,14 +225,26 @@ export function DiscoverForm({ type = "search" }: { type?: string }) {
|
|||
<FormLabel>{t("discover.target.label")}</FormLabel>
|
||||
<FormControl>
|
||||
<div className="flex gap-4 text-sm">
|
||||
<RadioGroup
|
||||
className="flex items-center"
|
||||
value={field.value}
|
||||
onValueChange={handleTargetChange}
|
||||
>
|
||||
<Radio label={t("discover.target.feeds")} value="feeds" />
|
||||
<Radio label={t("discover.target.lists")} value="lists" />
|
||||
</RadioGroup>
|
||||
{isMobile ? (
|
||||
<ResponsiveSelect
|
||||
size="sm"
|
||||
value={field.value}
|
||||
onValueChange={handleTargetChange}
|
||||
items={[
|
||||
{ label: t("discover.target.feeds"), value: "feeds" },
|
||||
{ label: t("discover.target.lists"), value: "lists" },
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<RadioGroup
|
||||
className="flex items-center"
|
||||
value={field.value}
|
||||
onValueChange={handleTargetChange}
|
||||
>
|
||||
<Radio label={t("discover.target.feeds")} value="feeds" />
|
||||
<Radio label={t("discover.target.lists")} value="lists" />
|
||||
</RadioGroup>
|
||||
)}
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
|
|
@ -244,7 +260,7 @@ export function DiscoverForm({ type = "search" }: { type?: string }) {
|
|||
</form>
|
||||
</Form>
|
||||
{mutation.isSuccess && (
|
||||
<div className="mt-8 max-w-lg">
|
||||
<div className="mt-8 w-full max-w-lg">
|
||||
<div className="mb-4 text-zinc-500">
|
||||
Found {mutation.data?.length || 0} feed
|
||||
{mutation.data?.length > 1 && "s"}
|
||||
|
|
|
|||
|
|
@ -100,7 +100,7 @@ export function DiscoverImport({ isInit = false }: { isInit?: boolean }) {
|
|||
return (
|
||||
<>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="w-[540px] space-y-8">
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="w-full max-w-[540px] space-y-8">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="file"
|
||||
|
|
@ -155,7 +155,7 @@ export function DiscoverImport({ isInit = false }: { isInit?: boolean }) {
|
|||
</form>
|
||||
</Form>
|
||||
{mutation.isSuccess && (
|
||||
<div className="mt-8 max-w-lg">
|
||||
<div className="mt-8 w-full max-w-lg">
|
||||
<Card>
|
||||
<CardHeader className="block text-zinc-500">
|
||||
<Trans
|
||||
|
|
|
|||
|
|
@ -1,32 +1,16 @@
|
|||
import { ActionButton, Button } from "@follow/components/ui/button/index.js"
|
||||
import { LoadingCircle } from "@follow/components/ui/loading/index.jsx"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@follow/components/ui/table/index.jsx"
|
||||
import { FeedViewType, UserRole } from "@follow/constants"
|
||||
import { env } from "@follow/shared/env"
|
||||
import { useMutation } from "@tanstack/react-query"
|
||||
import { memo } from "react"
|
||||
import { Button } from "@follow/components/ui/button/index.js"
|
||||
import { UserRole } from "@follow/constants"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { toast } from "sonner"
|
||||
import { useEventCallback } from "usehooks-ts"
|
||||
|
||||
import { useUserRole } from "~/atoms/user"
|
||||
import { CopyButton } from "~/components/ui/code-highlighter"
|
||||
import { useCurrentModal, useModalStack } from "~/components/ui/modal/stacked/hooks"
|
||||
import { useModalStack } from "~/components/ui/modal/stacked/hooks"
|
||||
import { CustomSafeError } from "~/errors/CustomSafeError"
|
||||
import { createErrorToaster } from "~/lib/error-parser"
|
||||
import { useInboxList } from "~/queries/inboxes"
|
||||
import { inboxActions, useInboxById } from "~/store/inbox"
|
||||
import { subscriptionActions } from "~/store/subscription"
|
||||
|
||||
import { useActivationModal } from "../activation"
|
||||
import { InboxForm } from "./inbox-form"
|
||||
import { InboxTable } from "./inbox-table"
|
||||
|
||||
const useCanCreateMoreInboxAndNotify = () => {
|
||||
const role = useUserRole()
|
||||
|
|
@ -54,7 +38,7 @@ const useCanCreateMoreInboxAndNotify = () => {
|
|||
}
|
||||
export function DiscoverInboxList() {
|
||||
const { t } = useTranslation()
|
||||
const inboxes = useInboxList()
|
||||
const { refetch } = useInboxList()
|
||||
|
||||
const { present } = useModalStack()
|
||||
|
||||
|
|
@ -62,7 +46,7 @@ export function DiscoverInboxList() {
|
|||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-4 flex items-center gap-2 text-sm text-zinc-500">
|
||||
<div className="mb-4 flex flex-wrap items-center gap-2 text-sm text-zinc-500">
|
||||
<span>{t("discover.inbox.description")}</span>
|
||||
<a
|
||||
href="https://github.com/RSSNext/Follow/wiki/Inbox#webhooks"
|
||||
|
|
@ -73,31 +57,8 @@ export function DiscoverInboxList() {
|
|||
{t("discover.inbox.webhooks_docs")}
|
||||
</a>
|
||||
</div>
|
||||
<Table className="mb-8 w-[600px]">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="pl-0 pr-6">{t("discover.inbox.handle")}</TableHead>
|
||||
<TableHead className="pl-0 pr-6">{t("discover.inbox.email")}</TableHead>
|
||||
<TableHead className="pl-0 pr-6">{t("discover.inbox.title")}</TableHead>
|
||||
<TableHead className="pl-0 pr-6">{t("discover.inbox.secret")}</TableHead>
|
||||
<TableHead className="center px-0">{t("discover.inbox.actions")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{inboxes.isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell size="sm" colSpan={5}>
|
||||
<div className="center w-full">
|
||||
<LoadingCircle size="large" />
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
inboxes.data?.map((inbox) => <Row id={inbox.id} key={inbox.id} />)
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<div className="center flex">
|
||||
<InboxTable />
|
||||
<div className="center mt-4 flex">
|
||||
{/* New Inbox */}
|
||||
<Button
|
||||
className="flex items-center gap-2"
|
||||
|
|
@ -109,7 +70,7 @@ export function DiscoverInboxList() {
|
|||
<InboxForm
|
||||
asWidget
|
||||
onSuccess={() => {
|
||||
inboxes.refetch()
|
||||
refetch()
|
||||
dismiss()
|
||||
}}
|
||||
/>
|
||||
|
|
@ -124,95 +85,3 @@ export function DiscoverInboxList() {
|
|||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const ConfirmDestroyModalContent = ({ id }: { id: string }) => {
|
||||
const { t } = useTranslation()
|
||||
const { dismiss } = useCurrentModal()
|
||||
|
||||
const mutationDestroy = useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
return inboxActions.deleteInbox(id)
|
||||
},
|
||||
onSuccess: () => {
|
||||
subscriptionActions.fetchByView(FeedViewType.Articles)
|
||||
toast.success(t("discover.inbox_destroy_success"))
|
||||
},
|
||||
onMutate: () => {
|
||||
dismiss()
|
||||
},
|
||||
onError: createErrorToaster(t("discover.inbox_destroy_error")),
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="w-[540px]">
|
||||
<div className="mb-4">
|
||||
<i className="i-mingcute-warning-fill -mb-1 mr-1 size-5 text-red-500" />
|
||||
{t("discover.inbox_destroy_warning")}
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button className="bg-red-600" onClick={() => mutationDestroy.mutate(id)}>
|
||||
{t("words.confirm")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Row = memo(({ id }: { id: string }) => {
|
||||
const { t } = useTranslation()
|
||||
const { present } = useModalStack()
|
||||
const inbox = useInboxById(id)
|
||||
if (!inbox) return null
|
||||
return (
|
||||
<TableRow key={inbox.id}>
|
||||
<TableCell size="sm">{inbox.id}</TableCell>
|
||||
<TableCell size="sm">
|
||||
<div className="group relative flex w-fit items-center gap-2">
|
||||
<span className="shrink-0">
|
||||
{inbox.id}
|
||||
{env.VITE_INBOXES_EMAIL}
|
||||
</span>
|
||||
<CopyButton
|
||||
value={`${inbox.id}${env.VITE_INBOXES_EMAIL}`}
|
||||
className="absolute -right-6 p-1 opacity-0 group-hover:opacity-100 [&_i]:size-3"
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell size="sm">{inbox.title}</TableCell>
|
||||
<TableCell size="sm">
|
||||
<div className="group relative flex w-fit items-center gap-2 font-mono">
|
||||
<span className="shrink-0">****</span>
|
||||
<CopyButton
|
||||
value={inbox.secret}
|
||||
className="absolute -right-6 p-1 opacity-0 group-hover:opacity-100 [&_i]:size-3"
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell size="sm" className="center">
|
||||
<ActionButton
|
||||
size="sm"
|
||||
tooltip={t("discover.inbox_destroy")}
|
||||
onClick={() =>
|
||||
present({
|
||||
title: t("discover.inbox_destroy_confirm"),
|
||||
content: () => <ConfirmDestroyModalContent id={inbox.id} />,
|
||||
})
|
||||
}
|
||||
>
|
||||
<i className="i-mgc-delete-2-cute-re" />
|
||||
</ActionButton>
|
||||
<ActionButton
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
present({
|
||||
title: t("sidebar.feed_actions.edit_inbox"),
|
||||
content: ({ dismiss }) => <InboxForm asWidget id={inbox.id} onSuccess={dismiss} />,
|
||||
})
|
||||
}}
|
||||
>
|
||||
<i className="i-mgc-edit-cute-re" />
|
||||
</ActionButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -0,0 +1,67 @@
|
|||
import { LoadingCircle } from "@follow/components/ui/loading/index.jsx"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@follow/components/ui/table/index.jsx"
|
||||
import { memo } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import { useInboxList } from "~/queries/inboxes"
|
||||
import { useInboxById } from "~/store/inbox"
|
||||
|
||||
import { InboxActions, InboxEmail, InboxSecret } from "./inbox-table.shared"
|
||||
|
||||
export const InboxTable = () => {
|
||||
const { t } = useTranslation()
|
||||
const inboxes = useInboxList()
|
||||
return (
|
||||
<Table containerClassName="overflow-auto mb-8">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="pl-0 pr-6">{t("discover.inbox.handle")}</TableHead>
|
||||
<TableHead className="pl-0 pr-6">{t("discover.inbox.email")}</TableHead>
|
||||
<TableHead className="pl-0 pr-6">{t("discover.inbox.title")}</TableHead>
|
||||
<TableHead className="pl-0 pr-6">{t("discover.inbox.secret")}</TableHead>
|
||||
<TableHead className="center px-0">{t("discover.inbox.actions")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{inboxes.isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell size="sm" colSpan={5}>
|
||||
<div className="center w-full">
|
||||
<LoadingCircle size="large" />
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
inboxes.data?.map((inbox) => <Row id={inbox.id} key={inbox.id} />)
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)
|
||||
}
|
||||
|
||||
const Row = memo(({ id }: { id: string }) => {
|
||||
const inbox = useInboxById(id)
|
||||
if (!inbox) return null
|
||||
return (
|
||||
<TableRow key={inbox.id}>
|
||||
<TableCell size="sm">{inbox.id}</TableCell>
|
||||
<TableCell size="sm">
|
||||
<InboxEmail id={inbox.id} />
|
||||
</TableCell>
|
||||
<TableCell size="sm">{inbox.title}</TableCell>
|
||||
<TableCell size="sm">
|
||||
<InboxSecret secret={inbox.secret} />
|
||||
</TableCell>
|
||||
<TableCell size="sm" className="center">
|
||||
<InboxActions id={inbox.id} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { InboxTable } from "./inbox-table.desktop"
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
import { LoadingCircle } from "@follow/components/ui/loading/index.jsx"
|
||||
import { memo } from "react"
|
||||
|
||||
import { useInboxList } from "~/queries/inboxes"
|
||||
import { useInboxById } from "~/store/inbox"
|
||||
|
||||
import { InboxActions, InboxEmail, InboxSecret } from "./inbox-table.shared"
|
||||
|
||||
export const InboxTable = () => {
|
||||
const inboxes = useInboxList()
|
||||
return inboxes.isLoading ? (
|
||||
<div className="center w-full">
|
||||
<LoadingCircle size="large" />
|
||||
</div>
|
||||
) : (
|
||||
<ul className="mt-4 flex flex-col gap-2">
|
||||
{inboxes.data?.map((inbox) => <Row id={inbox.id} key={inbox.id} />)}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
const Row = memo(({ id }: { id: string }) => {
|
||||
const inbox = useInboxById(id)
|
||||
if (!inbox) return null
|
||||
|
||||
return (
|
||||
<li className="flex flex-col gap-3 rounded border p-3">
|
||||
{!!inbox.title && <p className="text-sm font-semibold opacity-70">{inbox.title}</p>}
|
||||
<div className="flex gap-2">
|
||||
<InboxEmail id={inbox.id} />
|
||||
<InboxSecret secret={inbox.secret} />
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<InboxActions id={inbox.id} />
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
import { ActionButton, Button } from "@follow/components/ui/button/index.js"
|
||||
import { FeedViewType } from "@follow/constants"
|
||||
import { env } from "@follow/shared/env"
|
||||
import { useMutation } from "@tanstack/react-query"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { CopyButton } from "~/components/ui/code-highlighter/copy-button"
|
||||
import { useCurrentModal, useModalStack } from "~/components/ui/modal/stacked/hooks"
|
||||
import { createErrorToaster } from "~/lib/error-parser"
|
||||
import { inboxActions } from "~/store/inbox"
|
||||
import { subscriptionActions } from "~/store/subscription"
|
||||
|
||||
import { InboxForm } from "./inbox-form"
|
||||
|
||||
export const InboxEmail = ({ id }: { id: string }) => {
|
||||
return (
|
||||
<div className="group relative flex w-fit items-center gap-2">
|
||||
<span className="shrink-0">
|
||||
{id}
|
||||
{env.VITE_INBOXES_EMAIL}
|
||||
</span>
|
||||
<CopyButton
|
||||
value={`${id}${env.VITE_INBOXES_EMAIL}`}
|
||||
className="p-1 sm:absolute sm:-right-6 sm:opacity-0 sm:group-hover:opacity-100 [&_i]:size-3"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const InboxSecret = ({ secret }: { secret: string }) => {
|
||||
return (
|
||||
<div className="group relative flex w-fit items-center gap-2 font-mono">
|
||||
<span className="shrink-0">****</span>
|
||||
<CopyButton
|
||||
value={secret}
|
||||
className="p-1 sm:absolute sm:-right-6 sm:opacity-0 sm:group-hover:opacity-100 [&_i]:size-3"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const InboxActions = ({ id }: { id: string }) => {
|
||||
const { t } = useTranslation()
|
||||
const { present } = useModalStack()
|
||||
return (
|
||||
<>
|
||||
<ActionButton
|
||||
size="sm"
|
||||
tooltip={t("discover.inbox_destroy")}
|
||||
onClick={() =>
|
||||
present({
|
||||
title: t("discover.inbox_destroy_confirm"),
|
||||
content: () => <ConfirmDestroyModalContent id={id} />,
|
||||
})
|
||||
}
|
||||
>
|
||||
<i className="i-mgc-delete-2-cute-re" />
|
||||
</ActionButton>
|
||||
<ActionButton
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
present({
|
||||
title: t("sidebar.feed_actions.edit_inbox"),
|
||||
content: ({ dismiss }) => <InboxForm asWidget id={id} onSuccess={dismiss} />,
|
||||
})
|
||||
}}
|
||||
>
|
||||
<i className="i-mgc-edit-cute-re" />
|
||||
</ActionButton>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const ConfirmDestroyModalContent = ({ id }: { id: string }) => {
|
||||
const { t } = useTranslation()
|
||||
const { dismiss } = useCurrentModal()
|
||||
|
||||
const mutationDestroy = useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
return inboxActions.deleteInbox(id)
|
||||
},
|
||||
onSuccess: () => {
|
||||
subscriptionActions.fetchByView(FeedViewType.Articles)
|
||||
toast.success(t("discover.inbox_destroy_success"))
|
||||
},
|
||||
onMutate: () => {
|
||||
dismiss()
|
||||
},
|
||||
onError: createErrorToaster(t("discover.inbox_destroy_error")),
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-[540px]">
|
||||
<div className="mb-4">
|
||||
<i className="i-mingcute-warning-fill -mb-1 mr-1 size-5 text-red-500" />
|
||||
{t("discover.inbox_destroy_warning")}
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button className="bg-red-600" onClick={() => mutationDestroy.mutate(id)}>
|
||||
{t("words.confirm")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import { withResponsiveSyncComponent } from "@follow/components/utils/selector.js"
|
||||
|
||||
import { InboxTable as InboxTableDesktop } from "./inbox-table.desktop"
|
||||
import { InboxTable as InboxTableMobile } from "./inbox-table.mobile"
|
||||
|
||||
export const InboxTable = withResponsiveSyncComponent<ComponentType>(
|
||||
InboxTableDesktop,
|
||||
InboxTableMobile,
|
||||
)
|
||||
|
|
@ -8,7 +8,7 @@ export const RecommendationContent = ({
|
|||
route: RSSHubRoute
|
||||
routePrefix: string
|
||||
}) => (
|
||||
<div className="w-full min-w-[550px] max-w-[700px]">
|
||||
<div className="w-full max-w-[700px] sm:min-w-[550px]">
|
||||
<DiscoverFeedForm route={route} routePrefix={routePrefix} />
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,12 +1,6 @@
|
|||
import { LoadingCircle } from "@follow/components/ui/loading/index.js"
|
||||
import { ScrollArea } from "@follow/components/ui/scroll-area/index.js"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@follow/components/ui/select/index.jsx"
|
||||
import { ResponsiveSelect } from "@follow/components/ui/select/responsive.js"
|
||||
import { Tabs, TabsList, TabsTrigger } from "@follow/components/ui/tabs/index.js"
|
||||
import { cn, isASCII } from "@follow/utils/utils"
|
||||
import { keepPreviousData } from "@tanstack/react-query"
|
||||
|
|
@ -25,15 +19,15 @@ import { RecommendationCard } from "./recommendations-card"
|
|||
|
||||
const LanguageOptions = [
|
||||
{
|
||||
name: "All",
|
||||
label: "All",
|
||||
value: "all",
|
||||
},
|
||||
{
|
||||
name: "English",
|
||||
label: "English",
|
||||
value: "en",
|
||||
},
|
||||
{
|
||||
name: "中文",
|
||||
label: "中文",
|
||||
value: "zh-CN",
|
||||
},
|
||||
] as const
|
||||
|
|
@ -140,7 +134,7 @@ export function Recommendations({
|
|||
}
|
||||
|
||||
return (
|
||||
<div className={cn(!hideTitle && "mt-8 w-full max-w-[1200px] px-4")}>
|
||||
<div className={cn(!hideTitle && "mt-8 w-full max-w-[1200px]")}>
|
||||
{!hideTitle && (
|
||||
<div className="relative text-center text-lg font-bold">
|
||||
<span>{t("discover.popular")}</span>
|
||||
|
|
@ -158,14 +152,14 @@ export function Recommendations({
|
|||
|
||||
<div
|
||||
className={cn(
|
||||
"z-[9] my-3 flex w-full items-center gap-4 bg-theme-background",
|
||||
"z-[9] my-3 flex w-full flex-col items-end gap-4 bg-theme-background sm:flex-row sm:items-center",
|
||||
headerClassName,
|
||||
)}
|
||||
>
|
||||
<ScrollArea.ScrollArea
|
||||
ref={tabRef}
|
||||
orientation="horizontal"
|
||||
rootClassName="grow min-w-0 overflow-visible"
|
||||
rootClassName="grow min-w-0 max-w-full overflow-visible"
|
||||
scrollbarClassName="-mb-3 z-[100]"
|
||||
>
|
||||
<Tabs value={category} onValueChange={handleCategoryChange}>
|
||||
|
|
@ -178,19 +172,14 @@ export function Recommendations({
|
|||
</TabsList>
|
||||
</Tabs>
|
||||
</ScrollArea.ScrollArea>
|
||||
<Select value={selectedLang} onValueChange={handleLangChange}>
|
||||
<SelectTrigger size="sm" className="w-32">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent className="z-[1]" position="item-aligned">
|
||||
{LanguageOptions.map((lang) => (
|
||||
<SelectItem key={lang.value} value={lang.value}>
|
||||
{lang.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<ResponsiveSelect
|
||||
value={selectedLang}
|
||||
onValueChange={handleLangChange}
|
||||
triggerClassName="w-32"
|
||||
size="sm"
|
||||
items={LanguageOptions as any}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={cn(styles["recommendations-grid"], "mt-6", className)}>
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ export function DiscoverRSS3() {
|
|||
return (
|
||||
<>
|
||||
{data?.rss3.routes && (
|
||||
<div className="w-[540px]">
|
||||
<div className="w-full max-w-[540px]">
|
||||
<DiscoverFeedForm
|
||||
routePrefix="rss3"
|
||||
route={data.rss3.routes[Object.keys(data.rss3.routes)[0]]}
|
||||
|
|
|
|||
|
|
@ -68,7 +68,7 @@ export function DiscoverTransform() {
|
|||
return (
|
||||
<>
|
||||
{data?.rsshub.routes && (
|
||||
<div className="w-[540px]">
|
||||
<div className="w-full max-w-[540px]">
|
||||
<DiscoverFeedForm
|
||||
routePrefix="rsshub"
|
||||
route={data?.rsshub.routes["/transform/html/:url/:routeParams"]}
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ export function DiscoverUser() {
|
|||
return (
|
||||
<>
|
||||
{data?.follow.routes && (
|
||||
<div className="w-[540px]">
|
||||
<div className="w-full max-w-[540px]">
|
||||
<DiscoverFeedForm
|
||||
routePrefix="follow"
|
||||
route={data.follow.routes[Object.keys(data.follow.routes)[0]]}
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ export function AudioItemStateLess({ entry, feed }: EntryItemStatelessProps) {
|
|||
<span>·</span>
|
||||
<span>{!!entry.publishedAt && <RelativeTime date={entry.publishedAt} />}</span>
|
||||
</div>
|
||||
<div className="relative my-0.5 line-clamp-3 break-words font-medium">
|
||||
<div className="relative my-0.5 line-clamp-3 break-words font-medium">
|
||||
{entry.description}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -47,7 +47,7 @@ export function AudioItemStateLess({ entry, feed }: EntryItemStatelessProps) {
|
|||
blurhash={entry.media[0].blurhash}
|
||||
/>
|
||||
) : (
|
||||
<Skeleton className="mr-2 size-20 shrink-0 overflow-hidden rounded-sm " />
|
||||
<Skeleton className="mr-2 size-20 shrink-0 overflow-hidden rounded-sm" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -109,6 +109,8 @@ export const PictureWaterFallItem = memo(function PictureWaterFallItem({
|
|||
data-index={index}
|
||||
onMouseEnter={() => setIsMouseEnter(true)}
|
||||
onMouseLeave={() => setIsMouseEnter(false)}
|
||||
onTouchMove={() => setIsMouseEnter(true)}
|
||||
onTouchEnd={() => setIsMouseEnter(false)}
|
||||
className={className}
|
||||
>
|
||||
<EntryItemWrapper
|
||||
|
|
|
|||
|
|
@ -195,7 +195,7 @@ export const PictureMasonry: FC<MasonryProps> = (props) => {
|
|||
}, [])
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="px-4 pt-2">
|
||||
<div ref={containerRef} className="mx-4 pt-2">
|
||||
{isInitDim && deferIsInitLayout && (
|
||||
<MasonryItemWidthContext.Provider value={currentItemWidth}>
|
||||
<MasonryItemsAspectRatioContext.Provider value={masonryItemsRadio}>
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ export const SocialMediaItem: EntryListItemFC = ({ entryId, entryPreview, transl
|
|||
onMouseEnter={() => setShowAction(true)}
|
||||
onMouseLeave={() => setShowAction(false)}
|
||||
className={cn(
|
||||
"relative flex px-8 py-6",
|
||||
"relative flex px-2 py-6 lg:px-8",
|
||||
"group",
|
||||
!asRead &&
|
||||
"before:absolute before:left-2 before:top-10 before:block before:size-2 before:rounded-full before:bg-accent",
|
||||
|
|
@ -177,7 +177,6 @@ const ActionBar = ({ entryId }: { entryId: string }) => {
|
|||
{entryActions
|
||||
.filter(
|
||||
(item) =>
|
||||
!item.hide &&
|
||||
item.id !== COMMAND_ID.entry.read &&
|
||||
item.id !== COMMAND_ID.entry.unread &&
|
||||
item.id !== COMMAND_ID.entry.openInBrowser,
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ const useSticky = () => {
|
|||
}
|
||||
export const DateItem = memo(({ date, view }: { date: string; view: FeedViewType }) => {
|
||||
const className = cn(
|
||||
"pt-2",
|
||||
"lg:pt-2",
|
||||
`relative -mx-2 flex items-center gap-1 bg-background px-4 text-base font-bold text-zinc-800 dark:text-neutral-400`,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -150,7 +150,7 @@ const Popup = ({ which, containerRef, setPopoverRef, setShow, handleMarkAllAsRea
|
|||
transform: `translateY(${isElectronWindows ? "-95px" : "-70px"})`,
|
||||
}}
|
||||
transition={{ type: "spring", damping: 20, stiffness: 300 }}
|
||||
className="shadow-modal absolute z-50 bg-theme-modal-background-opaque shadow"
|
||||
className="shadow-modal absolute z-50 bg-background shadow lg:bg-theme-modal-background-opaque"
|
||||
style={{
|
||||
left: -paddingLeft,
|
||||
top: rect.top,
|
||||
|
|
|
|||
|
|
@ -222,8 +222,9 @@ export const useEntriesByView = ({
|
|||
|
||||
hasUpdate,
|
||||
refetch: useCallback(() => {
|
||||
query.refetch()
|
||||
const promise = query.refetch()
|
||||
feedUnreadActions.fetchUnreadByView(view)
|
||||
return promise
|
||||
}, [query, view]),
|
||||
entriesIds: sortEntries,
|
||||
groupedCounts,
|
||||
|
|
|
|||
|
|
@ -1,11 +1,9 @@
|
|||
import { AutoResizeHeight } from "@follow/components/ui/auto-resize-height/index.jsx"
|
||||
import { useMobile } from "@follow/components/hooks/useMobile.js"
|
||||
import { Button } from "@follow/components/ui/button/index.js"
|
||||
import { LoadingCircle } from "@follow/components/ui/loading/index.jsx"
|
||||
import { ScrollArea } from "@follow/components/ui/scroll-area/index.js"
|
||||
import { FeedViewType, views } from "@follow/constants"
|
||||
import { useTitle, useTypeScriptHappyCallback } from "@follow/hooks"
|
||||
import type { FeedModel } from "@follow/models/types"
|
||||
import { clsx, isBizId } from "@follow/utils/utils"
|
||||
import { isBizId } from "@follow/utils/utils"
|
||||
import type { FunctionComponent } from "react"
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
|
@ -19,11 +17,11 @@ import { VirtuosoGrid } from "react-virtuoso"
|
|||
|
||||
import { useGeneralSettingKey } from "~/atoms/settings/general"
|
||||
import { setUISetting, useUISettingKey } from "~/atoms/settings/ui"
|
||||
import { m } from "~/components/common/Motion"
|
||||
import { FeedFoundCanBeFollowError } from "~/components/errors/FeedFoundCanBeFollowErrorFallback"
|
||||
import { FeedNotFound } from "~/components/errors/FeedNotFound"
|
||||
import { ReactVirtuosoItemPlaceholder } from "~/components/ui/placeholder"
|
||||
import { FEED_COLLECTION_LIST, ROUTE_FEED_PENDING } from "~/constants"
|
||||
import { ENTRY_COLUMN_LIST_SCROLLER_ID } from "~/constants/dom"
|
||||
import { useNavigateEntry } from "~/hooks/biz/useNavigateEntry"
|
||||
import { useRouteParams, useRouteParamsSelector } from "~/hooks/biz/useRouteParams"
|
||||
import { useFeed } from "~/queries/feed"
|
||||
|
|
@ -38,12 +36,15 @@ import { PictureMasonry } from "./Items/picture-masonry"
|
|||
import { EntryListHeader } from "./layouts/EntryListHeader"
|
||||
import { EntryEmptyList, EntryList, EntryListContent } from "./lists"
|
||||
import { girdClassNames } from "./styles"
|
||||
import { EntryColumnWrapper } from "./wrapper"
|
||||
|
||||
const scrollSeekConfiguration: ScrollSeekConfiguration = {
|
||||
enter: (velocity) => Math.abs(velocity) > 1000,
|
||||
exit: (velocity) => Math.abs(velocity) < 1000,
|
||||
}
|
||||
|
||||
const prevScrollTopMap = {}
|
||||
|
||||
export type VirtuosoComponentProps = { onlyShowArchivedButton: boolean }
|
||||
type VirtuosoComponentPropsContext = { context?: VirtuosoComponentProps }
|
||||
|
||||
|
|
@ -138,6 +139,20 @@ function EntryColumnImpl() {
|
|||
hasNoEntries && !isArchived ? prevEntriesIdsRef.current || entriesIds : entriesIds
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const handleScroll = useCallback(
|
||||
(e: React.UIEvent<HTMLDivElement>) => {
|
||||
if (!isInteracted.current) {
|
||||
isInteracted.current = true
|
||||
}
|
||||
const { scrollTop } = e.currentTarget
|
||||
|
||||
if (!routeFeedId) return
|
||||
prevScrollTopMap[routeFeedId] = scrollTop
|
||||
},
|
||||
[routeFeedId],
|
||||
)
|
||||
|
||||
const virtuosoOptions = {
|
||||
components: {
|
||||
List: EntryListContent,
|
||||
|
|
@ -179,6 +194,7 @@ function EntryColumnImpl() {
|
|||
handleMarkReadInRange(...args, isInteracted.current)
|
||||
},
|
||||
customScrollParent: scrollRef.current!,
|
||||
initialScrollTop: prevScrollTopMap[routeFeedId || ""] || 0,
|
||||
|
||||
totalCount: entries.totalCount,
|
||||
endReached: useCallback(async () => {
|
||||
|
|
@ -190,11 +206,7 @@ function EntryColumnImpl() {
|
|||
}
|
||||
}, [entries]),
|
||||
data: finalEntriesIds,
|
||||
onScroll: () => {
|
||||
if (!isInteracted.current) {
|
||||
isInteracted.current = true
|
||||
}
|
||||
},
|
||||
onScroll: handleScroll,
|
||||
itemContent: useTypeScriptHappyCallback(
|
||||
(_, entryId) => {
|
||||
if (!entryId) return <ReactVirtuosoItemPlaceholder />
|
||||
|
|
@ -208,14 +220,18 @@ function EntryColumnImpl() {
|
|||
const navigate = useNavigateEntry()
|
||||
const isRefreshing = entries.isFetching && !entries.isFetchingNextPage
|
||||
|
||||
const isMobile = useMobile()
|
||||
return (
|
||||
<div
|
||||
data-hide-in-print
|
||||
className="relative flex h-full flex-1 flex-col @container"
|
||||
onClick={() =>
|
||||
navigate({
|
||||
entryId: null,
|
||||
})
|
||||
onClick={
|
||||
isMobile
|
||||
? undefined
|
||||
: () =>
|
||||
navigate({
|
||||
entryId: null,
|
||||
})
|
||||
}
|
||||
data-total-count={virtuosoOptions.totalCount}
|
||||
>
|
||||
|
|
@ -230,48 +246,31 @@ function EntryColumnImpl() {
|
|||
totalCount={virtuosoOptions.totalCount}
|
||||
hasUpdate={entries.hasUpdate}
|
||||
/>
|
||||
<AutoResizeHeight spring>
|
||||
{isRefreshing && (
|
||||
<div className="center box-content h-7 gap-2 py-3 text-xs">
|
||||
<LoadingCircle size="small" />
|
||||
{t("entry_column.refreshing")}
|
||||
</div>
|
||||
)}
|
||||
</AutoResizeHeight>
|
||||
<m.div
|
||||
|
||||
<EntryColumnWrapper
|
||||
onScroll={handleScroll}
|
||||
onPullToRefresh={entries.refetch}
|
||||
key={`${routeFeedId}-${view}`}
|
||||
className="relative mt-2 h-0 grow"
|
||||
initial={{ opacity: 0.01, y: 100 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0.01, y: -100 }}
|
||||
>
|
||||
<ScrollArea.ScrollArea
|
||||
scrollbarClassName={!views[view].wideMode ? "w-[5px] p-0" : ""}
|
||||
mask={false}
|
||||
ref={scrollRef}
|
||||
rootClassName={clsx("h-full", views[view].wideMode ? "mt-2" : "")}
|
||||
viewportClassName="[&>div]:grow flex"
|
||||
>
|
||||
{virtuosoOptions.totalCount === 0 && !showArchivedButton ? (
|
||||
entries.isLoading ? null : (
|
||||
<EntryEmptyList />
|
||||
)
|
||||
) : view && views[view].gridMode ? (
|
||||
<ListGird
|
||||
virtuosoOptions={virtuosoOptions}
|
||||
virtuosoRef={virtuosoRef}
|
||||
hasNextPage={entries.hasNextPage}
|
||||
/>
|
||||
) : (
|
||||
<EntryList
|
||||
{...virtuosoOptions}
|
||||
virtuosoRef={virtuosoRef}
|
||||
refetch={entries.refetch}
|
||||
groupCounts={groupedCounts}
|
||||
/>
|
||||
)}
|
||||
</ScrollArea.ScrollArea>
|
||||
</m.div>
|
||||
{virtuosoOptions.totalCount === 0 && !showArchivedButton ? (
|
||||
entries.isLoading ? null : (
|
||||
<EntryEmptyList />
|
||||
)
|
||||
) : view && views[view].gridMode ? (
|
||||
<ListGird
|
||||
virtuosoOptions={virtuosoOptions}
|
||||
virtuosoRef={virtuosoRef}
|
||||
hasNextPage={entries.hasNextPage}
|
||||
/>
|
||||
) : (
|
||||
<EntryList
|
||||
{...virtuosoOptions}
|
||||
virtuosoRef={virtuosoRef}
|
||||
refetch={entries.refetch}
|
||||
groupCounts={groupedCounts}
|
||||
/>
|
||||
)}
|
||||
</EntryColumnWrapper>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -291,7 +290,8 @@ const ListGird = ({
|
|||
virtuosoRef: React.RefObject<VirtuosoHandle>
|
||||
hasNextPage: boolean
|
||||
}) => {
|
||||
const masonry = useUISettingKey("pictureViewMasonry")
|
||||
const isMobile = useMobile()
|
||||
const masonry = useUISettingKey("pictureViewMasonry") || isMobile
|
||||
const view = useRouteParamsSelector((s) => s.view)
|
||||
const feedId = useRouteParamsSelector((s) => s.feedId)
|
||||
const filterNoImage = useUISettingKey("pictureViewFilterNoImage")
|
||||
|
|
@ -363,6 +363,7 @@ const ListGird = ({
|
|||
return (
|
||||
<>
|
||||
<VirtuosoGrid
|
||||
id={ENTRY_COLUMN_LIST_SCROLLER_ID}
|
||||
listClassName={girdClassNames}
|
||||
{...virtuosoOptions}
|
||||
data={nextData}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { useEntryActions } from "~/hooks/biz/useEntryActions"
|
|||
import { useFeedActions } from "~/hooks/biz/useFeedActions"
|
||||
import { useNavigateEntry } from "~/hooks/biz/useNavigateEntry"
|
||||
import { useRouteParamsSelector } from "~/hooks/biz/useRouteParams"
|
||||
import { useContextMenu } from "~/hooks/common/useContextMenu"
|
||||
import type { FlatEntryModel } from "~/store/entry"
|
||||
import { entryActions } from "~/store/entry"
|
||||
|
||||
|
|
@ -69,20 +70,19 @@ export const EntryItemWrapper: FC<
|
|||
)
|
||||
const [isContextMenuOpen, setIsContextMenuOpen] = useState(false)
|
||||
const showContextMenu = useShowContextMenu()
|
||||
const handleContextMenu: React.MouseEventHandler<HTMLDivElement> = useCallback(
|
||||
async (e) => {
|
||||
|
||||
const contextMenuProps = useContextMenu({
|
||||
onContextMenu: async (e) => {
|
||||
e.preventDefault()
|
||||
setIsContextMenuOpen(true)
|
||||
await showContextMenu(
|
||||
[
|
||||
...actionConfigs
|
||||
.filter((item) => !item.hide)
|
||||
.map((item) => ({
|
||||
type: "text" as const,
|
||||
label: item.name,
|
||||
click: () => item.onClick(),
|
||||
shortcut: item.shortcut,
|
||||
})),
|
||||
...actionConfigs.map((item) => ({
|
||||
type: "text" as const,
|
||||
label: item.name,
|
||||
click: () => item.onClick(),
|
||||
shortcut: item.shortcut,
|
||||
})),
|
||||
{
|
||||
type: "separator" as const,
|
||||
},
|
||||
|
|
@ -103,8 +103,7 @@ export const EntryItemWrapper: FC<
|
|||
)
|
||||
setIsContextMenuOpen(false)
|
||||
},
|
||||
[showContextMenu, actionConfigs, feedItems, t, entry.entries.id],
|
||||
)
|
||||
})
|
||||
|
||||
return (
|
||||
<div data-entry-id={entry.entries.id} style={style}>
|
||||
|
|
@ -121,7 +120,7 @@ export const EntryItemWrapper: FC<
|
|||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseEnter.cancel}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
onContextMenu={handleContextMenu}
|
||||
{...contextMenuProps}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,166 @@
|
|||
import { ActionButton } from "@follow/components/ui/button/index.js"
|
||||
import { DividerVertical } from "@follow/components/ui/divider/index.js"
|
||||
import { RotatingRefreshIcon } from "@follow/components/ui/loading/index.jsx"
|
||||
import { EllipsisHorizontalTextWithTooltip } from "@follow/components/ui/typography/index.js"
|
||||
import { FeedViewType, views } from "@follow/constants"
|
||||
import { useIsOnline } from "@follow/hooks"
|
||||
import { stopPropagation } from "@follow/utils/dom"
|
||||
import { cn, isBizId } from "@follow/utils/utils"
|
||||
import type { FC } from "react"
|
||||
import * as React from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import { setGeneralSetting, useGeneralSettingKey } from "~/atoms/settings/general"
|
||||
import { useFeedColumnShow } from "~/atoms/sidebar"
|
||||
import { useWhoami } from "~/atoms/user"
|
||||
import { FEED_COLLECTION_LIST, ROUTE_ENTRY_PENDING, ROUTE_FEED_IN_LIST } from "~/constants"
|
||||
import { shortcuts } from "~/constants/shortcuts"
|
||||
import { useRouteParams } from "~/hooks/biz/useRouteParams"
|
||||
import { EntryHeader } from "~/modules/entry-content/header"
|
||||
import { useRefreshFeedMutation } from "~/queries/feed"
|
||||
import { useFeedById, useFeedHeaderTitle } from "~/store/feed"
|
||||
|
||||
import { MarkAllReadWithOverlay } from "../components/mark-all-button"
|
||||
import {
|
||||
AppendTaildingDivider,
|
||||
DailyReportButton,
|
||||
FilterNoImageButton,
|
||||
SwitchToMasonryButton,
|
||||
WideModeButton,
|
||||
} from "./EntryListHeader.shared"
|
||||
|
||||
export const EntryListHeader: FC<{
|
||||
totalCount: number
|
||||
refetch: () => void
|
||||
isRefreshing: boolean
|
||||
hasUpdate: boolean
|
||||
}> = ({ totalCount, refetch, isRefreshing, hasUpdate }) => {
|
||||
const routerParams = useRouteParams()
|
||||
const { t } = useTranslation()
|
||||
|
||||
const unreadOnly = useGeneralSettingKey("unreadOnly")
|
||||
|
||||
const { feedId, entryId, view, listId } = routerParams
|
||||
|
||||
const headerTitle = useFeedHeaderTitle()
|
||||
|
||||
const isInCollectionList =
|
||||
feedId === FEED_COLLECTION_LIST || feedId?.startsWith(ROUTE_FEED_IN_LIST)
|
||||
|
||||
const titleInfo = !!headerTitle && (
|
||||
<div className={"min-w-0 translate-y-1"}>
|
||||
<div className="h-6 min-w-0 break-all text-lg font-bold leading-tight">
|
||||
<EllipsisHorizontalTextWithTooltip className="inline-block !w-auto max-w-full">
|
||||
<span className="relative -top-px">{headerTitle}</span>
|
||||
</EllipsisHorizontalTextWithTooltip>
|
||||
</div>
|
||||
<div className="whitespace-nowrap text-xs font-medium leading-none text-zinc-400">
|
||||
{totalCount || 0} {t("quantifier.piece", { ns: "common" })}
|
||||
{unreadOnly && !isInCollectionList ? t("words.unread") : ""}
|
||||
{t("space", { ns: "common" })}
|
||||
{t("words.items", { ns: "common", count: totalCount })}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
const { mutateAsync: refreshFeed, isPending } = useRefreshFeedMutation(feedId)
|
||||
|
||||
const user = useWhoami()
|
||||
const isOnline = useIsOnline()
|
||||
|
||||
const feed = useFeedById(feedId)
|
||||
const isList = !!listId
|
||||
|
||||
const containerRef = React.useRef<HTMLDivElement>(null)
|
||||
const titleStyleBasedView = ["pl-6", "pl-7", "pl-7", "pl-7", "px-5", "pl-6"]
|
||||
|
||||
const feedColumnShow = useFeedColumnShow()
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn(
|
||||
"mb-2 flex w-full flex-col pr-4 pt-2.5 transition-[padding] duration-300 ease-in-out",
|
||||
!feedColumnShow && "macos:mt-4 macos:pt-margin-macos-traffic-light-y",
|
||||
titleStyleBasedView[view],
|
||||
)}
|
||||
>
|
||||
<div className={"flex w-full justify-between"}>
|
||||
{titleInfo}
|
||||
<div
|
||||
className={cn(
|
||||
"relative z-[1] flex items-center gap-1 self-baseline text-zinc-500",
|
||||
(isInCollectionList || !headerTitle) && "pointer-events-none opacity-0",
|
||||
|
||||
"translate-x-[6px]",
|
||||
)}
|
||||
onClick={stopPropagation}
|
||||
>
|
||||
{views[view].wideMode && entryId && entryId !== ROUTE_ENTRY_PENDING && (
|
||||
<>
|
||||
<EntryHeader view={view} entryId={entryId} />
|
||||
<DividerVertical className="mx-2 w-px" />
|
||||
</>
|
||||
)}
|
||||
|
||||
<AppendTaildingDivider>
|
||||
{!views[view].wideMode && <WideModeButton />}
|
||||
{view === FeedViewType.SocialMedia && <DailyReportButton />}
|
||||
{view === FeedViewType.Pictures && <SwitchToMasonryButton />}
|
||||
{view === FeedViewType.Pictures && <FilterNoImageButton />}
|
||||
</AppendTaildingDivider>
|
||||
|
||||
{isOnline &&
|
||||
(feed?.ownerUserId === user?.id &&
|
||||
isBizId(routerParams.feedId!) &&
|
||||
feed?.type === "feed" ? (
|
||||
<ActionButton
|
||||
tooltip="Refresh"
|
||||
onClick={() => {
|
||||
refreshFeed()
|
||||
}}
|
||||
>
|
||||
<RotatingRefreshIcon isRefreshing={isPending} />
|
||||
</ActionButton>
|
||||
) : (
|
||||
<ActionButton
|
||||
tooltip={
|
||||
hasUpdate
|
||||
? t("entry_list_header.new_entries_available")
|
||||
: t("entry_list_header.refetch")
|
||||
}
|
||||
onClick={() => {
|
||||
refetch()
|
||||
}}
|
||||
>
|
||||
<RotatingRefreshIcon
|
||||
className={cn(hasUpdate && "text-accent")}
|
||||
isRefreshing={isRefreshing}
|
||||
/>
|
||||
</ActionButton>
|
||||
))}
|
||||
{!isList && (
|
||||
<>
|
||||
<ActionButton
|
||||
tooltip={
|
||||
!unreadOnly
|
||||
? t("entry_list_header.show_unread_only")
|
||||
: t("entry_list_header.show_all")
|
||||
}
|
||||
shortcut={shortcuts.entries.toggleUnreadOnly.key}
|
||||
onClick={() => setGeneralSetting("unreadOnly", !unreadOnly)}
|
||||
>
|
||||
{unreadOnly ? (
|
||||
<i className="i-mgc-round-cute-fi" />
|
||||
) : (
|
||||
<i className="i-mgc-round-cute-re" />
|
||||
)}
|
||||
</ActionButton>
|
||||
<MarkAllReadWithOverlay containerRef={containerRef} shortcut />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* <TimelineTabs /> */}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { EntryListHeader } from "./EntryListHeader.desktop"
|
||||
|
|
@ -0,0 +1,190 @@
|
|||
import { FollowIcon } from "@follow/components/icons/follow.js"
|
||||
import { ActionButton } from "@follow/components/ui/button/index.js"
|
||||
import { RotatingRefreshIcon } from "@follow/components/ui/loading/index.jsx"
|
||||
import { PresentSheet } from "@follow/components/ui/sheet/Sheet.js"
|
||||
import { EllipsisHorizontalTextWithTooltip } from "@follow/components/ui/typography/index.js"
|
||||
import { FeedViewType } from "@follow/constants"
|
||||
import { useIsOnline } from "@follow/hooks"
|
||||
import { stopPropagation } from "@follow/utils/dom"
|
||||
import { cn, isBizId } from "@follow/utils/utils"
|
||||
import type { FC } from "react"
|
||||
import * as React from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import { setGeneralSetting, useGeneralSettingKey } from "~/atoms/settings/general"
|
||||
import { useWhoami } from "~/atoms/user"
|
||||
import { HeaderTopReturnBackButton } from "~/components/mobile/button"
|
||||
import { FEED_COLLECTION_LIST, ROUTE_FEED_IN_LIST } from "~/constants"
|
||||
import { LOGO_MOBILE_ID } from "~/constants/dom"
|
||||
import { shortcuts } from "~/constants/shortcuts"
|
||||
import { useRouteParams } from "~/hooks/biz/useRouteParams"
|
||||
import { FeedColumnMobile } from "~/modules/app-layout/feed-column/mobile"
|
||||
import { useRefreshFeedMutation } from "~/queries/feed"
|
||||
import { useFeedById, useFeedHeaderTitle } from "~/store/feed"
|
||||
|
||||
import { MarkAllReadWithOverlay } from "../components/mark-all-button"
|
||||
import type { EntryListHeaderProps } from "./EntryListHeader.shared"
|
||||
import {
|
||||
AppendTaildingDivider,
|
||||
DailyReportButton,
|
||||
FilterNoImageButton,
|
||||
SwitchToMasonryButton,
|
||||
} from "./EntryListHeader.shared"
|
||||
import { TimelineTabs } from "./TimelineTabs"
|
||||
|
||||
export const EntryListHeader: FC<EntryListHeaderProps> = ({
|
||||
totalCount,
|
||||
refetch,
|
||||
isRefreshing,
|
||||
hasUpdate,
|
||||
}) => {
|
||||
const routerParams = useRouteParams()
|
||||
const { t } = useTranslation()
|
||||
|
||||
const unreadOnly = useGeneralSettingKey("unreadOnly")
|
||||
|
||||
const { feedId, view, listId } = routerParams
|
||||
|
||||
const headerTitle = useFeedHeaderTitle()
|
||||
|
||||
const isTimelineFirst = useGeneralSettingKey("startupScreen") === "timeline"
|
||||
|
||||
const isInCollectionList =
|
||||
feedId === FEED_COLLECTION_LIST || feedId?.startsWith(ROUTE_FEED_IN_LIST)
|
||||
|
||||
const titleInfo = !!headerTitle && (
|
||||
<div className="min-w-0">
|
||||
<div className="h-6 min-w-0 break-all text-lg font-bold leading-tight">
|
||||
<EllipsisHorizontalTextWithTooltip className="inline-block !w-auto max-w-full">
|
||||
<span className="relative -top-px">{headerTitle}</span>
|
||||
</EllipsisHorizontalTextWithTooltip>
|
||||
</div>
|
||||
<div className="whitespace-nowrap text-xs font-medium leading-none text-zinc-400">
|
||||
{totalCount || 0} {t("quantifier.piece", { ns: "common" })}
|
||||
{unreadOnly && !isInCollectionList ? t("words.unread") : ""}
|
||||
{t("space", { ns: "common" })}
|
||||
{t("words.items", { ns: "common", count: totalCount })}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
const { mutateAsync: refreshFeed, isPending } = useRefreshFeedMutation(feedId)
|
||||
|
||||
const user = useWhoami()
|
||||
const isOnline = useIsOnline()
|
||||
|
||||
const feed = useFeedById(feedId)
|
||||
const isList = !!listId
|
||||
|
||||
const containerRef = React.useRef<HTMLDivElement>(null)
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn(
|
||||
"mb-2 flex w-full flex-col pr-4 transition-[padding] duration-300 ease-in-out",
|
||||
"pl-6 pt-safe-offset-2.5",
|
||||
"bg-background",
|
||||
)}
|
||||
>
|
||||
<div className="flex w-full justify-between pb-1 pl-8">
|
||||
{isTimelineFirst ? (
|
||||
<FollowSubscriptionButton />
|
||||
) : (
|
||||
<HeaderTopReturnBackButton
|
||||
to={`/?view=${view}`}
|
||||
className="absolute left-3 translate-y-px text-zinc-500"
|
||||
/>
|
||||
)}
|
||||
{titleInfo}
|
||||
<div
|
||||
className={cn(
|
||||
"relative z-[1] flex items-center gap-1 self-baseline text-zinc-500",
|
||||
(isInCollectionList || !headerTitle) && "pointer-events-none opacity-0",
|
||||
|
||||
"translate-x-[6px]",
|
||||
)}
|
||||
onClick={stopPropagation}
|
||||
>
|
||||
<AppendTaildingDivider>
|
||||
{view === FeedViewType.SocialMedia && <DailyReportButton />}
|
||||
{view === FeedViewType.Pictures && <SwitchToMasonryButton />}
|
||||
{view === FeedViewType.Pictures && <FilterNoImageButton />}
|
||||
</AppendTaildingDivider>
|
||||
|
||||
{isOnline &&
|
||||
(feed?.ownerUserId === user?.id &&
|
||||
isBizId(routerParams.feedId!) &&
|
||||
feed?.type === "feed" ? (
|
||||
<ActionButton
|
||||
tooltip="Refresh"
|
||||
onClick={() => {
|
||||
refreshFeed()
|
||||
}}
|
||||
>
|
||||
<RotatingRefreshIcon isRefreshing={isPending} />
|
||||
</ActionButton>
|
||||
) : (
|
||||
<ActionButton
|
||||
tooltip={
|
||||
hasUpdate
|
||||
? t("entry_list_header.new_entries_available")
|
||||
: t("entry_list_header.refetch")
|
||||
}
|
||||
onClick={() => {
|
||||
refetch()
|
||||
}}
|
||||
>
|
||||
<RotatingRefreshIcon
|
||||
className={cn(hasUpdate && "text-accent")}
|
||||
isRefreshing={isRefreshing}
|
||||
/>
|
||||
</ActionButton>
|
||||
))}
|
||||
{!isList && (
|
||||
<>
|
||||
<ActionButton
|
||||
tooltip={
|
||||
!unreadOnly
|
||||
? t("entry_list_header.show_unread_only")
|
||||
: t("entry_list_header.show_all")
|
||||
}
|
||||
shortcut={shortcuts.entries.toggleUnreadOnly.key}
|
||||
onClick={() => setGeneralSetting("unreadOnly", !unreadOnly)}
|
||||
>
|
||||
{unreadOnly ? (
|
||||
<i className="i-mgc-round-cute-fi" />
|
||||
) : (
|
||||
<i className="i-mgc-round-cute-re" />
|
||||
)}
|
||||
</ActionButton>
|
||||
<MarkAllReadWithOverlay containerRef={containerRef} shortcut />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TimelineTabs />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const FollowSubscriptionButton = () => {
|
||||
return (
|
||||
<PresentSheet
|
||||
zIndex={90}
|
||||
dismissableClassName="mb-0"
|
||||
triggerAsChild
|
||||
content={<FeedColumnMobile asWidget />}
|
||||
modalClassName="bg-background pt-4 h-[calc(100svh-3rem)]"
|
||||
contentClassName="p-0 overflow-visible"
|
||||
>
|
||||
<ActionButton
|
||||
id={LOGO_MOBILE_ID}
|
||||
tooltip="Subscription"
|
||||
className="absolute left-3 translate-y-px text-zinc-500"
|
||||
>
|
||||
<FollowIcon className="size-4" />
|
||||
</ActionButton>
|
||||
</PresentSheet>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,156 @@
|
|||
import { useMobile } from "@follow/components/hooks/useMobile.js"
|
||||
import { MdiMeditation } from "@follow/components/icons/Meditation.js"
|
||||
import { ActionButton } from "@follow/components/ui/button/index.js"
|
||||
import { DividerVertical } from "@follow/components/ui/divider/Divider.js"
|
||||
import { cn } from "@follow/utils/utils"
|
||||
import type { FC } from "react"
|
||||
import * as React from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import {
|
||||
setUISetting,
|
||||
useIsZenMode,
|
||||
useRealInWideMode,
|
||||
useSetZenMode,
|
||||
useUISettingKey,
|
||||
} from "~/atoms/settings/ui"
|
||||
import { ImpressionView } from "~/components/common/ImpressionTracker"
|
||||
import { shortcuts } from "~/constants/shortcuts"
|
||||
import { useAIDailyReportModal } from "~/modules/ai/ai-daily/useAIDailyReportModal"
|
||||
|
||||
export const DailyReportButton: FC = () => {
|
||||
const present = useAIDailyReportModal()
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<ImpressionView event="Daily Report Modal">
|
||||
<ActionButton
|
||||
onClick={() => {
|
||||
present()
|
||||
window.analytics?.capture("Daily Report Modal", {
|
||||
click: 1,
|
||||
})
|
||||
}}
|
||||
tooltip={t("entry_list_header.daily_report")}
|
||||
>
|
||||
<i className="i-mgc-magic-2-cute-re" />
|
||||
</ActionButton>
|
||||
</ImpressionView>
|
||||
)
|
||||
}
|
||||
|
||||
export const FilterNoImageButton = () => {
|
||||
const enabled = useUISettingKey("pictureViewFilterNoImage")
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<ActionButton
|
||||
active={enabled}
|
||||
onClick={() => {
|
||||
setUISetting("pictureViewFilterNoImage", !enabled)
|
||||
}}
|
||||
tooltip={t(
|
||||
enabled ? "entry_list_header.show_all_items" : "entry_list_header.hide_no_image_items",
|
||||
)}
|
||||
>
|
||||
<i className={!enabled ? "i-mgc-photo-album-cute-re" : "i-mgc-photo-album-cute-fi"} />
|
||||
</ActionButton>
|
||||
)
|
||||
}
|
||||
|
||||
export const SwitchToMasonryButton = () => {
|
||||
const isMasonry = useUISettingKey("pictureViewMasonry")
|
||||
const { t } = useTranslation()
|
||||
const isMobile = useMobile()
|
||||
|
||||
if (isMobile) return null
|
||||
return (
|
||||
<ImpressionView
|
||||
event="Switch to Masonry"
|
||||
properties={{
|
||||
masonry: isMasonry ? 1 : 0,
|
||||
}}
|
||||
>
|
||||
<ActionButton
|
||||
onClick={() => {
|
||||
setUISetting("pictureViewMasonry", !isMasonry)
|
||||
window.analytics?.capture("Switch to Masonry", {
|
||||
masonry: !isMasonry ? 1 : 0,
|
||||
click: 1,
|
||||
})
|
||||
}}
|
||||
tooltip={
|
||||
!isMasonry
|
||||
? t("entry_list_header.switch_to_masonry")
|
||||
: t("entry_list_header.switch_to_grid")
|
||||
}
|
||||
>
|
||||
<i className={cn(!isMasonry ? "i-mgc-grid-cute-re" : "i-mgc-grid-2-cute-re")} />
|
||||
</ActionButton>
|
||||
</ImpressionView>
|
||||
)
|
||||
}
|
||||
|
||||
export const WideModeButton = () => {
|
||||
const isWideMode = useRealInWideMode()
|
||||
const isZenMode = useIsZenMode()
|
||||
const { t } = useTranslation()
|
||||
|
||||
const setIsZenMode = useSetZenMode()
|
||||
return (
|
||||
<ImpressionView
|
||||
event="Switch to Wide Mode"
|
||||
properties={{
|
||||
wideMode: isWideMode ? 1 : 0,
|
||||
}}
|
||||
>
|
||||
<ActionButton
|
||||
shortcut={shortcuts.layout.toggleWideMode.key}
|
||||
onClick={() => {
|
||||
if (isZenMode) {
|
||||
setIsZenMode(false)
|
||||
} else {
|
||||
setUISetting("wideMode", !isWideMode)
|
||||
// TODO: Remove this after useMeasure can get bounds in time
|
||||
window.dispatchEvent(new Event("resize"))
|
||||
}
|
||||
window.analytics?.capture("Switch to Wide Mode", {
|
||||
wideMode: !isWideMode ? 1 : 0,
|
||||
click: 1,
|
||||
})
|
||||
}}
|
||||
tooltip={
|
||||
isZenMode
|
||||
? t("zen.exit")
|
||||
: !isWideMode
|
||||
? t("entry_list_header.switch_to_widemode")
|
||||
: t("entry_list_header.switch_to_normalmode")
|
||||
}
|
||||
>
|
||||
{isZenMode ? (
|
||||
<MdiMeditation />
|
||||
) : (
|
||||
<i
|
||||
className={cn(isWideMode ? "i-mgc-align-justify-cute-re" : "i-mgc-align-left-cute-re")}
|
||||
/>
|
||||
)}
|
||||
</ActionButton>
|
||||
</ImpressionView>
|
||||
)
|
||||
}
|
||||
|
||||
export const AppendTaildingDivider = ({ children }: { children: React.ReactNode }) => (
|
||||
<>
|
||||
{children}
|
||||
{React.Children.toArray(children).filter(Boolean).length > 0 && (
|
||||
<DividerVertical className="mx-2 w-px" />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
export interface EntryListHeaderProps {
|
||||
totalCount: number
|
||||
refetch: () => void
|
||||
isRefreshing: boolean
|
||||
hasUpdate: boolean
|
||||
}
|
||||
|
|
@ -1,297 +1,10 @@
|
|||
import { MdiMeditation } from "@follow/components/icons/Meditation.jsx"
|
||||
import { ActionButton } from "@follow/components/ui/button/index.js"
|
||||
import { DividerVertical } from "@follow/components/ui/divider/index.js"
|
||||
import { RotatingRefreshIcon } from "@follow/components/ui/loading/index.jsx"
|
||||
import { EllipsisHorizontalTextWithTooltip } from "@follow/components/ui/typography/index.js"
|
||||
import { FeedViewType, views } from "@follow/constants"
|
||||
import { useIsOnline } from "@follow/hooks"
|
||||
import { stopPropagation } from "@follow/utils/dom"
|
||||
import { cn, isBizId } from "@follow/utils/utils"
|
||||
import type { FC } from "react"
|
||||
import * as React from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { withResponsiveSyncComponent } from "@follow/components/utils/selector.js"
|
||||
|
||||
import { setGeneralSetting, useGeneralSettingKey } from "~/atoms/settings/general"
|
||||
import {
|
||||
setUISetting,
|
||||
useIsZenMode,
|
||||
useRealInWideMode,
|
||||
useSetZenMode,
|
||||
useUISettingKey,
|
||||
} from "~/atoms/settings/ui"
|
||||
import { useFeedColumnShow } from "~/atoms/sidebar"
|
||||
import { useWhoami } from "~/atoms/user"
|
||||
import { ImpressionView } from "~/components/common/ImpressionTracker"
|
||||
import { FEED_COLLECTION_LIST, ROUTE_ENTRY_PENDING, ROUTE_FEED_IN_LIST } from "~/constants"
|
||||
import { shortcuts } from "~/constants/shortcuts"
|
||||
import { useRouteParams } from "~/hooks/biz/useRouteParams"
|
||||
import { useAIDailyReportModal } from "~/modules/ai/ai-daily/useAIDailyReportModal"
|
||||
import { EntryHeader } from "~/modules/entry-content/header"
|
||||
import { useRefreshFeedMutation } from "~/queries/feed"
|
||||
import { useFeedById, useFeedHeaderTitle } from "~/store/feed"
|
||||
import { EntryListHeader as EntryListHeaderDesktop } from "./EntryListHeader.desktop"
|
||||
import { EntryListHeader as EntryListHeaderMobile } from "./EntryListHeader.mobile"
|
||||
import type { EntryListHeaderProps } from "./EntryListHeader.shared"
|
||||
|
||||
import { MarkAllReadWithOverlay } from "../components/mark-all-button"
|
||||
|
||||
export const EntryListHeader: FC<{
|
||||
totalCount: number
|
||||
refetch: () => void
|
||||
isRefreshing: boolean
|
||||
hasUpdate: boolean
|
||||
}> = ({ totalCount, refetch, isRefreshing, hasUpdate }) => {
|
||||
const routerParams = useRouteParams()
|
||||
const { t } = useTranslation()
|
||||
|
||||
const unreadOnly = useGeneralSettingKey("unreadOnly")
|
||||
|
||||
const { feedId, entryId, view, listId } = routerParams
|
||||
|
||||
const headerTitle = useFeedHeaderTitle()
|
||||
|
||||
const isInCollectionList =
|
||||
feedId === FEED_COLLECTION_LIST || feedId?.startsWith(ROUTE_FEED_IN_LIST)
|
||||
|
||||
const titleInfo = !!headerTitle && (
|
||||
<div className={"min-w-0 translate-y-1"}>
|
||||
<div className="h-6 min-w-0 break-all text-lg font-bold leading-tight">
|
||||
<EllipsisHorizontalTextWithTooltip className="inline-block !w-auto max-w-full">
|
||||
<span className="relative -top-px">{headerTitle}</span>
|
||||
</EllipsisHorizontalTextWithTooltip>
|
||||
</div>
|
||||
<div className="whitespace-nowrap text-xs font-medium leading-none text-zinc-400">
|
||||
{totalCount || 0} {t("quantifier.piece", { ns: "common" })}
|
||||
{unreadOnly && !isInCollectionList ? t("words.unread") : ""}
|
||||
{t("space", { ns: "common" })}
|
||||
{t("words.items", { ns: "common", count: totalCount })}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
const { mutateAsync: refreshFeed, isPending } = useRefreshFeedMutation(feedId)
|
||||
|
||||
const user = useWhoami()
|
||||
const isOnline = useIsOnline()
|
||||
|
||||
const feed = useFeedById(feedId)
|
||||
const isList = !!listId
|
||||
|
||||
const containerRef = React.useRef<HTMLDivElement>(null)
|
||||
const titleStyleBasedView = ["pl-6", "pl-7", "pl-7", "pl-7", "px-5", "pl-6"]
|
||||
|
||||
const feedColumnShow = useFeedColumnShow()
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn(
|
||||
"mb-2 flex w-full flex-col pr-4 pt-2.5 transition-[padding] duration-300 ease-in-out",
|
||||
!feedColumnShow && "macos:mt-4 macos:pt-margin-macos-traffic-light-y",
|
||||
titleStyleBasedView[view],
|
||||
)}
|
||||
>
|
||||
<div className={"flex w-full justify-between"}>
|
||||
{titleInfo}
|
||||
<div
|
||||
className={cn(
|
||||
"relative z-[1] flex items-center gap-1 self-baseline text-zinc-500",
|
||||
(isInCollectionList || !headerTitle) && "pointer-events-none opacity-0",
|
||||
|
||||
"translate-x-[6px]",
|
||||
)}
|
||||
onClick={stopPropagation}
|
||||
>
|
||||
{views[view].wideMode && entryId && entryId !== ROUTE_ENTRY_PENDING && (
|
||||
<>
|
||||
<EntryHeader view={view} entryId={entryId} />
|
||||
<DividerVertical className="mx-2 w-px" />
|
||||
</>
|
||||
)}
|
||||
|
||||
<AppendTaildingDivider>
|
||||
{!views[view].wideMode && <WideModeButton />}
|
||||
{view === FeedViewType.SocialMedia && <DailyReportButton />}
|
||||
{view === FeedViewType.Pictures && <SwitchToMasonryButton />}
|
||||
{view === FeedViewType.Pictures && <FilterNoImageButton />}
|
||||
</AppendTaildingDivider>
|
||||
|
||||
{isOnline &&
|
||||
(feed?.ownerUserId === user?.id &&
|
||||
isBizId(routerParams.feedId!) &&
|
||||
feed?.type === "feed" ? (
|
||||
<ActionButton
|
||||
tooltip="Refresh"
|
||||
onClick={() => {
|
||||
refreshFeed()
|
||||
}}
|
||||
>
|
||||
<RotatingRefreshIcon isRefreshing={isPending} />
|
||||
</ActionButton>
|
||||
) : (
|
||||
<ActionButton
|
||||
tooltip={
|
||||
hasUpdate
|
||||
? t("entry_list_header.new_entries_available")
|
||||
: t("entry_list_header.refetch")
|
||||
}
|
||||
onClick={() => {
|
||||
refetch()
|
||||
}}
|
||||
>
|
||||
<RotatingRefreshIcon
|
||||
className={cn(hasUpdate && "text-accent")}
|
||||
isRefreshing={isRefreshing}
|
||||
/>
|
||||
</ActionButton>
|
||||
))}
|
||||
{!isList && (
|
||||
<>
|
||||
<ActionButton
|
||||
tooltip={
|
||||
!unreadOnly
|
||||
? t("entry_list_header.show_unread_only")
|
||||
: t("entry_list_header.show_all")
|
||||
}
|
||||
shortcut={shortcuts.entries.toggleUnreadOnly.key}
|
||||
onClick={() => setGeneralSetting("unreadOnly", !unreadOnly)}
|
||||
>
|
||||
{unreadOnly ? (
|
||||
<i className="i-mgc-round-cute-fi" />
|
||||
) : (
|
||||
<i className="i-mgc-round-cute-re" />
|
||||
)}
|
||||
</ActionButton>
|
||||
<MarkAllReadWithOverlay containerRef={containerRef} shortcut />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* <TimelineTabs /> */}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const DailyReportButton: FC = () => {
|
||||
const present = useAIDailyReportModal()
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<ImpressionView event="Daily Report Modal">
|
||||
<ActionButton
|
||||
onClick={() => {
|
||||
present()
|
||||
window.analytics?.capture("Daily Report Modal", {
|
||||
click: 1,
|
||||
})
|
||||
}}
|
||||
tooltip={t("entry_list_header.daily_report")}
|
||||
>
|
||||
<i className="i-mgc-magic-2-cute-re" />
|
||||
</ActionButton>
|
||||
</ImpressionView>
|
||||
)
|
||||
}
|
||||
|
||||
const FilterNoImageButton = () => {
|
||||
const enabled = useUISettingKey("pictureViewFilterNoImage")
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<ActionButton
|
||||
active={enabled}
|
||||
onClick={() => {
|
||||
setUISetting("pictureViewFilterNoImage", !enabled)
|
||||
}}
|
||||
tooltip={t(
|
||||
enabled ? "entry_list_header.show_all_items" : "entry_list_header.hide_no_image_items",
|
||||
)}
|
||||
>
|
||||
<i className={!enabled ? "i-mgc-photo-album-cute-re" : "i-mgc-photo-album-cute-fi"} />
|
||||
</ActionButton>
|
||||
)
|
||||
}
|
||||
|
||||
const SwitchToMasonryButton = () => {
|
||||
const isMasonry = useUISettingKey("pictureViewMasonry")
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<ImpressionView
|
||||
event="Switch to Masonry"
|
||||
properties={{
|
||||
masonry: isMasonry ? 1 : 0,
|
||||
}}
|
||||
>
|
||||
<ActionButton
|
||||
onClick={() => {
|
||||
setUISetting("pictureViewMasonry", !isMasonry)
|
||||
window.analytics?.capture("Switch to Masonry", {
|
||||
masonry: !isMasonry ? 1 : 0,
|
||||
click: 1,
|
||||
})
|
||||
}}
|
||||
tooltip={
|
||||
!isMasonry
|
||||
? t("entry_list_header.switch_to_masonry")
|
||||
: t("entry_list_header.switch_to_grid")
|
||||
}
|
||||
>
|
||||
<i className={cn(!isMasonry ? "i-mgc-grid-cute-re" : "i-mgc-grid-2-cute-re")} />
|
||||
</ActionButton>
|
||||
</ImpressionView>
|
||||
)
|
||||
}
|
||||
|
||||
const WideModeButton = () => {
|
||||
const isWideMode = useRealInWideMode()
|
||||
const isZenMode = useIsZenMode()
|
||||
const { t } = useTranslation()
|
||||
|
||||
const setIsZenMode = useSetZenMode()
|
||||
return (
|
||||
<ImpressionView
|
||||
event="Switch to Wide Mode"
|
||||
properties={{
|
||||
wideMode: isWideMode ? 1 : 0,
|
||||
}}
|
||||
>
|
||||
<ActionButton
|
||||
shortcut={shortcuts.layout.toggleWideMode.key}
|
||||
onClick={() => {
|
||||
if (isZenMode) {
|
||||
setIsZenMode(false)
|
||||
} else {
|
||||
setUISetting("wideMode", !isWideMode)
|
||||
// TODO: Remove this after useMeasure can get bounds in time
|
||||
window.dispatchEvent(new Event("resize"))
|
||||
}
|
||||
window.analytics?.capture("Switch to Wide Mode", {
|
||||
wideMode: !isWideMode ? 1 : 0,
|
||||
click: 1,
|
||||
})
|
||||
}}
|
||||
tooltip={
|
||||
isZenMode
|
||||
? t("zen.exit")
|
||||
: !isWideMode
|
||||
? t("entry_list_header.switch_to_widemode")
|
||||
: t("entry_list_header.switch_to_normalmode")
|
||||
}
|
||||
>
|
||||
{isZenMode ? (
|
||||
<MdiMeditation />
|
||||
) : (
|
||||
<i
|
||||
className={cn(isWideMode ? "i-mgc-align-justify-cute-re" : "i-mgc-align-left-cute-re")}
|
||||
/>
|
||||
)}
|
||||
</ActionButton>
|
||||
</ImpressionView>
|
||||
)
|
||||
}
|
||||
|
||||
const AppendTaildingDivider = ({ children }: { children: React.ReactNode }) => (
|
||||
<>
|
||||
{children}
|
||||
{React.Children.toArray(children).filter(Boolean).length > 0 && (
|
||||
<DividerVertical className="mx-2 w-px" />
|
||||
)}
|
||||
</>
|
||||
export const EntryListHeader = withResponsiveSyncComponent<EntryListHeaderProps>(
|
||||
EntryListHeaderDesktop,
|
||||
EntryListHeaderMobile,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -29,14 +29,9 @@ export const TimelineTabs = () => {
|
|||
const navigate = useNavigateEntry()
|
||||
if (!hasData) return null
|
||||
|
||||
const handleWheel = (e: React.WheelEvent<HTMLDivElement>) => {
|
||||
e.preventDefault()
|
||||
e.currentTarget.scrollLeft += e.deltaY
|
||||
}
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
className="-ml-3 -mr-4 mt-3 flex overflow-x-auto scrollbar-none"
|
||||
className="-ml-3 -mr-4 mt-2 flex overflow-x-auto overflow-y-hidden scrollbar-none"
|
||||
value={timeline}
|
||||
onValueChange={(val) => {
|
||||
if (!val) {
|
||||
|
|
@ -48,7 +43,7 @@ export const TimelineTabs = () => {
|
|||
}
|
||||
}}
|
||||
>
|
||||
<TabsList className="justify-start overflow-hidden border-b-0" onWheel={handleWheel}>
|
||||
<TabsList className="justify-start border-b-0">
|
||||
<TabsTrigger variant={"rounded"} className="p-0" value="">
|
||||
Yours
|
||||
</TabsTrigger>
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { GroupedVirtuoso, Virtuoso } from "react-virtuoso"
|
|||
import { useGeneralSettingKey } from "~/atoms/settings/general"
|
||||
import { m } from "~/components/common/Motion"
|
||||
import { ReactVirtuosoItemPlaceholder } from "~/components/ui/placeholder"
|
||||
import { ENTRY_COLUMN_LIST_SCROLLER_ID } from "~/constants/dom"
|
||||
import { useRouteParams } from "~/hooks/biz/useRouteParams"
|
||||
import { useEntry } from "~/store/entry"
|
||||
import { isListSubscription } from "~/store/subscription"
|
||||
|
|
@ -77,7 +78,12 @@ export const EntryList: FC<EntryListProps> = memo(
|
|||
ref={virtuosoRef}
|
||||
/>
|
||||
) : (
|
||||
<Virtuoso onKeyDown={handleKeyDown} {...virtuosoOptions} ref={virtuosoRef} />
|
||||
<Virtuoso
|
||||
id={ENTRY_COLUMN_LIST_SCROLLER_ID}
|
||||
onKeyDown={handleKeyDown}
|
||||
{...virtuosoOptions}
|
||||
ref={virtuosoRef}
|
||||
/>
|
||||
)}
|
||||
<EntryColumnShortcutHandler
|
||||
refetch={refetch}
|
||||
|
|
@ -97,6 +103,7 @@ const EntryGroupedList = forwardRef<
|
|||
}
|
||||
>(({ groupCounts, itemContent, onKeyDown, data, totalCount, ...virtuosoOptions }, ref) => (
|
||||
<GroupedVirtuoso
|
||||
id={ENTRY_COLUMN_LIST_SCROLLER_ID}
|
||||
className="z-0"
|
||||
ref={ref}
|
||||
groupContent={useCallback(
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { isMobile } from "@follow/components/hooks/useMobile.js"
|
||||
import { EllipsisHorizontalTextWithTooltip } from "@follow/components/ui/typography/index.js"
|
||||
import { cn, isSafari } from "@follow/utils/utils"
|
||||
|
||||
|
|
@ -50,16 +51,6 @@ export function ListItem({
|
|||
|
||||
const inbox = useInboxById(entry?.inboxId)
|
||||
|
||||
// const handlePrefetchEntry = useDebounceCallback(
|
||||
// () => {
|
||||
// inbox
|
||||
// ? Queries.entries.byInboxId(entryId).prefetch()
|
||||
// : Queries.entries.byId(entryId).prefetch()
|
||||
// },
|
||||
// 300,
|
||||
// { leading: false },
|
||||
// )
|
||||
|
||||
const settingWideMode = useRealInWideMode()
|
||||
const thumbnailRatio = useUISettingKey("thumbnailRatio")
|
||||
const rid = `list-item-${entryId}`
|
||||
|
|
@ -74,8 +65,6 @@ export function ListItem({
|
|||
|
||||
return (
|
||||
<div
|
||||
// onMouseEnter={handlePrefetchEntry}
|
||||
// onMouseLeave={handlePrefetchEntry.cancel}
|
||||
className={cn(
|
||||
"group relative flex cursor-menu pl-3 pr-2",
|
||||
!asRead &&
|
||||
|
|
@ -218,7 +207,8 @@ function AudioCover({
|
|||
|
||||
const estimatedMins = durationInSeconds ? Math.floor(durationInSeconds / 60) : undefined
|
||||
|
||||
const handleClickPlay = () => {
|
||||
const handleClickPlay = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (isMobile()) e.stopPropagation()
|
||||
if (!playStatus) {
|
||||
// switch this to play
|
||||
AudioPlayer.mount({
|
||||
|
|
|
|||
|
|
@ -0,0 +1,30 @@
|
|||
import { ScrollArea } from "@follow/components/ui/scroll-area/ScrollArea.js"
|
||||
import { views } from "@follow/constants"
|
||||
import { clsx } from "clsx"
|
||||
import { forwardRef } from "react"
|
||||
|
||||
import { useRouteParamsSelector } from "~/hooks/biz/useRouteParams"
|
||||
|
||||
import type { EntryColumnWrapperProps } from "./wrapper.shared"
|
||||
import { styles } from "./wrapper.shared"
|
||||
|
||||
export const EntryColumnWrapper = forwardRef<HTMLDivElement, EntryColumnWrapperProps>(
|
||||
({ children, onScroll }, ref) => {
|
||||
const view = useRouteParamsSelector((state) => state.view)
|
||||
|
||||
return (
|
||||
<div className={clsx(styles, "mt-2")}>
|
||||
<ScrollArea
|
||||
scrollbarClassName={!views[view].wideMode ? "w-[5px] p-0" : ""}
|
||||
mask={false}
|
||||
ref={ref}
|
||||
rootClassName={clsx("h-full", views[view].wideMode ? "mt-2" : "")}
|
||||
viewportClassName="[&>div]:grow flex"
|
||||
onScroll={onScroll}
|
||||
>
|
||||
{children}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { EntryColumnWrapper } from "./wrapper.desktop"
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
import { ScrollElementContext } from "@follow/components/ui/scroll-area/ctx.js"
|
||||
import { views } from "@follow/constants"
|
||||
import { clsx } from "clsx"
|
||||
import { forwardRef, useState } from "react"
|
||||
|
||||
import { PullToRefresh } from "~/components/ux/pull-to-refresh"
|
||||
import { useRouteParamsSelector } from "~/hooks/biz/useRouteParams"
|
||||
|
||||
import { CornerPlayer } from "../player/corner-player"
|
||||
import type { EntryColumnWrapperProps } from "./wrapper.shared"
|
||||
import { styles } from "./wrapper.shared"
|
||||
|
||||
const noop = async () => {}
|
||||
|
||||
export const EntryColumnWrapper = forwardRef<HTMLDivElement, EntryColumnWrapperProps>(
|
||||
({ children, onPullToRefresh }, ref) => {
|
||||
const view = useRouteParamsSelector((state) => state.view)
|
||||
|
||||
const [scrollElement, setScrollElement] = useState<HTMLElement | null>(null)
|
||||
return (
|
||||
<div className={clsx(styles, "relative flex flex-col")}>
|
||||
<div ref={ref} className={clsx("grow overflow-hidden", views[view].wideMode ? "mt-2" : "")}>
|
||||
<PullToRefresh className="h-full" onRefresh={onPullToRefresh || noop}>
|
||||
<ScrollElementContext.Provider value={scrollElement}>
|
||||
<div className="h-full overflow-y-auto" ref={setScrollElement}>
|
||||
{children}
|
||||
</div>
|
||||
</ScrollElementContext.Provider>
|
||||
</PullToRefresh>
|
||||
</div>
|
||||
|
||||
<CornerPlayer className="w-full md:w-[350px]" />
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
export const styles = tw`relative h-0 grow duration-200 ease-in-out animate-in fade-in slide-in-from-bottom-24 f-motion-reduce:fade-in-0 f-motion-reduce:slide-in-from-bottom-0`
|
||||
|
||||
export interface EntryColumnWrapperProps extends ComponentType {
|
||||
onScroll?: (e: React.UIEvent<HTMLDivElement>) => void
|
||||
onPullToRefresh?: () => Promise<any>
|
||||
}
|
||||