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>
This commit is contained in:
Innei 2024-11-22 19:32:39 +08:00 committed by GitHub
parent 78611bb598
commit edd4f9e5a6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
229 changed files with 9851 additions and 5453 deletions

2
.gitignore vendored
View File

@ -20,4 +20,6 @@ vite.config.*.mjs
.generated
.turbo
apps/renderer/dev-dist
tsconfig.tsbuildinfo

View File

@ -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"

View File

@ -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;

View File

@ -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",

Binary file not shown.

After

Width:  |  Height:  |  Size: 539 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 564 B

View File

@ -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"
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 460 B

View File

@ -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"],
})

View File

@ -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 />

View File

@ -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
},

View File

@ -7,6 +7,8 @@ const createDefaultSettings = (): GeneralSettings => ({
// App
appLaunchOnStartup: false,
language: "en",
// mobile app
startupScreen: "timeline",
// Data control
dataPersist: true,
sendAnonymousData: true,

View File

@ -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)
}

View File

@ -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>
)

View File

@ -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}

View File

@ -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>}

View File

@ -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

View File

@ -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 }
}

View File

@ -46,6 +46,7 @@ export type MediaProps = BaseProps &
previewImageUrl?: string
})
)
const MediaImpl: FC<MediaProps> = ({
className,
proxy,

View File

@ -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">

View File

@ -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>

View File

@ -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,

View File

@ -9,6 +9,7 @@ import { modalMontionConfig } from "../constants"
*/
export const useModalAnimate = (isTop: boolean) => {
const animateController = useAnimationControls()
useEffect(() => {
nextFrame(() => {
animateController.start(modalMontionConfig.animate)

View File

@ -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>
)
}

View File

@ -0,0 +1 @@
export { ModalStack } from "./modal-stack.desktop"

View File

@ -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}
/>
)
}

View File

@ -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,
}
}

View File

@ -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)

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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"

View File

@ -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) => {

View File

@ -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()}`)
}

View File

@ -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,
}
}

View File

@ -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()
}
}

View File

@ -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"

View File

@ -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">

View File

@ -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,
})

View File

@ -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}
/>

View File

@ -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>
)
}

View File

@ -0,0 +1,3 @@
export { CenterColumnDesktop as CenterColumnLayout } from "./desktop"
export const MobileCenterColumnScreen = () => null

View File

@ -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,
})),
)

View File

@ -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>
)
}

View File

@ -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>
)
}
}

View File

@ -0,0 +1,3 @@
export { RightContentDesktop as RightContentLayout } from "./desktop"
export const MobileRightContentScreen = () => null

View File

@ -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,
})),
)

View File

@ -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!} />
}

View File

@ -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>
)
}

View File

@ -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,
})
})
})
}

View File

@ -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" />
)}
</>
)
}

View File

@ -0,0 +1,3 @@
export { MainDestopLayout as LeftSidebarLayout } from "./desktop"
export const MobileFeedScreen = () => null

View File

@ -0,0 +1,8 @@
import { Outlet } from "react-router-dom"
import { useDailyTask } from "~/hooks/biz/useDailyTask"
export const MobileRootLayout = () => {
useDailyTask()
return <Outlet />
}

View File

@ -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,
})),
)

View File

@ -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);
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -0,0 +1 @@
export { SubviewLayout } from "./index.desktop"

View File

@ -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 />
</>
)
}

View File

@ -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)

View File

@ -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} />

View File

@ -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,
})
},
},
{

View File

@ -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,
)}
>

View File

@ -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"}

View File

@ -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

View File

@ -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>
)
})

View File

@ -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>
)
})

View File

@ -0,0 +1 @@
export { InboxTable } from "./inbox-table.desktop"

View File

@ -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>
)
})

View File

@ -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>
)
}

View File

@ -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,
)

View File

@ -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>
)

View File

@ -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)}>

View File

@ -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]]}

View File

@ -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"]}

View File

@ -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]]}

View File

@ -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>

View File

@ -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

View File

@ -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}>

View File

@ -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,

View File

@ -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`,
)

View File

@ -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,

View File

@ -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,

View File

@ -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}

View File

@ -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>

View File

@ -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>
)
}

View File

@ -0,0 +1 @@
export { EntryListHeader } from "./EntryListHeader.desktop"

View File

@ -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>
)
}

View File

@ -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
}

View File

@ -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,
)

View File

@ -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>

View File

@ -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(

View File

@ -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({

View File

@ -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>
)
},
)

View File

@ -0,0 +1 @@
export { EntryColumnWrapper } from "./wrapper.desktop"

View File

@ -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>
)
},
)

View File

@ -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>
}

Some files were not shown because too many files have changed in this diff Show More