diff --git a/.gitignore b/.gitignore index bb82b1017..6bdb869f6 100644 --- a/.gitignore +++ b/.gitignore @@ -20,4 +20,6 @@ vite.config.*.mjs .generated .turbo + +apps/renderer/dev-dist tsconfig.tsbuildinfo diff --git a/apps/renderer/global.d.ts b/apps/renderer/global.d.ts index c60d1f76d..97bf38522 100644 --- a/apps/renderer/global.d.ts +++ b/apps/renderer/global.d.ts @@ -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>] + offlineReady: [boolean, Dispatch>] + updateServiceWorker: (reloadPage?: boolean) => Promise + } +} + export {} + +export { type RegisterSWOptions } from "vite-plugin-pwa/types" diff --git a/apps/renderer/index.html b/apps/renderer/index.html index 9480df590..8e8fc1b1c 100644 --- a/apps/renderer/index.html +++ b/apps/renderer/index.html @@ -5,12 +5,15 @@ - + + - + + + Follow @@ -58,6 +61,10 @@ const isElectron = navigator.userAgent.includes("Electron") document.documentElement.dataset.buildType = isElectron ? "electron" : "web" +
@@ -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; diff --git a/apps/renderer/package.json b/apps/renderer/package.json index 2fb9331b6..9c947d5aa 100644 --- a/apps/renderer/package.json +++ b/apps/renderer/package.json @@ -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", diff --git a/apps/renderer/public/apple-touch-icon-180x180.png b/apps/renderer/public/apple-touch-icon-180x180.png new file mode 100644 index 000000000..a5da8888e Binary files /dev/null and b/apps/renderer/public/apple-touch-icon-180x180.png differ diff --git a/apps/renderer/public/favicon.ico b/apps/renderer/public/favicon.ico index 89db74809..9a85269e6 100644 Binary files a/apps/renderer/public/favicon.ico and b/apps/renderer/public/favicon.ico differ diff --git a/apps/renderer/public/manifest.json b/apps/renderer/public/manifest.json deleted file mode 100644 index 949134a10..000000000 --- a/apps/renderer/public/manifest.json +++ /dev/null @@ -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" - } - ] -} diff --git a/apps/renderer/public/maskable-icon-512x512.png b/apps/renderer/public/maskable-icon-512x512.png new file mode 100644 index 000000000..0d23bd1fa Binary files /dev/null and b/apps/renderer/public/maskable-icon-512x512.png differ diff --git a/apps/renderer/public/pwa-192x192.png b/apps/renderer/public/pwa-192x192.png new file mode 100644 index 000000000..e0352e0ae Binary files /dev/null and b/apps/renderer/public/pwa-192x192.png differ diff --git a/apps/renderer/public/pwa-512x512.png b/apps/renderer/public/pwa-512x512.png new file mode 100644 index 000000000..737497b85 Binary files /dev/null and b/apps/renderer/public/pwa-512x512.png differ diff --git a/apps/renderer/public/pwa-64x64.png b/apps/renderer/public/pwa-64x64.png new file mode 100644 index 000000000..33c679afe Binary files /dev/null and b/apps/renderer/public/pwa-64x64.png differ diff --git a/apps/renderer/pwa-assets.config.ts b/apps/renderer/pwa-assets.config.ts new file mode 100644 index 000000000..590eb8473 --- /dev/null +++ b/apps/renderer/pwa-assets.config.ts @@ -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"], +}) diff --git a/apps/renderer/src/App.tsx b/apps/renderer/src/App.tsx index f0943c91f..abfc9e6d5 100644 --- a/apps/renderer/src/App.tsx +++ b/apps/renderer/src/App.tsx @@ -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 ? : diff --git a/apps/renderer/src/atoms/player.ts b/apps/renderer/src/atoms/player.ts index fd6bf6f7e..d75c57f1a 100644 --- a/apps/renderer/src/atoms/player.ts +++ b/apps/renderer/src/atoms/player.ts @@ -29,13 +29,15 @@ const playerInitialValue: PlayerAtomValue = { } const jsonStorage = createJSONStorage() +let hydrationDone = false const patchedLocalStorage: SyncStorage = { 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 }, diff --git a/apps/renderer/src/atoms/settings/general.ts b/apps/renderer/src/atoms/settings/general.ts index 96322c782..c6021f948 100644 --- a/apps/renderer/src/atoms/settings/general.ts +++ b/apps/renderer/src/atoms/settings/general.ts @@ -7,6 +7,8 @@ const createDefaultSettings = (): GeneralSettings => ({ // App appLaunchOnStartup: false, language: "en", + // mobile app + startupScreen: "timeline", // Data control dataPersist: true, sendAnonymousData: true, diff --git a/apps/renderer/src/components/common/ReloadPrompt.tsx b/apps/renderer/src/components/common/ReloadPrompt.tsx new file mode 100644 index 000000000..dc6ff7030 --- /dev/null +++ b/apps/renderer/src/components/common/ReloadPrompt.tsx @@ -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) +} diff --git a/apps/renderer/src/components/mobile/button.tsx b/apps/renderer/src/components/mobile/button.tsx new file mode 100644 index 000000000..ff2ebd775 --- /dev/null +++ b/apps/renderer/src/components/mobile/button.tsx @@ -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 }) => ( + window.history.returnBack(to)} + className={cn("center size-8", className)} + > + + + Back + +) diff --git a/apps/renderer/src/components/ui/context-menu/context-menu.tsx b/apps/renderer/src/components/ui/context-menu/context-menu.tsx index cbea7e8bd..88951f355 100644 --- a/apps/renderer/src/components/ui/context-menu/context-menu.tsx +++ b/apps/renderer/src/components/ui/context-menu/context-menu.tsx @@ -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} diff --git a/apps/renderer/src/components/ui/markdown/HTML.tsx b/apps/renderer/src/components/ui/markdown/HTML.tsx index cd96dea96..c6f45f6da 100644 --- a/apps/renderer/src/components/ui/markdown/HTML.tsx +++ b/apps/renderer/src/components/ui/markdown/HTML.tsx @@ -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 = (props: HTMLProp {katexStyle} - {createElement(as, { ...rest, ref: setRefElement }, markdownElement)} + {createElement( + as, + { + ...rest, + id: ENTRY_CONTENT_RENDER_CONTAINER_ID, + ref: setRefElement, + }, + markdownElement, + )} {!!accessory && {accessory}} diff --git a/apps/renderer/src/components/ui/markdown/components/Toc.tsx b/apps/renderer/src/components/ui/markdown/components/Toc.tsx index a1d9321bf..f1620a1ab 100644 --- a/apps/renderer/src/components/ui/markdown/components/Toc.tsx +++ b/apps/renderer/src/components/ui/markdown/components/Toc.tsx @@ -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 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((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) => { - 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 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 diff --git a/apps/renderer/src/components/ui/markdown/components/hooks.tsx b/apps/renderer/src/components/ui/markdown/components/hooks.tsx new file mode 100644 index 000000000..62a51ab88 --- /dev/null +++ b/apps/renderer/src/components/ui/markdown/components/hooks.tsx @@ -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 any> = T & { + cancel: () => void + flush: () => void +} + +export const useScrollTracking = ( + toc: ITocItem[], + options: Pick & { + 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 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 } +} diff --git a/apps/renderer/src/components/ui/media.tsx b/apps/renderer/src/components/ui/media.tsx index 2258025b9..10f747b26 100644 --- a/apps/renderer/src/components/ui/media.tsx +++ b/apps/renderer/src/components/ui/media.tsx @@ -46,6 +46,7 @@ export type MediaProps = BaseProps & previewImageUrl?: string }) ) + const MediaImpl: FC = ({ className, proxy, diff --git a/apps/renderer/src/components/ui/media/hooks.tsx b/apps/renderer/src/components/ui/media/hooks.tsx index 5692a25c5..6a7471e8a 100644 --- a/apps/renderer/src/components/ui/media/hooks.tsx +++ b/apps/renderer/src/components/ui/media/hooks.tsx @@ -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: () => (
diff --git a/apps/renderer/src/components/ui/media/preview-media.tsx b/apps/renderer/src/components/ui/media/preview-media.tsx index 7d596cdc3..918966b41 100644 --- a/apps/renderer/src/components/ui/media/preview-media.tsx +++ b/apps/renderer/src/components/ui/media/preview-media.tsx @@ -33,7 +33,7 @@ const Wrapper: Component<{ const { t } = useTranslation(["shortcuts", "common"]) return ( -
+
- ) : ( + ) : isLoading ? ( - )} + ) : null}
diff --git a/apps/renderer/src/components/ui/modal/stacked/hooks.tsx b/apps/renderer/src/components/ui/modal/stacked/hooks.tsx index 4ce40744e..12d898d8c 100644 --- a/apps/renderer/src/components/ui/modal/stacked/hooks.tsx +++ b/apps/renderer/src/components/ui/modal/stacked/hooks.tsx @@ -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 = { + 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 = { - 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, diff --git a/apps/renderer/src/components/ui/modal/stacked/internal/use-animate.ts b/apps/renderer/src/components/ui/modal/stacked/internal/use-animate.ts index a964bea14..212696892 100644 --- a/apps/renderer/src/components/ui/modal/stacked/internal/use-animate.ts +++ b/apps/renderer/src/components/ui/modal/stacked/internal/use-animate.ts @@ -9,6 +9,7 @@ import { modalMontionConfig } from "../constants" */ export const useModalAnimate = (isTop: boolean) => { const animateController = useAnimationControls() + useEffect(() => { nextFrame(() => { animateController.start(modalMontionConfig.animate) diff --git a/apps/renderer/src/components/ui/modal/stacked/modal-stack.desktop.tsx b/apps/renderer/src/components/ui/modal/stacked/modal-stack.desktop.tsx new file mode 100644 index 000000000..3ccc950c7 --- /dev/null +++ b/apps/renderer/src/components/ui/modal/stacked/modal-stack.desktop.tsx @@ -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 ( + + {stack.map((item, index) => ( + + ))} + + ) +} diff --git a/apps/renderer/src/components/ui/modal/stacked/modal-stack.electron.tsx b/apps/renderer/src/components/ui/modal/stacked/modal-stack.electron.tsx new file mode 100644 index 000000000..8ddef9c66 --- /dev/null +++ b/apps/renderer/src/components/ui/modal/stacked/modal-stack.electron.tsx @@ -0,0 +1 @@ +export { ModalStack } from "./modal-stack.desktop" diff --git a/apps/renderer/src/components/ui/modal/stacked/modal-stack.mobile.tsx b/apps/renderer/src/components/ui/modal/stacked/modal-stack.mobile.tsx new file mode 100644 index 000000000..7842df864 --- /dev/null +++ b/apps/renderer/src/components/ui/modal/stacked/modal-stack.mobile.tsx @@ -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 ( + + {stack.map((item, index) => { + const shouldUseModal = !!item.CustomModalComponent + if (!shouldUseModal) return + return ( + + ) + })} + + ) +} + +const ModalToSheet = (props: ModalProps & { index: number; id: string }) => { + const sheetRef = useRef(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(null) + const ModalContextProps = useMemo( + () => ({ + ...ModalProps, + ref: modalContentRef, + }), + [ModalProps], + ) + + const finalChildren = ( + + {createElement(content, ModalProps)} + + ) + + return ( + { + if (!open) { + setTimeout(() => { + close() + }, 1000) + } + }} + content={finalChildren} + /> + ) +} diff --git a/apps/renderer/src/components/ui/modal/stacked/modal-stack.shared.tsx b/apps/renderer/src/components/ui/modal/stacked/modal-stack.shared.tsx new file mode 100644 index 000000000..009773c90 --- /dev/null +++ b/apps/renderer/src/components/ui/modal/stacked/modal-stack.shared.tsx @@ -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, + } +} diff --git a/apps/renderer/src/components/ui/modal/stacked/modal-stack.tsx b/apps/renderer/src/components/ui/modal/stacked/modal-stack.tsx new file mode 100644 index 000000000..b386d5451 --- /dev/null +++ b/apps/renderer/src/components/ui/modal/stacked/modal-stack.tsx @@ -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) diff --git a/apps/renderer/src/components/ui/modal/stacked/provider.tsx b/apps/renderer/src/components/ui/modal/stacked/provider.tsx index cbe957c83..fcffdb1cc 100644 --- a/apps/renderer/src/components/ui/modal/stacked/provider.tsx +++ b/apps/renderer/src/components/ui/modal/stacked/provider.tsx @@ -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 = ({ children }) => ( ) - -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 ( - - {stack.map((item, index) => ( - - ))} - - ) -} diff --git a/apps/renderer/src/components/ux/pull-to-refresh/index.tsx b/apps/renderer/src/components/ux/pull-to-refresh/index.tsx new file mode 100644 index 000000000..5325b58ab --- /dev/null +++ b/apps/renderer/src/components/ux/pull-to-refresh/index.tsx @@ -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 + 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(null) + const contentRef = useRef(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 ( +
+
0 ? "opacity-100" : "opacity-0", + !pulling && "duration-200", + )} + style={{ + transform: `translateY(${actualPullDistance - 60}px)`, + }} + > + + + +
+ + {/* Content area */} +
+ {children} +
+
+ ) +} diff --git a/apps/renderer/src/constants/dom.ts b/apps/renderer/src/constants/dom.ts new file mode 100644 index 000000000..4c65cbd84 --- /dev/null +++ b/apps/renderer/src/constants/dom.ts @@ -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" diff --git a/apps/renderer/src/hooks/biz/useEntryActions.tsx b/apps/renderer/src/hooks/biz/useEntryActions.tsx index 6de5d8b3c..fcc908df3 100644 --- a/apps/renderer/src/hooks/biz/useEntryActions.tsx +++ b/apps/renderer/src/hooks/biz/useEntryActions.tsx @@ -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) => { diff --git a/apps/renderer/src/hooks/biz/useNavigateEntry.ts b/apps/renderer/src/hooks/biz/useNavigateEntry.ts index 8a0e0c7a9..50ebcdcfe 100644 --- a/apps/renderer/src/hooks/biz/useNavigateEntry.ts +++ b/apps/renderer/src/hooks/biz/useNavigateEntry.ts @@ -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()}`) } diff --git a/apps/renderer/src/hooks/common/useContextMenu.tsx b/apps/renderer/src/hooks/common/useContextMenu.tsx new file mode 100644 index 000000000..2fe03a893 --- /dev/null +++ b/apps/renderer/src/hooks/common/useContextMenu.tsx @@ -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, + } +} diff --git a/apps/renderer/src/initialize/history.ts b/apps/renderer/src/initialize/history.ts new file mode 100644 index 000000000..df4ce15ae --- /dev/null +++ b/apps/renderer/src/initialize/history.ts @@ -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([]) + +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() + } +} diff --git a/apps/renderer/src/initialize/index.ts b/apps/renderer/src/initialize/index.ts index 59d7b0d43..83c4d3172 100644 --- a/apps/renderer/src/initialize/index.ts +++ b/apps/renderer/src/initialize/index.ts @@ -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" diff --git a/apps/renderer/src/modules/achievement/AchievementModalContent.tsx b/apps/renderer/src/modules/achievement/AchievementModalContent.tsx index 1d1431861..d159008e5 100644 --- a/apps/renderer/src/modules/achievement/AchievementModalContent.tsx +++ b/apps/renderer/src/modules/achievement/AchievementModalContent.tsx @@ -197,7 +197,7 @@ export const AchievementModalContent: FC = () => { return (
{ * - +