release(desktop): Release v0.4.6

release(desktop): Release v0.4.6
This commit is contained in:
DIYgod 2025-05-16 19:36:11 +08:00 committed by GitHub
commit e40f44487b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
229 changed files with 5199 additions and 2559 deletions

View File

@ -7,6 +7,7 @@ on:
paths:
- "apps/desktop/**"
- "pnpm-lock.yaml"
- ".github/workflows/build-desktop.yml"
workflow_dispatch:
inputs:
tag_version:
@ -124,6 +125,11 @@ jobs:
- name: Install dependencies
run: pnpm i
- name: Prebuild packages (Windows)
if: runner.os == 'windows'
run: pnpm run build:packages
- name: Update main hash
working-directory: apps/desktop
run: pnpm update:main-hash

View File

@ -18,8 +18,8 @@
<a href="https://discord.gg/followapp" target="_blank"><img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fdiscord.com%2Fapi%2Finvites%2Ffollowapp%3Fwith_counts%3Dtrue&query=approximate_member_count&color=5865F2&label=Discord&labelColor=black&logo=discord&logoColor=white&style=flat-square"/></a>
<br />
<a href="https://github.com/RSSNext/Folo/releases"><img src="https://img.shields.io/github/package-json/v/RSSNext/Folo?filename=%2Fapps%2Fmobile%2Fpackage.json&style=flat-square&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB3aWR0aD0iNTUiIGhlaWdodD0iNTUiIHZpZXdCb3g9IjAgMCA1NSA1NSIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTQxLjc5NDQgMTBIMTguNjc4NkMxNS44ODMzIDEwIDEzLjYxNzIgMTIuMTk2NSAxMy42MTcyIDE0LjkwNTlDMTMuNjE3MiAxNy42MTU0IDE1Ljg4MzMgMTkuODExOSAxOC42Nzg2IDE5LjgxMTlINDEuNzk0NEM0NC41ODk5IDE5LjgxMTkgNDYuODU1OSAxNy42MTU0IDQ2Ljg1NTkgMTQuOTA1OUM0Ni44NTU5IDEyLjE5NjUgNDQuNTg5OSAxMCA0MS43OTQ0IDEwWiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZD0iTTI5LjQ2ODMgMjIuNzU1OUgxNC4wNjE1QzExLjI2NjEgMjIuNzU1OSA5IDI0Ljk1MjMgOSAyNy42NjE4QzkgMzAuMzcxMyAxMS4yNjYxIDMyLjU2NzcgMTQuMDYxNSAzMi41Njc3SDI5LjQ2ODNDMzIuMjYzNyAzMi41Njc3IDM0LjUyOTggMzAuMzcxMyAzNC41Mjk4IDI3LjY2MThDMzQuNTI5OCAyNC45NTIzIDMyLjI2MzcgMjIuNzU1OSAyOS40NjgzIDIyLjc1NTlaIiBmaWxsPSJ3aGl0ZSIvPgo8cGF0aCBkPSJNMzAuOTU4OSA0MC40MjcyQzMwLjk1ODkgMzcuNzE3OSAyOC42OTI4IDM1LjUyMTUgMjUuODk3NCAzNS41MjE1QzIzLjEwMjEgMzUuNTIxNSAyMC44MzU5IDM3LjcxNzkgMjAuODM1OSA0MC40MjcyQzIwLjgzNTkgNDMuMTM3MSAyMy4xMDIxIDQ1LjMzMzIgMjUuODk3NCA0NS4zMzMyQzI4LjY5MjggNDUuMzMzMiAzMC45NTg5IDQzLjEzNzEgMzAuOTU4OSA0MC40MjcyWiIgZmlsbD0id2hpdGUiLz4KPC9zdmc%2BCg%3D%3D&label=Mobile&labelColor=black&color=FF5E01" /></a>
<a href="https://github.com/RSSNext/Folo/releases"><img src="https://img.shields.io/github/package-json/v/RSSNext/Folo?filename=%2Fapps%2Fdesktop%2Fpackage.json&style=flat-square&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB3aWR0aD0iNTUiIGhlaWdodD0iNTUiIHZpZXdCb3g9IjAgMCA1NSA1NSIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTQxLjc5NDQgMTBIMTguNjc4NkMxNS44ODMzIDEwIDEzLjYxNzIgMTIuMTk2NSAxMy42MTcyIDE0LjkwNTlDMTMuNjE3MiAxNy42MTU0IDE1Ljg4MzMgMTkuODExOSAxOC42Nzg2IDE5LjgxMTlINDEuNzk0NEM0NC41ODk5IDE5LjgxMTkgNDYuODU1OSAxNy42MTU0IDQ2Ljg1NTkgMTQuOTA1OUM0Ni44NTU5IDEyLjE5NjUgNDQuNTg5OSAxMCA0MS43OTQ0IDEwWiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZD0iTTI5LjQ2ODMgMjIuNzU1OUgxNC4wNjE1QzExLjI2NjEgMjIuNzU1OSA5IDI0Ljk1MjMgOSAyNy42NjE4QzkgMzAuMzcxMyAxMS4yNjYxIDMyLjU2NzcgMTQuMDYxNSAzMi41Njc3SDI5LjQ2ODNDMzIuMjYzNyAzMi41Njc3IDM0LjUyOTggMzAuMzcxMyAzNC41Mjk4IDI3LjY2MThDMzQuNTI5OCAyNC45NTIzIDMyLjI2MzcgMjIuNzU1OSAyOS40NjgzIDIyLjc1NTlaIiBmaWxsPSJ3aGl0ZSIvPgo8cGF0aCBkPSJNMzAuOTU4OSA0MC40MjcyQzMwLjk1ODkgMzcuNzE3OSAyOC42OTI4IDM1LjUyMTUgMjUuODk3NCAzNS41MjE1QzIzLjEwMjEgMzUuNTIxNSAyMC44MzU5IDM3LjcxNzkgMjAuODM1OSA0MC40MjcyQzIwLjgzNTkgNDMuMTM3MSAyMy4xMDIxIDQ1LjMzMzIgMjUuODk3NCA0NS4zMzMyQzI4LjY5MjggNDUuMzMzMiAzMC45NTg5IDQzLjEzNzEgMzAuOTU4OSA0MC40MjcyWiIgZmlsbD0id2hpdGUiLz4KPC9zdmc%2BCg%3D%3D&label=Desktop&labelColor=black&color=FF5E01" /></a>
<a href="https://apps.apple.com/us/app/folo-follow-everything/id6739802604"><img src="https://img.shields.io/itunes/v/6739802604?style=flat-square&logo=apple&label=App%20Store&color=FF5C00&labelColor=black" /></a>
<a href="https://github.com/RSSNext/Folo/releases"><img src="https://img.shields.io/github/package-json/v/RSSNext/Folo?filename=%2Fapps%2Fdesktop%2Fpackage.json&style=flat-square&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB3aWR0aD0iNTUiIGhlaWdodD0iNTUiIHZpZXdCb3g9IjAgMCA1NSA1NSIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTQxLjc5NDQgMTBIMTguNjc4NkMxNS44ODMzIDEwIDEzLjYxNzIgMTIuMTk2NSAxMy42MTcyIDE0LjkwNTlDMTMuNjE3MiAxNy42MTU0IDE1Ljg4MzMgMTkuODExOSAxOC42Nzg2IDE5LjgxMTlINDEuNzk0NEM0NC41ODk5IDE5LjgxMTkgNDYuODU1OSAxNy42MTU0IDQ2Ljg1NTkgMTQuOTA1OUM0Ni44NTU5IDEyLjE5NjUgNDQuNTg5OSAxMCA0MS43OTQ0IDEwWiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZD0iTTI5LjQ2ODMgMjIuNzU1OUgxNC4wNjE1QzExLjI2NjEgMjIuNzU1OSA5IDI0Ljk1MjMgOSAyNy42NjE4QzkgMzAuMzcxMyAxMS4yNjYxIDMyLjU2NzcgMTQuMDYxNSAzMi41Njc3SDI5LjQ2ODNDMzIuMjYzNyAzMi41Njc3IDM0LjUyOTggMzAuMzcxMyAzNC41Mjk4IDI3LjY2MThDMzQuNTI5OCAyNC45NTIzIDMyLjI2MzcgMjIuNzU1OSAyOS40NjgzIDIyLjc1NTlaIiBmaWxsPSJ3aGl0ZSIvPgo8cGF0aCBkPSJNMzAuOTU4OSA0MC40MjcyQzMwLjk1ODkgMzcuNzE3OSAyOC42OTI4IDM1LjUyMTUgMjUuODk3NCAzNS41MjE1QzIzLjEwMjEgMzUuNTIxNSAyMC44MzU5IDM3LjcxNzkgMjAuODM1OSA0MC40MjcyQzIwLjgzNTkgNDMuMTM3MSAyMy4xMDIxIDQ1LjMzMzIgMjUuODk3NCA0NS4zMzMyQzI4LjY5MjggNDUuMzMzMiAzMC45NTg5IDQzLjEzNzEgMzAuOTU4OSA0MC40MjcyWiIgZmlsbD0id2hpdGUiLz4KPC9zdmc%2BCg%3D%3D&label=Desktop&labelColor=black&color=FF5E01" /></a>
<a href="https://apps.apple.com/us/app/folo-follow-everything/id6739802604"><img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Ffolo-mac-app-store-version.rss3.workers.dev%2F&query=version&prefix=v&style=flat-square&logo=apple&label=Mac%20App%20Store&labelColor=black&color=FF5E01&cacheSeconds=3600" /></a>
<a href="https://apps.microsoft.com/detail/9nvfzpv0v0ht?mode=direct"><img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Ffolo-microsoft-store-version.rss3.workers.dev%2F&query=version&style=flat-square&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIj48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMyAzaDguNTN2OC41M0gzek0xMi40NjkgM2g4LjUzdjguNTNoLTguNTN6TTMgMTIuNDdoOC41M1YyMUgzek0xMi40NjkgMTIuNDdoOC41M1YyMWgtOC41M3oiLz48L3N2Zz4%3D&logoColor=white&label=Microsoft%20Store&labelColor=black&color=FF5E01&cacheSeconds=3600&prefix=v" /></a>
<br />
@ -52,7 +52,7 @@ Feel free to try it using the following methods:
You can also install using the following methods maintained by our community:
- If you are using Arch Linux, you can install package [follow-appimage](https://aur.archlinux.org/packages/follow-appimage) that maintained by [timochan](https://github.com/ttimochan).
- If you are using Arch Linux, you can install package [folo-appimage](https://aur.archlinux.org/packages/folo-appimage) that maintained by [timochan](https://github.com/ttimochan) and [grtsinry43](https://github.com/grtsinry43).
- If you are using Nix, you can install package [follow](https://github.com/NixOS/nixpkgs/blob/master/pkgs/by-name/fo/follow/package.nix) that maintained by [iosmanthus](https://github.com/iosmanthus).
- If you are using macOS with [Homebrew](https://brew.sh), you can install cask [folo](https://formulae.brew.sh/cask/folo) that maintained by [realSunyz](https://github.com/realSunyz).
- If you are using Windows with [Scoop](https://scoop.sh), you can install manifest [folo](https://github.com/cscnk52/cetacea/blob/master/bucket/folo.json) that maintained by [cscnk52](https://github.com/cscnk52).

View File

@ -29,3 +29,7 @@
- Picture and video view sizes no longer interfere with each other (#3645)
- Correct sorting icon in the feed list (#3675)
- Custom fonts containing spaces now render properly (#3674)
## Thanks
Special thanks to external contributors @kovsu @ericyzhu @cuikaipeng @grtsinry43 @cscnk52 for their valuable contributions

View File

@ -0,0 +1,29 @@
# What's new in v0.4.6
## Shiny new things
- Hide read subscriptions. You can now hide subscriptions with no unread items. Enable it in Settings → General → Subscriptions → Hide Read. (1b88d72)
- Vim-style keyboard navigation with smarter focus detection: (262a7ce c2d8ffa)
- Timeline focus: `j`/`k` or ↓/↑ to move between entries; `Enter` to open the selected entry.
- Entry focus: `h`/`l` or ←/→ to jump to previous/next entry, `j`/`k` or ↓/↑ to scroll, `Esc` or `Backspace` to return to the Timeline.
- Subscription list focus: `j`/`k` or ↓/↑ to move, `Enter` to open the Timeline, `Z` to collapse/expand a folder.
## Improvements
- Clearer RSSHub error messages. (ed95fb6b)
- AI summary is now enabled by default. (f329ae9)
- Social-media view shows a richer action bar. (b06d8ff)
- No more notification-permission prompts when your actions dont need them. (450b759)
- Discover page now remembers your language preference. (180933b)
## No longer broken
- Fixed incorrect language display in Shiki code blocks. (20049b0)
- Fixed occasional subscription-status errors. (9b0691a)
- Fixed default view in the subscription dialog. (bf26a3e)
- Fixed login dialog styling in dark mode. (e18f052)
- Fixed duplicated action bar in social-media view. (a917231)
## Thanks
Special thanks to external contributors @cleves0315 @grtsinry43 for their valuable contributions

View File

@ -5,3 +5,7 @@
## Improvements
## No longer broken
## Thanks
Special thanks to external contributors @ for their valuable contributions

View File

@ -5,3 +5,7 @@
## Improvements
## No longer broken
## Thanks
Special thanks to external contributors @ for their valuable contributions

View File

@ -52,7 +52,6 @@ export const viteRenderBaseConfig = {
codeInspectorPlugin({
bundler: "vite",
editor: "cursor",
hotKeys: ["metaKey"],
}),
sentryVitePlugin({
org: "follow-rg",

View File

@ -88,7 +88,9 @@
"react-zoom-pan-pinch": "3.7.0",
"shiki": "3.3.0",
"sonner": "2.0.3",
"tinykeys": "3.0.0",
"tldts": "7.0.4",
"ufo": "1.6.1",
"use-context-selector": "2.0.0",
"use-sync-external-store": "1.5.0",
"usehooks-ts": "3.1.1",

View File

@ -1,17 +0,0 @@
import type { Models } from "@follow/models"
import camelcaseKeys from "camelcase-keys"
import { apiFetch } from "~/lib/api-fetch"
const v1ApiPrefix = "/v1"
export const getTrendingAggregates = async (params: { language: string }) => {
const data = await apiFetch<Models.TrendingAggregates>(
`${v1ApiPrefix}/trendings?language=${params.language}`,
{
params: {
language: params.language,
},
},
)
return camelcaseKeys(data as any, { deep: true }) as Models.TrendingAggregates
}

View File

@ -3,35 +3,16 @@ import type { SupportedLanguages } from "@follow/models"
import { defaultGeneralSettings } from "@follow/shared/settings/defaults"
import { enhancedGeneralSettingKeys } from "@follow/shared/settings/enhanced"
import type { GeneralSettings } from "@follow/shared/settings/interface"
import LanguageDetector from "i18next-browser-languagedetector"
import { useCallback, useMemo } from "react"
import { currentSupportedLanguages } from "~/@types/constants"
import { I18N_LOCALE_KEY } from "~/constants"
import { jotaiStore } from "~/lib/jotai"
import { getDefaultLanguage } from "~/lib/language"
export const DEFAULT_ACTION_LANGUAGE = "default"
let language = "en"
const languageDetector = new LanguageDetector(null, {
order: ["querystring", "localStorage", "navigator"],
lookupQuerystring: "lng",
lookupLocalStorage: I18N_LOCALE_KEY,
caches: ["localStorage"],
})
const userLang = languageDetector.detect()
if (userLang) {
const firstUserLang = Array.isArray(userLang) ? userLang[0]! : userLang
const currentLang = currentSupportedLanguages.find((lang) => lang.includes(firstUserLang))
if (currentLang) {
language = currentLang
}
}
const createDefaultSettings = (): GeneralSettings => ({
...defaultGeneralSettings,
language,
language: getDefaultLanguage(),
})
const {
@ -208,6 +189,12 @@ export function getActionLanguage() {
) as SupportedLanguages
}
export function useHideAllReadSubscriptions() {
const hideAllReadSubscriptions = useGeneralSettingKey("hideAllReadSubscriptions")
const unreadOnly = useGeneralSettingKey("unreadOnly")
return hideAllReadSubscriptions && unreadOnly
}
export const subscribeShouldUseIndexedDB = (callback: (value: boolean) => void) =>
jotaiStore.sub(__generalSettingAtom, () => callback(getGeneralSettingsInternal().dataPersist))

View File

@ -6,6 +6,7 @@ import { jotaiStore } from "@follow/utils/jotai"
import { atom, useAtomValue, useSetAtom } from "jotai"
import { useEventCallback } from "usehooks-ts"
import { getDefaultLanguage } from "~/lib/language"
import { DEFAULT_ACTION_ORDER } from "~/modules/customize-toolbar/constant"
import { hookEnhancedSettings } from "./general"
@ -15,6 +16,9 @@ export const createDefaultSettings = (): UISettings => ({
// Action Order
toolbarOrder: DEFAULT_ACTION_ORDER,
// Discover
discoverLanguage: getDefaultLanguage().startsWith("zh") ? "all" : "eng",
})
const zenModeAtom = atom(false)

View File

@ -0,0 +1,45 @@
import {
Tooltip,
TooltipContent,
TooltipPortal,
TooltipTrigger,
} from "@follow/components/ui/tooltip/index.js"
import dayjs from "dayjs"
import { useTranslation } from "react-i18next"
export function ErrorTooltip({
errorAt,
errorMessage,
children,
showWhenError = false,
}: {
errorMessage?: string | null
errorAt?: string | null
children: React.ReactNode
showWhenError?: boolean
}) {
const { t } = useTranslation()
if (!errorAt || !errorMessage) {
return showWhenError ? children : null
}
return (
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>{children}</TooltipTrigger>
<TooltipPortal>
<TooltipContent>
<div className="flex items-center gap-1">
<i className="i-mgc-time-cute-re" />
{t("feed_item.error_since")}{" "}
{dayjs.duration(dayjs(errorAt).diff(dayjs(), "minute"), "minute").humanize(true)}
</div>
{!!errorMessage && (
<div className="flex items-center gap-1">
<i className="i-mgc-bug-cute-re" />
{errorMessage}
</div>
)}
</TooltipContent>
</TooltipPortal>
</Tooltip>
)
}

View File

@ -11,7 +11,7 @@ const PageErrorFallback: FC<AppErrorFallbackProps> = (props) => {
const { message, stack } = parseError(props.error)
useResetErrorWhenRouteChange(props.resetError)
return (
<div className="bg-theme-background flex w-full flex-col items-center justify-center rounded-md p-2">
<div className="bg-theme-background pointer-events-auto flex w-full flex-col items-center justify-center rounded-md p-2">
<div className="m-auto max-w-prose text-center">
<div className="mb-4">
<i className="i-mgc-bug-cute-re text-4xl text-red-500" />

View File

@ -1,4 +1,4 @@
import { Focusable } from "@follow/components/common/Focusable.js"
import { Focusable } from "@follow/components/common/Focusable/index.js"
import { SYSTEM_CAN_UNDER_BLUR_WINDOW } from "@follow/shared/constants"
import { cn } from "@follow/utils/utils"

View File

@ -193,7 +193,9 @@ const ShikiCode: FC<
)}
>
<div className="flex items-center justify-between border-b p-2">
{language !== "plaintext" && (
{language === "plaintext" ? (
<div />
) : (
<span className="center flex gap-1 text-xs uppercase opacity-80 dark:text-white">
<span className="center [&_svg]:size-4">{getLanguageIcon(language)}</span>
<span>{language}</span>

View File

@ -5,11 +5,12 @@ import { cn } from "@follow/utils/utils"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import * as React from "react"
import { useHotkeyScope } from "~/hooks/common"
import { HotkeyScope } from "~/constants"
import { useConditionalHotkeyScope } from "~/hooks/common"
const DropdownMenu: typeof DropdownMenuPrimitive.Root = (props) => {
const [open, setOpen] = React.useState(!!props.open)
useHotkeyScope("DropdownMenu", open)
useConditionalHotkeyScope(HotkeyScope.DropdownMenu, open)
return (
<DropdownMenuPrimitive.Root

View File

@ -1,4 +1,5 @@
import type { MediaModel } from "@follow/shared/hono"
import { stopPropagation } from "@follow/utils/dom"
import { cn } from "@follow/utils/utils"
import useEmblaCarousel from "embla-carousel-react"
import { WheelGesturesPlugin } from "embla-carousel-wheel-gestures"
@ -84,6 +85,7 @@ export function SwipeMedia({
type="button"
className="center absolute left-2 top-1/2 size-6 -translate-y-1/2 rounded-full bg-gray-800 text-white opacity-0 duration-200 group-hover:opacity-100"
onClick={scrollPrev}
onDoubleClick={stopPropagation}
>
<i className="i-mingcute-arrow-left-line" />
</button>
@ -93,6 +95,7 @@ export function SwipeMedia({
type="button"
className="center absolute right-2 top-1/2 size-6 -translate-y-1/2 rounded-full bg-gray-800 text-white opacity-0 duration-200 group-hover:opacity-100"
onClick={scrollNext}
onDoubleClick={stopPropagation}
>
<i className="i-mingcute-arrow-right-line" />
</button>

View File

@ -1,4 +1,4 @@
import { Focusable } from "@follow/components/common/Focusable.js"
import { Focusable } from "@follow/components/common/Focusable/index.js"
import { Spring } from "@follow/components/constants/spring.js"
import { ActionButton, MotionButtonBase } from "@follow/components/ui/button/index.js"
import type { HTMLMediaState } from "@follow/hooks"

View File

@ -2,6 +2,8 @@ import type { FC, RefObject } from "react"
import { createContext as reactCreateContext } from "react"
import { createContext as createContextSelector } from "use-context-selector"
import type { ModalProps } from "./types"
export type CurrentModalContentProps = ModalActionsInternal & {
ref: RefObject<HTMLElement | null>
modalElementRef: RefObject<HTMLElement | null>
@ -37,3 +39,10 @@ export type ModalActionsInternal = {
setClickOutSideToDismiss: (value: boolean) => void
getIndex: () => number
}
type Disposer = () => void
type PresentModalContextInternalFn = (props: ModalProps & { id?: string }) => Disposer
export const PresentModalContextInternal = reactCreateContext<PresentModalContextInternalFn>(() => {
warnNoProvider()
return () => {}
})

View File

@ -1,75 +1,30 @@
import { Button } from "@follow/components/ui/button/index.js"
import { nextFrame } from "@follow/utils/dom"
import { atom, useAtomValue } from "jotai"
import type { DragControls } from "motion/react"
import type { ResizeCallback, ResizeStartCallback } from "re-resizable"
import { use, useId, useRef, useState } from "react"
import { use, useState } from "react"
import { flushSync } from "react-dom"
import { useTranslation } from "react-i18next"
import { useContextSelector } from "use-context-selector"
import { useEventCallback } from "usehooks-ts"
import { getUISettings } from "~/atoms/settings/ui"
import { jotaiStore } from "~/lib/jotai"
import { modalStackAtom } from "./atom"
import { ModalEventBus } from "./bus"
import { CurrentModalContext, CurrentModalStateContext } from "./context"
import type { DialogInstance, ModalProps, ModalStackOptions } from "./types"
import {
CurrentModalContext,
CurrentModalStateContext,
PresentModalContextInternal,
} from "./context"
import type { DialogInstance, ModalProps } from "./types"
export const modalIdToPropsMap = {} as Record<string, ModalProps>
export const useModalStack = (options?: ModalStackOptions) => {
const id = useId()
const currentCount = useRef(0)
const { wrapper } = options || {}
export const useModalStack = () => {
const present = use(PresentModalContextInternal)
return {
present: useEventCallback((props: ModalProps & { id?: string }) => {
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 nextFrame(() => presentSync(props))
}),
present,
...actions,
}
}

View File

@ -1,17 +1,73 @@
import { nextFrame } from "@follow/utils/dom"
import type { FC, PropsWithChildren } from "react"
import { useId, useRef } from "react"
import { useEventCallback } from "usehooks-ts"
import { getUISettings } from "~/atoms/settings/ui"
import { jotaiStore } from "~/lib/jotai"
import { modalStackAtom } from "./atom"
import { PresentModalContextInternal } from "./context"
import { modalIdToPropsMap } from "./hooks"
import { ModalStack } from "./modal-stack"
import type { ModalProps } from "./types"
declare global {
interface Window {
presentModal: (modal: ModalProps) => void
presentModal: (modal: ModalProps & { id?: string }) => void
}
}
export const ModalStackProvider: FC<PropsWithChildren> = ({ children }) => (
<>
{children}
<ModalStack />
</>
)
export const ModalStackProvider: FC<PropsWithChildren> = ({ children }) => {
const id = useId()
const currentCount = useRef(0)
const presentModal = useEventCallback((props: ModalProps & { id?: string }) => {
const fallbackModelId = `${id}-${++currentCount.current}`
const modalId = props.id ?? fallbackModelId
const presentSync = (props: ModalProps & { id?: string }) => {
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,
}
modalIdToPropsMap[modalId] = modalProps
return p.concat({
id: modalId,
...modalProps,
})
})
}
}
nextFrame(() => presentSync(props))
return () => {
jotaiStore.set(modalStackAtom, (p) => p.filter((item) => item.id !== modalId))
}
})
return (
<PresentModalContextInternal value={presentModal}>
{children}
<ModalStack />
</PresentModalContextInternal>
)
}

View File

@ -32,9 +32,6 @@ export interface ModalProps {
autoFocus?: boolean
}
export interface ModalStackOptions {
wrapper?: FC
}
export interface DialogInstance {
ask: (options: {

View File

@ -1,6 +1,11 @@
export const HotKeyScopeMap = {
Home: ["home"],
Menu: ["menu"],
Modal: ["modal"],
DropdownMenu: ["dropdown-menu"],
export enum HotkeyScope {
Home = "home",
Menu = "menu",
Modal = "modal",
DropdownMenu = "dropdown-menu",
// Atom Scope
Timeline = "timeline",
EntryRender = "entry-render",
SubscriptionList = "subscription-list",
}

View File

@ -8,67 +8,83 @@ type Shortcuts = Record<
>
const shortcutConfigs = {
feeds: {
subscriptions: {
add: {
name: "keys.feeds.add",
name: tShortcuts("keys.subscriptions.add"),
key: "Meta+T",
},
switchToView: {
name: "keys.feeds.switchToView",
name: tShortcuts("keys.subscriptions.switchToView"),
key: "1, 2, 3, 4, 5, 6",
},
switchBetweenViews: {
name: "keys.feeds.switchBetweenViews",
key: "Tab, Left, Right",
switchNextView: {
name: tShortcuts("keys.subscriptions.switchNextView"),
key: "Tab, ArrowRight",
},
switchPreviousView: {
name: tShortcuts("keys.subscriptions.switchPreviousView"),
key: "Shift+Tab, ArrowLeft",
},
nextSubscription: {
name: tShortcuts("keys.subscriptions.nextSubscription"),
key: "J, ArrowDown",
},
previousSubscription: {
name: tShortcuts("keys.subscriptions.previousSubscription"),
key: "K, ArrowUp",
},
toggleFolderCollapse: {
name: tShortcuts("keys.subscriptions.toggleFolderCollapse"),
key: "Z",
},
},
layout: {
toggleSidebar: {
name: "keys.layout.toggleSidebar",
name: tShortcuts("keys.layout.toggleSidebar"),
key: "Meta+B, [",
},
showShortcuts: {
name: "keys.layout.showShortcuts",
name: tShortcuts("keys.layout.showShortcuts"),
key: "H",
},
toggleWideMode: {
name: "keys.layout.toggleWideMode",
name: tShortcuts("keys.layout.toggleWideMode"),
key: "Meta+[",
},
zenMode: {
name: "keys.layout.zenMode",
name: tShortcuts("keys.layout.zenMode"),
key: "Ctrl+Shift+Z",
},
},
entries: {
refetch: {
name: "keys.entries.refetch",
name: tShortcuts("keys.entries.refetch"),
key: "R",
},
previous: {
name: "keys.entries.previous",
key: "K, Up",
name: tShortcuts("keys.entries.previous"),
key: "K, ArrowUp",
},
next: {
name: "keys.entries.next",
key: "J, Down",
name: tShortcuts("keys.entries.next"),
key: "J, ArrowDown",
},
markAllAsRead: {
name: "keys.entries.markAllAsRead",
name: tShortcuts("keys.entries.markAllAsRead"),
key: "Shift+Meta+A",
},
toggleUnreadOnly: {
name: "keys.entries.toggleUnreadOnly",
name: tShortcuts("keys.entries.toggleUnreadOnly"),
key: "U",
},
},
entry: {
toggleRead: {
name: "keys.entry.toggleRead",
name: tShortcuts("keys.entry.toggleRead"),
key: "M",
},
toggleStarred: {
name: "keys.entry.toggleStarred",
name: tShortcuts("keys.entry.toggleStarred"),
key: "S",
},
openInBrowser: {
@ -77,43 +93,51 @@ const shortcutConfigs = {
extra: "Double Click",
},
tts: {
name: "keys.entry.tts",
name: tShortcuts("keys.entry.tts"),
key: "Shift+Meta+V",
},
copyLink: {
name: "keys.entry.copyLink",
name: tShortcuts("keys.entry.copyLink"),
key: "Shift+Meta+C",
},
copyTitle: {
name: "keys.entry.copyTitle",
name: tShortcuts("keys.entry.copyTitle"),
key: "Shift+Meta+B",
},
tip: {
name: "keys.entry.tip",
name: tShortcuts("keys.entry.tip"),
key: "Shift+Meta+T",
},
share: {
name: "keys.entry.share",
name: tShortcuts("keys.entry.share"),
key: "Meta+Alt+S",
},
scrollUp: {
name: "keys.entry.scrollUp",
key: "PageUp",
name: tShortcuts("keys.entry.scrollUp"),
key: "K, ArrowUp",
},
scrollDown: {
name: "keys.entry.scrollDown",
key: "PageDown",
name: tShortcuts("keys.entry.scrollDown"),
key: "J, ArrowDown",
},
nextEntry: {
name: tShortcuts("keys.entries.next"),
key: "L, ArrowRight",
},
previousEntry: {
name: tShortcuts("keys.entries.previous"),
key: "H, ArrowLeft",
},
},
audio: {
"play/pause": {
name: "keys.audio.playPause",
name: tShortcuts("keys.audio.playPause"),
key: "Space",
},
},
misc: {
quickSearch: {
name: "keys.misc.quickSearch",
name: tShortcuts("keys.misc.quickSearch"),
key: "Meta+K",
},
},
@ -135,7 +159,7 @@ function transformShortcuts<T extends Shortcuts>(configs: T) {
export const shortcuts = transformShortcuts(shortcutConfigs) satisfies Shortcuts
export const shortcutsType: { [key in keyof typeof shortcuts]: I18nKeysForShortcuts } = {
feeds: "keys.type.feeds",
subscriptions: "keys.type.subscriptions",
layout: "keys.type.layout",
entries: "keys.type.entries",
entry: "keys.type.entry",

View File

@ -34,6 +34,21 @@ declare global {
export type I18nKeys = OmitStringType<Parameters<typeof t>[0]>
export type I18nKeysForSettings = OmitStringType<Parameters<typeof settingsT>[0]>
export type I18nKeysForShortcuts = OmitStringType<Parameters<typeof shortcutsT>[0]>
// MACROS
/**
* This function is a macro, will replace in the build stage.
*/
export function tShortcuts(key: I18nKeysForShortcuts): I18nKeysForShortcuts
/**
* This function is a macro, will replace in the build stage.
*/
export function tSettings(key: I18nKeysForSettings): I18nKeysForSettings
/**
* This function is a macro, will replace in the build stage.
*/
export function t_(key: I18nKeys): I18nKeys
}
export {}

View File

@ -410,6 +410,7 @@ export const useSortedEntryActions = ({
return false
}
const order = orderMap.get(item.id)
if (!order) return false
return order.type === "main"
})

View File

@ -49,7 +49,7 @@ const ConfirmDestroyModalContent = ({ onConfirm }: { onConfirm: () => void }) =>
{t("sidebar.feed_actions.unfollow_feed_many_warning")}
</div>
<div className="flex justify-end">
<Button className="bg-red-600" onClick={onConfirm}>
<Button buttonClassName="bg-red-600" onClick={onConfirm}>
{t("words.confirm")}
</Button>
</div>

View File

@ -1,6 +1,7 @@
import { UserRole } from "@follow/constants"
import { t } from "i18next"
import { useCallback } from "react"
import { withoutTrailingSlash, withTrailingSlash } from "ufo"
import { useEventCallback } from "usehooks-ts"
import { useServerConfigs } from "~/atoms/server-configs"
@ -12,6 +13,7 @@ import type { FeedFormDataValuesType } from "~/modules/discover/FeedForm"
import { FeedForm } from "~/modules/discover/FeedForm"
import type { ListFormDataValuesType } from "~/modules/discover/ListForm"
import { ListForm } from "~/modules/discover/ListForm"
import { getFeedByIdOrUrl } from "~/store/feed"
import {
getSubscriptionByFeedId,
useFeedSubscriptionCount,
@ -73,10 +75,16 @@ export const useFollow = () => {
} else {
canFollowMoreInboxAndNotify("feed")
}
let isFollowed = false
if (options?.id) {
isFollowed = !!getSubscriptionByFeedId(options.id)
}
// Some feeds redirect xxx.com/feed to xxx.com/feed/
// Try to get a valid feed, then we can check isFollowed correctly
const feed =
getFeedByIdOrUrl({ id: options?.id, url: withTrailingSlash(options?.url) }) ??
getFeedByIdOrUrl({ id: options?.id, url: withoutTrailingSlash(options?.url) })
const id = options?.id || feed?.id
const url = feed?.type === "feed" ? feed.url : options?.url
const subscription = getSubscriptionByFeedId(id)
const isFollowed = !!subscription
present({
title: `${isFollowed ? `${t("common:words.edit")} ` : ""}${options?.isList ? t("words.lists") : t("words.feeds")}`,
@ -94,8 +102,8 @@ export const useFollow = () => {
/>
) : (
<FeedForm
id={options?.id}
url={options?.url}
id={id}
url={url}
defaultValues={options?.defaultValues as FeedFormDataValuesType}
onSuccess={onSuccess}
/>

View File

@ -83,7 +83,7 @@ export const navigateEntry = (options: NavigateEntryOptions) => {
finalFeedId = encodeURIComponent(finalFeedId)
if (finalView !== undefined) {
if (finalView !== undefined && !timelineId) {
finalTimelineId = `${ROUTE_TIMELINE_OF_VIEW}${finalView}`
}
@ -91,11 +91,7 @@ export const navigateEntry = (options: NavigateEntryOptions) => {
disableShowAISummaryOnce()
disableShowAITranslationOnce()
tracker.navigateEntry({
feedId: finalFeedId,
entryId: finalEntryId,
timelineId: finalTimelineId,
})
tracker.navigateEntry({ feedId: finalFeedId, entryId: finalEntryId, timelineId: finalTimelineId })
const path = `/timeline/${finalTimelineId}/${finalFeedId}/${finalEntryId}`
@ -103,3 +99,14 @@ export const navigateEntry = (options: NavigateEntryOptions) => {
if (path === currentPath) return
return getStableRouterNavigate()?.(path)
}
export const useBackHome = (timelineId?: string) => {
const navigate = useNavigateEntry()
return useCallback(
(overvideTimelineId?: string) => {
navigate({ feedId: null, entryId: null, timelineId: overvideTimelineId ?? timelineId })
},
[timelineId, navigate],
)
}

View File

@ -4,7 +4,7 @@ import { useHotkeys } from "react-hotkeys-hook"
import { Trans, useTranslation } from "react-i18next"
import { toast } from "sonner"
import { HotKeyScopeMap } from "~/constants"
import { HotkeyScope } from "~/constants"
import { apiClient } from "~/lib/api-fetch"
import { subscription as subscriptionQuery } from "~/queries/subscriptions"
import type { SubscriptionFlatModel } from "~/store/subscription"
@ -64,7 +64,7 @@ export const useDeleteSubscription = ({ onSuccess }: { onSuccess?: () => void }
label: (
<span className="flex items-center gap-1">
{t("words.undo")}
<Kbd className="border-border inline-flex items-center border bg-transparent dark:text-white">
<Kbd className="border-border inline-flex items-center border bg-transparent text-white">
Meta+Z
</Kbd>
</span>
@ -92,7 +92,7 @@ export const useDeleteSubscription = ({ onSuccess }: { onSuccess?: () => void }
const UnfollowInfo = ({ title, undo }: { title: string; undo: () => any }) => {
useHotkeys("ctrl+z,meta+z", undo, {
scopes: HotKeyScopeMap.Home,
scopes: HotkeyScope.Home,
preventDefault: true,
})
return (

View File

@ -1,55 +1,54 @@
import { useCallback, useLayoutEffect, useRef } from "react"
import { useHotkeysContext } from "react-hotkeys-hook"
import { HotKeyScopeMap } from "~/constants"
import { HotkeyScope } from "~/constants"
const allScopes = Object.keys(HotKeyScopeMap).reduce((acc, key) => {
acc.push(...HotKeyScopeMap[key])
const allScopes = Object.keys(HotkeyScope).reduce((acc, key) => {
acc.push(HotkeyScope[key])
return acc
}, [] as string[])
export const useSwitchHotKeyScope = () => {
const { enableScope, disableScope } = useHotkeysContext()
return useCallback(
(scope: keyof typeof HotKeyScopeMap) => {
const nextScope = HotKeyScopeMap[scope]
(scope: keyof typeof HotkeyScope) => {
const nextScope = HotkeyScope[scope]
if (!nextScope) return
for (const key of allScopes) {
disableScope(key)
}
for (const key of nextScope) {
enableScope(key)
}
enableScope(nextScope)
},
[disableScope, enableScope],
)
}
export const useHotkeyScopeFn = (scope: keyof typeof HotKeyScopeMap) => {
export const useConditionalHotkeyScopeFn = (scope: HotkeyScope, replaceAll = true) => {
const { enableScope, disableScope, activeScopes } = useHotkeysContext()
const currentScopeRef = useRef(activeScopes)
return useCallback(() => {
const currentScope = currentScopeRef.current
for (const key of allScopes) {
disableScope(key)
}
for (const key of scope) {
enableScope(key)
}
return () => {
for (const key of scope) {
if (replaceAll) {
for (const key of allScopes) {
disableScope(key)
}
for (const scope of currentScope) {
enableScope(scope)
}
enableScope(scope)
return () => {
disableScope(scope)
if (replaceAll) {
for (const key of currentScope) {
enableScope(key)
}
}
}
}, [enableScope, disableScope, scope])
}, [enableScope, disableScope, scope, replaceAll])
}
export const useHotkeyScope = (scope: keyof typeof HotKeyScopeMap, when: boolean) => {
const fn = useHotkeyScopeFn(scope)
export const useConditionalHotkeyScope = (scope: HotkeyScope, when: boolean, append = false) => {
const fn = useConditionalHotkeyScopeFn(scope, !append)
useLayoutEffect(() => {
if (when) {
return fn()

View File

@ -0,0 +1,25 @@
import LanguageDetector from "i18next-browser-languagedetector"
import { currentSupportedLanguages } from "~/@types/constants"
import { I18N_LOCALE_KEY } from "~/constants"
let defaultLanguage = "en"
const languageDetector = new LanguageDetector(null, {
order: ["querystring", "localStorage", "navigator"],
lookupQuerystring: "lng",
lookupLocalStorage: I18N_LOCALE_KEY,
caches: ["localStorage"],
})
const userLang = languageDetector.detect()
if (userLang) {
const firstUserLang = Array.isArray(userLang) ? userLang[0]! : userLang
const currentLang = currentSupportedLanguages.find((lang) => lang.includes(firstUserLang))
if (currentLang) {
defaultLanguage = currentLang
}
}
export const getDefaultLanguage = () => {
return defaultLanguage
}

View File

@ -7,14 +7,14 @@ import { toast } from "sonner"
import { currentSupportedLanguages, dayjsLocaleImportMap } from "~/@types/constants"
import { defaultResources } from "~/@types/default-resource"
import { fallbackLanguage, i18nAtom, langChain, LocaleCache } from "~/i18n"
import { i18nAtom, langChain, LocaleCache } from "~/i18n"
import { jotaiStore } from "~/lib/jotai"
import { tipcClient } from "./client"
import { appLog } from "./log"
const loadingLangLock = new Set<string>()
const loadedLangs = new Set<string>([fallbackLanguage])
const loadedLangs = new Set<string>(["en"])
export const loadLanguageAndApply = async (lang: string) => {
const dayjsImport = dayjsLocaleImportMap[lang]

View File

@ -6,6 +6,7 @@ import { MarkdownLink } from "~/components/ui/markdown/renderers/MarkdownLink"
import { VideoPlayer } from "~/components/ui/media/VideoPlayer"
export const parseMarkdown = (content: string, options?: Partial<RemarkOptions>) => {
const videoExts = ["mp4", "webm"]
return parseMarkdownImpl(content, {
...options,
components: {
@ -13,9 +14,40 @@ export const parseMarkdown = (content: string, options?: Partial<RemarkOptions>)
img: ({ node, ...props }) => {
const { src } = props
try {
const path = new URL(src || "").pathname
const isVideo = path.endsWith(".mp4")
const url = new URL(src || "")
const path = url.pathname
const search = url.searchParams
const ratio = search.get("ratio")
const isVideo = videoExts.some((ext) => path.endsWith(ext))
if (isVideo) {
if (ratio) {
return createElement(
"div",
{
style: {
width: "100%",
paddingTop: `${(1 / Number(ratio)) * 100}%`,
position: "relative",
},
},
createElement(
"div",
{
style: {
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: "100%",
},
},
createElement(VideoPlayer, {
src: src as string,
}),
),
)
}
return createElement(VideoPlayer, {
src: src as string,
})

View File

@ -9,14 +9,6 @@ import {
SelectValue,
} from "@follow/components/ui/select/index.jsx"
import { ResponsiveSelect } from "@follow/components/ui/select/responsive.js"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@follow/components/ui/table/index.jsx"
import type { ActionFeedField, ActionOperation } from "@follow/models/types"
import { cn } from "@follow/utils/utils"
import { Fragment, useMemo } from "react"
@ -111,9 +103,9 @@ export const FeedFilter = ({ index }: { index: number }) => {
{condition.length > 0 && (
<div className="pl-6">
<Table>
<FieldTableHeader />
<TableBody>
<div className="w-full">
<GridHeader />
<div className="mt-2">
{condition.flatMap((orConditions, orConditionIdx) => {
return orConditions.map((condition, conditionIdx) => {
const change = (key: string, value: string | number) => {
@ -129,17 +121,14 @@ export const FeedFilter = ({ index }: { index: number }) => {
return (
<Fragment key={`${orConditionIdx}${conditionIdx}`}>
{conditionIdx === 0 && orConditionIdx !== 0 && (
<TableRow className="flex h-16 items-center">
<Button disabled variant="outline">
<div className="flex h-16 items-center justify-center">
<span className="text-text-secondary text-sm uppercase">
{t("actions.action_card.or")}
</Button>
</TableRow>
</span>
</div>
)}
<TableRow className="max-sm:flex max-sm:flex-col">
<TableCell
size="sm"
className="max-sm:flex max-sm:items-center max-sm:justify-between max-sm:pr-0"
>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-4 sm:gap-4">
<div className="flex items-center justify-between max-sm:flex-row sm:justify-start">
<span className="sm:hidden">{t("actions.action_card.field")}</span>
<ResponsiveSelect
placeholder="Select Field"
@ -147,78 +136,106 @@ export const FeedFilter = ({ index }: { index: number }) => {
value={condition.field}
onValueChange={(value) => change("field", value as ActionFeedField)}
items={FeedOptions}
triggerClassName="max-sm:w-fit"
triggerClassName="max-sm:w-fit h-8"
/>
</TableCell>
<OperationTableCell
type={type}
disabled={disabled}
value={condition.operator}
onValueChange={(value) => change("operator", value)}
/>
<TableCell
size="sm"
className="max-sm:flex max-sm:items-center max-sm:justify-between max-sm:gap-4 max-sm:pr-0"
>
</div>
<div className="flex items-center justify-between max-sm:flex-row sm:justify-start">
<span className="sm:hidden">{t("actions.action_card.operator")}</span>
<OperationSelect
type={type}
disabled={disabled}
value={condition.operator}
onValueChange={(value) => change("operator", value)}
/>
</div>
<div className="flex items-center justify-between gap-4 max-sm:flex-row sm:justify-start">
<span className="sm:hidden">{t("actions.action_card.value")}</span>
<ValueTableCell
<ValueInput
type={type}
value={condition.value}
onChange={(value) => change("value", value)}
disabled={disabled}
/>
</TableCell>
</div>
<ActionTableCell
disabled={disabled}
onAnd={() => {
onChange((data) => {
data.condition[orConditionIdx]!.push({})
})
}}
onDelete={() => {
onChange((data) => {
if (data.condition[orConditionIdx]!.length === 1) {
data.condition.splice(orConditionIdx, 1)
} else {
data.condition[orConditionIdx]!.splice(conditionIdx, 1)
}
})
}}
/>
</TableRow>
<div className="flex items-center justify-end gap-2">
<Button
buttonClassName="w-full sm:hidden"
variant="outline"
disabled={disabled}
onClick={() => {
onChange((data) => {
data.condition[orConditionIdx]!.push({})
})
}}
>
{t("actions.action_card.and")}
</Button>
<Button
variant="outline"
buttonClassName="w-full max-sm:hidden"
disabled={disabled}
onClick={() => {
onChange((data) => {
data.condition[orConditionIdx]!.push({})
})
}}
>
{t("actions.action_card.and")}
</Button>
<Button
variant="ghost"
disabled={disabled}
onClick={() => {
onChange((data) => {
if (data.condition[orConditionIdx]!.length === 1) {
data.condition.splice(orConditionIdx, 1)
} else {
data.condition[orConditionIdx]!.splice(conditionIdx, 1)
}
})
}}
>
<i className="i-mgc-delete-2-cute-re size-5 text-zinc-600" />
</Button>
</div>
</div>
{conditionIdx !== orConditions.length - 1 && (
<TableRow className="relative flex items-center">
<div className="relative my-4 flex w-full items-center justify-center">
<div className="relative">
<Button disabled variant="outline">
<span className="text-text-secondary text-sm uppercase">
{t("actions.action_card.and")}
</Button>
<div className="bg-theme-disabled absolute left-1/2 h-[5px] w-px" />
<div className="bg-theme-disabled absolute left-1/2 top-0 h-[5px] w-px -translate-y-full" />
</span>
<div className="bg-border absolute left-1/2 h-[10px] w-px" />
<div className="bg-border absolute left-1/2 top-0 h-[10px] w-px -translate-y-full" />
</div>
</TableRow>
</div>
)}
</Fragment>
)
})
})}
</TableBody>
</Table>
<OrTableRow
disabled={disabled}
</div>
</div>
<Button
variant="outline"
buttonClassName="mt-4 w-full gap-1 py-1"
onClick={() => {
onChange((data) => {
data.condition.push([{}])
})
}}
/>
disabled={disabled}
>
{t("actions.action_card.or")}
</Button>
</div>
)}
</div>
)
}
const OperationTableCell = ({
const OperationSelect = ({
type,
value,
onValueChange,
@ -276,24 +293,18 @@ const OperationTableCell = ({
onValueChange?.(options[0]!.value as ActionOperation)
}
return (
<TableCell
size="sm"
className="max-sm:flex max-sm:items-center max-sm:justify-between max-sm:pr-0"
>
<span className="sm:hidden">{t("actions.action_card.operator")}</span>
<ResponsiveSelect
placeholder="Select Field"
disabled={disabled}
value={value}
onValueChange={(value) => onValueChange?.(value as ActionOperation)}
items={options}
triggerClassName="h-8 max-sm:w-fit"
/>
</TableCell>
<ResponsiveSelect
placeholder="Select Operation"
disabled={disabled}
value={value}
onValueChange={(value) => onValueChange?.(value as ActionOperation)}
items={options}
triggerClassName="h-8 max-sm:w-fit"
/>
)
}
const ValueTableCell = ({
const ValueInput = ({
type,
value,
onChange,
@ -360,60 +371,14 @@ const CommonSelectTrigger = ({ className }: { className?: string }) => (
</SelectTrigger>
)
const ActionTableCell = ({
disabled,
onAnd,
onDelete,
}: {
disabled?: boolean
onAnd?: () => void
onDelete?: () => void
}) => {
const GridHeader = () => {
const { t } = useTranslation("settings")
return (
<>
<TableCell size="sm" className="max-sm:hidden">
<Button variant="outline" className="w-full" disabled={disabled} onClick={onAnd}>
{t("actions.action_card.and")}
</Button>
</TableCell>
<TableCell size="sm" className="w-full max-sm:flex max-sm:space-x-2 max-sm:pr-0 lg:w-8">
<Button className="w-full sm:hidden" variant="outline" disabled={disabled} onClick={onAnd}>
{t("actions.action_card.and")}
</Button>
<Button variant="ghost" disabled={disabled} onClick={onDelete}>
<i className="i-mgc-delete-2-cute-re size-5 text-zinc-600" />
</Button>
</TableCell>
</>
)
}
const OrTableRow = ({ onClick, disabled }: { onClick?: () => void; disabled?: boolean }) => {
const { t } = useTranslation("settings")
return (
<Button
variant="outline"
className="mt-1 w-full gap-1"
buttonClassName="py-1"
onClick={onClick}
disabled={disabled}
>
{t("actions.action_card.or")}
</Button>
)
}
const FieldTableHeader = () => {
const { t } = useTranslation("settings")
return (
<TableHeader className="max-sm:hidden">
<TableRow>
<TableHead size="sm">{t("actions.action_card.field")}</TableHead>
<TableHead size="sm">{t("actions.action_card.operator")}</TableHead>
<TableHead size="sm">{t("actions.action_card.value")}</TableHead>
<TableHead size="sm" />
</TableRow>
</TableHeader>
<div className="text-text-secondary grid grid-cols-4 gap-4 pb-2 text-sm font-medium max-sm:hidden">
<div className="pl-1">{t("actions.action_card.field")}</div>
<div className="pl-1">{t("actions.action_card.operator")}</div>
<div className="pl-1">{t("actions.action_card.value")}</div>
<div />
</div>
)
}

View File

@ -36,8 +36,8 @@ const AddTableRow = ({ onClick, disabled }: { onClick?: () => void; disabled?: b
return (
<Button
variant="outline"
className="mt-1 w-full gap-1"
buttonClassName="py-1"
textClassName="w-full"
buttonClassName="py-1 mt-1 gap-1 w-full"
onClick={onClick}
disabled={disabled}
>

View File

@ -16,7 +16,10 @@ export const FeedDailyModalContent = () => {
<TabsList className="w-full">
{tabs.map((tab: any) => (
<TabsTrigger key={tab} value={tab}>
<DailyReportTitle {...(tab === DayOf.Today ? today : yesterday)} />
<DailyReportTitle
containerClassName="pb-0"
{...(tab === DayOf.Today ? today : yesterday)}
/>
</TabsTrigger>
))}
</TabsList>

View File

@ -44,7 +44,6 @@ import { apiClient } from "~/lib/api-fetch"
import { defineQuery } from "~/lib/defineQuery"
import { COMMAND_ID } from "~/modules/command/commands/id"
import { hasCommand } from "~/modules/command/hooks/use-command"
import { FlatMarkAllReadButton } from "~/modules/entry-column/components/mark-all-button"
import { StarIcon } from "~/modules/entry-column/star-icon"
import { EntryContent } from "~/modules/entry-content"
import { CommandDropdownMenuItem } from "~/modules/entry-content/actions/more-actions"
@ -92,10 +91,12 @@ export const DailyReportTitle = ({
endDate,
startDate,
title,
containerClassName,
}: {
title: string
startDate: number
endDate: number
containerClassName?: string
}) => {
const { t } = useTranslation()
const language = useGeneralSettingSelector((s) => s.language)
@ -109,7 +110,7 @@ export const DailyReportTitle = ({
return (
<m.div
className="flex items-center justify-center gap-2 pb-6 text-base"
className={cn("flex items-center justify-center gap-2 pb-6 text-base", containerClassName)}
layoutId={`daily-report-title-${title}`}
transition={Spring.presets.smooth}
>
@ -268,16 +269,6 @@ export const DailyReportModalContent: Component<DailyReportContentProps> = ({
</Markdown>
) : null}
</div>
{!!content.data && (
<FlatMarkAllReadButton
className="ml-auto shrink-0"
filter={{
startTime: startDate,
endTime: endDate,
}}
/>
)}
</div>
)
}

View File

@ -35,7 +35,11 @@ export const EntryColumnMobile = () => {
<MobileFloatBar
scrollContainer={scrollContainer}
onLogoClick={() => {
;(document.querySelector(`#${LOGO_MOBILE_ID}`) as HTMLElement)?.click()
const logo = document.querySelector(`#${LOGO_MOBILE_ID}`) as HTMLElement
if (logo) {
logo.click()
logo.blur()
}
}}
onViewChange={(view) => {
navigateEntry({

View File

@ -11,7 +11,7 @@ import { useRealInWideMode } from "~/atoms/settings/ui"
import { useTimelineColumnShow, useTimelineColumnTempShow } from "~/atoms/sidebar"
import { m } from "~/components/common/Motion"
import { FixedModalCloseButton } from "~/components/ui/modal/components/close"
import { HotKeyScopeMap, ROUTE_ENTRY_PENDING } from "~/constants"
import { HotkeyScope, ROUTE_ENTRY_PENDING } from "~/constants"
import { useNavigateEntry } from "~/hooks/biz/useNavigateEntry"
import { useRouteParams } from "~/hooks/biz/useRouteParams"
import { EntryContent } from "~/modules/entry-content"
@ -39,7 +39,7 @@ export const RightContentDesktop = () => {
},
{
enabled: showEntryContent && settingWideMode,
scopes: HotKeyScopeMap.Home,
scopes: HotkeyScope.Home,
preventDefault: true,
},
)

View File

@ -12,7 +12,6 @@ import { debounce } from "es-toolkit/compat"
import type { PropsWithChildren } from "react"
import * as React from "react"
import { 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"
@ -31,17 +30,17 @@ 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 } from "~/constants"
import { HotkeyScope } from "~/constants"
import { ROOT_CONTAINER_ID } from "~/constants/dom"
import { shortcuts } from "~/constants/shortcuts"
import { useDailyTask } from "~/hooks/biz/useDailyTask"
import { useBatchUpdateSubscription } from "~/hooks/biz/useSubscriptionActions"
import { useI18n } from "~/hooks/common"
import { EnvironmentIndicator } from "~/modules/app/EnvironmentIndicator"
import { NetworkStatusIndicator } from "~/modules/app/NetworkStatusIndicator"
import { LoginModalContent } from "~/modules/auth/LoginModalContent"
import { COMMAND_ID } from "~/modules/command/commands/id"
import { useCommandBinding } from "~/modules/command/hooks/use-register-hotkey"
import { DebugRegistry } from "~/modules/debug/registry"
import { useShortcutsModal } from "~/modules/modal/shortcuts"
import { CmdF } from "~/modules/panel/cmdf"
import { SearchCmdK } from "~/modules/panel/cmdk"
import { CmdNTrigger } from "~/modules/panel/cmdn"
@ -51,6 +50,7 @@ import { getSelectedFeedIds, resetSelectedFeedIds } from "~/modules/timeline-col
import { UpdateNotice } from "~/modules/update-notice/UpdateNotice"
import { AppNotificationContainer } from "~/modules/upgrade/lazy/index"
import { AppLayoutGridContainerProvider } from "~/providers/app-grid-layout-container-provider"
import { useHotkeyScope } from "~/providers/hotkey-provider"
import { NewUserGuide } from "./index.shared"
@ -241,17 +241,12 @@ const FeedResponsiveResizerContainer = ({
}
}, [feedColumnShow])
useHotkeys(
shortcuts.layout.toggleSidebar.key,
() => {
setTimelineColumnShow(!feedColumnShow)
},
{
scopes: HotKeyScopeMap.Home,
},
)
const showShortcuts = useShortcutsModal()
useHotkeys(shortcuts.layout.showShortcuts.key, showShortcuts)
const activeScopes = useHotkeyScope()
useCommandBinding({
commandId: COMMAND_ID.layout.toggleTimelineColumn,
when: activeScopes.includes(HotkeyScope.Home),
})
const [delayShowSplitter, setDelayShowSplitter] = useState(feedColumnShow)

View File

@ -13,6 +13,7 @@ import { useRouteParamsSelector } from "~/hooks/biz/useRouteParams"
import { useEntry } from "~/store/entry"
import { useFeedById } from "~/store/feed"
import { feedIconSelector } from "~/store/feed/selector"
import { useViewWithSubscription } from "~/store/subscription/hooks"
import { ProfileButton } from "../../user/ProfileButton"
import { PodcastButton } from "./components/PodcastButton"
@ -73,53 +74,60 @@ export const MobileFloatBar = ({
return (
<div
className={clsx(
"pb-safe-offset-6 pointer-events-none absolute inset-x-0 bottom-0 flex h-36 items-end",
"pb-safe-offset-2 pointer-events-none absolute inset-x-0 bottom-0 flex h-40 items-end",
className,
)}
>
<m.div
className={clsx(
"bg-background mx-1 inline-flex h-10 w-full min-w-0 items-center rounded-full border border-neutral-200 pl-4 pr-2 shadow-sm shadow-zinc-100 dark:border-neutral-800",
"[box-shadow:0px_8px_30px_rgba(122,122,122,0.2)] dark:[box-shadow:0px_8px_30px_rgba(122,122,122,0.2)]",
"pointer-events-auto",
"bg-background/90 mx-3 inline-flex h-12 w-full min-w-0 items-center justify-between rounded-2xl border border-neutral-200/60 px-4 backdrop-blur-md dark:border-neutral-800/60",
"shadow-lg shadow-zinc-200/40 dark:shadow-zinc-900/40",
"pointer-events-auto transition-all duration-200",
)}
transition={{ type: "spring" }}
transition={{ type: "spring", damping: 20 }}
animate={animateController}
>
<PlayerIcon isScrollDown={isScrollDown} onLogoClick={onLogoClick} />
<DividerVertical className="h-3/4 shrink-0" />
<div className="flex items-center gap-3">
<PlayerIcon isScrollDown={isScrollDown} onLogoClick={onLogoClick} />
<DividerVertical className="h-5 shrink-0 opacity-60" />
</div>
<ViewTabs onViewChange={onViewChange} />
<DividerVertical className="h-3/4 shrink-0" />
<ProfileButton />
<div className="flex items-center gap-3">
<DividerVertical className="h-5 shrink-0 opacity-60" />
<ProfileButton />
</div>
</m.div>
</div>
)
}
const ViewTabs = ({ onViewChange }: { onViewChange?: (view: number) => void }) => {
const viewsWithSubscription = useViewWithSubscription()
const view = useRouteParamsSelector((s) => s.view)
const navigate = useNavigateEntry()
return (
<div
className="text-text-secondary flex w-full shrink items-center justify-between gap-4 overflow-x-auto overflow-y-hidden text-xl"
className="text-text-secondary scrollbar-none flex shrink items-center justify-center gap-6 overflow-x-auto overflow-y-hidden text-xl"
onClick={stopPropagation}
>
{views.map((item) => (
<MotionButtonBase
className={clsx(
"center flex transition-colors duration-200",
view === item.view && item.className,
)}
key={item.name}
onClick={() => {
navigate({ view: item.view })
onViewChange?.(item.view)
}}
>
<div className="relative flex flex-col items-center">{item.icon}</div>
</MotionButtonBase>
))}
{views
.filter((item) => viewsWithSubscription.includes(item.view))
.map((item) => (
<MotionButtonBase
className={clsx(
"center flex transition-colors duration-200",
view === item.view && item.className,
)}
key={item.name}
onClick={() => {
navigate({ view: item.view })
onViewChange?.(item.view)
}}
>
<div className="relative flex flex-col items-center">{item.icon}</div>
</MotionButtonBase>
))}
</div>
)
}

View File

@ -230,7 +230,7 @@ export function RegisterForm() {
)}
/>
<ReCAPTCHA ref={recaptchaRef} sitekey={env.VITE_RECAPTCHA_V2_SITE_KEY} size="invisible" />
<Button disabled={!isValid} type="submit" className="w-full" size="lg">
<Button disabled={!isValid} type="submit" buttonClassName="w-full" size="lg">
{t("register.submit")}
</Button>
</form>

View File

@ -0,0 +1,25 @@
import { legalHtml } from "@follow/legal"
import { stopPropagation } from "@follow/utils/dom"
import { m } from "motion/react"
import type { FC } from "react"
type LegalModalProps = {
type: "privacy" | "tos"
}
export const LegalModalContent: FC<LegalModalProps> = ({ type }) => {
const content = type === "privacy" ? legalHtml.privacy : legalHtml.tos
return (
<m.div className="size-full overflow-hidden">
<div className="bg-background size-full overflow-auto rounded-lg" onClick={stopPropagation}>
<iframe
sandbox="allow-scripts"
srcDoc={content}
title={type === "privacy" ? "Privacy Policy" : "Terms of Service"}
className="size-full border-0"
/>
</div>
</m.div>
)
}

View File

@ -10,11 +10,12 @@ import { m } from "motion/react"
import { useState } from "react"
import { Trans, useTranslation } from "react-i18next"
import { useCurrentModal } from "~/components/ui/modal/stacked/hooks"
import { useCurrentModal, useModalStack } from "~/components/ui/modal/stacked/hooks"
import { loginHandler } from "~/lib/auth"
import { useAuthProviders } from "~/queries/users"
import { LoginWithPassword, RegisterForm } from "./Form"
import { LegalModalContent } from "./LegalModal"
interface LoginModalContentProps {
runtime: LoginRuntime
@ -23,11 +24,12 @@ interface LoginModalContentProps {
export const LoginModalContent = (props: LoginModalContentProps) => {
const modal = useCurrentModal()
const { present } = useModalStack()
const { canClose = true, runtime } = props
const { t } = useTranslation()
const { data: authProviders } = useAuthProviders()
const { data: authProviders, isLoading } = useAuthProviders()
const isMobile = useMobile()
@ -36,6 +38,17 @@ export const LoginModalContent = (props: LoginModalContentProps) => {
const [isRegister, setIsRegister] = useState(true)
const [isEmail, setIsEmail] = useState(false)
const handleOpenLegal = (type: "privacy" | "tos") => {
present({
id: `legal-${type}`,
title: type === "privacy" ? t("login.privacy") : t("login.terms"),
content: () => <LegalModalContent type={type} />,
resizeable: true,
clickOutsideToDismiss: true,
max: true,
})
}
const Inner = (
<>
<div className="-mt-9 mb-4 flex items-center justify-center">
@ -66,37 +79,44 @@ export const LoginModalContent = (props: LoginModalContentProps) => {
)
) : (
<div className="mb-3 flex flex-col items-center justify-center gap-4">
{providers.map(([key, provider]) => (
<MotionButtonBase
key={key}
onClick={() => {
if (key === "credential") {
setIsEmail(true)
} else {
loginHandler(key, "app")
}
}}
className="center hover:bg-material-medium relative w-full gap-2 rounded-xl border p-2.5 pl-5 font-semibold duration-200"
>
<img
className="absolute left-9 h-5"
style={{
color: provider.color,
}}
src={provider.icon64}
/>
<span>{t("login.continueWith", { provider: provider.name })}</span>
</MotionButtonBase>
))}
{isLoading
? // Skeleton loaders to prevent CLS
Array.from({ length: 4 })
.fill(0)
.map((_, index) => (
<div
key={index}
className="bg-material-ultra-thick border-material-medium relative h-12 w-full animate-pulse rounded-xl border"
/>
))
: providers.map(([key, provider]) => (
<MotionButtonBase
key={key}
onClick={() => {
if (key === "credential") {
setIsEmail(true)
} else {
loginHandler(key, "app")
}
}}
className="center hover:bg-material-medium relative w-full gap-2 rounded-xl border py-3 pl-5 font-semibold duration-200"
>
<img
className="absolute left-9 h-5 dark:brightness-[0.85] dark:hue-rotate-180 dark:invert"
src={provider.icon64}
/>
<span>{t("login.continueWith", { provider: provider.name })}</span>
</MotionButtonBase>
))}
</div>
)}
<Divider className="mb-5 mt-6" />
{isEmail ? (
<div className="pb-2 text-center" onClick={() => setIsEmail(false)}>
Back
<div className="pb-2 text-center font-medium" onClick={() => setIsEmail(false)}>
{t("login.back")}
</div>
) : (
<div className="pb-2 text-center" onClick={() => setIsRegister(!isRegister)}>
<div className="pb-2 text-center font-medium" onClick={() => setIsRegister(!isRegister)}>
<Trans
t={t}
i18nKey={isRegister ? "login.have_account" : "login.no_account"}
@ -106,6 +126,23 @@ export const LoginModalContent = (props: LoginModalContentProps) => {
/>
</div>
)}
<div className="text-text-secondary mt-3 text-center text-xs leading-5">
<span>{t("login.agree_to")}</span> <br />
<a
onClick={() => handleOpenLegal("tos")}
className="text-accent cursor-pointer hover:underline"
>
{t("login.terms")}
</a>{" "}
&{" "}
<a
onClick={() => handleOpenLegal("privacy")}
className="text-accent cursor-pointer hover:underline"
>
{t("login.privacy")}
</a>
</div>
</>
)
if (isMobile) {

View File

@ -24,7 +24,7 @@ export const BoostProgress = ({
const nextLevel = level + 1
return (
<div className="flex w-full flex-col px-2">
<div className="relative -mx-2 pt-12">
<div className="relative -mx-1 pt-12">
<span
className={cn(
"absolute -bottom-3 mb-10 flex h-7 -translate-x-1/2 items-center justify-center whitespace-nowrap rounded-full p-2 text-sm font-bold text-white transition-all duration-500 ease-out after:absolute after:bottom-[-5px] after:left-1/2 after:-z-10 after:flex after:size-3 after:-translate-x-1/2 after:rotate-45",
@ -43,7 +43,7 @@ export const BoostProgress = ({
aria-valuemin={0}
aria-valuemax={remainingBoostsToLevelUp}
style={{ width: `calc(${percentage}% + 0.5rem)` }}
className="bg-accent flex h-full -translate-x-2 items-center justify-center rounded-3xl text-white transition-all duration-500 ease-out"
className="bg-accent flex h-full -translate-x-1 items-center justify-center rounded-3xl text-white transition-all duration-500 ease-out"
/>
</div>
</div>

View File

@ -1,8 +1,8 @@
import { assertType, test } from "vitest"
import { CommandActionButton, CommandIdButton } from "./command-button"
import type { OpenInBrowserCommand, TipCommand } from "./commands/entry"
import { COMMAND_ID } from "./commands/id"
import type { OpenInBrowserCommand, TipCommand } from "./commands/types"
test("CommandActionButton types", () => {
const mockCommand = {} as OpenInBrowserCommand

View File

@ -1,16 +1,22 @@
import { useRegisterEntryCommands } from "./commands/entry"
import { useRegisterEntryRenderCommand } from "./commands/entry-render"
import { useRegisterGlobalCommands } from "./commands/global"
import { useRegisterIntegrationCommands } from "./commands/integration"
import { useRegisterLayoutCommands } from "./commands/layout"
import { useRegisterListCommands } from "./commands/list"
import { useRegisterSettingsCommands } from "./commands/settings"
import { useRegisterSubscriptionCommands } from "./commands/subscription"
import { useRegisterTimelineCommand } from "./commands/timeline"
export function useRegisterFollowCommands() {
export const FollowCommandManager = () => {
useRegisterSettingsCommands()
useRegisterListCommands()
useRegisterEntryCommands()
useRegisterIntegrationCommands()
}
export const FollowCommandManager = () => {
useRegisterFollowCommands()
useRegisterGlobalCommands()
useRegisterLayoutCommands()
useRegisterTimelineCommand()
useRegisterEntryRenderCommand()
useRegisterSubscriptionCommands()
return null
}

View File

@ -0,0 +1,81 @@
import { EventBus } from "@follow/utils/event-bus"
import { useRegisterCommandEffect } from "../hooks/use-register-command"
import type { Command } from "../types"
import { COMMAND_ID } from "./id"
declare module "@follow/utils/event-bus" {
interface EventBusMap {
"entry-render:scroll-down": never
"entry-render:scroll-up": never
"entry-render:next-entry": never
"entry-render:previous-entry": never
}
}
const LABEL_PREFIX = "Entry Render"
const category = "follow:entry-render"
export const useRegisterEntryRenderCommand = () => {
useRegisterCommandEffect([
{
id: COMMAND_ID.entryRender.scrollDown,
run: () => {
EventBus.dispatch(COMMAND_ID.entryRender.scrollDown)
},
category,
label: `${LABEL_PREFIX}: Scroll down`,
},
{
id: COMMAND_ID.entryRender.scrollUp,
run: () => {
EventBus.dispatch(COMMAND_ID.entryRender.scrollUp)
},
category,
label: `${LABEL_PREFIX}: Scroll up`,
},
{
id: COMMAND_ID.entryRender.nextEntry,
run: () => {
EventBus.dispatch(COMMAND_ID.timeline.switchToNext)
EventBus.dispatch(COMMAND_ID.entryRender.nextEntry)
},
category,
label: `${LABEL_PREFIX}: Next entry`,
},
{
id: COMMAND_ID.entryRender.previousEntry,
run: () => {
EventBus.dispatch(COMMAND_ID.timeline.switchToPrevious)
EventBus.dispatch(COMMAND_ID.entryRender.previousEntry)
},
category,
label: `${LABEL_PREFIX}: Previous entry`,
},
])
}
type EntryScrollDownCommand = Command<{
id: typeof COMMAND_ID.entryRender.scrollDown
fn: () => void
}>
type EntryScrollUpCommand = Command<{
id: typeof COMMAND_ID.entryRender.scrollUp
fn: () => void
}>
type EntryNextEntryCommand = Command<{
id: typeof COMMAND_ID.entryRender.nextEntry
fn: () => void
}>
type EntryPreviousEntryCommand = Command<{
id: typeof COMMAND_ID.entryRender.previousEntry
fn: () => void
}>
export type EntryRenderCommand =
| EntryScrollDownCommand
| EntryScrollUpCommand
| EntryNextEntryCommand
| EntryPreviousEntryCommand

View File

@ -28,6 +28,7 @@ import { useTipModal } from "~/modules/wallet/hooks"
import { entryActions, useEntryStore } from "~/store/entry"
import { useRegisterFollowCommand } from "../hooks/use-register-command"
import type { Command } from "../types"
import { COMMAND_ID } from "./id"
const useCollect = () => {
@ -421,3 +422,107 @@ export const useRegisterEntryCommands = () => {
},
)
}
export type TipCommand = Command<{
id: typeof COMMAND_ID.entry.tip
fn: (data: { userId?: string | null; feedId?: string; entryId?: string }) => void
}>
export type StarCommand = Command<{
id: typeof COMMAND_ID.entry.star
fn: (data: { entryId: string; view?: FeedViewType }) => void
}>
export type DeleteCommand = Command<{
id: typeof COMMAND_ID.entry.delete
fn: (data: { entryId: string }) => void
}>
export type CopyLinkCommand = Command<{
id: typeof COMMAND_ID.entry.copyLink
fn: (data: { entryId: string }) => void
}>
export type ExportAsPDFCommand = Command<{
id: typeof COMMAND_ID.entry.exportAsPDF
fn: (data: { entryId: string }) => void
}>
export type CopyTitleCommand = Command<{
id: typeof COMMAND_ID.entry.copyTitle
fn: (data: { entryId: string }) => void
}>
export type OpenInBrowserCommand = Command<{
id: typeof COMMAND_ID.entry.openInBrowser
fn: (data: { entryId: string }) => void
}>
export type ViewSourceContentCommand = Command<{
id: typeof COMMAND_ID.entry.viewSourceContent
fn: (data: { entryId: string; siteUrl?: string | null | undefined }) => void
}>
export type ShareCommand = Command<{
id: typeof COMMAND_ID.entry.share
fn: (data: { entryId: string }) => void
}>
export type ReadCommand = Command<{
id: typeof COMMAND_ID.entry.read
fn: (data: { entryId: string }) => void
}>
export type ReadAboveCommand = Command<{
id: typeof COMMAND_ID.entry.readAbove
fn: (data: { publishedAt: string }) => void
}>
export type ReadBelowCommand = Command<{
id: typeof COMMAND_ID.entry.readBelow
fn: (data: { publishedAt: string }) => void
}>
export type ToggleAISummaryCommand = Command<{
id: typeof COMMAND_ID.entry.toggleAISummary
fn: () => void
}>
export type ToggleAITranslationCommand = Command<{
id: typeof COMMAND_ID.entry.toggleAITranslation
fn: () => void
}>
export type ImageGalleryCommand = Command<{
id: typeof COMMAND_ID.entry.imageGallery
fn: (data: { entryId: string }) => void
}>
export type TTSCommand = Command<{
id: typeof COMMAND_ID.entry.tts
fn: (data: { entryId: string; entryContent: string }) => void
}>
export type ReadabilityCommand = Command<{
id: typeof COMMAND_ID.entry.readability
fn: (data: { entryId: string; entryUrl: string }) => void
}>
export type EntryCommand =
| TipCommand
| StarCommand
| DeleteCommand
| CopyLinkCommand
| ExportAsPDFCommand
| CopyTitleCommand
| OpenInBrowserCommand
| ViewSourceContentCommand
| ShareCommand
| ReadCommand
| ReadAboveCommand
| ReadBelowCommand
| ToggleAISummaryCommand
| ToggleAITranslationCommand
| ImageGalleryCommand
| TTSCommand
| ReadabilityCommand

View File

@ -0,0 +1,26 @@
import { useShortcutsModal } from "~/modules/modal/shortcuts"
import { useRegisterCommandEffect } from "../hooks/use-register-command"
import type { Command } from "../types"
import { COMMAND_ID } from "./id"
export const useRegisterGlobalCommands = () => {
const showShortcuts = useShortcutsModal()
useRegisterCommandEffect([
{
id: COMMAND_ID.global.showShortcuts,
label: "Show shortcuts",
run: () => {
showShortcuts()
},
},
])
}
export type ShowShortcutsCommand = Command<{
id: typeof COMMAND_ID.global.showShortcuts
fn: () => void
}>
export type GlobalCommand = ShowShortcutsCommand

View File

@ -41,4 +41,39 @@ export const COMMAND_ID = {
changeThemeToLight: "follow:change-color-mode-to-light",
customizeToolbar: "follow:customize-toolbar",
},
global: {
showShortcuts: "global:show-shortcuts",
},
layout: {
toggleTimelineColumn: "layout:toggle-timeline-column",
focusToTimeline: "layout:focus-to-timeline",
focusToSubscription: "layout:focus-to-subscription",
},
timeline: {
switchToNext: "timeline:switch-to-next",
switchToPrevious: "timeline:switch-to-previous",
refetch: "timeline:refetch",
enter: "timeline:enter",
},
entryRender: {
scrollDown: "entry-render:scroll-down",
scrollUp: "entry-render:scroll-up",
nextEntry: "entry-render:next-entry",
previousEntry: "entry-render:previous-entry",
},
subscription: {
switchTabToNext: "subscription:switch-tab-to-next",
switchTabToPrevious: "subscription:switch-tab-to-previous",
switchTabToArticle: "subscription:switch-tab-to-article",
switchTabToSocial: "subscription:switch-tab-to-social",
switchTabToPicture: "subscription:switch-tab-to-picture",
switchTabToVideo: "subscription:switch-tab-to-video",
switchTabToAudio: "subscription:switch-tab-to-audio",
switchTabToNotification: "subscription:switch-tab-to-notification",
nextSubscription: "subscription:next",
previousSubscription: "subscription:previous",
toggleFolderCollapse: "subscription:toggle-folder-collapse",
},
} as const

View File

@ -28,6 +28,7 @@ import { useEntryStore } from "~/store/entry"
import { useRegisterCommandEffect } from "../hooks/use-register-command"
import { defineFollowCommand } from "../registry/command"
import type { Command } from "../types"
import { COMMAND_ID } from "./id"
export const useRegisterIntegrationCommands = () => {
@ -558,3 +559,47 @@ const buildMemoRequestBody = (entry: FlatEntryModel, selectedText: string) => {
source_url: entry.entries.url,
}
}
export type SaveToEagleCommand = Command<{
id: typeof COMMAND_ID.integration.saveToEagle
fn: (payload: { entryId: string }) => void
}>
export type SaveToReadwiseCommand = Command<{
id: typeof COMMAND_ID.integration.saveToReadwise
fn: (payload: { entryId: string }) => void
}>
export type SaveToInstapaperCommand = Command<{
id: typeof COMMAND_ID.integration.saveToInstapaper
fn: (payload: { entryId: string }) => void
}>
export type SaveToObsidianCommand = Command<{
id: typeof COMMAND_ID.integration.saveToObsidian
fn: (payload: { entryId: string }) => void
}>
export type SaveToOutlineCommand = Command<{
id: typeof COMMAND_ID.integration.saveToOutline
fn: (payload: { entryId: string }) => void
}>
export type SaveToReadeckCommand = Command<{
id: typeof COMMAND_ID.integration.saveToReadeck
fn: (payload: { entryId: string }) => void
}>
export type SaveToCuboxCommand = Command<{
id: typeof COMMAND_ID.integration.saveToCubox
fn: (payload: { entryId: string }) => void
}>
export type IntegrationCommand =
| SaveToEagleCommand
| SaveToReadwiseCommand
| SaveToInstapaperCommand
| SaveToObsidianCommand
| SaveToOutlineCommand
| SaveToReadeckCommand
| SaveToCuboxCommand

View File

@ -0,0 +1,59 @@
import { EventBus } from "@follow/utils/event-bus"
import { setTimelineColumnShow } from "~/atoms/sidebar"
import { useRegisterCommandEffect } from "../hooks/use-register-command"
import type { Command } from "../types"
import { COMMAND_ID } from "./id"
declare module "@follow/utils/event-bus" {
interface EventBusMap {
"layout:focus-to-timeline": never
"layout:focus-to-subscription": never
}
}
export const useRegisterLayoutCommands = () => {
useRegisterCommandEffect([
{
id: COMMAND_ID.layout.toggleTimelineColumn,
label: "Toggle timeline column",
run: () => {
setTimelineColumnShow((show) => !show)
},
},
{
id: COMMAND_ID.layout.focusToTimeline,
label: "Focus to timeline",
run: () => {
EventBus.dispatch(COMMAND_ID.layout.focusToTimeline)
},
},
{
id: COMMAND_ID.layout.focusToSubscription,
label: "Focus to subscription",
run: () => {
EventBus.dispatch(COMMAND_ID.layout.focusToSubscription)
},
},
])
}
export type FocusToSubscriptionCommand = Command<{
id: typeof COMMAND_ID.layout.focusToSubscription
fn: () => void
}>
export type ToggleTimelineColumnCommand = Command<{
id: typeof COMMAND_ID.layout.toggleTimelineColumn
fn: () => void
}>
export type FocusToTimelineCommand = Command<{
id: typeof COMMAND_ID.layout.focusToTimeline
fn: () => void
}>
export type LayoutCommand =
| ToggleTimelineColumnCommand
| FocusToTimelineCommand
| FocusToSubscriptionCommand

View File

@ -5,6 +5,7 @@ import { useSetTheme } from "~/hooks/common"
import { useShowCustomizeToolbarModal } from "~/modules/customize-toolbar/modal"
import { useRegisterCommandEffect } from "../hooks/use-register-command"
import type { Command } from "../types"
import { COMMAND_ID } from "./id"
export const useRegisterSettingsCommands = () => {
@ -66,3 +67,10 @@ const useRegisterThemeCommands = () => {
},
])
}
export type CustomizeToolbarCommand = Command<{
id: typeof COMMAND_ID.settings.customizeToolbar
fn: () => void
}>
export type SettingsCommand = CustomizeToolbarCommand

View File

@ -0,0 +1,172 @@
import { EventBus } from "@follow/utils/event-bus"
import { useRegisterCommandEffect } from "../hooks/use-register-command"
import type { Command } from "../types"
import { COMMAND_ID } from "./id"
declare module "@follow/utils/event-bus" {
interface EventBusMap {
"subscription:switch-tab-to-next": never
"subscription:switch-tab-to-previous": never
"subscription:switch-tab-to-article": never
"subscription:switch-tab-to-social": never
"subscription:switch-tab-to-picture": never
"subscription:switch-tab-to-video": never
"subscription:switch-tab-to-audio": never
"subscription:switch-tab-to-notification": never
"subscription:next": never
"subscription:previous": never
"subscription:toggle-folder-collapse": never
}
}
const LABEL_PREFIX = "Subscription"
export const useRegisterSubscriptionCommands = () => {
useRegisterCommandEffect([
{
id: COMMAND_ID.subscription.switchTabToNext,
label: `${LABEL_PREFIX}: Switch to next tab`,
run: () => {
EventBus.dispatch(COMMAND_ID.subscription.switchTabToNext)
},
},
{
id: COMMAND_ID.subscription.switchTabToPrevious,
label: `${LABEL_PREFIX}: Switch to previous tab`,
run: () => {
EventBus.dispatch(COMMAND_ID.subscription.switchTabToPrevious)
},
},
{
id: COMMAND_ID.subscription.switchTabToArticle,
label: `${LABEL_PREFIX}: Switch to article tab`,
run: () => {
EventBus.dispatch(COMMAND_ID.subscription.switchTabToArticle)
},
},
{
id: COMMAND_ID.subscription.switchTabToSocial,
label: `${LABEL_PREFIX}: Switch to social tab`,
run: () => {
EventBus.dispatch(COMMAND_ID.subscription.switchTabToSocial)
},
},
{
id: COMMAND_ID.subscription.switchTabToPicture,
label: `${LABEL_PREFIX}: Switch to picture tab`,
run: () => {
EventBus.dispatch(COMMAND_ID.subscription.switchTabToPicture)
},
},
{
id: COMMAND_ID.subscription.switchTabToVideo,
label: `${LABEL_PREFIX}: Switch to video tab`,
run: () => {
EventBus.dispatch(COMMAND_ID.subscription.switchTabToVideo)
},
},
{
id: COMMAND_ID.subscription.switchTabToAudio,
label: `${LABEL_PREFIX}: Switch to audio tab`,
run: () => {
EventBus.dispatch(COMMAND_ID.subscription.switchTabToAudio)
},
},
{
id: COMMAND_ID.subscription.switchTabToNotification,
label: `${LABEL_PREFIX}: Switch to notification tab`,
run: () => {
EventBus.dispatch(COMMAND_ID.subscription.switchTabToNotification)
},
},
{
id: COMMAND_ID.subscription.nextSubscription,
label: `${LABEL_PREFIX}: Next Subscription`,
run: () => {
EventBus.dispatch(COMMAND_ID.subscription.nextSubscription)
},
},
{
id: COMMAND_ID.subscription.previousSubscription,
label: `${LABEL_PREFIX}: Previous Subscription`,
run: () => {
EventBus.dispatch(COMMAND_ID.subscription.previousSubscription)
},
},
{
id: COMMAND_ID.subscription.toggleFolderCollapse,
label: `${LABEL_PREFIX}: Toggle Folder Collapse`,
run: () => {
EventBus.dispatch(COMMAND_ID.subscription.toggleFolderCollapse)
},
},
])
}
type SwitchTabToNextCommand = Command<{
id: typeof COMMAND_ID.subscription.switchTabToNext
fn: () => void
}>
type SwitchTabToPreviousCommand = Command<{
id: typeof COMMAND_ID.subscription.switchTabToPrevious
fn: () => void
}>
type SwitchTabToArticleCommand = Command<{
id: typeof COMMAND_ID.subscription.switchTabToArticle
fn: () => void
}>
type SwitchTabToSocialCommand = Command<{
id: typeof COMMAND_ID.subscription.switchTabToSocial
fn: () => void
}>
type SwitchTabToPictureCommand = Command<{
id: typeof COMMAND_ID.subscription.switchTabToPicture
fn: () => void
}>
type SwitchTabToVideoCommand = Command<{
id: typeof COMMAND_ID.subscription.switchTabToVideo
fn: () => void
}>
type SwitchTabToAudioCommand = Command<{
id: typeof COMMAND_ID.subscription.switchTabToAudio
fn: () => void
}>
type SwitchTabToNotificationCommand = Command<{
id: typeof COMMAND_ID.subscription.switchTabToNotification
fn: () => void
}>
type NextSubscriptionCommand = Command<{
id: typeof COMMAND_ID.subscription.nextSubscription
fn: () => void
}>
type PreviousSubscriptionCommand = Command<{
id: typeof COMMAND_ID.subscription.previousSubscription
fn: () => void
}>
type ToggleFolderCollapseCommand = Command<{
id: typeof COMMAND_ID.subscription.toggleFolderCollapse
fn: () => void
}>
export type SubscriptionCommand =
| SwitchTabToNextCommand
| SwitchTabToPreviousCommand
| SwitchTabToArticleCommand
| SwitchTabToSocialCommand
| SwitchTabToPictureCommand
| SwitchTabToVideoCommand
| SwitchTabToAudioCommand
| SwitchTabToNotificationCommand
| NextSubscriptionCommand
| PreviousSubscriptionCommand
| ToggleFolderCollapseCommand

View File

@ -0,0 +1,73 @@
import { EventBus } from "@follow/utils/event-bus"
import { useRegisterCommandEffect } from "../hooks/use-register-command"
import type { Command } from "../types"
import { COMMAND_ID } from "./id"
declare module "@follow/utils/event-bus" {
interface EventBusMap {
"timeline:switch-to-next": never
"timeline:switch-to-previous": never
"timeline:refetch": never
"timeline:enter": never
}
}
export const useRegisterTimelineCommand = () => {
useRegisterCommandEffect([
{
id: COMMAND_ID.timeline.switchToNext,
label: "Switch to next timeline",
run: () => {
EventBus.dispatch("timeline:switch-to-next")
},
},
{
id: COMMAND_ID.timeline.switchToPrevious,
label: "Switch to previous timeline",
run: () => {
EventBus.dispatch("timeline:switch-to-previous")
},
},
{
id: COMMAND_ID.timeline.refetch,
label: "Refetch timeline",
run: () => {
EventBus.dispatch("timeline:refetch")
},
},
{
id: COMMAND_ID.timeline.enter,
label: "Enter Selected Entry",
run: () => {
EventBus.dispatch("timeline:enter")
},
},
])
}
export type SwitchToNextTimelineCommand = Command<{
id: typeof COMMAND_ID.timeline.switchToNext
fn: () => void
}>
export type SwitchToPreviousTimelineCommand = Command<{
id: typeof COMMAND_ID.timeline.switchToPrevious
fn: () => void
}>
export type RefetchTimelineCommand = Command<{
id: typeof COMMAND_ID.timeline.refetch
fn: () => void
}>
export type EnterTimelineCommand = Command<{
id: typeof COMMAND_ID.timeline.enter
fn: () => void
}>
export type TimelineCommand =
| SwitchToNextTimelineCommand
| SwitchToPreviousTimelineCommand
| RefetchTimelineCommand
| EnterTimelineCommand

View File

@ -1,167 +1,20 @@
// Entry commands
import type { FeedViewType } from "@follow/constants"
import type { EntryCommand } from "./entry"
import type { EntryRenderCommand } from "./entry-render"
import type { GlobalCommand } from "./global"
import type { IntegrationCommand } from "./integration"
import type { LayoutCommand } from "./layout"
import type { SettingsCommand } from "./settings"
import type { SubscriptionCommand } from "./subscription"
import type { TimelineCommand } from "./timeline"
import type { Command } from "../types"
import type { COMMAND_ID } from "./id"
export type TipCommand = Command<{
id: typeof COMMAND_ID.entry.tip
fn: (data: { userId?: string | null; feedId?: string; entryId?: string }) => void
}>
export type StarCommand = Command<{
id: typeof COMMAND_ID.entry.star
fn: (data: { entryId: string; view?: FeedViewType }) => void
}>
export type DeleteCommand = Command<{
id: typeof COMMAND_ID.entry.delete
fn: (data: { entryId: string }) => void
}>
export type CopyLinkCommand = Command<{
id: typeof COMMAND_ID.entry.copyLink
fn: (data: { entryId: string }) => void
}>
export type ExportAsPDFCommand = Command<{
id: typeof COMMAND_ID.entry.exportAsPDF
fn: (data: { entryId: string }) => void
}>
export type CopyTitleCommand = Command<{
id: typeof COMMAND_ID.entry.copyTitle
fn: (data: { entryId: string }) => void
}>
export type OpenInBrowserCommand = Command<{
id: typeof COMMAND_ID.entry.openInBrowser
fn: (data: { entryId: string }) => void
}>
export type ViewSourceContentCommand = Command<{
id: typeof COMMAND_ID.entry.viewSourceContent
fn: (data: { entryId: string; siteUrl?: string | null | undefined }) => void
}>
export type ShareCommand = Command<{
id: typeof COMMAND_ID.entry.share
fn: (data: { entryId: string }) => void
}>
export type ReadCommand = Command<{
id: typeof COMMAND_ID.entry.read
fn: (data: { entryId: string }) => void
}>
export type ReadAboveCommand = Command<{
id: typeof COMMAND_ID.entry.readAbove
fn: (data: { publishedAt: string }) => void
}>
export type ReadBelowCommand = Command<{
id: typeof COMMAND_ID.entry.readBelow
fn: (data: { publishedAt: string }) => void
}>
export type ToggleAISummaryCommand = Command<{
id: typeof COMMAND_ID.entry.toggleAISummary
fn: () => void
}>
export type ToggleAITranslationCommand = Command<{
id: typeof COMMAND_ID.entry.toggleAITranslation
fn: () => void
}>
export type ImageGalleryCommand = Command<{
id: typeof COMMAND_ID.entry.imageGallery
fn: (data: { entryId: string }) => void
}>
export type TTSCommand = Command<{
id: typeof COMMAND_ID.entry.tts
fn: (data: { entryId: string; entryContent: string }) => void
}>
export type ReadabilityCommand = Command<{
id: typeof COMMAND_ID.entry.readability
fn: (data: { entryId: string; entryUrl: string }) => void
}>
export type EntryCommand =
| TipCommand
| StarCommand
| DeleteCommand
| CopyLinkCommand
| ExportAsPDFCommand
| CopyTitleCommand
| OpenInBrowserCommand
| ViewSourceContentCommand
| ShareCommand
| ReadCommand
| ReadAboveCommand
| ReadBelowCommand
| ToggleAISummaryCommand
| ToggleAITranslationCommand
| ImageGalleryCommand
| TTSCommand
| ReadabilityCommand
// Settings commands
export type CustomizeToolbarCommand = Command<{
id: typeof COMMAND_ID.settings.customizeToolbar
fn: () => void
}>
export type SettingsCommand = CustomizeToolbarCommand
// Integration commands
export type SaveToEagleCommand = Command<{
id: typeof COMMAND_ID.integration.saveToEagle
fn: (payload: { entryId: string }) => void
}>
export type SaveToReadwiseCommand = Command<{
id: typeof COMMAND_ID.integration.saveToReadwise
fn: (payload: { entryId: string }) => void
}>
export type SaveToInstapaperCommand = Command<{
id: typeof COMMAND_ID.integration.saveToInstapaper
fn: (payload: { entryId: string }) => void
}>
export type SaveToObsidianCommand = Command<{
id: typeof COMMAND_ID.integration.saveToObsidian
fn: (payload: { entryId: string }) => void
}>
export type SaveToOutlineCommand = Command<{
id: typeof COMMAND_ID.integration.saveToOutline
fn: (payload: { entryId: string }) => void
}>
export type SaveToReadeckCommand = Command<{
id: typeof COMMAND_ID.integration.saveToReadeck
fn: (payload: { entryId: string }) => void
}>
export type SaveToCuboxCommand = Command<{
id: typeof COMMAND_ID.integration.saveToCubox
fn: (payload: { entryId: string }) => void
}>
export type IntegrationCommand =
| SaveToEagleCommand
| SaveToReadwiseCommand
| SaveToInstapaperCommand
| SaveToObsidianCommand
| SaveToOutlineCommand
| SaveToReadeckCommand
| SaveToCuboxCommand
export type BasicCommand = EntryCommand | SettingsCommand | IntegrationCommand
export type BasicCommand =
| EntryCommand
| SettingsCommand
| IntegrationCommand
| GlobalCommand
| LayoutCommand
| TimelineCommand
| EntryRenderCommand
| SubscriptionCommand

View File

@ -0,0 +1,44 @@
import { shortcuts } from "~/constants/shortcuts"
import { COMMAND_ID } from "../commands/id"
const defaultCommandShortcuts = {
[COMMAND_ID.entry.read]: shortcuts.entry.toggleRead.key,
[COMMAND_ID.entry.openInBrowser]: shortcuts.entry.openInBrowser.key,
[COMMAND_ID.entry.star]: shortcuts.entry.toggleStarred.key,
[COMMAND_ID.entry.copyLink]: shortcuts.entry.copyLink.key,
[COMMAND_ID.entry.copyTitle]: shortcuts.entry.copyTitle.key,
[COMMAND_ID.entry.tts]: shortcuts.entry.tts.key,
[COMMAND_ID.entry.tip]: shortcuts.entry.tip.key,
[COMMAND_ID.entry.share]: shortcuts.entry.share.key,
[COMMAND_ID.entryRender.scrollUp]: shortcuts.entry.scrollUp.key,
[COMMAND_ID.entryRender.scrollDown]: shortcuts.entry.scrollDown.key,
[COMMAND_ID.timeline.switchToNext]: shortcuts.entries.next.key,
[COMMAND_ID.timeline.switchToPrevious]: shortcuts.entries.previous.key,
[COMMAND_ID.timeline.refetch]: shortcuts.entries.refetch.key,
[COMMAND_ID.layout.toggleTimelineColumn]: shortcuts.layout.toggleSidebar.key,
[COMMAND_ID.subscription.nextSubscription]: shortcuts.subscriptions.nextSubscription.key,
[COMMAND_ID.subscription.previousSubscription]: shortcuts.subscriptions.previousSubscription.key,
[COMMAND_ID.subscription.switchTabToNext]: shortcuts.subscriptions.switchNextView.key,
[COMMAND_ID.subscription.switchTabToPrevious]: shortcuts.subscriptions.switchPreviousView.key,
[COMMAND_ID.global.showShortcuts]: shortcuts.layout.showShortcuts.key,
[COMMAND_ID.entryRender.nextEntry]: shortcuts.entry.nextEntry.key,
[COMMAND_ID.entryRender.previousEntry]: shortcuts.entry.previousEntry.key,
[COMMAND_ID.subscription.toggleFolderCollapse]: shortcuts.subscriptions.toggleFolderCollapse.key,
} as const
export type BindingCommandId = keyof typeof defaultCommandShortcuts
// eslint-disable-next-line @eslint-react/hooks-extra/no-unnecessary-use-prefix, @eslint-react/hooks-extra/ensure-custom-hooks-using-other-hooks
export const useCommandShortcut = (commandId: BindingCommandId): string => {
const commandShortcut = defaultCommandShortcuts[commandId]
return commandShortcut
}

View File

@ -1,7 +1,7 @@
import { assertType, expectTypeOf, test } from "vitest"
import type { TipCommand } from "../commands/entry"
import { COMMAND_ID } from "../commands/id"
import type { TipCommand } from "../commands/types"
import { getCommand, useCommand, useRunCommandFn } from "./use-command"
test("getCommand types work properly", () => {

View File

@ -1,14 +1,16 @@
import { useHotkeys } from "react-hotkeys-hook"
import { useEffect } from "react"
import { tinykeys } from "tinykeys"
import type { FollowCommand, FollowCommandId } from "../types"
import { getCommand } from "./use-command"
import type { BindingCommandId } from "./use-command-shortcut"
import { useCommandShortcut } from "./use-command-shortcut"
interface RegisterHotkeyOptions<T extends FollowCommandId> {
shortcut: string
commandId: T
args?: Parameters<Extract<FollowCommand, { id: T }>["run"]>
when?: boolean
// hotkeyOptions?: Options
}
export const useCommandHotkey = <T extends FollowCommandId>({
@ -17,27 +19,60 @@ export const useCommandHotkey = <T extends FollowCommandId>({
when,
args,
}: RegisterHotkeyOptions<T>) => {
useHotkeys(
shortcut,
() => {
const command = getCommand(commandId)
useEffect(() => {
if (!when) {
return
}
if (!command) return
if (Array.isArray(args)) {
// It should be safe to spread the args here because we are checking if it is an array
// @ts-expect-error - A spread argument must either have a tuple type or be passed to a rest parameter.ts(2556)
command.run(...args)
return
if (!shortcut) {
return
}
// Handle comma-separated shortcuts
const shortcuts = shortcut.split(",").map((s) => s.trim())
const keyMap: Record<string, (event: KeyboardEvent) => void> = {}
// Create a handler for each shortcut
shortcuts.forEach((key) => {
keyMap[key] = (event) => {
event.preventDefault()
event.stopPropagation()
const command = getCommand(commandId)
if (!command) return
if (Array.isArray(args)) {
// It should be safe to spread the args here because we are checking if it is an array
// @ts-expect-error - A spread argument must either have a tuple type or be passed to a rest parameter.ts(2556)
command.run(...args)
return
}
if (args === undefined) {
// @ts-expect-error
command.run()
return
}
console.error("Invalid args", typeof args, args)
}
if (args === undefined) {
// @ts-expect-error
command.run()
return
}
console.error("Invalid args", typeof args, args)
},
{
enabled: when,
},
)
})
return tinykeys(document.documentElement, keyMap)
}, [shortcut, commandId, when, args])
}
export const useCommandBinding = <T extends BindingCommandId>({
commandId,
when = true,
args,
}: Omit<RegisterHotkeyOptions<T>, "shortcut">) => {
const commandShortcut = useCommandShortcut(commandId)
return useCommandHotkey({
shortcut: commandShortcut,
commandId,
when,
args,
})
}

View File

@ -23,9 +23,6 @@ export function createCommand<
label = typeof label === "string" ? { title: label } : label
return label
},
// when: !!(options.when ?? true),
// keyBinding:
// typeof options.keyBinding === "string" ? { binding: options.keyBinding } : options.keyBinding,
}
}

View File

@ -8,6 +8,7 @@ export type CommandCategory =
| "follow:updates"
| "follow:help"
| "follow:general"
| "follow:entry-render"
export interface KeybindingOptions {
binding: string
@ -17,36 +18,6 @@ export interface KeybindingOptions {
// skipRegister?: boolean
}
export interface CommandKeybindingOptions<
ID extends string,
T extends (...args: any[]) => unknown = (...args: unknown[]) => unknown,
> {
/**
* the command id.
*/
commandId: ID
/**
* a set of predefined precondition strategies.
*
* note: this only used for keybinding and command menu.
* command will always available when called directly.
*/
when?: boolean
/**
* we use https://github.com/jamiebuilds/tinykeys so that we can use the same keybinding definition
* for both mac and windows.
*
* Use `$mod` for `Cmd` on Mac and `Ctrl` on Windows and Linux.
*/
keyBinding?: KeybindingOptions | string
/**
* additional arguments for the command.
*
* Only used when the command is called from a keybinding.
*/
args?: Parameters<T>
}
export interface Command<
T extends { id: string; fn: (...args: any[]) => unknown } = {
id: string
@ -61,9 +32,6 @@ export interface Command<
readonly icon?: ReactNode | ((props?: { isActive?: boolean }) => ReactNode)
readonly category: CommandCategory
readonly run: T["fn"]
// readonly when: boolean
// readonly keyBinding?: KeybindingOptions
}
export type SimpleCommand<T extends string> = Command<{ id: T; fn: () => void }>
@ -87,7 +55,6 @@ export interface CommandOptions<
run: T["fn"]
when?: boolean
keyBinding?: T["fn"] extends () => void ? KeybindingOptions | string : never
}
export type FollowCommandMap = {

View File

@ -7,24 +7,29 @@ export interface ToolbarActionOrder {
more: UniqueIdentifier[]
}
const entryItemInMore = new Set<string>([
COMMAND_ID.entry.copyLink,
COMMAND_ID.entry.openInBrowser,
COMMAND_ID.entry.exportAsPDF,
COMMAND_ID.entry.read,
COMMAND_ID.entry.tts,
])
export const entryItemHideInHeader = new Set<string>([
export const ENTRY_ITEM_HIDE_IN_HEADER = new Set<UniqueIdentifier>([
COMMAND_ID.entry.readAbove,
COMMAND_ID.entry.readBelow,
])
const MAIN_ACTIONS = [
COMMAND_ID.entry.readability,
COMMAND_ID.entry.tts,
COMMAND_ID.entry.star,
COMMAND_ID.entry.toggleAISummary,
COMMAND_ID.entry.toggleAITranslation,
COMMAND_ID.entry.imageGallery,
COMMAND_ID.entry.share,
]
const MAIN_ACTIONS_SET = new Set<UniqueIdentifier>(MAIN_ACTIONS)
export const DEFAULT_ACTION_ORDER: ToolbarActionOrder = {
main: Object.values(COMMAND_ID.entry).filter((id) => !entryItemInMore.has(id)),
main: MAIN_ACTIONS,
more: [
...Object.values(COMMAND_ID.integration),
...Object.values(COMMAND_ID.entry).filter((id) => entryItemInMore.has(id)),
...Object.values(COMMAND_ID.entry).filter((id) => !MAIN_ACTIONS_SET.has(id)),
COMMAND_ID.settings.customizeToolbar,
],
}

View File

@ -3,10 +3,11 @@ import { useMemo } from "react"
import { useUISettingSelector } from "~/atoms/settings/ui"
import { DEFAULT_ACTION_ORDER, entryItemHideInHeader } from "./constant"
import { DEFAULT_ACTION_ORDER, ENTRY_ITEM_HIDE_IN_HEADER } from "./constant"
export const useActionOrder = () => {
const actionOrderSetting = useUISettingSelector((s) => s.toolbarOrder)
return useMemo(() => {
const { main, more } = actionOrderSetting
const missingMainActions = DEFAULT_ACTION_ORDER.main.filter(
@ -18,10 +19,10 @@ export const useActionOrder = () => {
return {
main: [...actionOrderSetting.main, ...missingMainActions].filter(
(id) => !entryItemHideInHeader.has(id as string),
(id) => !ENTRY_ITEM_HIDE_IN_HEADER.has(id as string),
),
more: [...actionOrderSetting.more, ...missingMoreActions].filter(
(id) => !entryItemHideInHeader.has(id as string),
(id) => !ENTRY_ITEM_HIDE_IN_HEADER.has(id as string),
),
}
}, [actionOrderSetting])
@ -52,5 +53,6 @@ export const useToolbarOrderMap = () => {
)
return actionOrderMap
}, [actionOrder])
return actionOrderMap
}

View File

@ -114,7 +114,7 @@ export const DiscoverFeedCard: FC<DiscoverFeedCardProps> = memo(
{item.docs ? (
<CardContent className="p-0">
<a href={item.docs} target="_blank" rel="noreferrer">
<Button className="rounded-full bg-zinc-900 px-6 text-white transition-opacity hover:opacity-90 dark:bg-white dark:text-zinc-900">
<Button buttonClassName="rounded-full bg-zinc-900 px-6 text-white transition-opacity hover:opacity-90 dark:bg-white dark:text-zinc-900">
View Docs
</Button>
</a>

View File

@ -63,7 +63,7 @@ export function DiscoverInboxList() {
<div className="center mt-4 flex">
{/* New Inbox */}
<Button
className="flex items-center gap-2"
textClassName="flex items-center gap-2"
onClick={() =>
preCheck() &&
present({

View File

@ -71,6 +71,16 @@ export const FeedForm: Component<{
const isInModal = useIsInModal()
const placeholderRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
if (!feedQuery.isLoading) {
tracker.subscribeModalOpened({
feedId: id,
feedUrl: feedQuery.data?.feed.url || url,
isError: feedQuery.isError,
})
}
}, [feedQuery.isLoading])
return (
<div
className={cn(
@ -209,7 +219,11 @@ const FeedInnerForm = ({
}, [subscription])
useEffect(() => {
if (analytics?.view !== undefined && !subscription && defaultValues?.view === undefined) {
if (
typeof analytics?.view === "number" &&
!subscription &&
typeof defaultValues?.view !== "number"
) {
form.setValue("view", `${analytics.view}`)
}
}, [analytics, subscription, defaultValues?.view])

View File

@ -97,7 +97,7 @@ const ConfirmDestroyModalContent = ({ id }: { id: string }) => {
{t("discover.inbox_destroy_warning")}
</div>
<div className="flex justify-end">
<Button className="bg-red-600" onClick={() => mutationDestroy.mutate(id)}>
<Button buttonClassName="bg-red-600" onClick={() => mutationDestroy.mutate(id)}>
{t("words.confirm")}
</Button>
</div>

View File

@ -66,6 +66,15 @@ export const ListForm: Component<{
const { t } = useTranslation()
useEffect(() => {
if (!feedQuery.isLoading) {
tracker.subscribeModalOpened({
listId: id,
isError: feedQuery.isError,
})
}
}, [feedQuery.isLoading])
return (
<div
className={cn(

View File

@ -13,7 +13,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"
import { flushSync } from "react-dom"
import { useTranslation } from "react-i18next"
import { useGeneralSettingKey } from "~/atoms/settings/general"
import { setUISetting, useUISettingKey } from "~/atoms/settings/ui"
import { DrawerModalLayout } from "~/components/ui/modal/stacked/custom-modal"
import { useCurrentModal, useModalStack } from "~/components/ui/modal/stacked/hooks"
import { useAuthQuery } from "~/hooks/common"
@ -29,35 +29,39 @@ const LanguageOptions = [
},
{
label: "English",
value: "en",
value: "eng",
},
{
label: "中文",
value: "zh-CN",
value: "cmn",
},
] as const
type Language = (typeof LanguageOptions)[number]["value"]
type DiscoverCategories = (typeof RSSHubCategories)[number] | string
const LanguageMap = {
all: "all",
eng: "en",
cmn: "zh-CN",
} as const
const fetchRsshubPopular = (category: DiscoverCategories, lang: Language) => {
return Queries.discover.rsshubCategory({
category: "popular",
categories: category === "all" ? "popular" : `popular,${category}`,
lang,
lang: LanguageMap[lang],
})
}
let firstLoad = true
export function Recommendations() {
const { t } = useTranslation()
const lang = useGeneralSettingKey("language")
const { present } = useModalStack()
const defaultLang = !lang || ["zh-CN", "zh-HK", "zh-TW"].includes(lang) ? "all" : "en"
const [category, setCategory] = useState<DiscoverCategories>("all")
const [selectedLang, setSelectedLang] = useState<Language>(defaultLang)
const lang = useUISettingKey("discoverLanguage")
const rsshubPopular = useAuthQuery(fetchRsshubPopular(category, selectedLang), {
const rsshubPopular = useAuthQuery(fetchRsshubPopular(category, lang), {
meta: {
persist: true,
},
@ -107,7 +111,7 @@ export function Recommendations() {
const handleLangChange = useCallback(
(value: string) => {
flushSync(() => {
setSelectedLang(value as Language)
setUISetting("discoverLanguage", value as Language)
})
rsshubPopular.refetch()
},
@ -152,7 +156,7 @@ export function Recommendations() {
<div className="flex items-center gap-2">
<span className="text-text shrink-0 text-sm font-medium">{t("words.language")}</span>
<ResponsiveSelect
value={selectedLang}
value={lang}
onValueChange={handleLangChange}
triggerClassName="h-8 rounded border-0"
size="sm"

View File

@ -1,13 +1,19 @@
import { useFocusable, useFocusActions } from "@follow/components/common/Focusable/hooks.js"
import { useScrollViewElement } from "@follow/components/ui/scroll-area/hooks.js"
import { useRefValue } from "@follow/hooks"
import { nextFrame } from "@follow/utils/dom"
import { EventBus } from "@follow/utils/event-bus"
import type { FC } from "react"
import { memo, useLayoutEffect, useState } from "react"
import { useHotkeys } from "react-hotkeys-hook"
import { memo, useEffect } from "react"
import { useMainContainerElement } from "~/atoms/dom"
import { HotKeyScopeMap } from "~/constants"
import { shortcuts } from "~/constants/shortcuts"
import { HotkeyScope } from "~/constants"
import { useNavigateEntry } from "~/hooks/biz/useNavigateEntry"
import { useRouteEntryId } from "~/hooks/biz/useRouteParams"
import { useConditionalHotkeyScope } from "~/hooks/common"
import { useHotkeyScope } from "~/providers/hotkey-provider"
import { COMMAND_ID } from "../command/commands/id"
import { useCommandBinding, useCommandHotkey } from "../command/hooks/use-register-hotkey"
export const EntryColumnShortcutHandler: FC<{
refetch: () => void
@ -16,42 +22,43 @@ export const EntryColumnShortcutHandler: FC<{
}> = memo(({ data, refetch, handleScrollTo }) => {
const dataRef = useRefValue(data!)
useHotkeys(
shortcuts.entries.refetch.key,
() => {
refetch()
},
{ scopes: HotKeyScopeMap.Home },
)
const currentEntryIdRef = useRefValue(useRouteEntryId())
const activeScope = useHotkeyScope()
const when =
activeScope.includes(HotkeyScope.Timeline) && !activeScope.includes(HotkeyScope.EntryRender)
useCommandBinding({
commandId: COMMAND_ID.timeline.switchToNext,
when,
})
useCommandBinding({
commandId: COMMAND_ID.timeline.switchToPrevious,
when,
})
useCommandBinding({
commandId: COMMAND_ID.timeline.refetch,
when,
})
useCommandHotkey({
commandId: COMMAND_ID.timeline.enter,
shortcut: "Enter",
when,
})
useCommandHotkey({
commandId: COMMAND_ID.layout.focusToSubscription,
shortcut: "Backspace, Escape",
when,
})
const currentEntryIdRef = useRefValue(useRouteEntryId())
const navigate = useNavigateEntry()
const $mainContainer = useMainContainerElement()
const [enabledArrowKey, setEnabledArrowKey] = useState(false)
// Enable arrow key navigation shortcuts only when focus is on entryContent or entryList,
// entryList shortcuts should not be triggered in the feed col
useLayoutEffect(() => {
if (!$mainContainer) return
const handler = () => {
const target = document.activeElement
const isFocusIn = $mainContainer.contains(target) || $mainContainer === target
setEnabledArrowKey(isFocusIn)
}
handler()
// NOTE: focusin event will bubble to the document
document.addEventListener("focusin", handler)
return () => {
document.removeEventListener("focusin", handler)
}
}, [$mainContainer])
useHotkeys(
shortcuts.entries.next.key,
() => {
useEffect(() => {
return EventBus.subscribe(COMMAND_ID.timeline.switchToNext, () => {
const data = dataRef.current
const currentActiveEntryIndex = data.indexOf(currentEntryIdRef.current || "")
@ -63,12 +70,11 @@ export const EntryColumnShortcutHandler: FC<{
navigate({
entryId: nextId,
})
},
{ scopes: HotKeyScopeMap.Home, enabled: enabledArrowKey, preventDefault: true },
)
useHotkeys(
shortcuts.entries.previous.key,
() => {
})
}, [currentEntryIdRef, dataRef, handleScrollTo, navigate, when])
useEffect(() => {
return EventBus.subscribe(COMMAND_ID.timeline.switchToPrevious, () => {
const data = dataRef.current
const currentActiveEntryIndex = data.indexOf(currentEntryIdRef.current || "")
@ -81,8 +87,27 @@ export const EntryColumnShortcutHandler: FC<{
navigate({
entryId: nextId,
})
},
{ scopes: HotKeyScopeMap.Home, enabled: enabledArrowKey, preventDefault: true },
)
})
}, [currentEntryIdRef, dataRef, handleScrollTo, navigate])
useEffect(() => {
return EventBus.subscribe(COMMAND_ID.timeline.refetch, () => {
refetch()
})
}, [refetch])
const $scrollArea = useScrollViewElement()
const { highlightBoundary } = useFocusActions()
useEffect(() => {
return EventBus.subscribe(COMMAND_ID.layout.focusToTimeline, () => {
$scrollArea?.focus()
nextFrame(highlightBoundary)
})
}, [$scrollArea, highlightBoundary])
const isFocusIn = useFocusable()
useConditionalHotkeyScope(HotkeyScope.Timeline, isFocusIn, true)
return null
})

View File

@ -1,3 +1,4 @@
import { useMobile } from "@follow/components/hooks/useMobile.js"
import {
MasonryIntersectionContext,
MasonryItemsAspectRatioContext,
@ -46,6 +47,7 @@ const gutter = 24
export const PictureMasonry: FC<MasonryProps> = (props) => {
const { data } = props
const isMobile = useMobile()
const cacheMap = useState(() => new Map<string, object>())[0]
const [isInitDim, setIsInitDim] = useState(false)
const [isInitLayout, setIsInitLayout] = useState(false)
@ -79,7 +81,7 @@ export const PictureMasonry: FC<MasonryProps> = (props) => {
},
)
const finalColumn = customizeColumn !== -1 ? customizeColumn : currentColumn
const finalColumn = customizeColumn !== -1 && !isMobile ? customizeColumn : currentColumn
const finalItemWidth = useMemo(
() => (customizeColumn !== -1 ? calcItemWidth(finalColumn) : currentItemWidth),
[calcItemWidth, currentItemWidth, customizeColumn, finalColumn],

View File

@ -2,6 +2,7 @@ import { PassviseFragment } from "@follow/components/common/Fragment.js"
import { useMobile } from "@follow/components/hooks/useMobile.js"
import { AutoResizeHeight } from "@follow/components/ui/auto-resize-height/index.js"
import { Skeleton } from "@follow/components/ui/skeleton/index.jsx"
import { FeedViewType } from "@follow/constants"
import type { MediaModel } from "@follow/shared/hono"
import { getImageProxyUrl } from "@follow/utils/img-proxy"
import { LRUCache } from "@follow/utils/lru-cache"
@ -10,9 +11,7 @@ import { atom } from "jotai"
import { useLayoutEffect, useMemo, useRef, useState } from "react"
import { useTranslation } from "react-i18next"
import { MenuItemText } from "~/atoms/context-menu"
import { useGeneralSettingKey } from "~/atoms/settings/general"
import { CommandActionButton } from "~/components/ui/button/CommandActionButton"
import { RelativeTime } from "~/components/ui/datetime"
import { HTML } from "~/components/ui/markdown/HTML"
import { Media } from "~/components/ui/media"
@ -22,7 +21,8 @@ import { useSortedEntryActions } from "~/hooks/biz/useEntryActions"
import { useRenderStyle } from "~/hooks/biz/useRenderStyle"
import { jotaiStore } from "~/lib/jotai"
import { parseSocialMedia } from "~/lib/parsers"
import { COMMAND_ID } from "~/modules/command/commands/id"
import { EntryHeaderActions } from "~/modules/entry-content/actions/header-actions"
import { MoreActions } from "~/modules/entry-content/actions/more-actions"
import { FeedIcon } from "~/modules/feed/feed-icon"
import { FeedTitle } from "~/modules/feed/feed-title"
import { useEntry } from "~/store/entry/hooks"
@ -73,7 +73,7 @@ export const SocialMediaItem: EntryListItemFC = ({ entryId, entryPreview, transl
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
className={cn(
"relative flex px-5 py-4 lg:px-8",
"relative flex px-5 py-4 first:mt-6 lg:px-8",
"group",
!asRead &&
"before:bg-accent before:absolute before:left-1 before:top-8 before:block before:size-2 before:rounded-full md:before:-left-2 lg:before:left-2",
@ -139,20 +139,8 @@ const ActionBar = ({ entryId }: { entryId: string }) => {
return (
<div className="absolute right-1 top-0 -translate-y-1/2 rounded-lg border border-gray-200 bg-white/90 p-1 shadow-sm backdrop-blur-sm dark:border-neutral-900 dark:bg-neutral-900">
<div className="flex items-center gap-1">
{entryActions
.filter((item) => item instanceof MenuItemText)
.filter(
(item) =>
item.id !== COMMAND_ID.entry.read && item.id !== COMMAND_ID.entry.openInBrowser,
)
.map((item) => (
<CommandActionButton
commandId={item.id}
active={item.active}
onClick={item.click!}
key={item.id}
/>
))}
<EntryHeaderActions entryId={entryId} view={FeedViewType.SocialMedia} />
<MoreActions entryId={entryId} />
</div>
</div>
)

View File

@ -8,7 +8,7 @@ import { useHotkeys } from "react-hotkeys-hook"
import { Trans, useTranslation } from "react-i18next"
import { toast } from "sonner"
import { HotKeyScopeMap } from "~/constants"
import { HotkeyScope } from "~/constants"
import { shortcuts } from "~/constants/shortcuts"
import { useI18n } from "~/hooks/common"
@ -50,7 +50,7 @@ export const MarkAllReadButton = ({
label: (
<span className="flex items-center gap-1">
{t("mark_all_read_button.undo")}
<Kbd className="border-border inline-flex items-center border bg-transparent dark:text-white">
<Kbd className="border-border inline-flex items-center border bg-transparent text-white">
Meta+Z
</Kbd>
</span>
@ -61,7 +61,7 @@ export const MarkAllReadButton = ({
},
{
preventDefault: true,
scopes: HotKeyScopeMap.Home,
scopes: HotkeyScope.Home,
},
)
@ -100,7 +100,7 @@ const ConfirmMarkAllReadInfo = ({ undo }: { undo: () => any }) => {
const [countdown] = useCountdown({ countStart: 3 })
useHotkeys("ctrl+z,meta+z", undo, {
scopes: HotKeyScopeMap.Home,
scopes: HotkeyScope.Home,
preventDefault: true,
})
@ -137,7 +137,7 @@ export const FlatMarkAllReadButton: FC<
variant="ghost"
disabled={status === "done"}
buttonClassName={buttonClassName}
className={cn(
textClassName={cn(
"center relative flex h-auto gap-1",
className,

View File

@ -1,3 +1,4 @@
import { Focusable } from "@follow/components/common/Focusable/index.js"
import { useMobile } from "@follow/components/hooks/useMobile.js"
import { FeedViewType, views } from "@follow/constants"
import { useTitle } from "@follow/hooks"
@ -116,7 +117,7 @@ function EntryColumnImpl() {
const ListComponent = views[view]!.gridMode ? EntryColumnGrid : EntryList
return (
<div
<Focusable
data-hide-in-print
className="@container relative flex h-full flex-1 flex-col"
onClick={
@ -164,7 +165,7 @@ function EntryColumnImpl() {
/>
)}
</EntryColumnWrapper>
</div>
</Focusable>
)
}

View File

@ -175,7 +175,7 @@ const PreviewHeaderInfoWrapper: Component = ({ children }) => {
<button
type="button"
className="text-accent cursor-button from-accent/10 via-accent/15 to-accent/20 hover:bg-accent animate-gradient-x -mx-4 mt-2 flex place-items-center justify-center gap-1 bg-gradient-to-r px-3 py-2 font-semibold transition-all duration-300 hover:text-white"
className="text-accent cursor-button from-accent/10 via-accent/15 to-accent/20 hover:bg-accent animate-gradient-x -mx-4 mt-3.5 flex place-items-center justify-center gap-1 bg-gradient-to-r px-3 py-2 font-semibold transition-all duration-300 hover:text-white"
onClick={() => {
const { feedId, listId } = getRouteParams()
if (!feedId) return

View File

@ -163,7 +163,7 @@ const FollowSubscriptionButton = () => {
<ActionButton
id={LOGO_MOBILE_ID}
tooltip="Subscription"
className="absolute left-3 translate-y-px text-zinc-500"
className="text-text-secondary absolute left-3 translate-y-px"
>
<FollowIcon className="size-4" />
</ActionButton>

View File

@ -156,7 +156,7 @@ export function ListItem({
>
{entry.entries.title ? (
<EntryTranslation
className={cn("hyphens-auto", lineClamp.title)}
className={cn("hyphens-auto font-medium", lineClamp.title)}
source={entry.entries.title}
target={translation?.title}
/>

View File

@ -1,6 +1,6 @@
import { ScrollArea } from "@follow/components/ui/scroll-area/ScrollArea.js"
import { views } from "@follow/constants"
import { clsx } from "clsx"
import { FeedViewType, views } from "@follow/constants"
import { cn } from "@follow/utils/utils"
import { useIsZenMode } from "~/atoms/settings/ui"
import { useRouteParamsSelector } from "~/hooks/biz/useRouteParams"
@ -14,17 +14,17 @@ export const EntryColumnWrapper = ({ ref, children, onScroll }: EntryColumnWrapp
const isZenMode = useIsZenMode()
return (
<div className={clsx(styles, animationStyles, "mt-2")}>
<div className={cn(styles, animationStyles, view !== FeedViewType.SocialMedia && "mt-2")}>
<ScrollArea
scrollbarClassName={clsx(!views[view]!.wideMode ? "w-[5px] p-0" : "", "z-[3]")}
scrollbarClassName={cn(!views[view]!.wideMode ? "w-[5px] p-0" : "", "z-[3]")}
mask={false}
ref={ref}
rootClassName={clsx(
rootClassName={cn(
"h-full",
views[view]!.wideMode ? "mt-2" : "",
isZenMode ? "max-w-[80ch] mx-auto" : "",
)}
viewportClassName="[&>div]:grow flex"
viewportClassName={"[&>div]:grow flex"}
onScroll={onScroll}
>
{children}

View File

@ -3,10 +3,9 @@ import type { FeedViewType } from "@follow/constants"
import { MenuItemText } from "~/atoms/context-menu"
import { CommandActionButton } from "~/components/ui/button/CommandActionButton"
import { useHasModal } from "~/components/ui/modal/stacked/hooks"
import { shortcuts } from "~/constants/shortcuts"
import { useSortedEntryActions } from "~/hooks/biz/useEntryActions"
import { COMMAND_ID } from "~/modules/command/commands/id"
import { useCommandHotkey } from "~/modules/command/hooks/use-register-hotkey"
import { useCommandBinding } from "~/modules/command/hooks/use-register-hotkey"
import { useEntry } from "~/store/entry/hooks"
export const EntryHeaderActions = ({
@ -23,9 +22,8 @@ export const EntryHeaderActions = ({
const hasModal = useHasModal()
useCommandHotkey({
useCommandBinding({
when: !!entry?.entries.url && !hasModal,
shortcut: shortcuts.entry.openInBrowser.key,
commandId: COMMAND_ID.entry.openInBrowser,
args: [{ entryId }],
})

View File

@ -1,19 +1,32 @@
import { ScrollArea } from "@follow/components/ui/scroll-area/index.js"
import {
HoverCard,
HoverCardContent,
HoverCardPortal,
HoverCardTrigger,
} from "@radix-ui/react-hover-card"
import clsx from "clsx"
import { AvatarGroup } from "@follow/components/ui/avatar-group/index.js"
import { FeedViewType } from "@follow/constants"
import { useWhoami } from "~/atoms/user"
import { getRouteParams } from "~/hooks/biz/useRouteParams"
import { useAuthQuery } from "~/hooks/common"
import { useAppLayoutGridContainerWidth } from "~/providers/app-grid-layout-container-provider"
import { Queries } from "~/queries"
import { useEntryReadHistory } from "~/store/entry"
import { EntryUser, EntryUserRow, getLimit } from "./EntryReadHistory.shared"
import { EntryUser } from "./EntryReadHistory.shared"
const getLimit = (width: number): number => {
const routeParams = getRouteParams()
// social media view has four extra buttons
if (
[FeedViewType.SocialMedia, FeedViewType.Pictures, FeedViewType.Videos].includes(
routeParams.view,
)
) {
if (width > 1100) return 15
if (width > 950) return 10
if (width > 800) return 5
return 3
}
if (width > 900) return 15
if (width > 600) return 10
return 5
}
export const EntryReadHistory: Component<{ entryId: string }> = ({ entryId }) => {
const me = useWhoami()
@ -37,64 +50,27 @@ export const EntryReadHistory: Component<{ entryId: string }> = ({ entryId }) =>
className="animate-in fade-in @md:flex hidden items-center duration-200"
data-hide-in-print
>
{entryHistory.userIds
.filter((id) => id !== me?.id)
.slice(0, LIMIT)
<AvatarGroup>
{entryHistory.userIds
.filter((id) => id !== me?.id)
.slice(0, LIMIT)
.map((userId, i) => (
<EntryUser userId={userId} i={i} key={userId} />
))}
.map((userId) => (
<EntryUser userId={userId} key={userId} />
))}
</AvatarGroup>
{!!totalCount && totalCount > LIMIT && (
<HoverCard>
<HoverCardTrigger asChild>
<button
type="button"
style={{
marginLeft: `-${LIMIT * 8}px`,
zIndex: LIMIT + 1,
}}
className="no-drag-region border-border bg-material-opaque ring-background relative flex size-7 items-center justify-center rounded-full border ring-2"
>
<span className="text-text-secondary text-[10px] font-medium tabular-nums">
+{Math.min(totalCount - LIMIT, 99)}
</span>
</button>
</HoverCardTrigger>
{totalCount > LIMIT && (
<HoverCardPortal>
<HoverCardContent
sideOffset={12}
align="start"
side="right"
className={clsx(
"shadow-menu bg-material-medium shadow-context-menu backdrop-blur-background rounded-md border",
"data-[state=open]:animate-in data-[state=closed]:animate-out",
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
"data-[state=closed]:slide-out-to-left-5 data-[state=open]:slide-in-from-left-5",
"data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
"transition-all duration-200 ease-in-out",
)}
>
<ScrollArea.ScrollArea
rootClassName="max-h-[300px] max-w-[20ch] flex flex-col"
flex
>
<ul>
{entryHistory.userIds
.filter((id) => id !== me?.id)
.slice(LIMIT)
.map((userId) => (
<EntryUserRow userId={userId} key={userId} />
))}
</ul>
</ScrollArea.ScrollArea>
</HoverCardContent>
</HoverCardPortal>
)}
</HoverCard>
)}
<div
style={{
margin: "-8px",
zIndex: LIMIT + 1,
}}
className="no-drag-region border-border bg-material-opaque ring-background relative flex size-7 items-center justify-center rounded-full border ring-2"
>
<span className="text-text-secondary text-[10px] font-medium tabular-nums">
+{Math.min(totalCount - LIMIT, 99)}
</span>
</div>
</div>
)
}

View File

@ -1,109 +1,46 @@
import { Avatar, AvatarFallback, AvatarImage } from "@follow/components/ui/avatar/index.jsx"
import {
Tooltip,
TooltipContent,
TooltipPortal,
TooltipTrigger,
} from "@follow/components/ui/tooltip/index.jsx"
import { EllipsisHorizontalTextWithTooltip } from "@follow/components/ui/typography/index.js"
import { FeedViewType } from "@follow/constants"
import { TooltipContent, TooltipPortal } from "@follow/components/ui/tooltip/index.jsx"
import { getNameInitials } from "@follow/utils/cjk"
import { m } from "motion/react"
import { memo } from "react"
import { useTranslation } from "react-i18next"
import { getRouteParams } from "~/hooks/biz/useRouteParams"
import { replaceImgUrlIfNeed } from "~/lib/img-proxy"
import { useUserById } from "~/store/user"
import { usePresentUserProfileModal } from "../../profile/hooks"
export const getLimit = (width: number): number => {
const routeParams = getRouteParams()
// social media view has four extra buttons
if (
[FeedViewType.SocialMedia, FeedViewType.Pictures, FeedViewType.Videos].includes(
routeParams.view,
)
) {
if (width > 1100) return 15
if (width > 950) return 10
if (width > 800) return 5
return 3
}
if (width > 900) return 15
if (width > 600) return 10
return 5
}
export const EntryUserRow: Component<{ userId: string }> = memo(({ userId }) => {
const user = useUserById(userId)
const presentUserProfile = usePresentUserProfileModal("drawer")
if (!user) return null
return (
<li
onClick={() => {
presentUserProfile(userId)
}}
role="button"
tabIndex={0}
className="cursor-button hover:bg-theme-selection-hover hover:text-theme-selection-foreground relative flex min-w-0 max-w-[50ch] shrink-0 items-center gap-2 truncate rounded-md p-1 px-2"
>
<Avatar className="border-border ring-background block aspect-square size-7 overflow-hidden rounded-full border ring-1">
<AvatarImage src={replaceImgUrlIfNeed(user?.image || undefined)} />
<AvatarFallback>{getNameInitials(user.name || "")}</AvatarFallback>
</Avatar>
{user.name && (
<EllipsisHorizontalTextWithTooltip className="pr-8 text-xs font-medium">
<span>{user.name}</span>
</EllipsisHorizontalTextWithTooltip>
)}
</li>
)
})
export const EntryUser: Component<{
userId: string
i: number
}> = memo(({ userId, i }) => {
ref?: React.Ref<HTMLDivElement>
}> = memo(({ userId, ref }) => {
const user = useUserById(userId)
const { t } = useTranslation()
const presentUserProfile = usePresentUserProfileModal("drawer")
if (!user) return null
return (
<Tooltip>
<TooltipTrigger
className="no-drag-region relative cursor-pointer"
style={{
right: `${i * 8}px`,
zIndex: i,
<div className="no-drag-region relative cursor-pointer hover:!z-[99999]" ref={ref}>
<m.button
layout="position"
layoutId={userId}
type="button"
onClick={() => {
presentUserProfile(userId)
}}
asChild
>
<m.button
layout="position"
layoutId={userId}
type="button"
onClick={() => {
presentUserProfile(userId)
}}
>
<Avatar className="border-border ring-background aspect-square size-7 border ring-1">
<AvatarImage
src={replaceImgUrlIfNeed(user?.image || undefined)}
className="bg-material-ultra-thick"
/>
<AvatarFallback>{getNameInitials(user.name || "")}</AvatarFallback>
</Avatar>
</m.button>
</TooltipTrigger>
<Avatar className="border-border ring-background aspect-square size-7 border ring-1">
<AvatarImage
src={replaceImgUrlIfNeed(user?.image || undefined)}
className="bg-material-ultra-thick"
/>
<AvatarFallback>{getNameInitials(user.name || "")}</AvatarFallback>
</Avatar>
</m.button>
<TooltipPortal>
<TooltipContent side="top">
{t("entry_actions.recent_reader")} {user.name}
</TooltipContent>
</TooltipPortal>
</Tooltip>
</div>
)
})

View File

@ -1,4 +1,4 @@
import { views } from "@follow/constants"
import { FeedViewType, views } from "@follow/constants"
import { cn } from "@follow/utils/utils"
import { AnimatePresence, m } from "motion/react"
import { memo } from "react"
@ -60,8 +60,8 @@ function EntryHeaderImpl({ view, entryId, className, compact }: EntryHeaderProps
<span className="min-w-[50%] shrink truncate font-bold">
{entryTitleMeta.title}
</span>
<i className="i-mgc-line-cute-re size-[10px] shrink-0 translate-y-[-3px] rotate-[-25deg]" />
<span className="text-text-secondary text-headline shrink truncate">
<i className="i-mgc-line-cute-re text-text-secondary size-[10px] shrink-0 translate-y-[-3px] rotate-[-25deg]" />
<span className="text-text-secondary text-headline shrink -translate-y-px truncate">
{entryTitleMeta.description}
</span>
</m.div>
@ -69,10 +69,12 @@ function EntryHeaderImpl({ view, entryId, className, compact }: EntryHeaderProps
</AnimatePresence>
</div>
<div className="relative flex shrink-0 items-center justify-end gap-2">
<EntryHeaderActions entryId={entry.entries.id} view={view} compact={compact} />
<MoreActions entryId={entry.entries.id} />
</div>
{view !== FeedViewType.SocialMedia && (
<div className="relative flex shrink-0 items-center justify-end gap-2">
<EntryHeaderActions entryId={entry.entries.id} view={view} compact={compact} />
<MoreActions entryId={entry.entries.id} />
</div>
)}
</div>
</div>
)

View File

@ -1,29 +1,22 @@
import { tracker } from "@follow/tracker"
import { EventBus } from "@follow/utils/event-bus"
import { createElement, useCallback, useEffect } from "react"
import { createElement, useCallback, useMemo } from "react"
import { useTranslation } from "react-i18next"
import { toast } from "sonner"
import {
useEntryIsInReadability,
useEntryIsInReadabilitySuccess,
useEntryReadabilityContent,
} from "~/atoms/readability"
import { useModalStack } from "~/components/ui/modal/stacked/hooks"
import { useAuthQuery } from "~/hooks/common/useBizQuery"
import { Queries } from "~/queries"
import { useEntryTranslation } from "~/store/ai/hook"
import { useEntry } from "~/store/entry/hooks"
import { useInboxById } from "~/store/inbox/hooks"
import { ImageGalleryContent } from "./components/ImageGalleryContent"
declare module "@follow/utils/event-bus" {
export interface CustomEvent {
FOCUS_ENTRY_CONTAINER: never
}
}
export const useFocusEntryContainerSubscriptions = (
ref: React.RefObject<HTMLDivElement | null>,
) => {
useEffect(() => {
return EventBus.subscribe("FOCUS_ENTRY_CONTAINER", () => {
ref.current?.focus()
})
}, [ref])
}
export const useGalleryModal = () => {
const { present } = useModalStack()
const { t } = useTranslation()
@ -47,3 +40,62 @@ export const useGalleryModal = () => {
[present, t],
)
}
export const useEntryContent = (entryId: string) => {
const entry = useEntry(entryId)
const isInbox = useInboxById(entry?.inboxId, (inbox) => inbox !== null)
const { error, data, isPending } = useAuthQuery(
isInbox ? Queries.entries.byInboxId(entryId) : Queries.entries.byId(entryId),
{
staleTime: 300_000,
},
)
const isInReadabilityMode = useEntryIsInReadability(entryId)
const isReadabilitySuccess = useEntryIsInReadabilitySuccess(entryId)
const readabilityContent = useEntryReadabilityContent(entryId)
const contentTranslated = useEntryTranslation({
entry,
extraFields: isReadabilitySuccess ? ["readabilityContent"] : ["content"],
})
return useMemo(() => {
const entryContent = isInReadabilityMode
? readabilityContent?.content
: (entry?.entries.content ?? data?.entries.content)
const translatedContent = isInReadabilityMode
? contentTranslated.data?.readabilityContent
: contentTranslated.data?.content
const content = translatedContent || entryContent
return {
content,
error,
isPending,
}
}, [
contentTranslated.data?.content,
contentTranslated.data?.readabilityContent,
data?.entries.content,
entry?.entries.content,
error,
isInReadabilityMode,
isPending,
readabilityContent?.content,
])
}
export const useEntryMediaInfo = (entryId: string) => {
return useEntry(entryId, (entry) =>
Object.fromEntries(
entry?.entries.media
?.filter((m) => m.type === "photo")
.map((cur) => [
cur.url,
{
width: cur.width,
height: cur.height,
},
]) ?? [],
),
)
}

View File

@ -1,34 +1,40 @@
import {
Focusable,
useFocusable,
useFocusActions,
} from "@follow/components/common/Focusable/index.js"
import { MemoedDangerousHTMLStyle } from "@follow/components/common/MemoedDangerousHTMLStyle.js"
import { MotionButtonBase } from "@follow/components/ui/button/index.js"
import { ScrollArea } from "@follow/components/ui/scroll-area/index.js"
import type { FeedViewType } from "@follow/constants"
import { useTitle } from "@follow/hooks"
import type { FeedModel, InboxModel } from "@follow/models/types"
import { stopPropagation } from "@follow/utils/dom"
import { cn } from "@follow/utils/utils"
import { nextFrame, stopPropagation } from "@follow/utils/dom"
import { EventBus } from "@follow/utils/event-bus"
import { springScrollTo } from "@follow/utils/scroller"
import { cn, combineCleanupFunctions } from "@follow/utils/utils"
import { ErrorBoundary } from "@sentry/react"
import * as React from "react"
import { useEffect, useMemo, useRef } from "react"
import { useEffect, useMemo, useRef, useState } from "react"
import {
useEntryIsInReadability,
useEntryIsInReadabilitySuccess,
useEntryReadabilityContent,
} from "~/atoms/readability"
import { useUISettingKey } from "~/atoms/settings/ui"
import { useEntryIsInReadability } from "~/atoms/readability"
import { useIsZenMode, useUISettingKey } from "~/atoms/settings/ui"
import { ShadowDOM } from "~/components/common/ShadowDOM"
import type { TocRef } from "~/components/ui/markdown/components/Toc"
import { useInPeekModal } from "~/components/ui/modal/inspire/InPeekModal"
import { HotkeyScope } from "~/constants"
import { useRenderStyle } from "~/hooks/biz/useRenderStyle"
import { useRouteParamsSelector } from "~/hooks/biz/useRouteParams"
import { useAuthQuery } from "~/hooks/common"
import { useConditionalHotkeyScope } from "~/hooks/common"
import { useFeedSafeUrl } from "~/hooks/common/useFeedSafeUrl"
import { useHotkeyScope } from "~/providers/hotkey-provider"
import { WrappedElementProvider } from "~/providers/wrapped-element-provider"
import { Queries } from "~/queries"
import { useEntryTranslation } from "~/store/ai/hook"
import { useEntry } from "~/store/entry"
import { useFeedById } from "~/store/feed"
import { useInboxById } from "~/store/inbox"
import { COMMAND_ID } from "../command/commands/id"
import { useCommandBinding, useCommandHotkey } from "../command/hooks/use-register-hotkey"
import { EntryContentHTMLRenderer } from "../renderer/html"
import { AISummary } from "./AISummary"
import { EntryTimelineSidebar } from "./components/EntryTimelineSidebar"
@ -36,7 +42,7 @@ import { EntryTitle } from "./components/EntryTitle"
import { SourceContentPanel } from "./components/SourceContentView"
import { SupportCreator } from "./components/SupportCreator"
import { EntryHeader } from "./header"
import { useFocusEntryContainerSubscriptions } from "./hooks"
import { useEntryContent, useEntryMediaInfo } from "./hooks"
import type { EntryContentProps } from "./index.shared"
import {
ContainerToc,
@ -62,50 +68,38 @@ export const EntryContent: Component<EntryContentProps> = ({
const feed = useFeedById(entry?.feedId) as FeedModel | InboxModel
const inbox = useInboxById(entry?.inboxId, (inbox) => inbox !== null)
const isInbox = !!inbox
const isInReadabilityMode = useEntryIsInReadability(entryId)
const { error, data, isPending } = useAuthQuery(
inbox ? Queries.entries.byInboxId(entryId) : Queries.entries.byId(entryId),
{
staleTime: 300_000,
},
)
const readabilityContent = useEntryReadabilityContent(entryId)
const { error, content, isPending } = useEntryContent(entryId)
const view = useRouteParamsSelector((route) => route.view)
const isInReadabilityMode = useEntryIsInReadability(entryId)
const isReadabilitySuccess = useEntryIsInReadabilitySuccess(entryId)
const scrollerRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
scrollerRef.current?.scrollTo(0, 0)
scrollerRef.current?.focus()
}, [entryId])
useFocusEntryContainerSubscriptions(scrollerRef)
useEffect(() => {
const scrollAndFocus = () => {
scrollerRef.current?.scrollTo(0, 0)
}
scrollAndFocus()
return combineCleanupFunctions(
EventBus.subscribe(COMMAND_ID.timeline.switchToNext, scrollAndFocus),
EventBus.subscribe(COMMAND_ID.timeline.switchToPrevious, scrollAndFocus),
)
}, [entryId])
const safeUrl = useFeedSafeUrl(entryId)
const customCSS = useUISettingKey("customCSS")
const contentTranslated = useEntryTranslation({
entry,
extraFields: isReadabilitySuccess ? ["readabilityContent"] : ["content"],
})
const isInPeekModal = useInPeekModal()
const [isUserInteraction, setIsUserInteraction] = useState(false)
const isZenMode = useIsZenMode()
if (!entry) return null
const entryContent = isInReadabilityMode
? readabilityContent?.content
: (entry?.entries.content ?? data?.entries.content)
const translatedContent = isInReadabilityMode
? contentTranslated.data?.readabilityContent
: contentTranslated.data?.content
const content = translatedContent || entryContent
const isInbox = !!inbox
return (
<>
{!isInPeekModal && (
@ -117,17 +111,55 @@ export const EntryContent: Component<EntryContentProps> = ({
/>
)}
<div className="@container relative flex size-full flex-col overflow-hidden print:size-auto print:overflow-visible">
<Focusable
className="@container relative flex size-full flex-col overflow-hidden print:size-auto print:overflow-visible"
onFocus={() => setIsUserInteraction(true)}
>
<RegisterCommands
scrollerRef={scrollerRef}
isUserInteraction={isUserInteraction}
setIsUserInteraction={setIsUserInteraction}
/>
<EntryTimelineSidebar entryId={entry.entries.id} />
<EntryScrollArea className={className} scrollerRef={scrollerRef}>
{/* Indicator for the entry */}
<div
className="animate-in fade-in slide-in-from-bottom-24 f-motion-reduce:fade-in-0 f-motion-reduce:slide-in-from-bottom-0 select-text duration-200 ease-in-out"
key={entry.entries.id}
>
{!isZenMode && (
<>
<div className="absolute inset-y-0 left-0 flex w-12 items-center justify-center opacity-0 duration-200 hover:opacity-100">
<MotionButtonBase
// -12 Visual center point
className="absolute left-0 shrink-0 !-translate-y-12 cursor-pointer"
onClick={() => {
EventBus.dispatch(COMMAND_ID.timeline.switchToPrevious)
}}
>
<i className="i-mgc-left-small-sharp text-text-secondary size-16" />
</MotionButtonBase>
</div>
<div className="absolute inset-y-0 right-0 flex w-12 items-center justify-center opacity-0 duration-200 hover:opacity-100">
<MotionButtonBase
className="absolute right-0 shrink-0 !-translate-y-12 cursor-pointer"
onClick={() => {
EventBus.dispatch(COMMAND_ID.timeline.switchToNext)
}}
>
<i className="i-mgc-right-small-sharp text-text-secondary size-16" />
</MotionButtonBase>
</div>
</>
)}
<article
tabIndex={-1}
onFocus={() => setIsUserInteraction(true)}
data-testid="entry-render"
onContextMenu={stopPropagation}
className="@[47.5rem]:max-w-[70ch] @7xl:max-w-[80ch] relative m-auto min-w-0 max-w-[550px]"
className="@[950px]:max-w-[70ch] @7xl:max-w-[80ch] relative m-auto min-w-0 max-w-[550px]"
>
<EntryTitle entryId={entryId} compact={compact} />
@ -188,7 +220,7 @@ export const EntryContent: Component<EntryContentProps> = ({
</div>
</EntryScrollArea>
<SourceContentPanel src={safeUrl ?? "#"} />
</div>
</Focusable>
</>
)
}
@ -224,19 +256,7 @@ const Renderer: React.FC<{
noMedia?: boolean
content?: Nullable<string>
}> = React.memo(({ entryId, view, feedId, noMedia = false, content = "" }) => {
const mediaInfo = useEntry(entryId, (entry) =>
Object.fromEntries(
entry?.entries.media
?.filter((m) => m.type === "photo")
.map((cur) => [
cur.url,
{
width: cur.width,
height: cur.height,
},
]) ?? [],
),
)
const mediaInfo = useEntryMediaInfo(entryId)
const readerRenderInlineStyle = useUISettingKey("readerRenderInlineStyle")
@ -271,3 +291,79 @@ const Renderer: React.FC<{
</EntryContentHTMLRenderer>
)
})
const RegisterCommands = ({
scrollerRef,
isUserInteraction,
setIsUserInteraction,
}: {
scrollerRef: React.RefObject<HTMLDivElement | null>
isUserInteraction: boolean
setIsUserInteraction: (isUserInteraction: boolean) => void
}) => {
const containerFocused = useFocusable()
useConditionalHotkeyScope(HotkeyScope.EntryRender, isUserInteraction && containerFocused, true)
const activeScope = useHotkeyScope()
const when = activeScope.includes(HotkeyScope.EntryRender)
useCommandBinding({
commandId: COMMAND_ID.entryRender.scrollUp,
when,
})
useCommandBinding({
commandId: COMMAND_ID.entryRender.scrollDown,
when,
})
useCommandBinding({
commandId: COMMAND_ID.entryRender.nextEntry,
when,
})
useCommandBinding({
commandId: COMMAND_ID.entryRender.previousEntry,
when,
})
useCommandHotkey({
commandId: COMMAND_ID.layout.focusToTimeline,
when,
shortcut: "Backspace, Escape",
})
const { highlightBoundary } = useFocusActions()
useEffect(() => {
return combineCleanupFunctions(
EventBus.subscribe(COMMAND_ID.entryRender.scrollUp, () => {
const currentScroll = scrollerRef.current?.scrollTop
const delta = window.innerHeight
if (typeof currentScroll === "number" && delta) {
springScrollTo(currentScroll - delta, scrollerRef.current!)
}
}),
EventBus.subscribe(COMMAND_ID.entryRender.scrollDown, () => {
const currentScroll = scrollerRef.current?.scrollTop
const delta = window.innerHeight
if (typeof currentScroll === "number" && delta) {
springScrollTo(currentScroll + delta, scrollerRef.current!)
}
}),
EventBus.subscribe(COMMAND_ID.timeline.enter, () => {
const $scroller = scrollerRef.current
if ($scroller) {
springScrollTo(0, $scroller)
$scroller.focus()
nextFrame(highlightBoundary)
setIsUserInteraction(true)
}
}),
)
}, [highlightBoundary, scrollerRef, setIsUserInteraction])
return null
}

View File

@ -5,7 +5,7 @@ import type { FeedModel, InboxModel } from "@follow/models/types"
import { nextFrame, stopPropagation } from "@follow/utils/dom"
import { cn } from "@follow/utils/utils"
import { ErrorBoundary } from "@sentry/react"
import { useEffect, useMemo, useState } from "react"
import { useEffect, useState } from "react"
import { useAudioPlayerAtomSelector } from "~/atoms/player"
import { useUISettingKey } from "~/atoms/settings/ui"
@ -13,10 +13,8 @@ import { ShadowDOM } from "~/components/common/ShadowDOM"
import { useNavigateEntry } from "~/hooks/biz/useNavigateEntry"
import { useRenderStyle } from "~/hooks/biz/useRenderStyle"
import { useRouteParamsSelector } from "~/hooks/biz/useRouteParams"
import { useAuthQuery, usePreventOverscrollBounce } from "~/hooks/common"
import { usePreventOverscrollBounce } from "~/hooks/common"
import { WrappedElementProvider } from "~/providers/wrapped-element-provider"
import { Queries } from "~/queries"
import { useEntryTranslation } from "~/store/ai/hook"
import { useEntry } from "~/store/entry"
import { useFeedById } from "~/store/feed"
import { useInboxById } from "~/store/inbox"
@ -28,7 +26,14 @@ import { EntryReadHistory } from "./components/EntryReadHistory"
import { EntryTitle } from "./components/EntryTitle"
import { SupportCreator } from "./components/SupportCreator"
import { EntryHeader } from "./header"
import { NoContent, RenderError, TitleMetaHandler } from "./index.shared"
import { useEntryContent, useEntryMediaInfo } from "./hooks"
import {
NoContent,
ReadabilityAutoToggleEffect,
ReadabilityNotice,
RenderError,
TitleMetaHandler,
} from "./index.shared"
import { EntryContentLoading } from "./loading"
export interface EntryContentClassNames {
@ -63,37 +68,19 @@ export const EntryContent: Component<{
}, [navigateEntry, params])
const entry = useEntry(entryId)
const mediaInfo = useEntryMediaInfo(entryId)
useTitle(entry?.entries.title)
const feed = useFeedById(entry?.feedId) as FeedModel | InboxModel
const readerRenderInlineStyle = useUISettingKey("readerRenderInlineStyle")
const inbox = useInboxById(entry?.inboxId, (inbox) => inbox !== null)
const isInbox = !!inbox
const { error, data, isPending } = useAuthQuery(
inbox ? Queries.entries.byInboxId(entryId) : Queries.entries.byId(entryId),
{
enabled: !!entryId,
staleTime: 300_000,
},
)
const { error, content, isPending } = useEntryContent(entryId)
const view = useRouteParamsSelector((route) => route.view)
const mediaInfo = useMemo(
() =>
Object.fromEntries(
(entry?.entries.media ?? data?.entries.media)
?.filter((m) => m.type === "photo")
.map((cur) => [
cur.url,
{
width: cur.width,
height: cur.height,
},
]) ?? [],
),
[entry?.entries.media, data?.entries.media],
)
const hideRecentReader = useUISettingKey("hideRecentReader")
const { entryId: audioEntryId } = useAudioPlayerAtomSelector((state) => state)
@ -102,19 +89,10 @@ export const EntryContent: Component<{
const [scrollElement, setScrollElement] = useState<HTMLElement | null>(null)
const customCSS = useUISettingKey("customCSS")
const contentTranslated = useEntryTranslation({ entry, extraFields: ["content"] })
const stableRenderStyle = useRenderStyle()
if (!entry) return null
const entryContent = entry?.entries.content ?? data?.entries.content
const translatedContent = contentTranslated.data?.content
const content = translatedContent || entryContent
const isInbox = !!inbox
return (
<WrappedElementProvider>
<ScrollElementContext value={scrollElement}>
@ -162,6 +140,7 @@ export const EntryContent: Component<{
<TitleMetaHandler entryId={entry.entries.id} />
<AISummary entryId={entry.entries.id} />
<ErrorBoundary fallback={RenderError}>
<ReadabilityNotice entryId={entryId} />
<ShadowDOM injectHostStyles={!isInbox}>
{!!customCSS && (
<MemoedDangerousHTMLStyle>{customCSS}</MemoedDangerousHTMLStyle>
@ -184,6 +163,13 @@ export const EntryContent: Component<{
</div>
</WrappedElementProvider>
{entry.settings?.readability && (
<ReadabilityAutoToggleEffect
id={entry.entries.id}
url={entry.entries.url ?? ""}
/>
)}
{!content && (
<div className="center mt-16 min-w-0">
{isPending ? (

View File

@ -5,7 +5,6 @@ import { LoadingWithIcon } from "@follow/components/ui/loading/index.jsx"
import { RootPortal } from "@follow/components/ui/portal/index.jsx"
import { useScrollViewElement } from "@follow/components/ui/scroll-area/hooks.js"
import { WEB_BUILD } from "@follow/shared/constants"
import { EventBus } from "@follow/utils/event-bus"
import { springScrollTo } from "@follow/utils/scroller"
import { cn } from "@follow/utils/utils"
import type { FallbackRender } from "@sentry/react"
@ -254,13 +253,13 @@ export const ContainerToc = memo(
return (
<RootPortal to={wrappedElement!}>
<div className="group absolute right-[-130px] top-0 h-full w-[100px]" data-hide-in-print>
<div
className="@[770px]:block group absolute right-[-130px] top-0 hidden h-full w-[100px]"
data-hide-in-print
>
<div className="sticky top-0">
<Toc
ref={ref}
onItemClick={() => {
EventBus.dispatch("FOCUS_ENTRY_CONTAINER")
}}
className={cn(
"animate-in fade-in-0 slide-in-from-bottom-12 easing-spring spring-soft flex flex-col items-end",
"scrollbar-none max-h-[calc(100vh-100px)] overflow-auto",

View File

@ -55,7 +55,7 @@ const ShortcutModalContent = () => {
<div className="w-full space-y-6 px-4 pb-5 pt-6">
{Object.keys(shortcuts).map((type) => (
<section key={type}>
<div className="text-text-secondary mb-2 pl-4 text-xs font-medium capitalize">
<div className="text-text-secondary mb-2 pl-3 text-xs font-medium capitalize">
{t(shortcutsType[type])}
</div>
<div className="text-text rounded-md border text-[13px]">

View File

@ -1,6 +1,7 @@
import { Logo } from "@follow/components/icons/logo.jsx"
import { Button } from "@follow/components/ui/button/index.js"
import { Kbd } from "@follow/components/ui/kbd/Kbd.js"
import { ScrollArea } from "@follow/components/ui/scroll-area/index.js"
import { tracker } from "@follow/tracker"
import { cn } from "@follow/utils/utils"
import { AnimatePresence, m } from "motion/react"
@ -188,13 +189,15 @@ export function GuideModalContent({ onClose }: { onClose: () => void }) {
)}
</div>
)}
{status === "initial" ? (
<Intro />
) : status === "active" ? (
guideSteps[step - 1]!.content
) : status === "complete" ? (
<Outtro />
) : null}
<ScrollArea.ScrollArea viewportClassName="px-6">
{status === "initial" ? (
<Intro />
) : status === "active" ? (
guideSteps[step - 1]!.content
) : status === "complete" ? (
<Outtro />
) : null}
</ScrollArea.ScrollArea>
</m.div>
</AnimatePresence>
</div>

View File

@ -12,7 +12,7 @@ import { z } from "zod"
import { m } from "~/components/common/Motion"
import { PlainModal } from "~/components/ui/modal/stacked/custom-modal"
import { useModalStack } from "~/components/ui/modal/stacked/hooks"
import { HotKeyScopeMap } from "~/constants"
import { HotkeyScope } from "~/constants"
import { getRouteParams } from "~/hooks/biz/useRouteParams"
import { tipcClient } from "~/lib/client"
@ -116,7 +116,7 @@ export const CmdNTrigger = () => {
useRegisterGlobalContext("quickAdd", handler)
useHotkeys("meta+n,ctrl+n", handler, {
scopes: HotKeyScopeMap.Home,
scopes: HotkeyScope.Home,
preventDefault: true,
})

View File

@ -1,4 +1,4 @@
import { useFocusable } from "@follow/components/common/Focusable.js"
import { useFocusable } from "@follow/components/common/Focusable/index.js"
import { Spring } from "@follow/components/constants/spring.js"
import { useMobile } from "@follow/components/hooks/useMobile.js"
import { Tooltip, TooltipContent, TooltipTrigger } from "@follow/components/ui/tooltip/index.jsx"
@ -20,7 +20,7 @@ import {
useAudioPlayerAtomValue,
} from "~/atoms/player"
import { VolumeSlider } from "~/components/ui/media/VolumeSlider"
import { HotKeyScopeMap } from "~/constants"
import { HotkeyScope } from "~/constants"
import type { NavigateEntryOptions } from "~/hooks/biz/useNavigateEntry"
import { useNavigateEntry } from "~/hooks/biz/useNavigateEntry"
import { FeedIcon } from "~/modules/feed/feed-icon"
@ -118,7 +118,7 @@ const CornerPlayerImpl = ({ hideControls, rounded }: ControlButtonProps) => {
const isFocused = useFocusable()
useHotkeys("space", handleClickPlay, {
preventDefault: true,
scopes: HotKeyScopeMap.Home,
scopes: HotkeyScope.Home,
enabled: isFocused,
})

View File

@ -74,7 +74,7 @@ export const UserProfileModalContent: FC<SubscriptionModalContentProps> = ({ use
</div>
<Button
buttonClassName={"mt-4"}
className="gap-1"
textClassName="gap-1"
onClick={() => {
follow({
url: `rsshub://follow/profile/${userInfo.id}`,

View File

@ -29,7 +29,7 @@ export const ConfirmDeleteModalContent = ({ id, dismiss }: { dismiss: () => void
</div>
<div className="flex justify-end">
<Button
className="bg-red-600"
buttonClassName="bg-red-600"
onClick={() => {
deleteMutation.mutate()
dismiss()

View File

@ -65,7 +65,7 @@ export const SettingDataControl = () => {
<p>{t("general.rebuild_database.warning.line2")}</p>
<div className="mt-4 flex justify-end">
<Button
className="bg-red-500 px-3 text-white"
buttonClassName="bg-red-500 px-3 text-white"
onClick={async () => {
await clearLocalPersistStoreData()
window.location.reload()

View File

@ -97,13 +97,18 @@ export const SettingGeneral = () => {
{
type: "title",
value: t("general.sidebar"),
value: t("general.subscription"),
},
defineSettingItem("autoGroup", {
label: t("general.auto_group.label"),
description: t("general.auto_group.description"),
}),
defineSettingItem("hideAllReadSubscriptions", {
label: t("general.hide_all_read_subscriptions.label"),
description: t("general.hide_all_read_subscriptions.description"),
}),
{
type: "title",
value: t("general.timeline"),

View File

@ -57,7 +57,7 @@ const ConfirmDestroyModalContent = ({ listId }: { listId: string }) => {
{t.settings("lists.delete.warning")}
</div>
<div className="flex justify-end">
<Button className="bg-red-600" onClick={() => deleteFeedList.mutate({ listId })}>
<Button buttonClassName="bg-red-600" onClick={() => deleteFeedList.mutate({ listId })}>
{t("words.confirm")}
</Button>
</div>

View File

@ -254,7 +254,7 @@ export const ListFeedsModalContent = ({ id }: { id: string }) => {
suggestions={autocompleteSuggestions}
/>
<Button
className="whitespace-nowrap"
textClassName="whitespace-nowrap"
onClick={() => {
if (isBizId(feedSearchFor)) {
addMutation.mutate({ feedId: feedSearchFor, listId: id })

View File

@ -18,7 +18,7 @@ import { useEventCallback, useOnClickOutside } from "usehooks-ts"
import type { MenuItemInput } from "~/atoms/context-menu"
import { MenuItemSeparator, MenuItemText, useShowContextMenu } from "~/atoms/context-menu"
import { useGeneralSettingSelector } from "~/atoms/settings/general"
import { useGeneralSettingSelector, useHideAllReadSubscriptions } from "~/atoms/settings/general"
import { ROUTE_FEED_IN_FOLDER } from "~/constants"
import { useAddFeedToFeedList } from "~/hooks/biz/useFeedActions"
import { useNavigateEntry } from "~/hooks/biz/useNavigateEntry"
@ -38,7 +38,7 @@ import { useModalStack } from "../../components/ui/modal/stacked/hooks"
import { ListCreationModalContent } from "../settings/tabs/lists/modals"
import { useFeedListSortSelector } from "./atom"
import { CategoryRemoveDialogContent } from "./CategoryRemoveDialogContent"
import { FeedItem } from "./FeedItem"
import { FeedItemAutoHideUnread } from "./FeedItem"
import { feedColumnStyles } from "./styles"
import { UnreadNumber } from "./UnreadNumber"
@ -73,15 +73,15 @@ function FeedCategoryImpl({ data: ids, view, categoryOpenStateData }: FeedCatego
const folderName =
subscription?.category || (autoGroup ? subscription.defaultCategory : subscription.feedId)
const showCollapse = sortByUnreadFeedList.length > 1 || !!subscription?.category
const isCategory = sortByUnreadFeedList.length > 1 || !!subscription?.category
const open = useMemo(() => {
if (!showCollapse) return true
if (!isCategory) return true
if (folderName && typeof categoryOpenStateData[folderName] === "boolean") {
return categoryOpenStateData[folderName]
}
return false
}, [categoryOpenStateData, folderName, showCollapse])
}, [categoryOpenStateData, folderName, isCategory])
const setOpen = useCallback(
(next: boolean) => {
@ -164,7 +164,6 @@ function FeedCategoryImpl({ data: ids, view, categoryOpenStateData }: FeedCatego
})
const [isCategoryEditing, setIsCategoryEditing] = useState(false)
const [isContextMenuOpen, setIsContextMenuOpen] = useState(false)
const isCategoryIsWaiting = isChangePending
@ -270,7 +269,7 @@ function FeedCategoryImpl({ data: ids, view, categoryOpenStateData }: FeedCatego
})
return (
<div tabIndex={-1} onClick={stopPropagation}>
{!!showCollapse && (
{!!isCategory && (
<div
ref={setNodeRef}
data-active={isActive || isContextMenuOpen}
@ -279,6 +278,7 @@ function FeedCategoryImpl({ data: ids, view, categoryOpenStateData }: FeedCatego
"my-px px-2.5",
feedColumnStyles.item,
)}
data-sub={`feed-category-${folderName}`}
onClick={(e) => {
e.stopPropagation()
if (!isCategoryEditing) {
@ -289,6 +289,7 @@ function FeedCategoryImpl({ data: ids, view, categoryOpenStateData }: FeedCatego
>
<div className="flex w-full min-w-0 items-center" onDoubleClick={toggleCategoryOpenState}>
<button
data-type="collapse"
type="button"
onClick={toggleCategoryOpenState}
data-state={open ? "open" : "close"}
@ -335,7 +336,7 @@ function FeedCategoryImpl({ data: ids, view, categoryOpenStateData }: FeedCatego
ref={itemsRef}
className="space-y-px"
initial={
!!showCollapse && {
!!isCategory && {
height: 0,
opacity: 0.01,
}
@ -351,7 +352,7 @@ function FeedCategoryImpl({ data: ids, view, categoryOpenStateData }: FeedCatego
>
<SortedFeedItems
ids={ids}
showCollapse={showCollapse as boolean}
showCollapse={isCategory as boolean}
view={view as FeedViewType}
/>
</m.div>
@ -361,7 +362,24 @@ function FeedCategoryImpl({ data: ids, view, categoryOpenStateData }: FeedCatego
)
}
export const FeedCategory = memo(FeedCategoryImpl)
function FilterReadFeedCategory(props: FeedCategoryProps) {
const unread = useFeedUnreadStore(
useCallback(
(state) => props.data.reduce((acc, feedId) => (state.data[feedId] || 0) + acc, 0),
[props.data],
),
)
if (!unread) return null
return <FeedCategoryImpl {...props} />
}
export function FeedCategoryAutoHideUnread(props: FeedCategoryProps) {
const hideAllReadSubscriptions = useHideAllReadSubscriptions()
if (hideAllReadSubscriptions) {
return <FilterReadFeedCategory {...props} />
}
return <FeedCategoryImpl {...props} />
}
const RenameCategoryForm: FC<{
currentCategory: string
@ -487,7 +505,7 @@ const SortByAlphabeticalList = (props: SortListProps) => {
return (
<Fragment>
{sortedFeedList.map((feedId) => (
<FeedItem
<FeedItemAutoHideUnread
key={feedId}
feedId={feedId}
view={view}
@ -512,7 +530,7 @@ const SortByUnreadList = ({ ids, showCollapse, view }: SortListProps) => {
return (
<Fragment>
{sortByUnreadFeedList.map((feedId) => (
<FeedItem
<FeedItemAutoHideUnread
key={feedId}
feedId={feedId}
view={view}

View File

@ -9,14 +9,13 @@ import {
} from "@follow/components/ui/tooltip/index.jsx"
import { EllipsisHorizontalTextWithTooltip } from "@follow/components/ui/typography/index.js"
import type { FeedViewType } from "@follow/constants"
import { nextFrame } from "@follow/utils/dom"
import { cn, isKeyForMultiSelectPressed } from "@follow/utils/utils"
import dayjs from "dayjs"
import { memo, use, useCallback, useState } from "react"
import { createElement, memo, use, useCallback, useState } from "react"
import { useTranslation } from "react-i18next"
import { MenuItemSeparator, MenuItemText, useShowContextMenu } from "~/atoms/context-menu"
import { getMainContainerElement } from "~/atoms/dom"
import { useHideAllReadSubscriptions } from "~/atoms/settings/general"
import { ErrorTooltip } from "~/components/common/ErrorTooltip"
import { useFeedActions, useInboxActions, useListActions } from "~/hooks/biz/useFeedActions"
import { useFollow } from "~/hooks/biz/useFollow"
import { useNavigateEntry } from "~/hooks/biz/useNavigateEntry"
@ -105,10 +104,6 @@ const FeedItemImpl = ({ view, feedId, className, isPreview }: FeedItemProps) =>
entryId: null,
view,
})
// focus to main container in order to let keyboard can navigate entry items by arrow keys
nextFrame(() => {
getMainContainerElement()?.focus()
})
},
[feedId, navigate, setSelectedFeedIds, view],
)
@ -166,6 +161,7 @@ const FeedItemImpl = ({ view, feedId, className, isPreview }: FeedItemProps) =>
<DraggableItemWrapper
isInMultipleSelection={isInMultipleSelection}
data-feed-id={feedId}
data-sub={`feed-${feedId}`}
data-active={
isMultiSelectingButNotSelected
? false
@ -186,29 +182,10 @@ const FeedItemImpl = ({ view, feedId, className, isPreview }: FeedItemProps) =>
<div className={cn("flex min-w-0 items-center", isFeed && feed.errorAt && "text-red")}>
<FeedIcon fallback feed={feed} size={16} />
<FeedTitle feed={feed} />
{isFeed && feed.errorAt && (
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<i className="i-mgc-wifi-off-cute-re ml-1 shrink-0 text-base" />
</TooltipTrigger>
<TooltipPortal>
<TooltipContent>
<div className="flex items-center gap-1">
<i className="i-mgc-time-cute-re" />
{t("feed_item.error_since")}{" "}
{dayjs
.duration(dayjs(feed.errorAt).diff(dayjs(), "minute"), "minute")
.humanize(true)}
</div>
{!!feed.errorMessage && (
<div className="flex items-center gap-1">
<i className="i-mgc-bug-cute-re" />
{feed.errorMessage}
</div>
)}
</TooltipContent>
</TooltipPortal>
</Tooltip>
{isFeed && (
<ErrorTooltip errorAt={feed.errorAt} errorMessage={feed.errorMessage}>
<i className="i-mgc-wifi-off-cute-re ml-1 shrink-0 text-base" />
</ErrorTooltip>
)}
{subscription?.isPrivate && (
<Tooltip delayDuration={300}>
@ -242,14 +219,35 @@ const FeedItemImpl = ({ view, feedId, className, isPreview }: FeedItemProps) =>
)
}
const FilterReadFeedItem: Component<FeedItemProps> = (props) => {
const feedUnread = useFeedUnreadStore((state) => state.data[props.feedId] || 0)
if (!feedUnread) return null
return createElement(FeedItemImpl, props)
}
export const FeedItem = memo(FeedItemImpl)
const ListItemImpl: Component<{
export const FeedItemAutoHideUnread: Component<FeedItemProps> = memo((props) => {
const hideAllReadSubscriptions = useHideAllReadSubscriptions()
if (hideAllReadSubscriptions) return createElement(FilterReadFeedItem, props)
return createElement(FeedItemImpl, props)
})
interface ListItemProps {
listId: string
view: FeedViewType
iconSize?: number
isPreview?: boolean
}> = ({ view, listId, className, iconSize = 22, isPreview }) => {
}
const ListItemImpl: Component<ListItemProps> = ({
view,
listId,
className,
iconSize = 22,
isPreview,
}) => {
const list = useListById(listId)
const isActive = useRouteParamsSelector((routerParams) => routerParams.listId === listId)
@ -269,10 +267,6 @@ const ListItemImpl: Component<{
entryId: null,
view,
})
// focus to main container in order to let keyboard can navigate entry items by arrow keys
nextFrame(() => {
getMainContainerElement()?.focus()
})
},
[listId, navigate, view],
)
@ -292,6 +286,7 @@ const ListItemImpl: Component<{
return (
<div
data-list-id={listId}
data-sub={`list-${listId}`}
data-active={isActive || isContextMenuOpen}
className={cn(feedColumnStyles.item, "py-1 pl-2.5", className)}
onClick={handleNavigate}
@ -339,11 +334,26 @@ const ListItemImpl: Component<{
export const ListItem = memo(ListItemImpl)
const InboxItemImpl: Component<{
const FilterReadListItem: Component<ListItemProps> = (props) => {
const listUnread = useUnreadByListId(props.listId)
if (!listUnread) return null
return createElement(ListItem, props)
}
export const ListItemAutoHideUnread: Component<ListItemProps> = memo((props) => {
const hideAllReadSubscriptions = useHideAllReadSubscriptions()
if (hideAllReadSubscriptions) return createElement(FilterReadListItem, props)
return createElement(ListItemImpl, props)
})
interface InboxItemProps {
inboxId: string
view: FeedViewType
iconSize?: number
}> = ({ view, inboxId, className, iconSize = 16 }) => {
}
const InboxItemImpl: Component<InboxItemProps> = ({ view, inboxId, className, iconSize = 16 }) => {
const inbox = useInboxById(inboxId)
const isActive = useRouteParamsSelector((routerParams) => routerParams.inboxId === inboxId)
@ -378,6 +388,7 @@ const InboxItemImpl: Component<{
return (
<div
data-active={isActive || isContextMenuOpen}
data-sub={`inbox-${inboxId}`}
data-inbox-id={inboxId}
className={cn(
"cursor-menu flex w-full items-center justify-between rounded-md pr-2.5 text-base font-medium leading-loose lg:text-sm",

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