From 172ea12fed35e1aabf043ec682304f5b88f15453 Mon Sep 17 00:00:00 2001 From: lawvs <18554747+lawvs@users.noreply.github.com> Date: Fri, 9 May 2025 01:00:29 +0800 Subject: [PATCH 001/104] chore: add vibration feedback on context menu open for Android --- .../src/components/ui/context-menu/index.tsx | 32 ++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/components/ui/context-menu/index.tsx b/apps/mobile/src/components/ui/context-menu/index.tsx index 0d5074fbb..77b6fe925 100644 --- a/apps/mobile/src/components/ui/context-menu/index.tsx +++ b/apps/mobile/src/components/ui/context-menu/index.tsx @@ -1,2 +1,32 @@ -export * as ContextMenu from "zeego/context-menu" +import { composeEventHandlers } from "@follow/utils" +import { Vibration } from "react-native" +import * as ZeegoContextMenu from "zeego/context-menu" + +import { isAndroid } from "@/src/lib/platform" + export * as DropdownMenu from "zeego/dropdown-menu" + +const handleContextMenuOpenWithVibration = (open: boolean) => { + if (!isAndroid) return + if (open) { + Vibration.vibrate(10) + } +} + +const ContextMenuRoot: typeof ZeegoContextMenu.Root = (props) => { + return ( + + {/* Add your context menu items here */} + + ) +} + +const ContextMenu = { + ...ZeegoContextMenu, + Root: ContextMenuRoot, +} + +export { ContextMenu } From 5a9af9fdcef7126e886e1ba504f70e514f25fcc1 Mon Sep 17 00:00:00 2001 From: Innei Date: Fri, 9 May 2025 14:41:14 +0800 Subject: [PATCH 002/104] fix(rn): clicking on the avatar under the category will trigger two jump logics, fixed #3415 Signed-off-by: Innei --- apps/mobile/src/lib/navigation/Navigation.ts | 1 + .../src/lib/navigation/StackNavigation.tsx | 1 + apps/mobile/src/lib/navigation/types.ts | 25 ++++++------- .../entry-list/templates/EntrySocialItem.tsx | 36 +++++++++++-------- 4 files changed, 37 insertions(+), 26 deletions(-) diff --git a/apps/mobile/src/lib/navigation/Navigation.ts b/apps/mobile/src/lib/navigation/Navigation.ts index de502241d..d3d70f7f3 100644 --- a/apps/mobile/src/lib/navigation/Navigation.ts +++ b/apps/mobile/src/lib/navigation/Navigation.ts @@ -87,6 +87,7 @@ export class Navigation { screenOptions, }) } + private __pop() { const routes = jotaiStore.get(this.ctxValue.routesAtom) const lastRoute = routes.at(-1) diff --git a/apps/mobile/src/lib/navigation/StackNavigation.tsx b/apps/mobile/src/lib/navigation/StackNavigation.tsx index e4489206b..235b10528 100644 --- a/apps/mobile/src/lib/navigation/StackNavigation.tsx +++ b/apps/mobile/src/lib/navigation/StackNavigation.tsx @@ -105,6 +105,7 @@ const StateHandler = () => { }, [jotaiStore, nameAtom, navigation, navigationInstance]) return null } + const ScreenItemsMapper = () => { const chainCtxValue = use(ChainNavigationContext) const routes = useAtomValue(chainCtxValue.routesAtom) diff --git a/apps/mobile/src/lib/navigation/types.ts b/apps/mobile/src/lib/navigation/types.ts index 009771f9e..0b1ce03c0 100644 --- a/apps/mobile/src/lib/navigation/types.ts +++ b/apps/mobile/src/lib/navigation/types.ts @@ -5,7 +5,18 @@ export interface NavigationPushOptions { Component?: NavigationControllerView element?: React.ReactElement } -export type NavigationControllerViewExtraProps = { + +export interface NavigationControllerViewExtraProps + extends Pick< + ScreenProps, + | "sheetAllowedDetents" + | "sheetCornerRadius" + | "sheetExpandsWhenScrolledToEdge" + | "sheetElevation" + | "sheetGrabberVisible" + | "sheetInitialDetentIndex" + | "sheetLargestUndimmedDetentIndex" + > { /** * Unique identifier for the view. */ @@ -20,17 +31,7 @@ export type NavigationControllerViewExtraProps = { * Whether the view is transparent. */ transparent?: boolean -} & Pick< - ScreenProps, - | "sheetAllowedDetents" - | "sheetCornerRadius" - | "sheetExpandsWhenScrolledToEdge" - | "sheetElevation" - | "sheetGrabberVisible" - | "sheetInitialDetentIndex" - | "sheetLargestUndimmedDetentIndex" -> - +} // eslint-disable-next-line @typescript-eslint/no-empty-object-type export type NavigationControllerView

= FC

& NavigationControllerViewExtraProps export type NavigationControllerViewType = StackPresentationTypes diff --git a/apps/mobile/src/modules/entry-list/templates/EntrySocialItem.tsx b/apps/mobile/src/modules/entry-list/templates/EntrySocialItem.tsx index 1ce706528..5b728d008 100644 --- a/apps/mobile/src/modules/entry-list/templates/EntrySocialItem.tsx +++ b/apps/mobile/src/modules/entry-list/templates/EntrySocialItem.tsx @@ -11,6 +11,7 @@ import { Galeria } from "@/src/components/ui/image/galeria" import { Image } from "@/src/components/ui/image/Image" import { ItemPressableStyle } from "@/src/components/ui/pressable/enum" import { ItemPressable } from "@/src/components/ui/pressable/ItemPressable" +import { NativePressable } from "@/src/components/ui/pressable/NativePressable" import { VideoPlayer } from "@/src/components/ui/video/VideoPlayer" import { useNavigation } from "@/src/lib/navigation/hooks" import { EntryDetailScreen } from "@/src/screens/(stack)/entries/[entryId]/EntryDetailScreen" @@ -53,6 +54,14 @@ export const EntrySocialItem = memo(({ entryId }: { entryId: string }) => { .filter(Boolean) || []) as string[] }, [entry]) + const navigationToFeedEntryList = useCallback(() => { + if (!entry) return + if (!entry.feedId) return + navigation.pushControllerView(FeedScreen, { + feedId: entry.feedId, + }) + }, [entry?.feedId, navigation]) + if (!entry) return const { description, publishedAt, media } = entry @@ -67,26 +76,25 @@ export const EntrySocialItem = memo(({ entryId }: { entryId: string }) => { {!entry.read && } - { - if (!entry.feedId) return - navigation.pushControllerView(FeedScreen, { - feedId: entry.feedId, - }) - }} - > + {entry.authorAvatar ? ( - + ) : ( feed && )} - + - - {entry.author || feed?.title} - + + + {entry.author || feed?.title} + + · From 1ad7598a96215b6c3da9bf1f581b13e4b3ebd080 Mon Sep 17 00:00:00 2001 From: lawvs <18554747+lawvs@users.noreply.github.com> Date: Fri, 9 May 2025 14:44:32 +0800 Subject: [PATCH 003/104] fix: adjust padding in Settings screen scroll view --- apps/mobile/src/screens/(stack)/(tabs)/settings.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mobile/src/screens/(stack)/(tabs)/settings.tsx b/apps/mobile/src/screens/(stack)/(tabs)/settings.tsx index 964379567..87bc75a16 100644 --- a/apps/mobile/src/screens/(stack)/(tabs)/settings.tsx +++ b/apps/mobile/src/screens/(stack)/(tabs)/settings.tsx @@ -35,7 +35,7 @@ export function Settings() { ref={scrollViewRef} style={{ paddingTop: insets.top }} className="bg-system-grouped-background flex-1" - contentViewClassName="-mt-24 pb-36" + contentViewClassName="-mt-24 pb-8" > From b9d841600dbd7f34f8a222a20da57bf163a44578 Mon Sep 17 00:00:00 2001 From: Innei Date: Fri, 9 May 2025 14:46:49 +0800 Subject: [PATCH 004/104] fix(desktop): refactor UpdateNotice component to use useRef for handleClick and adjust styles Signed-off-by: Innei --- .../src/modules/update-notice/UpdateNotice.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/desktop/layer/renderer/src/modules/update-notice/UpdateNotice.tsx b/apps/desktop/layer/renderer/src/modules/update-notice/UpdateNotice.tsx index 3874c868d..928d293e0 100644 --- a/apps/desktop/layer/renderer/src/modules/update-notice/UpdateNotice.tsx +++ b/apps/desktop/layer/renderer/src/modules/update-notice/UpdateNotice.tsx @@ -2,7 +2,7 @@ import { Spring } from "@follow/components/constants/spring.js" import { tracker } from "@follow/tracker" import { cn } from "@follow/utils/utils" import { m, useMotionTemplate, useMotionValue } from "motion/react" -import { useCallback, useEffect } from "react" +import { useCallback, useEffect, useRef } from "react" import { useTranslation } from "react-i18next" import { useAudioPlayerAtomSelector } from "~/atoms/player" @@ -24,7 +24,7 @@ export const UpdateNotice = () => { return unlisten }, []) - const handleClick = useCallback(() => { + const handleClick = useRef(() => { const status = getUpdaterStatus() if (!status) return tracker.updateRestart({ @@ -45,7 +45,7 @@ export const UpdateNotice = () => { } } setUpdaterStatus(null) - }, []) + }).current const playerIsShow = useAudioPlayerAtomSelector((s) => s.show) @@ -70,8 +70,8 @@ export const UpdateNotice = () => { Date: Fri, 9 May 2025 14:51:04 +0800 Subject: [PATCH 005/104] fix(desktop): scroll in GuideModalContent for improved scrolling experience Signed-off-by: Innei --- .../new-user-guide/guide-modal-content.tsx | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/apps/desktop/layer/renderer/src/modules/new-user-guide/guide-modal-content.tsx b/apps/desktop/layer/renderer/src/modules/new-user-guide/guide-modal-content.tsx index 787898f06..317a8e645 100644 --- a/apps/desktop/layer/renderer/src/modules/new-user-guide/guide-modal-content.tsx +++ b/apps/desktop/layer/renderer/src/modules/new-user-guide/guide-modal-content.tsx @@ -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 }) { )} )} - {status === "initial" ? ( - - ) : status === "active" ? ( - guideSteps[step - 1]!.content - ) : status === "complete" ? ( - - ) : null} + + {status === "initial" ? ( + + ) : status === "active" ? ( + guideSteps[step - 1]!.content + ) : status === "complete" ? ( + + ) : null} + From 5d4dbff9fcfcfc47405b01ceb92eb097ab5ee328 Mon Sep 17 00:00:00 2001 From: Innei Date: Fri, 9 May 2025 14:54:45 +0800 Subject: [PATCH 006/104] fix(desktop): adjust margin in EntryListHeader button for improved layout Signed-off-by: Innei --- .../modules/entry-column/layouts/EntryListHeader.electron.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/layouts/EntryListHeader.electron.tsx b/apps/desktop/layer/renderer/src/modules/entry-column/layouts/EntryListHeader.electron.tsx index f227bc674..33749cf03 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-column/layouts/EntryListHeader.electron.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-column/layouts/EntryListHeader.electron.tsx @@ -175,7 +175,7 @@ const PreviewHeaderInfoWrapper: Component = ({ children }) => { diff --git a/apps/desktop/layer/renderer/src/modules/action/feed-filter.tsx b/apps/desktop/layer/renderer/src/modules/action/feed-filter.tsx index 43885fe72..185356efd 100644 --- a/apps/desktop/layer/renderer/src/modules/action/feed-filter.tsx +++ b/apps/desktop/layer/renderer/src/modules/action/feed-filter.tsx @@ -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 && (

- - - +
+ +
{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 ( {conditionIdx === 0 && orConditionIdx !== 0 && ( - - - + +
)} - - +
+
{t("actions.action_card.field")} { value={condition.field} onValueChange={(value) => change("field", value as ActionFeedField)} items={FeedOptions} - triggerClassName="max-sm:w-fit" + triggerClassName="max-sm:w-fit h-8" /> - - change("operator", value)} - /> - +
+
+ {t("actions.action_card.operator")} + change("operator", value)} + /> +
+
{t("actions.action_card.value")} - change("value", value)} disabled={disabled} /> - +
- { - 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) - } - }) - }} - /> - +
+ + + +
+
{conditionIdx !== orConditions.length - 1 && ( - +
- -
-
+ +
+
- +
)} ) }) })} - -
- +
+ )} ) } -const OperationTableCell = ({ +const OperationSelect = ({ type, value, onValueChange, @@ -276,24 +293,18 @@ const OperationTableCell = ({ onValueChange?.(options[0]!.value as ActionOperation) } return ( - - {t("actions.action_card.operator")} - onValueChange?.(value as ActionOperation)} - items={options} - triggerClassName="h-8 max-sm:w-fit" - /> - + 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 }) => ( ) -const ActionTableCell = ({ - disabled, - onAnd, - onDelete, -}: { - disabled?: boolean - onAnd?: () => void - onDelete?: () => void -}) => { +const GridHeader = () => { const { t } = useTranslation("settings") return ( - <> - - - - - - - - - ) -} - -const OrTableRow = ({ onClick, disabled }: { onClick?: () => void; disabled?: boolean }) => { - const { t } = useTranslation("settings") - return ( - - ) -} - -const FieldTableHeader = () => { - const { t } = useTranslation("settings") - return ( - - - {t("actions.action_card.field")} - {t("actions.action_card.operator")} - {t("actions.action_card.value")} - - - +
+
{t("actions.action_card.field")}
+
{t("actions.action_card.operator")}
+
{t("actions.action_card.value")}
+
+
) } diff --git a/apps/desktop/layer/renderer/src/modules/action/target-action-list.tsx b/apps/desktop/layer/renderer/src/modules/action/target-action-list.tsx index 1e19653d3..b68542840 100644 --- a/apps/desktop/layer/renderer/src/modules/action/target-action-list.tsx +++ b/apps/desktop/layer/renderer/src/modules/action/target-action-list.tsx @@ -36,8 +36,8 @@ const AddTableRow = ({ onClick, disabled }: { onClick?: () => void; disabled?: b return (
- - {!!content.data && ( - - )} ) } diff --git a/apps/desktop/layer/renderer/src/modules/auth/Form.tsx b/apps/desktop/layer/renderer/src/modules/auth/Form.tsx index e270bc6b0..1e97b9e5a 100644 --- a/apps/desktop/layer/renderer/src/modules/auth/Form.tsx +++ b/apps/desktop/layer/renderer/src/modules/auth/Form.tsx @@ -230,7 +230,7 @@ export function RegisterForm() { )} /> - diff --git a/apps/desktop/layer/renderer/src/modules/discover/DiscoverFeedCard.tsx b/apps/desktop/layer/renderer/src/modules/discover/DiscoverFeedCard.tsx index e135ab80f..c61047cbd 100644 --- a/apps/desktop/layer/renderer/src/modules/discover/DiscoverFeedCard.tsx +++ b/apps/desktop/layer/renderer/src/modules/discover/DiscoverFeedCard.tsx @@ -114,7 +114,7 @@ export const DiscoverFeedCard: FC = memo( {item.docs ? ( - diff --git a/apps/desktop/layer/renderer/src/modules/discover/DiscoverInboxList.tsx b/apps/desktop/layer/renderer/src/modules/discover/DiscoverInboxList.tsx index bba2ee6bc..f468ec1f5 100644 --- a/apps/desktop/layer/renderer/src/modules/discover/DiscoverInboxList.tsx +++ b/apps/desktop/layer/renderer/src/modules/discover/DiscoverInboxList.tsx @@ -63,7 +63,7 @@ export function DiscoverInboxList() {
{/* New Inbox */}
-
diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/components/mark-all-button.tsx b/apps/desktop/layer/renderer/src/modules/entry-column/components/mark-all-button.tsx index 966995e47..3b687f0df 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-column/components/mark-all-button.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-column/components/mark-all-button.tsx @@ -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, diff --git a/apps/desktop/layer/renderer/src/modules/profile/user-profile-modal.mobile.tsx b/apps/desktop/layer/renderer/src/modules/profile/user-profile-modal.mobile.tsx index d3bbb9078..9b093c9c4 100644 --- a/apps/desktop/layer/renderer/src/modules/profile/user-profile-modal.mobile.tsx +++ b/apps/desktop/layer/renderer/src/modules/profile/user-profile-modal.mobile.tsx @@ -74,7 +74,7 @@ export const UserProfileModalContent: FC = ({ use diff --git a/apps/desktop/layer/renderer/src/modules/settings/tabs/lists/modals.tsx b/apps/desktop/layer/renderer/src/modules/settings/tabs/lists/modals.tsx index 93722f64d..fb03a8c0a 100644 --- a/apps/desktop/layer/renderer/src/modules/settings/tabs/lists/modals.tsx +++ b/apps/desktop/layer/renderer/src/modules/settings/tabs/lists/modals.tsx @@ -254,7 +254,7 @@ export const ListFeedsModalContent = ({ id }: { id: string }) => { suggestions={autocompleteSuggestions} /> diff --git a/apps/desktop/layer/renderer/src/pages/(main)/(layer)/(subview)/rsshub/index.tsx b/apps/desktop/layer/renderer/src/pages/(main)/(layer)/(subview)/rsshub/index.tsx index 85f6b9dff..bf2f07167 100644 --- a/apps/desktop/layer/renderer/src/pages/(main)/(layer)/(subview)/rsshub/index.tsx +++ b/apps/desktop/layer/renderer/src/pages/(main)/(layer)/(subview)/rsshub/index.tsx @@ -110,7 +110,7 @@ function List({ data }: { data?: RSSHubModel[] }) { {t("rsshub.table.unlimited")} {!status?.data?.usage?.rsshubId && ( - )} @@ -192,7 +192,7 @@ function List({ data }: { data?: RSSHubModel[] }) {
-
diff --git a/apps/ssr/client/pages/(login)/register.tsx b/apps/ssr/client/pages/(login)/register.tsx index b4cba69c7..97935c021 100644 --- a/apps/ssr/client/pages/(login)/register.tsx +++ b/apps/ssr/client/pages/(login)/register.tsx @@ -186,7 +186,7 @@ function RegisterForm() { isLoading={isSubmitting} disabled={isSubmitting} type="submit" - className="w-full" + buttonClassName="w-full" size="lg" > {t("register.submit")} From 794da2c2a41d41e2962febc27225e20ee59b2437 Mon Sep 17 00:00:00 2001 From: lawvs <18554747+lawvs@users.noreply.github.com> Date: Sun, 11 May 2025 23:32:58 +0800 Subject: [PATCH 018/104] fix: import path --- packages/internal/components/src/ui/switch/index.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/internal/components/src/ui/switch/index.tsx b/packages/internal/components/src/ui/switch/index.tsx index 9e184288a..1af897dc3 100644 --- a/packages/internal/components/src/ui/switch/index.tsx +++ b/packages/internal/components/src/ui/switch/index.tsx @@ -1,6 +1,5 @@ "use client" -import { Spring } from "@follow/components/constants/spring" import { cn } from "@follow/utils/utils" import type { SwitchProps as SwitchPrimitiveProps } from "@headlessui/react" import { Switch as SwitchPrimitive } from "@headlessui/react" @@ -9,6 +8,8 @@ import { m as motion } from "motion/react" import * as React from "react" import { useMemo } from "react" +import { Spring } from "../../constants/spring" + type SwitchProps = SwitchPrimitiveProps & Omit, "children"> & { From 523e472a7ce5950fcf895aeb5a59f9e7f83f20db Mon Sep 17 00:00:00 2001 From: grtsinry43 Date: Mon, 12 May 2025 00:02:09 +0800 Subject: [PATCH 019/104] docs(README.md): Update link and info to AUR repository in README (#3698) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index d1b13e03e..e5f2a3e9e 100644 --- a/README.md +++ b/README.md @@ -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). From 69a0c940d2b0943fc51410c40d80c4c7c76cf886 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Mon, 12 May 2025 00:06:36 +0800 Subject: [PATCH 020/104] fix(desktop): can not navigate to view from feed --- apps/desktop/layer/renderer/src/hooks/biz/useNavigateEntry.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/layer/renderer/src/hooks/biz/useNavigateEntry.ts b/apps/desktop/layer/renderer/src/hooks/biz/useNavigateEntry.ts index e71ba3256..c859505f6 100644 --- a/apps/desktop/layer/renderer/src/hooks/biz/useNavigateEntry.ts +++ b/apps/desktop/layer/renderer/src/hooks/biz/useNavigateEntry.ts @@ -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}` } From 5cbdf69eebd97e684001d3512db292231d79e90a Mon Sep 17 00:00:00 2001 From: lawvs <18554747+lawvs@users.noreply.github.com> Date: Mon, 12 May 2025 14:25:02 +0800 Subject: [PATCH 021/104] chore: adjust padding in ProfileScreen for improved layout --- apps/mobile/src/screens/(modal)/ProfileScreen.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mobile/src/screens/(modal)/ProfileScreen.tsx b/apps/mobile/src/screens/(modal)/ProfileScreen.tsx index 2e67d95b7..b94c2f4bd 100644 --- a/apps/mobile/src/screens/(modal)/ProfileScreen.tsx +++ b/apps/mobile/src/screens/(modal)/ProfileScreen.tsx @@ -151,7 +151,7 @@ function ProfileScreenImpl(props: { userId: string }) { From b031880b5941f722bd5decf347e9d8f47291b957 Mon Sep 17 00:00:00 2001 From: Innei Date: Mon, 12 May 2025 15:05:42 +0800 Subject: [PATCH 022/104] fix(mobile): bottom bar safe area padding and should press id to action button, closes #3706 #3699 - Updated the EntryColumnMobile to improve logo click handling with blur effect. - Refined MobileFloatBar styles for better visual consistency and responsiveness. - Adjusted EntryListHeader button styling for improved text visibility. - Added id prop to ActionButton for better accessibility and interaction tracking. Signed-off-by: Innei --- .../app-layout/entry-column/mobile.tsx | 6 ++++- .../feed-column/float-bar.mobile.tsx | 24 +++++++++++-------- .../layouts/EntryListHeader.mobile.tsx | 2 +- .../src/ui/button/action-button.tsx | 1 + 4 files changed, 21 insertions(+), 12 deletions(-) diff --git a/apps/desktop/layer/renderer/src/modules/app-layout/entry-column/mobile.tsx b/apps/desktop/layer/renderer/src/modules/app-layout/entry-column/mobile.tsx index e0d5f7808..b55807b97 100644 --- a/apps/desktop/layer/renderer/src/modules/app-layout/entry-column/mobile.tsx +++ b/apps/desktop/layer/renderer/src/modules/app-layout/entry-column/mobile.tsx @@ -35,7 +35,11 @@ export const EntryColumnMobile = () => { { - ;(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({ diff --git a/apps/desktop/layer/renderer/src/modules/app-layout/feed-column/float-bar.mobile.tsx b/apps/desktop/layer/renderer/src/modules/app-layout/feed-column/float-bar.mobile.tsx index 50611c47f..1d011dcd8 100644 --- a/apps/desktop/layer/renderer/src/modules/app-layout/feed-column/float-bar.mobile.tsx +++ b/apps/desktop/layer/renderer/src/modules/app-layout/feed-column/float-bar.mobile.tsx @@ -73,24 +73,28 @@ export const MobileFloatBar = ({ return (
- - +
+ + +
- - +
+ + +
) @@ -102,7 +106,7 @@ const ViewTabs = ({ onViewChange }: { onViewChange?: (view: number) => void }) = return (
{views.map((item) => ( diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/layouts/EntryListHeader.mobile.tsx b/apps/desktop/layer/renderer/src/modules/entry-column/layouts/EntryListHeader.mobile.tsx index 6e09e42a1..372d128dd 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-column/layouts/EntryListHeader.mobile.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-column/layouts/EntryListHeader.mobile.tsx @@ -163,7 +163,7 @@ const FollowSubscriptionButton = () => { diff --git a/packages/internal/components/src/ui/button/action-button.tsx b/packages/internal/components/src/ui/button/action-button.tsx index c36a324b9..853f21a32 100644 --- a/packages/internal/components/src/ui/button/action-button.tsx +++ b/packages/internal/components/src/ui/button/action-button.tsx @@ -116,6 +116,7 @@ export const ActionButton = ({ } : void 0 } + id={id} {...rest} > {loading ? ( From e798a9410fce6366b5fc245da5942622675c061e Mon Sep 17 00:00:00 2001 From: lawvs <18554747+lawvs@users.noreply.github.com> Date: Mon, 12 May 2025 14:44:35 +0800 Subject: [PATCH 023/104] chore: add padding to text elements in DiscoverContent for improved layout --- apps/mobile/src/modules/discover/DiscoverContent.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/modules/discover/DiscoverContent.tsx b/apps/mobile/src/modules/discover/DiscoverContent.tsx index 3b5e42508..ac6fb9730 100644 --- a/apps/mobile/src/modules/discover/DiscoverContent.tsx +++ b/apps/mobile/src/modules/discover/DiscoverContent.tsx @@ -14,13 +14,13 @@ import { RecommendationCategoryScreen } from "@/src/screens/(stack)/recommendati export function DiscoverContent() { return ( - + Good Luck - + Categories From 4970110c70d47dcdae89eb6a9dadb32ed483a405 Mon Sep 17 00:00:00 2001 From: lawvs <18554747+lawvs@users.noreply.github.com> Date: Mon, 12 May 2025 15:45:10 +0800 Subject: [PATCH 024/104] fix: adjust header position in ProfileScreen for better layout --- apps/mobile/src/screens/(modal)/ProfileScreen.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/screens/(modal)/ProfileScreen.tsx b/apps/mobile/src/screens/(modal)/ProfileScreen.tsx index b94c2f4bd..76c065bd6 100644 --- a/apps/mobile/src/screens/(modal)/ProfileScreen.tsx +++ b/apps/mobile/src/screens/(modal)/ProfileScreen.tsx @@ -166,8 +166,9 @@ function ProfileScreenImpl(props: { userId: string }) { ({ opacity: interpolate(headerOpacity.value, [0, 1], [1, 0]), + top: insets.top, }))} - className="absolute top-5 flex w-full flex-row items-center justify-between px-4" + className="absolute flex w-full flex-row items-center justify-between px-4" > From 4905d61debdac5dab5cc723290b6364424330bc9 Mon Sep 17 00:00:00 2001 From: Innei Date: Mon, 12 May 2025 15:58:13 +0800 Subject: [PATCH 025/104] refactor: streamline navigateEntry calls and move useBackHome hook to its own export Signed-off-by: Innei --- .../src/hooks/biz/useNavigateEntry.ts | 17 +- .../timeline-column/TimelineColumnHeader.tsx | 32 +--- .../src/modules/timeline-column/index.tsx | 152 +++++++----------- 3 files changed, 78 insertions(+), 123 deletions(-) diff --git a/apps/desktop/layer/renderer/src/hooks/biz/useNavigateEntry.ts b/apps/desktop/layer/renderer/src/hooks/biz/useNavigateEntry.ts index c859505f6..7d5340c9a 100644 --- a/apps/desktop/layer/renderer/src/hooks/biz/useNavigateEntry.ts +++ b/apps/desktop/layer/renderer/src/hooks/biz/useNavigateEntry.ts @@ -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], + ) +} diff --git a/apps/desktop/layer/renderer/src/modules/timeline-column/TimelineColumnHeader.tsx b/apps/desktop/layer/renderer/src/modules/timeline-column/TimelineColumnHeader.tsx index 110242f62..657769969 100644 --- a/apps/desktop/layer/renderer/src/modules/timeline-column/TimelineColumnHeader.tsx +++ b/apps/desktop/layer/renderer/src/modules/timeline-column/TimelineColumnHeader.tsx @@ -6,7 +6,7 @@ import { stopPropagation } from "@follow/utils/dom" import { cn } from "@follow/utils/utils" import { m } from "motion/react" import type { FC, PropsWithChildren } from "react" -import { memo, useCallback, useEffect, useRef, useState } from "react" +import { memo, useEffect, useRef, useState } from "react" import { useHotkeys } from "react-hotkeys-hook" import { useTranslation } from "react-i18next" import { Link } from "react-router" @@ -22,27 +22,12 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "~/components/ui/dropdown-menu/dropdown-menu" -import { useNavigateEntry } from "~/hooks/biz/useNavigateEntry" +import { useBackHome } from "~/hooks/biz/useNavigateEntry" import { useRouteParamsSelector } from "~/hooks/biz/useRouteParams" import { useI18n } from "~/hooks/common" import { useContextMenu } from "~/hooks/common/useContextMenu" import { ProfileButton } from "~/modules/user/ProfileButton" -const useBackHome = (timelineId?: string) => { - const navigate = useNavigateEntry() - - return useCallback( - (overvideTimelineId?: string) => { - navigate({ - feedId: null, - entryId: null, - timelineId: overvideTimelineId ?? timelineId, - }) - }, - [timelineId, navigate], - ) -} - export const TimelineColumnHeader = memo(() => { const timelineId = useRouteParamsSelector((s) => s.timelineId) const navigateBackHome = useBackHome(timelineId) @@ -88,15 +73,11 @@ export const TimelineColumnHeader = memo(() => { const LayoutActionButton = () => { const feedColumnShow = useTimelineColumnShow() - const [animation, setAnimation] = useState({ - width: !feedColumnShow ? "auto" : 0, - }) + const [animation, setAnimation] = useState({ width: !feedColumnShow ? "auto" : 0 }) const isZenMode = useIsZenMode() const setIsZenMode = useSetZenMode() useEffect(() => { - setAnimation({ - width: !feedColumnShow ? "auto" : 0, - }) + setAnimation({ width: !feedColumnShow ? "auto" : 0 }) }, [feedColumnShow]) const t = useI18n() @@ -183,10 +164,7 @@ const SearchTrigger = () => { () => { setAppSearchOpen(true) }, - { - enabled: canSearch, - preventDefault: true, - }, + { enabled: canSearch, preventDefault: true }, ) return null diff --git a/apps/desktop/layer/renderer/src/modules/timeline-column/index.tsx b/apps/desktop/layer/renderer/src/modules/timeline-column/index.tsx index cca02cf60..ce7780ab6 100644 --- a/apps/desktop/layer/renderer/src/modules/timeline-column/index.tsx +++ b/apps/desktop/layer/renderer/src/modules/timeline-column/index.tsx @@ -18,7 +18,7 @@ import { useUISettingKey } from "~/atoms/settings/ui" import { setTimelineColumnShow, useTimelineColumnShow } from "~/atoms/sidebar" import { HotKeyScopeMap } from "~/constants" import { shortcuts } from "~/constants/shortcuts" -import { navigateEntry, useNavigateEntry } from "~/hooks/biz/useNavigateEntry" +import { navigateEntry, useBackHome } from "~/hooks/biz/useNavigateEntry" import { useReduceMotion } from "~/hooks/biz/useReduceMotion" import { useRouteParamsSelector } from "~/hooks/biz/useRouteParams" import { useTimelineList } from "~/hooks/biz/useTimelineList" @@ -32,21 +32,6 @@ import { TimelineSwitchButton } from "./TimelineSwitchButton" const lethargy = new Lethargy() -const useBackHome = (timelineId?: string) => { - const navigate = useNavigateEntry() - - return useCallback( - (overvideTimelineId?: string) => { - navigate({ - feedId: null, - entryId: null, - timelineId: overvideTimelineId ?? timelineId, - }) - }, - [timelineId, navigate], - ) -} - export function FeedColumn({ children, className }: PropsWithChildren<{ className?: string }>) { const carouselRef = useRef(null) const timelineList = useTimelineList() @@ -98,9 +83,7 @@ export function FeedColumn({ children, className }: PropsWithChildren<{ classNam return false } }, - { - target: carouselRef, - }, + { target: carouselRef }, ) useHotkeys( @@ -128,18 +111,10 @@ export function FeedColumn({ children, className }: PropsWithChildren<{ classNam const location = useLocation() useRegisterGlobalContext("goToFeed", ({ id, view }: { id: string; view?: number }) => { - navigateEntry({ - feedId: id, - view: view ?? 0, - backPath: location.pathname, - }) + navigateEntry({ feedId: id, view: view ?? 0, backPath: location.pathname }) }) useRegisterGlobalContext("goToList", ({ id, view }: { id: string; view?: number }) => { - navigateEntry({ - listId: id, - view: view ?? 0, - backPath: location.pathname, - }) + navigateEntry({ listId: id, view: view ?? 0, backPath: location.pathname }) }) const shouldFreeUpSpace = useShouldFreeUpSpace() @@ -155,7 +130,11 @@ export function FeedColumn({ children, className }: PropsWithChildren<{ classNam !feedColumnShow && ELECTRON_BUILD && "bg-material-opaque", className, )} - onClick={useCallback(() => navigateBackHome(), [navigateBackHome])} + onClick={useCallback(async () => { + if (document.hasFocus()) { + navigateBackHome() + } + }, [navigateBackHome])} > {!feedColumnShow && ( @@ -205,69 +184,60 @@ export function FeedColumn({ children, className }: PropsWithChildren<{ classNam ) } -const SwipeWrapper: FC<{ - active: string - children: React.JSX.Element[] -}> = memo(({ children, active }) => { - const reduceMotion = useReduceMotion() - const timelineList = useTimelineList() - const index = timelineList.indexOf(active) +const SwipeWrapper: FC<{ active: string; children: React.JSX.Element[] }> = memo( + ({ children, active }) => { + const reduceMotion = useReduceMotion() + const timelineList = useTimelineList() + const index = timelineList.indexOf(active) - const feedColumnWidth = useUISettingKey("feedColWidth") - const containerRef = useRef(null) + const feedColumnWidth = useUISettingKey("feedColWidth") + const containerRef = useRef(null) - const prevActiveIndexRef = useRef(-1) - const [isReady, setIsReady] = useState(false) + const prevActiveIndexRef = useRef(-1) + const [isReady, setIsReady] = useState(false) - const [direction, setDirection] = useState<"left" | "right">("right") - const [currentAnimtedActive, setCurrentAnimatedActive] = useState(index) + const [direction, setDirection] = useState<"left" | "right">("right") + const [currentAnimtedActive, setCurrentAnimatedActive] = useState(index) - useLayoutEffect(() => { - const prevActiveIndex = prevActiveIndexRef.current - if (prevActiveIndex !== index) { - if (prevActiveIndex < index) { - setDirection("right") - } else { - setDirection("left") - } - } - // eslint-disable-next-line @eslint-react/web-api/no-leaked-timeout - setTimeout(() => { - setCurrentAnimatedActive(index) - }, 0) - if (prevActiveIndexRef.current !== -1) { - setIsReady(true) - } - prevActiveIndexRef.current = index - }, [index]) - - if (reduceMotion) { - return
{children[currentAnimtedActive]}
- } - - return ( - - { + const prevActiveIndex = prevActiveIndexRef.current + if (prevActiveIndex !== index) { + if (prevActiveIndex < index) { + setDirection("right") + } else { + setDirection("left") } - animate={{ x: 0 }} - exit={{ - x: direction === "right" ? -feedColumnWidth : feedColumnWidth, - }} - transition={{ - x: { type: "spring", stiffness: 700, damping: 40 }, - }} - ref={containerRef} - > - {children[currentAnimtedActive]} - - - ) -}) + } + // eslint-disable-next-line @eslint-react/web-api/no-leaked-timeout + setTimeout(() => { + setCurrentAnimatedActive(index) + }, 0) + if (prevActiveIndexRef.current !== -1) { + setIsReady(true) + } + prevActiveIndexRef.current = index + }, [index]) + + if (reduceMotion) { + return
{children[currentAnimtedActive]}
+ } + + return ( + + + {children[currentAnimtedActive]} + + + ) + }, +) From 5e3cebd85f6349bbf5fe4e9bfc5fd554ea198f2c Mon Sep 17 00:00:00 2001 From: Innei Date: Mon, 12 May 2025 17:30:44 +0800 Subject: [PATCH 026/104] chore: fix typo Signed-off-by: Innei --- packages/internal/utils/src/route-builder.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/internal/utils/src/route-builder.ts b/packages/internal/utils/src/route-builder.ts index c617d86f0..08729f245 100644 --- a/packages/internal/utils/src/route-builder.ts +++ b/packages/internal/utils/src/route-builder.ts @@ -33,7 +33,7 @@ export function buildGlobRoutes(glob: Record Promise>): Route const routeObject: RouteObject[] = [] - function dtsRoutes( + function dfsRoutes( parentKey: string, children: RouteObject[], paths: NestedStructure, @@ -97,7 +97,7 @@ export function buildGlobRoutes(glob: Record Promise>): Route // } const childrenChildren: RouteObject[] = [] - dtsRoutes(`${segmentPathKey}/`, childrenChildren, paths[key]!, parentPath) + dfsRoutes(`${segmentPathKey}/`, childrenChildren, paths[key]!, parentPath) children.push({ path: "", lazy: globGetter, @@ -118,7 +118,7 @@ export function buildGlobRoutes(glob: Record Promise>): Route const childrenChildren: RouteObject[] = [] // should omit layout, because layout is already handled - dtsRoutes(parentKey, childrenChildren, omit(paths, "layout") as NestedStructure, parentPath) + dfsRoutes(parentKey, childrenChildren, omit(paths, "layout") as NestedStructure, parentPath) children.push({ path: "", lazy: globGetter, @@ -157,7 +157,7 @@ export function buildGlobRoutes(glob: Record Promise>): Route } else { const childrenChildren: RouteObject[] = [] const fullPath = `${parentPath}/${normalizeKey}` - dtsRoutes(`${segmentPathKey}/`, childrenChildren, paths[key]!, fullPath) + dfsRoutes(`${segmentPathKey}/`, childrenChildren, paths[key]!, fullPath) children.push({ path: normalizeKey, children: childrenChildren, @@ -171,7 +171,7 @@ export function buildGlobRoutes(glob: Record Promise>): Route } } - dtsRoutes("./pages/", routeObject, paths) + dfsRoutes("./pages/", routeObject, paths) return routeObject } From cfa1391c7e529eb0da53aaf002c003650859da94 Mon Sep 17 00:00:00 2001 From: Innei Date: Mon, 12 May 2025 18:08:14 +0800 Subject: [PATCH 027/104] feat: add tinykeys for hotkey management and refactor command handling, fixed #3635 - Introduced `tinykeys` for improved hotkey management in the application. - Added global hotkey support for showing shortcuts. - Refactored command registration to streamline the integration of new commands. - Updated hotkey scope handling for better context awareness in the application. This enhances user experience by providing more responsive and context-sensitive keyboard shortcuts. Signed-off-by: Innei --- apps/desktop/layer/renderer/package.json | 1 + .../layer/renderer/src/constants/hotkeys.ts | 8 +- .../src/hooks/common/useSwitchHotkeyScope.ts | 18 +- .../app-layout/feed-column/desktop.tsx | 22 +-- .../src/modules/command/command-manager.ts | 10 +- .../src/modules/command/commands/entry.tsx | 105 +++++++++++ .../src/modules/command/commands/global.tsx | 26 +++ .../src/modules/command/commands/id.ts | 6 + .../modules/command/commands/integration.tsx | 45 +++++ .../src/modules/command/commands/layout.tsx | 24 +++ .../src/modules/command/commands/settings.tsx | 8 + .../src/modules/command/commands/types.ts | 175 ++---------------- .../command/hooks/use-register-hotkey.ts | 68 ++++--- .../src/modules/command/registry/command.ts | 3 - .../renderer/src/modules/command/types.ts | 34 ---- .../src/providers/global-hotkeys-provider.tsx | 18 ++ .../renderer/src/providers/root-providers.tsx | 4 +- pnpm-lock.yaml | 8 + 18 files changed, 323 insertions(+), 260 deletions(-) create mode 100644 apps/desktop/layer/renderer/src/modules/command/commands/global.tsx create mode 100644 apps/desktop/layer/renderer/src/modules/command/commands/layout.tsx create mode 100644 apps/desktop/layer/renderer/src/providers/global-hotkeys-provider.tsx diff --git a/apps/desktop/layer/renderer/package.json b/apps/desktop/layer/renderer/package.json index 0897a59ae..366ea43a8 100644 --- a/apps/desktop/layer/renderer/package.json +++ b/apps/desktop/layer/renderer/package.json @@ -88,6 +88,7 @@ "react-zoom-pan-pinch": "3.7.0", "shiki": "3.3.0", "sonner": "2.0.3", + "tinykeys": "3.0.0", "tldts": "7.0.4", "use-context-selector": "2.0.0", "use-sync-external-store": "1.5.0", diff --git a/apps/desktop/layer/renderer/src/constants/hotkeys.ts b/apps/desktop/layer/renderer/src/constants/hotkeys.ts index 90a797b8b..ba62a0b8d 100644 --- a/apps/desktop/layer/renderer/src/constants/hotkeys.ts +++ b/apps/desktop/layer/renderer/src/constants/hotkeys.ts @@ -1,6 +1,6 @@ export const HotKeyScopeMap = { - Home: ["home"], - Menu: ["menu"], - Modal: ["modal"], - DropdownMenu: ["dropdown-menu"], + Home: "home", + Menu: "menu", + Modal: "modal", + DropdownMenu: "dropdown-menu", } diff --git a/apps/desktop/layer/renderer/src/hooks/common/useSwitchHotkeyScope.ts b/apps/desktop/layer/renderer/src/hooks/common/useSwitchHotkeyScope.ts index b24c298a3..9f0c88be5 100644 --- a/apps/desktop/layer/renderer/src/hooks/common/useSwitchHotkeyScope.ts +++ b/apps/desktop/layer/renderer/src/hooks/common/useSwitchHotkeyScope.ts @@ -4,7 +4,7 @@ import { useHotkeysContext } from "react-hotkeys-hook" import { HotKeyScopeMap } from "~/constants" const allScopes = Object.keys(HotKeyScopeMap).reduce((acc, key) => { - acc.push(...HotKeyScopeMap[key]) + acc.push(HotKeyScopeMap[key]) return acc }, [] as string[]) export const useSwitchHotKeyScope = () => { @@ -18,9 +18,7 @@ export const useSwitchHotKeyScope = () => { for (const key of allScopes) { disableScope(key) } - for (const key of nextScope) { - enableScope(key) - } + enableScope(nextScope) }, [disableScope, enableScope], ) @@ -34,15 +32,11 @@ export const useHotkeyScopeFn = (scope: keyof typeof HotKeyScopeMap) => { for (const key of allScopes) { disableScope(key) } - for (const key of scope) { - enableScope(key) - } + enableScope(scope) return () => { - for (const key of scope) { - disableScope(key) - } - for (const scope of currentScope) { - enableScope(scope) + disableScope(scope) + for (const key of currentScope) { + enableScope(key) } } }, [enableScope, disableScope, scope]) diff --git a/apps/desktop/layer/renderer/src/modules/app-layout/feed-column/desktop.tsx b/apps/desktop/layer/renderer/src/modules/app-layout/feed-column/desktop.tsx index 1d94a8f57..fd4a30045 100644 --- a/apps/desktop/layer/renderer/src/modules/app-layout/feed-column/desktop.tsx +++ b/apps/desktop/layer/renderer/src/modules/app-layout/feed-column/desktop.tsx @@ -12,7 +12,7 @@ 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 { useHotkeysContext } from "react-hotkeys-hook" import { Trans } from "react-i18next" import { useResizable } from "react-resizable-layout" import { Outlet } from "react-router" @@ -40,8 +40,9 @@ 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 { useCommandHotkey } 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" @@ -241,17 +242,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 } = useHotkeysContext() + useCommandHotkey({ + commandId: COMMAND_ID.layout.toggleTimelineColumn, + shortcut: shortcuts.layout.toggleSidebar.key, + when: activeScopes.includes(HotKeyScopeMap.Home), + }) const [delayShowSplitter, setDelayShowSplitter] = useState(feedColumnShow) diff --git a/apps/desktop/layer/renderer/src/modules/command/command-manager.ts b/apps/desktop/layer/renderer/src/modules/command/command-manager.ts index 63eb4b674..d78cede38 100644 --- a/apps/desktop/layer/renderer/src/modules/command/command-manager.ts +++ b/apps/desktop/layer/renderer/src/modules/command/command-manager.ts @@ -1,16 +1,16 @@ import { useRegisterEntryCommands } from "./commands/entry" +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" -export function useRegisterFollowCommands() { +export const FollowCommandManager = () => { useRegisterSettingsCommands() useRegisterListCommands() useRegisterEntryCommands() useRegisterIntegrationCommands() -} - -export const FollowCommandManager = () => { - useRegisterFollowCommands() + useRegisterGlobalCommands() + useRegisterLayoutCommands() return null } diff --git a/apps/desktop/layer/renderer/src/modules/command/commands/entry.tsx b/apps/desktop/layer/renderer/src/modules/command/commands/entry.tsx index ad6654795..f0eb7d157 100644 --- a/apps/desktop/layer/renderer/src/modules/command/commands/entry.tsx +++ b/apps/desktop/layer/renderer/src/modules/command/commands/entry.tsx @@ -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 diff --git a/apps/desktop/layer/renderer/src/modules/command/commands/global.tsx b/apps/desktop/layer/renderer/src/modules/command/commands/global.tsx new file mode 100644 index 000000000..60fd1a3d2 --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/command/commands/global.tsx @@ -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 diff --git a/apps/desktop/layer/renderer/src/modules/command/commands/id.ts b/apps/desktop/layer/renderer/src/modules/command/commands/id.ts index 5790d9a24..6c6ac541a 100644 --- a/apps/desktop/layer/renderer/src/modules/command/commands/id.ts +++ b/apps/desktop/layer/renderer/src/modules/command/commands/id.ts @@ -41,4 +41,10 @@ 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", + }, } as const diff --git a/apps/desktop/layer/renderer/src/modules/command/commands/integration.tsx b/apps/desktop/layer/renderer/src/modules/command/commands/integration.tsx index 508b97e4b..7895ad87e 100644 --- a/apps/desktop/layer/renderer/src/modules/command/commands/integration.tsx +++ b/apps/desktop/layer/renderer/src/modules/command/commands/integration.tsx @@ -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 diff --git a/apps/desktop/layer/renderer/src/modules/command/commands/layout.tsx b/apps/desktop/layer/renderer/src/modules/command/commands/layout.tsx new file mode 100644 index 000000000..309d3811f --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/command/commands/layout.tsx @@ -0,0 +1,24 @@ +import { setTimelineColumnShow } from "~/atoms/sidebar" + +import { useRegisterCommandEffect } from "../hooks/use-register-command" +import type { Command } from "../types" +import { COMMAND_ID } from "./id" + +export const useRegisterLayoutCommands = () => { + useRegisterCommandEffect([ + { + id: COMMAND_ID.layout.toggleTimelineColumn, + label: "Toggle timeline column", + run: () => { + setTimelineColumnShow((show) => !show) + }, + }, + ]) +} + +export type ToggleTimelineColumnCommand = Command<{ + id: typeof COMMAND_ID.layout.toggleTimelineColumn + fn: () => void +}> + +export type LayoutCommand = ToggleTimelineColumnCommand diff --git a/apps/desktop/layer/renderer/src/modules/command/commands/settings.tsx b/apps/desktop/layer/renderer/src/modules/command/commands/settings.tsx index 2dc753a12..6231b0992 100644 --- a/apps/desktop/layer/renderer/src/modules/command/commands/settings.tsx +++ b/apps/desktop/layer/renderer/src/modules/command/commands/settings.tsx @@ -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 diff --git a/apps/desktop/layer/renderer/src/modules/command/commands/types.ts b/apps/desktop/layer/renderer/src/modules/command/commands/types.ts index 17bd050aa..5c3c57689 100644 --- a/apps/desktop/layer/renderer/src/modules/command/commands/types.ts +++ b/apps/desktop/layer/renderer/src/modules/command/commands/types.ts @@ -1,167 +1,14 @@ // Entry commands -import type { FeedViewType } from "@follow/constants" +import type { EntryCommand } from "./entry" +import type { GlobalCommand } from "./global" +import type { IntegrationCommand } from "./integration" +import type { LayoutCommand } from "./layout" +import type { SettingsCommand } from "./settings" -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 diff --git a/apps/desktop/layer/renderer/src/modules/command/hooks/use-register-hotkey.ts b/apps/desktop/layer/renderer/src/modules/command/hooks/use-register-hotkey.ts index 5c4f1ff50..9da07a532 100644 --- a/apps/desktop/layer/renderer/src/modules/command/hooks/use-register-hotkey.ts +++ b/apps/desktop/layer/renderer/src/modules/command/hooks/use-register-hotkey.ts @@ -1,4 +1,5 @@ -import { useHotkeys } from "react-hotkeys-hook" +import { useEffect, useRef } from "react" +import { tinykeys } from "tinykeys" import type { FollowCommand, FollowCommandId } from "../types" import { getCommand } from "./use-command" @@ -8,36 +9,55 @@ interface RegisterHotkeyOptions { commandId: T args?: Parameters["run"]> when?: boolean - // hotkeyOptions?: Options } export const useCommandHotkey = ({ shortcut, commandId, - when, + when = true, args, }: RegisterHotkeyOptions) => { - useHotkeys( - shortcut, - () => { - const command = getCommand(commandId) + const unsubscribeRef = useRef<() => void>(void 0) - 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 + useEffect(() => { + if (!when) { + unsubscribeRef.current?.() + return + } + + // Handle comma-separated shortcuts + const shortcuts = shortcut.split(",").map((s) => s.trim()) + const keyMap: Record void> = {} + + // Create a handler for each shortcut + shortcuts.forEach((key) => { + keyMap[key] = (event) => { + event.preventDefault() + + 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, - }, - ) + }) + + unsubscribeRef.current = tinykeys(document.documentElement, keyMap) + + return () => { + unsubscribeRef.current?.() + } + }, [shortcut, commandId, when, args]) } diff --git a/apps/desktop/layer/renderer/src/modules/command/registry/command.ts b/apps/desktop/layer/renderer/src/modules/command/registry/command.ts index de30ab791..ffadc8d08 100644 --- a/apps/desktop/layer/renderer/src/modules/command/registry/command.ts +++ b/apps/desktop/layer/renderer/src/modules/command/registry/command.ts @@ -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, } } diff --git a/apps/desktop/layer/renderer/src/modules/command/types.ts b/apps/desktop/layer/renderer/src/modules/command/types.ts index 8e84bec41..d62b74587 100644 --- a/apps/desktop/layer/renderer/src/modules/command/types.ts +++ b/apps/desktop/layer/renderer/src/modules/command/types.ts @@ -17,36 +17,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 -} - export interface Command< T extends { id: string; fn: (...args: any[]) => unknown } = { id: string @@ -61,9 +31,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 = Command<{ id: T; fn: () => void }> @@ -87,7 +54,6 @@ export interface CommandOptions< run: T["fn"] when?: boolean - keyBinding?: T["fn"] extends () => void ? KeybindingOptions | string : never } export type FollowCommandMap = { diff --git a/apps/desktop/layer/renderer/src/providers/global-hotkeys-provider.tsx b/apps/desktop/layer/renderer/src/providers/global-hotkeys-provider.tsx new file mode 100644 index 000000000..cddf3909f --- /dev/null +++ b/apps/desktop/layer/renderer/src/providers/global-hotkeys-provider.tsx @@ -0,0 +1,18 @@ +import { useHotkeysContext } from "react-hotkeys-hook" + +import { HotKeyScopeMap } from "~/constants/hotkeys" +import { shortcuts } from "~/constants/shortcuts" +import { COMMAND_ID } from "~/modules/command/commands/id" +import { useCommandHotkey } from "~/modules/command/hooks/use-register-hotkey" + +export const GlobalHotkeysProvider = () => { + const { activeScopes } = useHotkeysContext() + + useCommandHotkey({ + commandId: COMMAND_ID.global.showShortcuts, + shortcut: shortcuts.layout.showShortcuts.key, + when: activeScopes.includes(HotKeyScopeMap.Home), + }) + + return null +} diff --git a/apps/desktop/layer/renderer/src/providers/root-providers.tsx b/apps/desktop/layer/renderer/src/providers/root-providers.tsx index 00a56ac2f..63c683920 100644 --- a/apps/desktop/layer/renderer/src/providers/root-providers.tsx +++ b/apps/desktop/layer/renderer/src/providers/root-providers.tsx @@ -18,6 +18,7 @@ import { jotaiStore } from "~/lib/jotai" import { persistConfig, queryClient } from "~/lib/query-client" import { FollowCommandManager } from "~/modules/command/command-manager" +import { GlobalHotkeysProvider } from "./global-hotkeys-provider" import { I18nProvider } from "./i18n-provider" import { InvalidateQueryProvider } from "./invalidate-query-provider" import { @@ -42,7 +43,7 @@ export const RootProviders: FC = ({ children }) => ( - + @@ -54,6 +55,7 @@ export const RootProviders: FC = ({ children }) => ( + {import.meta.env.DEV && } {children} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 418be9bae..77a8001dc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -677,6 +677,9 @@ importers: sonner: specifier: 2.0.3 version: 2.0.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + tinykeys: + specifier: 3.0.0 + version: 3.0.0 tldts: specifier: 7.0.4 version: 7.0.4 @@ -14426,6 +14429,9 @@ packages: resolution: {integrity: sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw==} engines: {node: '>=12.0.0'} + tinykeys@3.0.0: + resolution: {integrity: sha512-nazawuGv5zx6MuDfDY0rmfXjuOGhD5XU2z0GLURQ1nzl0RUe9OuCJq+0u8xxJZINHe+mr7nw8PWYYZ9WhMFujw==} + tinypool@1.0.2: resolution: {integrity: sha512-al6n+QEANGFOMf/dmUMsuS5/r9B06uwlyNjZZql/zv8J7ybHCgoihBNORZCY2mzUuAnomQa2JdhyHKzZxPCrFA==} engines: {node: ^18.0.0 || >=20.0.0} @@ -31117,6 +31123,8 @@ snapshots: fdir: 6.4.4(picomatch@4.0.2) picomatch: 4.0.2 + tinykeys@3.0.0: {} + tinypool@1.0.2: {} tinyrainbow@2.0.0: {} From 591d473acf9d83bb2574a096e3f013aeaac2cda6 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Mon, 12 May 2025 18:48:37 +0800 Subject: [PATCH 028/104] fix(desktop): ignore empty self referer matches --- packages/internal/utils/src/headers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/internal/utils/src/headers.ts b/packages/internal/utils/src/headers.ts index 578a8ec8a..2429092f8 100644 --- a/packages/internal/utils/src/headers.ts +++ b/packages/internal/utils/src/headers.ts @@ -14,7 +14,7 @@ export const createBuildSafeHeaders = } // referer and origin - if (selfRefererMatches.some((item) => url.startsWith(item))) { + if (selfRefererMatches.filter((i) => !!i).some((item) => url.startsWith(item))) { headers.Referer = webUrl return headers } From 90718d932a31b794fe351bd22153d9d94b652cea Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Mon, 12 May 2025 18:58:41 +0800 Subject: [PATCH 029/104] chore: fix type --- .../layer/renderer/src/modules/command/command-button.test-d.ts | 2 +- .../renderer/src/modules/command/hooks/use-command.test-d.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/desktop/layer/renderer/src/modules/command/command-button.test-d.ts b/apps/desktop/layer/renderer/src/modules/command/command-button.test-d.ts index d2d611c93..3cc5d02fa 100644 --- a/apps/desktop/layer/renderer/src/modules/command/command-button.test-d.ts +++ b/apps/desktop/layer/renderer/src/modules/command/command-button.test-d.ts @@ -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 diff --git a/apps/desktop/layer/renderer/src/modules/command/hooks/use-command.test-d.ts b/apps/desktop/layer/renderer/src/modules/command/hooks/use-command.test-d.ts index 04e6eef5a..450d1784b 100644 --- a/apps/desktop/layer/renderer/src/modules/command/hooks/use-command.test-d.ts +++ b/apps/desktop/layer/renderer/src/modules/command/hooks/use-command.test-d.ts @@ -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", () => { From c3067b99529ecaebc775f670e2fe9b95910402be Mon Sep 17 00:00:00 2001 From: lawvs <18554747+lawvs@users.noreply.github.com> Date: Mon, 12 May 2025 19:41:32 +0800 Subject: [PATCH 030/104] fix: add experimentalBlurMethod prop to ThemedBlurView in EntryNormalItem for better Android support --- apps/mobile/src/components/common/ThemedBlurView.tsx | 10 +++++++++- .../modules/entry-list/templates/EntryNormalItem.tsx | 6 +++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/components/common/ThemedBlurView.tsx b/apps/mobile/src/components/common/ThemedBlurView.tsx index 794011105..f9e16ee7e 100644 --- a/apps/mobile/src/components/common/ThemedBlurView.tsx +++ b/apps/mobile/src/components/common/ThemedBlurView.tsx @@ -4,6 +4,12 @@ import { useColorScheme } from "nativewind" import { Platform, StyleSheet, View } from "react-native" import { useColor } from "react-native-uikit-colors" +/** + * @android In Android, the BlurView is experimental and not fully supported, + * so we use a normal View with a background color with **100% opacity**. + * However, if the `experimentalBlurMethod` prop is explicitly provided, + * we'll use the BlurView even on Android, + */ export const ThemedBlurView = ({ ref, tint, @@ -13,7 +19,9 @@ export const ThemedBlurView = ({ const background = useColor("systemBackground") - return Platform.OS === "ios" ? ( + const useBlurView = Platform.OS === "ios" || "experimentalBlurMethod" in rest + + return useBlurView ? ( - + {isPlaying ? ( ) : isLoading ? ( From 0345fc216ac6fb2afb49f1fe8ceeee5e78ec8114 Mon Sep 17 00:00:00 2001 From: Innei Date: Mon, 12 May 2025 21:13:57 +0800 Subject: [PATCH 031/104] refactor: update hotkey management and command handling - Replaced `HotKeyScopeMap` with `HotkeyScope` enum for better type safety and clarity. - Refactored hotkey scope handling across various components to utilize the new enum. - Introduced new commands for entry rendering and timeline navigation, enhancing user interaction. - Improved the use of conditional hotkey scopes for better context awareness. This refactor aims to streamline hotkey management and improve the overall user experience. Signed-off-by: Innei --- .../ui/dropdown-menu/dropdown-menu.tsx | 5 +- .../layer/renderer/src/constants/hotkeys.ts | 14 +- .../layer/renderer/src/constants/shortcuts.ts | 4 +- .../src/hooks/biz/useSubscriptionActions.tsx | 4 +- .../src/hooks/common/useSwitchHotkeyScope.ts | 31 +++-- .../app-layout/entry-content/desktop.tsx | 4 +- .../app-layout/feed-column/desktop.tsx | 9 +- .../src/modules/command/command-manager.ts | 4 + .../modules/command/commands/entry-render.tsx | 45 +++++++ .../src/modules/command/commands/id.ts | 9 ++ .../src/modules/command/commands/timeline.tsx | 59 +++++++++ .../src/modules/command/commands/types.ts | 4 + .../renderer/src/modules/command/types.ts | 1 + .../EntryColumnShortcutHandler.tsx | 120 +++++++++++------- .../components/mark-all-button.tsx | 6 +- .../modules/entry-content/index.electron.tsx | 52 +++++++- .../renderer/src/modules/modal/shortcuts.tsx | 2 +- .../layer/renderer/src/modules/panel/cmdn.tsx | 4 +- .../src/modules/player/corner-player.tsx | 4 +- .../src/modules/timeline-column/index.tsx | 4 +- .../src/providers/context-menu-provider.tsx | 4 +- .../src/providers/global-hotkeys-provider.tsx | 10 +- .../src/providers/hotkey-provider.tsx | 42 ++++++ .../renderer/src/providers/root-providers.tsx | 11 +- 24 files changed, 351 insertions(+), 101 deletions(-) create mode 100644 apps/desktop/layer/renderer/src/modules/command/commands/entry-render.tsx create mode 100644 apps/desktop/layer/renderer/src/modules/command/commands/timeline.tsx create mode 100644 apps/desktop/layer/renderer/src/providers/hotkey-provider.tsx diff --git a/apps/desktop/layer/renderer/src/components/ui/dropdown-menu/dropdown-menu.tsx b/apps/desktop/layer/renderer/src/components/ui/dropdown-menu/dropdown-menu.tsx index 7dc6e0525..bd944ac24 100644 --- a/apps/desktop/layer/renderer/src/components/ui/dropdown-menu/dropdown-menu.tsx +++ b/apps/desktop/layer/renderer/src/components/ui/dropdown-menu/dropdown-menu.tsx @@ -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 ( void } const UnfollowInfo = ({ title, undo }: { title: string; undo: () => any }) => { useHotkeys("ctrl+z,meta+z", undo, { - scopes: HotKeyScopeMap.Home, + scopes: HotkeyScope.Home, preventDefault: true, }) return ( diff --git a/apps/desktop/layer/renderer/src/hooks/common/useSwitchHotkeyScope.ts b/apps/desktop/layer/renderer/src/hooks/common/useSwitchHotkeyScope.ts index 9f0c88be5..a0f821a63 100644 --- a/apps/desktop/layer/renderer/src/hooks/common/useSwitchHotkeyScope.ts +++ b/apps/desktop/layer/renderer/src/hooks/common/useSwitchHotkeyScope.ts @@ -1,18 +1,19 @@ 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) { @@ -23,27 +24,31 @@ export const useSwitchHotKeyScope = () => { [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) + if (replaceAll) { + for (const key of allScopes) { + disableScope(key) + } } enableScope(scope) return () => { disableScope(scope) - for (const key of currentScope) { - enableScope(key) + 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() diff --git a/apps/desktop/layer/renderer/src/modules/app-layout/entry-content/desktop.tsx b/apps/desktop/layer/renderer/src/modules/app-layout/entry-content/desktop.tsx index 757520080..5f5e2b9c8 100644 --- a/apps/desktop/layer/renderer/src/modules/app-layout/entry-content/desktop.tsx +++ b/apps/desktop/layer/renderer/src/modules/app-layout/entry-content/desktop.tsx @@ -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, }, ) diff --git a/apps/desktop/layer/renderer/src/modules/app-layout/feed-column/desktop.tsx b/apps/desktop/layer/renderer/src/modules/app-layout/feed-column/desktop.tsx index fd4a30045..111ec8d80 100644 --- a/apps/desktop/layer/renderer/src/modules/app-layout/feed-column/desktop.tsx +++ b/apps/desktop/layer/renderer/src/modules/app-layout/feed-column/desktop.tsx @@ -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 { useHotkeysContext } from "react-hotkeys-hook" import { Trans } from "react-i18next" import { useResizable } from "react-resizable-layout" import { Outlet } from "react-router" @@ -31,7 +30,7 @@ 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" @@ -52,6 +51,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" @@ -242,11 +242,12 @@ const FeedResponsiveResizerContainer = ({ } }, [feedColumnShow]) - const { activeScopes } = useHotkeysContext() + const activeScopes = useHotkeyScope() + useCommandHotkey({ commandId: COMMAND_ID.layout.toggleTimelineColumn, shortcut: shortcuts.layout.toggleSidebar.key, - when: activeScopes.includes(HotKeyScopeMap.Home), + when: activeScopes.includes(HotkeyScope.Home), }) const [delayShowSplitter, setDelayShowSplitter] = useState(feedColumnShow) diff --git a/apps/desktop/layer/renderer/src/modules/command/command-manager.ts b/apps/desktop/layer/renderer/src/modules/command/command-manager.ts index d78cede38..f0fe2a53d 100644 --- a/apps/desktop/layer/renderer/src/modules/command/command-manager.ts +++ b/apps/desktop/layer/renderer/src/modules/command/command-manager.ts @@ -1,9 +1,11 @@ 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 { useRegisterTimelineCommand } from "./commands/timeline" export const FollowCommandManager = () => { useRegisterSettingsCommands() @@ -12,5 +14,7 @@ export const FollowCommandManager = () => { useRegisterIntegrationCommands() useRegisterGlobalCommands() useRegisterLayoutCommands() + useRegisterTimelineCommand() + useRegisterEntryRenderCommand() return null } diff --git a/apps/desktop/layer/renderer/src/modules/command/commands/entry-render.tsx b/apps/desktop/layer/renderer/src/modules/command/commands/entry-render.tsx new file mode 100644 index 000000000..1a2094f30 --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/command/commands/entry-render.tsx @@ -0,0 +1,45 @@ +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 + } +} +const LABEL_PREFIX = "Entry Render" +export const useRegisterEntryRenderCommand = () => { + useRegisterCommandEffect([ + { + id: COMMAND_ID.entryRender.scrollDown, + run: () => { + EventBus.dispatch(COMMAND_ID.entryRender.scrollDown) + }, + category: "follow:entry-render", + label: `${LABEL_PREFIX}: Scroll down`, + }, + { + id: COMMAND_ID.entryRender.scrollUp, + run: () => { + EventBus.dispatch(COMMAND_ID.entryRender.scrollUp) + }, + category: "follow:entry-render", + label: `${LABEL_PREFIX}: Scroll up`, + }, + ]) +} + +type EntryScrollDownCommand = Command<{ + id: typeof COMMAND_ID.entryRender.scrollDown + fn: () => void +}> + +type EntryScrollUpCommand = Command<{ + id: typeof COMMAND_ID.entryRender.scrollUp + fn: () => void +}> + +export type EntryRenderCommand = EntryScrollDownCommand | EntryScrollUpCommand diff --git a/apps/desktop/layer/renderer/src/modules/command/commands/id.ts b/apps/desktop/layer/renderer/src/modules/command/commands/id.ts index 6c6ac541a..25f6ef16d 100644 --- a/apps/desktop/layer/renderer/src/modules/command/commands/id.ts +++ b/apps/desktop/layer/renderer/src/modules/command/commands/id.ts @@ -47,4 +47,13 @@ export const COMMAND_ID = { layout: { toggleTimelineColumn: "layout:toggle-timeline-column", }, + timeline: { + switchToNext: "timeline:switch-to-next", + switchToPrevious: "timeline:switch-to-previous", + refetch: "timeline:refetch", + }, + entryRender: { + scrollDown: "entry-render:scroll-down", + scrollUp: "entry-render:scroll-up", + }, } as const diff --git a/apps/desktop/layer/renderer/src/modules/command/commands/timeline.tsx b/apps/desktop/layer/renderer/src/modules/command/commands/timeline.tsx new file mode 100644 index 000000000..f9e883cd3 --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/command/commands/timeline.tsx @@ -0,0 +1,59 @@ +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 + } +} +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") + }, + }, + ]) +} + +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 TimelineCommand = + | SwitchToNextTimelineCommand + | SwitchToPreviousTimelineCommand + | RefetchTimelineCommand diff --git a/apps/desktop/layer/renderer/src/modules/command/commands/types.ts b/apps/desktop/layer/renderer/src/modules/command/commands/types.ts index 5c3c57689..8ee490c68 100644 --- a/apps/desktop/layer/renderer/src/modules/command/commands/types.ts +++ b/apps/desktop/layer/renderer/src/modules/command/commands/types.ts @@ -1,10 +1,12 @@ // Entry commands 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 { TimelineCommand } from "./timeline" export type BasicCommand = | EntryCommand @@ -12,3 +14,5 @@ export type BasicCommand = | IntegrationCommand | GlobalCommand | LayoutCommand + | TimelineCommand + | EntryRenderCommand diff --git a/apps/desktop/layer/renderer/src/modules/command/types.ts b/apps/desktop/layer/renderer/src/modules/command/types.ts index d62b74587..edb0121f5 100644 --- a/apps/desktop/layer/renderer/src/modules/command/types.ts +++ b/apps/desktop/layer/renderer/src/modules/command/types.ts @@ -8,6 +8,7 @@ export type CommandCategory = | "follow:updates" | "follow:help" | "follow:general" + | "follow:entry-render" export interface KeybindingOptions { binding: string diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/EntryColumnShortcutHandler.tsx b/apps/desktop/layer/renderer/src/modules/entry-column/EntryColumnShortcutHandler.tsx index 998e2718b..ebe39a1c2 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-column/EntryColumnShortcutHandler.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-column/EntryColumnShortcutHandler.tsx @@ -1,13 +1,18 @@ import { useRefValue } from "@follow/hooks" +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, useLayoutEffect, useState } from "react" import { useMainContainerElement } from "~/atoms/dom" -import { HotKeyScopeMap } from "~/constants" +import { HotkeyScope } from "~/constants" import { shortcuts } from "~/constants/shortcuts" 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 { useCommandHotkey } from "../command/hooks/use-register-hotkey" export const EntryColumnShortcutHandler: FC<{ refetch: () => void @@ -16,42 +21,33 @@ export const EntryColumnShortcutHandler: FC<{ }> = memo(({ data, refetch, handleScrollTo }) => { const dataRef = useRefValue(data!) - useHotkeys( - shortcuts.entries.refetch.key, - () => { - refetch() - }, - { scopes: HotKeyScopeMap.Home }, - ) + const activeScope = useHotkeyScope() + + const when = + activeScope.includes(HotkeyScope.Timeline) && !activeScope.includes(HotkeyScope.EntryRender) + + useCommandHotkey({ + shortcut: shortcuts.entries.next.key, + commandId: COMMAND_ID.timeline.switchToNext, + when, + }) + + useCommandHotkey({ + shortcut: shortcuts.entries.previous.key, + commandId: COMMAND_ID.timeline.switchToPrevious, + when, + }) + + useCommandHotkey({ + shortcut: shortcuts.entries.refetch.key, + commandId: COMMAND_ID.timeline.refetch, + 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("timeline:switch-to-next", () => { const data = dataRef.current const currentActiveEntryIndex = data.indexOf(currentEntryIdRef.current || "") @@ -63,12 +59,11 @@ export const EntryColumnShortcutHandler: FC<{ navigate({ entryId: nextId, }) - }, - { scopes: HotKeyScopeMap.Home, enabled: enabledArrowKey, preventDefault: true }, - ) - useHotkeys( - shortcuts.entries.previous.key, - () => { + }) + }, [currentEntryIdRef, dataRef, handleScrollTo, navigate]) + + useEffect(() => { + return EventBus.subscribe("timeline:switch-to-previous", () => { const data = dataRef.current const currentActiveEntryIndex = data.indexOf(currentEntryIdRef.current || "") @@ -81,8 +76,41 @@ export const EntryColumnShortcutHandler: FC<{ navigate({ entryId: nextId, }) - }, - { scopes: HotKeyScopeMap.Home, enabled: enabledArrowKey, preventDefault: true }, - ) + }) + }, [currentEntryIdRef, dataRef, handleScrollTo, navigate]) + + useEffect(() => { + return EventBus.subscribe("timeline:refetch", () => { + refetch() + }) + }, [refetch]) + + const $mainContainer = useMainContainerElement() + const [isFocusIn, setIsFocusIn] = useState(false) + + useConditionalHotkeyScope(HotkeyScope.Timeline, isFocusIn, true) + + // 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 + + setIsFocusIn(isFocusIn) + } + + handler() + // NOTE: focusin event will bubble to the document + document.addEventListener("focusin", handler) + document.addEventListener("focusout", handler) + return () => { + document.removeEventListener("focusin", handler) + document.removeEventListener("focusout", handler) + } + }, [$mainContainer]) + return null }) diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/components/mark-all-button.tsx b/apps/desktop/layer/renderer/src/modules/entry-column/components/mark-all-button.tsx index 3b687f0df..7ecc22664 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-column/components/mark-all-button.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-column/components/mark-all-button.tsx @@ -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" @@ -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, }) diff --git a/apps/desktop/layer/renderer/src/modules/entry-content/index.electron.tsx b/apps/desktop/layer/renderer/src/modules/entry-content/index.electron.tsx index 4fcc6c40e..15ea0c924 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-content/index.electron.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-content/index.electron.tsx @@ -4,6 +4,8 @@ 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 { EventBus } from "@follow/utils/event-bus" +import { springScrollTo } from "@follow/utils/scroller" import { cn } from "@follow/utils/utils" import { ErrorBoundary } from "@sentry/react" import * as React from "react" @@ -18,10 +20,13 @@ import { 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 { shortcuts } from "~/constants/shortcuts" import { useRenderStyle } from "~/hooks/biz/useRenderStyle" import { useRouteParamsSelector } from "~/hooks/biz/useRouteParams" -import { useAuthQuery } from "~/hooks/common" +import { useAuthQuery, 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" @@ -29,6 +34,8 @@ import { useEntry } from "~/store/entry" import { useFeedById } from "~/store/feed" import { useInboxById } from "~/store/inbox" +import { COMMAND_ID } from "../command/commands/id" +import { useCommandHotkey } from "../command/hooks/use-register-hotkey" import { EntryContentHTMLRenderer } from "../renderer/html" import { AISummary } from "./AISummary" import { EntryTimelineSidebar } from "./components/EntryTimelineSidebar" @@ -93,7 +100,48 @@ export const EntryContent: Component = ({ }) const isInPeekModal = useInPeekModal() + const [isUserInteraction, setIsUserInteraction] = React.useState(false) + useConditionalHotkeyScope(HotkeyScope.EntryRender, isUserInteraction, true) + + const activeScope = useHotkeyScope() + + useCommandHotkey({ + shortcut: shortcuts.entry.scrollUp.key, + commandId: COMMAND_ID.entryRender.scrollUp, + when: activeScope.includes(HotkeyScope.EntryRender), + }) + + useCommandHotkey({ + shortcut: shortcuts.entry.scrollDown.key, + commandId: COMMAND_ID.entryRender.scrollDown, + when: activeScope.includes(HotkeyScope.EntryRender), + }) + + useEffect(() => { + return 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!) + } + }) + }, []) + + useEffect(() => { + return 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!) + } + }) + }, []) + + useEffect(() => { + return () => setIsUserInteraction(false) + }, [entryId]) if (!entry) return null const entryContent = isInReadabilityMode @@ -125,6 +173,8 @@ export const EntryContent: Component = ({ key={entry.entries.id} >
setIsUserInteraction(true)} data-testid="entry-render" onContextMenu={stopPropagation} className="@[950px]:max-w-[70ch] @7xl:max-w-[80ch] relative m-auto min-w-0 max-w-[550px]" diff --git a/apps/desktop/layer/renderer/src/modules/modal/shortcuts.tsx b/apps/desktop/layer/renderer/src/modules/modal/shortcuts.tsx index d4cc41a19..4bcdafcb5 100644 --- a/apps/desktop/layer/renderer/src/modules/modal/shortcuts.tsx +++ b/apps/desktop/layer/renderer/src/modules/modal/shortcuts.tsx @@ -55,7 +55,7 @@ const ShortcutModalContent = () => {
{Object.keys(shortcuts).map((type) => (
-
+
{t(shortcutsType[type])}
diff --git a/apps/desktop/layer/renderer/src/modules/panel/cmdn.tsx b/apps/desktop/layer/renderer/src/modules/panel/cmdn.tsx index f12088a8b..6b6de0944 100644 --- a/apps/desktop/layer/renderer/src/modules/panel/cmdn.tsx +++ b/apps/desktop/layer/renderer/src/modules/panel/cmdn.tsx @@ -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, }) diff --git a/apps/desktop/layer/renderer/src/modules/player/corner-player.tsx b/apps/desktop/layer/renderer/src/modules/player/corner-player.tsx index f9bd62509..fb578f721 100644 --- a/apps/desktop/layer/renderer/src/modules/player/corner-player.tsx +++ b/apps/desktop/layer/renderer/src/modules/player/corner-player.tsx @@ -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, }) diff --git a/apps/desktop/layer/renderer/src/modules/timeline-column/index.tsx b/apps/desktop/layer/renderer/src/modules/timeline-column/index.tsx index ce7780ab6..e7dd920d9 100644 --- a/apps/desktop/layer/renderer/src/modules/timeline-column/index.tsx +++ b/apps/desktop/layer/renderer/src/modules/timeline-column/index.tsx @@ -16,7 +16,7 @@ import { useLocation } from "react-router" import { useRootContainerElement } from "~/atoms/dom" import { useUISettingKey } from "~/atoms/settings/ui" import { setTimelineColumnShow, useTimelineColumnShow } from "~/atoms/sidebar" -import { HotKeyScopeMap } from "~/constants" +import { HotkeyScope } from "~/constants" import { shortcuts } from "~/constants/shortcuts" import { navigateEntry, useBackHome } from "~/hooks/biz/useNavigateEntry" import { useReduceMotion } from "~/hooks/biz/useReduceMotion" @@ -102,7 +102,7 @@ export function FeedColumn({ children, className }: PropsWithChildren<{ classNam setActive((_, i) => timelineList[(i + 1) % timelineList.length]!) } }, - { scopes: HotKeyScopeMap.Home }, + { scopes: HotkeyScope.Home }, ) useRegisterGlobalContext("goToDiscover", () => { diff --git a/apps/desktop/layer/renderer/src/providers/context-menu-provider.tsx b/apps/desktop/layer/renderer/src/providers/context-menu-provider.tsx index 136ee92b3..f2871da12 100644 --- a/apps/desktop/layer/renderer/src/providers/context-menu-provider.tsx +++ b/apps/desktop/layer/renderer/src/providers/context-menu-provider.tsx @@ -24,7 +24,7 @@ import { ContextMenuSubTrigger, ContextMenuTrigger, } from "~/components/ui/context-menu" -import { HotKeyScopeMap } from "~/constants" +import { HotkeyScope } from "~/constants" import { useSwitchHotKeyScope } from "~/hooks/common" export const ContextMenuProvider: Component = ({ children }) => ( @@ -114,7 +114,7 @@ const Item = memo(({ item }: { item: FollowMenuItem }) => { useHotkeys((item as any as MenuItemText).shortcut!, () => itemRef.current?.click(), { // enabled: item.enabled !== false && item.shortcut !== undefined, enabled: item instanceof MenuItemText && !!item.shortcut, - scopes: HotKeyScopeMap.Menu, + scopes: HotkeyScope.Menu, preventDefault: true, }) diff --git a/apps/desktop/layer/renderer/src/providers/global-hotkeys-provider.tsx b/apps/desktop/layer/renderer/src/providers/global-hotkeys-provider.tsx index cddf3909f..7306820fe 100644 --- a/apps/desktop/layer/renderer/src/providers/global-hotkeys-provider.tsx +++ b/apps/desktop/layer/renderer/src/providers/global-hotkeys-provider.tsx @@ -1,17 +1,17 @@ -import { useHotkeysContext } from "react-hotkeys-hook" - -import { HotKeyScopeMap } from "~/constants/hotkeys" +import { HotkeyScope } from "~/constants/hotkeys" import { shortcuts } from "~/constants/shortcuts" import { COMMAND_ID } from "~/modules/command/commands/id" import { useCommandHotkey } from "~/modules/command/hooks/use-register-hotkey" +import { useHotkeyScope } from "./hotkey-provider" + export const GlobalHotkeysProvider = () => { - const { activeScopes } = useHotkeysContext() + const activeScopes = useHotkeyScope() useCommandHotkey({ commandId: COMMAND_ID.global.showShortcuts, shortcut: shortcuts.layout.showShortcuts.key, - when: activeScopes.includes(HotKeyScopeMap.Home), + when: activeScopes.includes(HotkeyScope.Home), }) return null diff --git a/apps/desktop/layer/renderer/src/providers/hotkey-provider.tsx b/apps/desktop/layer/renderer/src/providers/hotkey-provider.tsx new file mode 100644 index 000000000..29d5c8cd2 --- /dev/null +++ b/apps/desktop/layer/renderer/src/providers/hotkey-provider.tsx @@ -0,0 +1,42 @@ +import type { PropsWithChildren } from "react" +import { createContext, use, useEffect } from "react" +import { HotkeysProvider, useHotkeysContext } from "react-hotkeys-hook" + +import { HotkeyScope } from "~/constants" +import { appLog } from "~/lib/log" + +import { GlobalHotkeysProvider } from "./global-hotkeys-provider" + +const initialActiveScopes = [HotkeyScope.Home] + +const HotkeyScopeContext = createContext(null!) +export const HotkeyProvider: Component = ({ children }) => { + return ( + + + {children} + + + + ) +} + +const HotkeyScopeProvider = ({ children }: PropsWithChildren) => { + const { activeScopes } = useHotkeysContext() + + if (import.meta.env.DEV) { + // eslint-disable-next-line react-hooks/rules-of-hooks + useEffect(() => { + appLog("activeScopes change to:", activeScopes) + }, [JSON.stringify(activeScopes)]) + } + return {children} +} + +export const useHotkeyScope = () => { + const hotkeyScope = use(HotkeyScopeContext) + if (!hotkeyScope) { + throw new Error("HotkeyScopeContext not found") + } + return hotkeyScope +} diff --git a/apps/desktop/layer/renderer/src/providers/root-providers.tsx b/apps/desktop/layer/renderer/src/providers/root-providers.tsx index 63c683920..164d49ec8 100644 --- a/apps/desktop/layer/renderer/src/providers/root-providers.tsx +++ b/apps/desktop/layer/renderer/src/providers/root-providers.tsx @@ -10,15 +10,13 @@ import { Provider } from "jotai" import type { FC, PropsWithChildren } from "react" import { Suspense } from "react" import { GoogleReCaptchaProvider } from "react-google-recaptcha-v3" -import { HotkeysProvider } from "react-hotkeys-hook" import { ModalStackProvider } from "~/components/ui/modal" -import { HotKeyScopeMap } from "~/constants/hotkeys" import { jotaiStore } from "~/lib/jotai" import { persistConfig, queryClient } from "~/lib/query-client" import { FollowCommandManager } from "~/modules/command/command-manager" -import { GlobalHotkeysProvider } from "./global-hotkeys-provider" +import { HotkeyProvider } from "./hotkey-provider" import { I18nProvider } from "./i18n-provider" import { InvalidateQueryProvider } from "./invalidate-query-provider" import { @@ -28,7 +26,6 @@ import { LazyLottieRenderContainer, LazyPWAPrompt, LazyReloadPrompt, - // specific import should add `index` postfix } from "./lazy/index" import { ServerConfigsProvider } from "./server-configs-provider" import { SettingSync } from "./setting-sync" @@ -43,7 +40,7 @@ export const RootProviders: FC = ({ children }) => ( - + @@ -55,7 +52,7 @@ export const RootProviders: FC = ({ children }) => ( - + {import.meta.env.DEV && } {children} @@ -70,7 +67,7 @@ export const RootProviders: FC = ({ children }) => ( - + From 75a0520faa4435b01d79ee1d576025d663a3a4ee Mon Sep 17 00:00:00 2001 From: Innei Date: Mon, 12 May 2025 21:58:16 +0800 Subject: [PATCH 032/104] feat: enhance subscription command handling and shortcuts - Added new subscription commands for switching between tabs, improving user navigation. - Updated hotkeys for switching views to utilize the new commands, enhancing user experience. - Refactored command manager to include subscription commands, ensuring better integration. - Updated localization for new shortcuts to provide clear user guidance. This update aims to streamline subscription navigation and improve overall usability. Signed-off-by: Innei --- .../layer/renderer/src/constants/hotkeys.ts | 1 + .../layer/renderer/src/constants/shortcuts.ts | 10 +- .../src/modules/command/command-manager.ts | 2 + .../src/modules/command/commands/id.ts | 10 ++ .../modules/command/commands/subscription.tsx | 129 ++++++++++++++++++ .../src/modules/command/commands/types.ts | 2 + .../EntryColumnShortcutHandler.tsx | 6 +- .../src/modules/timeline-column/index.tsx | 75 +++++++--- locales/shortcuts/en.json | 2 + 9 files changed, 211 insertions(+), 26 deletions(-) create mode 100644 apps/desktop/layer/renderer/src/modules/command/commands/subscription.tsx diff --git a/apps/desktop/layer/renderer/src/constants/hotkeys.ts b/apps/desktop/layer/renderer/src/constants/hotkeys.ts index f68bbb202..bf62b4f95 100644 --- a/apps/desktop/layer/renderer/src/constants/hotkeys.ts +++ b/apps/desktop/layer/renderer/src/constants/hotkeys.ts @@ -7,4 +7,5 @@ export enum HotkeyScope { // Atom Scope Timeline = "timeline", EntryRender = "entry-render", + SubscriptionList = "subscription-list", } diff --git a/apps/desktop/layer/renderer/src/constants/shortcuts.ts b/apps/desktop/layer/renderer/src/constants/shortcuts.ts index 335adb052..aa69d8512 100644 --- a/apps/desktop/layer/renderer/src/constants/shortcuts.ts +++ b/apps/desktop/layer/renderer/src/constants/shortcuts.ts @@ -17,9 +17,13 @@ const shortcutConfigs = { name: "keys.feeds.switchToView", key: "1, 2, 3, 4, 5, 6", }, - switchBetweenViews: { - name: "keys.feeds.switchBetweenViews", - key: "Tab, Left, Right", + switchNextView: { + name: "keys.feeds.switchNextView", + key: "Tab, ArrowRight", + }, + switchPreviousView: { + name: "keys.feeds.switchPreviousView", + key: "Shift+Tab, ArrowLeft", }, }, layout: { diff --git a/apps/desktop/layer/renderer/src/modules/command/command-manager.ts b/apps/desktop/layer/renderer/src/modules/command/command-manager.ts index f0fe2a53d..0c5d34957 100644 --- a/apps/desktop/layer/renderer/src/modules/command/command-manager.ts +++ b/apps/desktop/layer/renderer/src/modules/command/command-manager.ts @@ -5,6 +5,7 @@ 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 const FollowCommandManager = () => { @@ -16,5 +17,6 @@ export const FollowCommandManager = () => { useRegisterLayoutCommands() useRegisterTimelineCommand() useRegisterEntryRenderCommand() + useRegisterSubscriptionCommands() return null } diff --git a/apps/desktop/layer/renderer/src/modules/command/commands/id.ts b/apps/desktop/layer/renderer/src/modules/command/commands/id.ts index 25f6ef16d..dd37fcea8 100644 --- a/apps/desktop/layer/renderer/src/modules/command/commands/id.ts +++ b/apps/desktop/layer/renderer/src/modules/command/commands/id.ts @@ -56,4 +56,14 @@ export const COMMAND_ID = { scrollDown: "entry-render:scroll-down", scrollUp: "entry-render:scroll-up", }, + 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", + }, } as const diff --git a/apps/desktop/layer/renderer/src/modules/command/commands/subscription.tsx b/apps/desktop/layer/renderer/src/modules/command/commands/subscription.tsx new file mode 100644 index 000000000..03be9e054 --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/command/commands/subscription.tsx @@ -0,0 +1,129 @@ +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 + } +} +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) + }, + }, + ]) +} + +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 +}> + +export type SubscriptionCommand = + | SwitchTabToNextCommand + | SwitchTabToPreviousCommand + | SwitchTabToArticleCommand + | SwitchTabToSocialCommand + | SwitchTabToPictureCommand + | SwitchTabToVideoCommand + | SwitchTabToAudioCommand + | SwitchTabToNotificationCommand diff --git a/apps/desktop/layer/renderer/src/modules/command/commands/types.ts b/apps/desktop/layer/renderer/src/modules/command/commands/types.ts index 8ee490c68..1db475a15 100644 --- a/apps/desktop/layer/renderer/src/modules/command/commands/types.ts +++ b/apps/desktop/layer/renderer/src/modules/command/commands/types.ts @@ -6,6 +6,7 @@ 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" export type BasicCommand = @@ -16,3 +17,4 @@ export type BasicCommand = | LayoutCommand | TimelineCommand | EntryRenderCommand + | SubscriptionCommand diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/EntryColumnShortcutHandler.tsx b/apps/desktop/layer/renderer/src/modules/entry-column/EntryColumnShortcutHandler.tsx index ebe39a1c2..17e918885 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-column/EntryColumnShortcutHandler.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-column/EntryColumnShortcutHandler.tsx @@ -47,7 +47,7 @@ export const EntryColumnShortcutHandler: FC<{ const currentEntryIdRef = useRefValue(useRouteEntryId()) const navigate = useNavigateEntry() useEffect(() => { - return EventBus.subscribe("timeline:switch-to-next", () => { + return EventBus.subscribe(COMMAND_ID.timeline.switchToNext, () => { const data = dataRef.current const currentActiveEntryIndex = data.indexOf(currentEntryIdRef.current || "") @@ -63,7 +63,7 @@ export const EntryColumnShortcutHandler: FC<{ }, [currentEntryIdRef, dataRef, handleScrollTo, navigate]) useEffect(() => { - return EventBus.subscribe("timeline:switch-to-previous", () => { + return EventBus.subscribe(COMMAND_ID.timeline.switchToPrevious, () => { const data = dataRef.current const currentActiveEntryIndex = data.indexOf(currentEntryIdRef.current || "") @@ -80,7 +80,7 @@ export const EntryColumnShortcutHandler: FC<{ }, [currentEntryIdRef, dataRef, handleScrollTo, navigate]) useEffect(() => { - return EventBus.subscribe("timeline:refetch", () => { + return EventBus.subscribe(COMMAND_ID.timeline.refetch, () => { refetch() }) }, [refetch]) diff --git a/apps/desktop/layer/renderer/src/modules/timeline-column/index.tsx b/apps/desktop/layer/renderer/src/modules/timeline-column/index.tsx index e7dd920d9..952c4ecff 100644 --- a/apps/desktop/layer/renderer/src/modules/timeline-column/index.tsx +++ b/apps/desktop/layer/renderer/src/modules/timeline-column/index.tsx @@ -4,13 +4,13 @@ import { Routes } from "@follow/constants" import { useTypeScriptHappyCallback } from "@follow/hooks" import { useRegisterGlobalContext } from "@follow/shared/bridge" import { ELECTRON_BUILD } from "@follow/shared/constants" +import { EventBus } from "@follow/utils/event-bus" import { clamp, cn } from "@follow/utils/utils" import { useWheel } from "@use-gesture/react" import { Lethargy } from "lethargy" import { AnimatePresence, m } from "motion/react" import type { FC, PropsWithChildren } from "react" import { memo, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react" -import { isHotkeyPressed, useHotkeys } from "react-hotkeys-hook" import { useLocation } from "react-router" import { useRootContainerElement } from "~/atoms/dom" @@ -22,8 +22,12 @@ import { navigateEntry, useBackHome } from "~/hooks/biz/useNavigateEntry" import { useReduceMotion } from "~/hooks/biz/useReduceMotion" import { useRouteParamsSelector } from "~/hooks/biz/useRouteParams" import { useTimelineList } from "~/hooks/biz/useTimelineList" +import { useConditionalHotkeyScope } from "~/hooks/common" +import { useHotkeyScope } from "~/providers/hotkey-provider" import { WindowUnderBlur } from "../../components/ui/background" +import { COMMAND_ID } from "../command/commands/id" +import { useCommandHotkey } from "../command/hooks/use-register-hotkey" import { getSelectedFeedIds, resetSelectedFeedIds, setSelectedFeedIds } from "./atom" import { useShouldFreeUpSpace } from "./hook" import { TimelineColumnHeader } from "./TimelineColumnHeader" @@ -86,25 +90,6 @@ export function FeedColumn({ children, className }: PropsWithChildren<{ classNam { target: carouselRef }, ) - useHotkeys( - shortcuts.feeds.switchBetweenViews.key, - (e) => { - e.preventDefault() - if (isHotkeyPressed("Left")) { - setActive((_, i) => { - if (i === 0) { - return timelineList.at(-1)! - } else { - return timelineList[i - 1]! - } - }) - } else { - setActive((_, i) => timelineList[(i + 1) % timelineList.length]!) - } - }, - { scopes: HotkeyScope.Home }, - ) - useRegisterGlobalContext("goToDiscover", () => { window.router.navigate(Routes.Discover) }) @@ -121,9 +106,17 @@ export function FeedColumn({ children, className }: PropsWithChildren<{ classNam const feedColumnShow = useTimelineColumnShow() const rootContainerElement = useRootContainerElement() + const { setIsFocus } = useRegisterCommands({ setActive, timelineList }) + return ( { + setIsFocus(true) + }, [setIsFocus])} + onBlur={useCallback(() => { + setIsFocus(false) + }, [setIsFocus])} className={cn( "relative flex h-full flex-col pt-2.5", @@ -241,3 +234,45 @@ const SwipeWrapper: FC<{ active: string; children: React.JSX.Element[] }> = memo ) }, ) + +const useRegisterCommands = ({ + setActive, + timelineList, +}: { + setActive: (args: string | ((prev: string | undefined, index: number) => string)) => void + timelineList: string[] +}) => { + const activeScope = useHotkeyScope() + const when = + activeScope.includes(HotkeyScope.SubscriptionList) || activeScope.includes(HotkeyScope.Timeline) + useCommandHotkey({ + commandId: COMMAND_ID.subscription.switchTabToNext, + shortcut: shortcuts.feeds.switchNextView.key, + when, + }) + + useCommandHotkey({ + commandId: COMMAND_ID.subscription.switchTabToPrevious, + shortcut: shortcuts.feeds.switchPreviousView.key, + when, + }) + + useEffect(() => { + return EventBus.subscribe(COMMAND_ID.subscription.switchTabToNext, () => { + setActive((_, i) => timelineList[(i + 1) % timelineList.length]!) + }) + }, [activeScope, setActive, timelineList]) + + useEffect(() => { + return EventBus.subscribe(COMMAND_ID.subscription.switchTabToPrevious, () => { + setActive((_, i) => timelineList[(i - 1 + timelineList.length) % timelineList.length]!) + }) + }, [activeScope, setActive, timelineList]) + + const [isFocus, setIsFocus] = useState(false) + + useConditionalHotkeyScope(HotkeyScope.SubscriptionList, isFocus, true) + return { + setIsFocus, + } +} diff --git a/locales/shortcuts/en.json b/locales/shortcuts/en.json index 8a89fc50d..9aa03b7ce 100644 --- a/locales/shortcuts/en.json +++ b/locales/shortcuts/en.json @@ -18,6 +18,8 @@ "keys.entry.tts": "Play TTS", "keys.feeds.add": "Add Subscription", "keys.feeds.switchBetweenViews": "Switch Between Views", + "keys.feeds.switchNextView": "Switch to Next View", + "keys.feeds.switchPreviousView": "Switch to Previous View", "keys.feeds.switchToView": "Switch to View", "keys.layout.showShortcuts": "Show/Hide Shortcuts", "keys.layout.toggleSidebar": "Show/Hide Feed Sidebar", From 1d9507842ea058b74f0b2036d851a909b58ffe2c Mon Sep 17 00:00:00 2001 From: Innei Date: Mon, 12 May 2025 22:21:20 +0800 Subject: [PATCH 033/104] feat: enhance shortcut management and command registration - Introduced new macro functions for shortcut localization, improving the handling of shortcut keys. - Updated shortcut configurations to utilize the new macro functions, ensuring consistency in key naming. - Refactored command registration to include new entry navigation commands, enhancing user interaction. - Improved hotkey management by adding support for new entry scrolling commands. This update aims to streamline shortcut handling and improve overall user experience in navigation. Signed-off-by: Innei --- .../layer/renderer/src/constants/shortcuts.ts | 60 +++++----- apps/desktop/layer/renderer/src/global.d.ts | 15 +++ .../command/hooks/use-register-hotkey.ts | 1 + .../modules/entry-content/index.electron.tsx | 110 +++++++++++------- apps/desktop/plugins/vite/ast.ts | 4 +- .../internal/components/src/ui/kbd/Kbd.tsx | 4 + 6 files changed, 128 insertions(+), 66 deletions(-) diff --git a/apps/desktop/layer/renderer/src/constants/shortcuts.ts b/apps/desktop/layer/renderer/src/constants/shortcuts.ts index aa69d8512..7ec5793a7 100644 --- a/apps/desktop/layer/renderer/src/constants/shortcuts.ts +++ b/apps/desktop/layer/renderer/src/constants/shortcuts.ts @@ -10,69 +10,69 @@ type Shortcuts = Record< const shortcutConfigs = { feeds: { add: { - name: "keys.feeds.add", + name: tShortcuts("keys.feeds.add"), key: "Meta+T", }, switchToView: { - name: "keys.feeds.switchToView", + name: tShortcuts("keys.feeds.switchToView"), key: "1, 2, 3, 4, 5, 6", }, switchNextView: { - name: "keys.feeds.switchNextView", + name: tShortcuts("keys.feeds.switchNextView"), key: "Tab, ArrowRight", }, switchPreviousView: { - name: "keys.feeds.switchPreviousView", + name: tShortcuts("keys.feeds.switchPreviousView"), key: "Shift+Tab, ArrowLeft", }, }, 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: { @@ -81,43 +81,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", + name: tShortcuts("keys.entry.scrollUp"), key: "K", }, scrollDown: { - name: "keys.entry.scrollDown", + name: tShortcuts("keys.entry.scrollDown"), key: "J", }, + 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", }, }, diff --git a/apps/desktop/layer/renderer/src/global.d.ts b/apps/desktop/layer/renderer/src/global.d.ts index 1b6c450b3..61459bfcb 100644 --- a/apps/desktop/layer/renderer/src/global.d.ts +++ b/apps/desktop/layer/renderer/src/global.d.ts @@ -34,6 +34,21 @@ declare global { export type I18nKeys = OmitStringType[0]> export type I18nKeysForSettings = OmitStringType[0]> export type I18nKeysForShortcuts = OmitStringType[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 {} diff --git a/apps/desktop/layer/renderer/src/modules/command/hooks/use-register-hotkey.ts b/apps/desktop/layer/renderer/src/modules/command/hooks/use-register-hotkey.ts index 9da07a532..9adc46c98 100644 --- a/apps/desktop/layer/renderer/src/modules/command/hooks/use-register-hotkey.ts +++ b/apps/desktop/layer/renderer/src/modules/command/hooks/use-register-hotkey.ts @@ -33,6 +33,7 @@ export const useCommandHotkey = ({ shortcuts.forEach((key) => { keyMap[key] = (event) => { event.preventDefault() + event.stopPropagation() const command = getCommand(commandId) if (!command) return diff --git a/apps/desktop/layer/renderer/src/modules/entry-content/index.electron.tsx b/apps/desktop/layer/renderer/src/modules/entry-content/index.electron.tsx index 15ea0c924..bb55f1fd8 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-content/index.electron.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-content/index.electron.tsx @@ -100,48 +100,12 @@ export const EntryContent: Component = ({ }) const isInPeekModal = useInPeekModal() - const [isUserInteraction, setIsUserInteraction] = React.useState(false) - useConditionalHotkeyScope(HotkeyScope.EntryRender, isUserInteraction, true) - - const activeScope = useHotkeyScope() - - useCommandHotkey({ - shortcut: shortcuts.entry.scrollUp.key, - commandId: COMMAND_ID.entryRender.scrollUp, - when: activeScope.includes(HotkeyScope.EntryRender), + const { setIsUserInteraction } = useRegisterCommands({ + entryId, + scrollerRef, }) - useCommandHotkey({ - shortcut: shortcuts.entry.scrollDown.key, - commandId: COMMAND_ID.entryRender.scrollDown, - when: activeScope.includes(HotkeyScope.EntryRender), - }) - - useEffect(() => { - return 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!) - } - }) - }, []) - - useEffect(() => { - return 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!) - } - }) - }, []) - - useEffect(() => { - return () => setIsUserInteraction(false) - }, [entryId]) if (!entry) return null const entryContent = isInReadabilityMode @@ -321,3 +285,71 @@ const Renderer: React.FC<{ ) }) + +const useRegisterCommands = ({ + entryId, + scrollerRef, +}: { + entryId: string + scrollerRef: React.RefObject +}) => { + const [isUserInteraction, setIsUserInteraction] = React.useState(false) + useConditionalHotkeyScope(HotkeyScope.EntryRender, isUserInteraction, true) + + const activeScope = useHotkeyScope() + const when = activeScope.includes(HotkeyScope.EntryRender) + + useCommandHotkey({ + shortcut: shortcuts.entry.scrollUp.key, + commandId: COMMAND_ID.entryRender.scrollUp, + when, + }) + + useCommandHotkey({ + shortcut: shortcuts.entry.scrollDown.key, + commandId: COMMAND_ID.entryRender.scrollDown, + when, + }) + + useCommandHotkey({ + shortcut: shortcuts.entry.nextEntry.key, + commandId: COMMAND_ID.timeline.switchToNext, + + when, + }) + + useCommandHotkey({ + shortcut: shortcuts.entry.previousEntry.key, + commandId: COMMAND_ID.timeline.switchToPrevious, + when, + }) + + useEffect(() => { + return 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!) + } + }) + }, [scrollerRef]) + + useEffect(() => { + return 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!) + } + }) + }, [scrollerRef]) + + useEffect(() => { + return () => setIsUserInteraction(false) + }, [entryId]) + + return { + setIsUserInteraction, + } +} diff --git a/apps/desktop/plugins/vite/ast.ts b/apps/desktop/plugins/vite/ast.ts index 33b7f4c1a..080488b4c 100644 --- a/apps/desktop/plugins/vite/ast.ts +++ b/apps/desktop/plugins/vite/ast.ts @@ -2,5 +2,7 @@ import { RemoveWrapperFunction } from "unplugin-ast/transformers" import AST from "unplugin-ast/vite" export const astPlugin = AST({ - transformer: [RemoveWrapperFunction(["tw", "defineSettingPageData"])], + transformer: [ + RemoveWrapperFunction(["tw", "defineSettingPageData", "t_", "tShortcuts", "tSettings"]), + ], }) diff --git a/packages/internal/components/src/ui/kbd/Kbd.tsx b/packages/internal/components/src/ui/kbd/Kbd.tsx index 7cab7676f..c12f40534 100644 --- a/packages/internal/components/src/ui/kbd/Kbd.tsx +++ b/packages/internal/components/src/ui/kbd/Kbd.tsx @@ -10,6 +10,10 @@ const SharedKeys = { pageup: "PageUp", pagedown: "PageDown", tab: "Tab", + arrowup: "↑", + arrowdown: "↓", + arrowleft: "←", + arrowright: "→", } const SpecialKeys = { Windows: { From b33c6e9ee91eae2e2be357ca58bd54cb7a70764f Mon Sep 17 00:00:00 2001 From: lawvs <18554747+lawvs@users.noreply.github.com> Date: Tue, 13 May 2025 01:36:23 +0800 Subject: [PATCH 034/104] refactor: extract ThumbnailImage component for better readability and maintainability --- .../entry-list/templates/EntryNormalItem.tsx | 134 ++++++++++-------- 1 file changed, 78 insertions(+), 56 deletions(-) diff --git a/apps/mobile/src/modules/entry-list/templates/EntryNormalItem.tsx b/apps/mobile/src/modules/entry-list/templates/EntryNormalItem.tsx index db44c2e5b..5d57beb16 100644 --- a/apps/mobile/src/modules/entry-list/templates/EntryNormalItem.tsx +++ b/apps/mobile/src/modules/entry-list/templates/EntryNormalItem.tsx @@ -20,6 +20,7 @@ import { useNavigation } from "@/src/lib/navigation/hooks" import { getAttachmentState, player } from "@/src/lib/player" import { EntryDetailScreen } from "@/src/screens/(stack)/entries/[entryId]/EntryDetailScreen" import { useEntry } from "@/src/store/entry/hooks" +import type { EntryModel } from "@/src/store/entry/types" import { getInboxFrom } from "@/src/store/entry/utils" import { useFeed } from "@/src/store/feed/hooks" import { useEntryTranslation } from "@/src/store/translation/hooks" @@ -50,18 +51,9 @@ export const EntryNormalItem = memo( } }, [entryId, entry, navigation, view]) - const thumbnailRatio = useUISettingKey("thumbnailRatio") - - const coverImage = entry?.media?.[0] - const image = coverImage?.url || (view === FeedViewType.Audios ? feed?.image : null) - const blurhash = coverImage?.blurhash - const audio = entry?.attachments?.find((attachment) => attachment.mime_type?.startsWith("audio/"), ) - const audioState = getAttachmentState(extraData, audio) - const isPlaying = audioState === "playing" - const isLoading = audioState === "loading" const estimatedMins = useMemo(() => { const durationInSeconds = formatTimeToSeconds(audio?.duration_in_seconds) @@ -133,53 +125,7 @@ export const EntryNormalItem = memo( )} {view !== FeedViewType.Notifications && ( - - {image && - (thumbnailRatio === "square" ? ( - - ) : ( - - ))} - - {audio && ( - { - if (isLoading) return - if (isPlaying) { - player.pause() - return - } - player.play({ - url: audio.url, - title: entry?.title, - artist: feed?.title, - artwork: image, - }) - }} - > - - - {isPlaying ? ( - - ) : isLoading ? ( - - ) : ( - - )} - - - )} - + )} @@ -189,6 +135,82 @@ export const EntryNormalItem = memo( EntryNormalItem.displayName = "EntryNormalItem" +const ThumbnailImage = ({ + view, + extraData, + entry, +}: { + view: FeedViewType + extraData: string + entry: EntryModel +}) => { + const feed = useFeed(entry?.feedId as string) + + const thumbnailRatio = useUISettingKey("thumbnailRatio") + + const coverImage = entry?.media?.[0] + const image = coverImage?.url || (view === FeedViewType.Audios ? feed?.image : null) + const blurhash = coverImage?.blurhash + + const audio = entry?.attachments?.find((attachment) => attachment.mime_type?.startsWith("audio/")) + const audioState = getAttachmentState(extraData, audio) + const isPlaying = audioState === "playing" + const isLoading = audioState === "loading" + + const handlePressPlayAudio = useCallback(() => { + if (!audio) return + if (isLoading) return + if (isPlaying) { + player.pause() + return + } + player.play({ + url: audio.url, + title: entry?.title, + artist: feed?.title, + artwork: image, + }) + }, [audio, entry?.title, feed?.title, image, isLoading, isPlaying]) + + return ( + + {image && + (thumbnailRatio === "square" ? ( + + ) : ( + + ))} + + {audio && ( + + + + {isPlaying ? ( + + ) : isLoading ? ( + + ) : ( + + )} + + + )} + + ) +} + const AspectRatioImage = ({ image, blurhash, From 4213459ae05994904359fff101feb28349e82766 Mon Sep 17 00:00:00 2001 From: lawvs <18554747+lawvs@users.noreply.github.com> Date: Tue, 13 May 2025 01:48:28 +0800 Subject: [PATCH 035/104] feat: add feed icon display for audio entries without images --- .../src/modules/entry-list/templates/EntryNormalItem.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/modules/entry-list/templates/EntryNormalItem.tsx b/apps/mobile/src/modules/entry-list/templates/EntryNormalItem.tsx index 5d57beb16..4eca6e656 100644 --- a/apps/mobile/src/modules/entry-list/templates/EntryNormalItem.tsx +++ b/apps/mobile/src/modules/entry-list/templates/EntryNormalItem.tsx @@ -145,7 +145,6 @@ const ThumbnailImage = ({ entry: EntryModel }) => { const feed = useFeed(entry?.feedId as string) - const thumbnailRatio = useUISettingKey("thumbnailRatio") const coverImage = entry?.media?.[0] @@ -186,6 +185,9 @@ const ThumbnailImage = ({ /> ))} + {/* Show feed icon if no image but audio is present */} + {audio && !image && } + {audio && ( Date: Tue, 13 May 2025 15:34:29 +0800 Subject: [PATCH 036/104] feat: show rsshub error message --- .../src/components/common/ErrorTooltip.tsx | 45 ++++++++++++ .../src/modules/timeline-column/FeedItem.tsx | 29 ++------ .../(main)/(layer)/(subview)/rsshub/index.tsx | 70 ++++++++++++------- locales/errors/en.json | 1 + locales/settings/en.json | 2 + packages/internal/shared/src/hono.ts | 44 ++++++++++++ 6 files changed, 142 insertions(+), 49 deletions(-) create mode 100644 apps/desktop/layer/renderer/src/components/common/ErrorTooltip.tsx diff --git a/apps/desktop/layer/renderer/src/components/common/ErrorTooltip.tsx b/apps/desktop/layer/renderer/src/components/common/ErrorTooltip.tsx new file mode 100644 index 000000000..2e5031649 --- /dev/null +++ b/apps/desktop/layer/renderer/src/components/common/ErrorTooltip.tsx @@ -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 ( + + {children} + + +
+ + {t("feed_item.error_since")}{" "} + {dayjs.duration(dayjs(errorAt).diff(dayjs(), "minute"), "minute").humanize(true)} +
+ {!!errorMessage && ( +
+ + {errorMessage} +
+ )} +
+
+
+ ) +} diff --git a/apps/desktop/layer/renderer/src/modules/timeline-column/FeedItem.tsx b/apps/desktop/layer/renderer/src/modules/timeline-column/FeedItem.tsx index 4a54bb652..f269283fd 100644 --- a/apps/desktop/layer/renderer/src/modules/timeline-column/FeedItem.tsx +++ b/apps/desktop/layer/renderer/src/modules/timeline-column/FeedItem.tsx @@ -11,13 +11,13 @@ import { EllipsisHorizontalTextWithTooltip } from "@follow/components/ui/typogra import type { FeedViewType } from "@follow/constants" import { nextFrame } from "@follow/utils/dom" import { cn, isKeyForMultiSelectPressed } from "@follow/utils/utils" -import dayjs from "dayjs" 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 { useGeneralSettingKey } 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" @@ -187,29 +187,10 @@ const FeedItemImpl = ({ view, feedId, className, isPreview }: FeedItemProps) =>
- {isFeed && feed.errorAt && ( - - - - - - -
- - {t("feed_item.error_since")}{" "} - {dayjs - .duration(dayjs(feed.errorAt).diff(dayjs(), "minute"), "minute") - .humanize(true)} -
- {!!feed.errorMessage && ( -
- - {feed.errorMessage} -
- )} -
-
-
+ {isFeed && ( + + + )} {subscription?.isPrivate && ( diff --git a/apps/desktop/layer/renderer/src/pages/(main)/(layer)/(subview)/rsshub/index.tsx b/apps/desktop/layer/renderer/src/pages/(main)/(layer)/(subview)/rsshub/index.tsx index bf2f07167..b7669286a 100644 --- a/apps/desktop/layer/renderer/src/pages/(main)/(layer)/(subview)/rsshub/index.tsx +++ b/apps/desktop/layer/renderer/src/pages/(main)/(layer)/(subview)/rsshub/index.tsx @@ -14,6 +14,7 @@ import { useTranslation } from "react-i18next" import RSSHubIconUrl from "~/assets/rsshub-icon.png?url" import { whoami } from "~/atoms/user" +import { ErrorTooltip } from "~/components/common/ErrorTooltip" import { useModalStack } from "~/components/ui/modal/stacked/hooks" import { useAuthQuery } from "~/hooks/common" import { useSubViewTitle } from "~/modules/app-layout/subview/hooks" @@ -129,6 +130,13 @@ function List({ data }: { data?: RSSHubModel[] }) { return 1 } + if (a.errorMessage && !b.errorMessage) { + return 1 + } + if (!a.errorMessage && b.errorMessage) { + return -1 + } + const loadA = Math.min((a.userCount ?? 0) / (a.userLimit ?? Infinity), 1) const loadB = Math.min((b.userCount ?? 0) / (b.userLimit ?? Infinity), 1) @@ -191,31 +199,7 @@ function List({ data }: { data?: RSSHubModel[] }) {
- + {me?.id === instance.ownerUserId && ( + + ) +} diff --git a/locales/errors/en.json b/locales/errors/en.json index d06890517..1d1cab113 100644 --- a/locales/errors/en.json +++ b/locales/errors/en.json @@ -64,6 +64,7 @@ "13004": "RSSHub user limit exceeded", "13005": "RSSHub purchase not found", "13006": "RSSHub config invalid", + "13007": "This RSSHub instance is unavailable currently", "14000": "Invalid file", "14001": "File too large", "14002": "Upload failed" diff --git a/locales/settings/en.json b/locales/settings/en.json index ec2722555..dd92d048a 100644 --- a/locales/settings/en.json +++ b/locales/settings/en.json @@ -393,10 +393,12 @@ "rsshub.table.description": "Description", "rsshub.table.edit": "Edit", "rsshub.table.inuse": "In Use", + "rsshub.table.limit_reached": "Limit Reached", "rsshub.table.official": "Official", "rsshub.table.owner": "Owner", "rsshub.table.price": "Monthly Price", "rsshub.table.private": "Private", + "rsshub.table.unavailable": "Unavailable", "rsshub.table.unlimited": "Unlimited", "rsshub.table.use": "Use", "rsshub.table.userCount": "User Count", diff --git a/packages/internal/shared/src/hono.ts b/packages/internal/shared/src/hono.ts index 549c98987..6da66015e 100644 --- a/packages/internal/shared/src/hono.ts +++ b/packages/internal/shared/src/hono.ts @@ -5211,6 +5211,40 @@ declare const rsshub: drizzle_orm_pg_core.PgTableWithColumns<{ identity: undefined; generated: undefined; }, {}, {}>; + errorMessage: drizzle_orm_pg_core.PgColumn<{ + name: "error_message"; + tableName: "rsshub"; + dataType: "string"; + columnType: "PgText"; + data: string; + driverParam: string; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + errorAt: drizzle_orm_pg_core.PgColumn<{ + name: "error_at"; + tableName: "rsshub"; + dataType: "date"; + columnType: "PgTimestamp"; + data: Date; + driverParam: string; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; }; dialect: "pg"; }>; @@ -5222,9 +5256,13 @@ declare const rsshubOpenAPISchema: zod.ZodObject<{ price: zod.ZodNumber; description: zod.ZodNullable; userLimit: zod.ZodNullable; + errorMessage: zod.ZodNullable; + errorAt: zod.ZodNullable; }, zod.UnknownKeysParam, zod.ZodTypeAny, { id: string; description: string | null; + errorMessage: string | null; + errorAt: string | null; ownerUserId: string; baseUrl: string; accessKey: string | null; @@ -5233,6 +5271,8 @@ declare const rsshubOpenAPISchema: zod.ZodObject<{ }, { id: string; description: string | null; + errorMessage: string | null; + errorAt: string | null; ownerUserId: string; baseUrl: string; accessKey: string | null; @@ -16844,6 +16884,8 @@ declare const _routes: hono_hono_base.HonoBase Date: Tue, 13 May 2025 15:35:03 +0800 Subject: [PATCH 037/104] fix: toast rsshub fetch error --- apps/desktop/layer/renderer/src/queries/rsshub.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/desktop/layer/renderer/src/queries/rsshub.ts b/apps/desktop/layer/renderer/src/queries/rsshub.ts index f4d5f76bf..b7fcdcda7 100644 --- a/apps/desktop/layer/renderer/src/queries/rsshub.ts +++ b/apps/desktop/layer/renderer/src/queries/rsshub.ts @@ -1,8 +1,8 @@ import { useMutation } from "@tanstack/react-query" -import { toast } from "sonner" import { apiClient } from "~/lib/api-fetch" import { defineQuery } from "~/lib/defineQuery" +import { toastFetchError } from "~/lib/error-parser" import { userActions } from "~/store/user" import type { MutationBaseProps } from "./types" @@ -23,7 +23,7 @@ export const useSetRSSHubMutation = ({ onError }: MutationBaseProps = {}) => onError: (error) => { onError?.(error) - toast.error(error.message) + toastFetchError(error) }, }) @@ -53,7 +53,7 @@ export const useAddRSSHubMutation = ({ onError }: MutationBaseProps = {}) => onError: (error) => { onError?.(error) - toast.error(error.message) + toastFetchError(error) }, }) @@ -63,7 +63,7 @@ export const useDeleteRSSHubMutation = ({ onError }: MutationBaseProps = {}) => onError: (error) => { onError?.(error) - toast.error(error.message) + toastFetchError(error) }, }) From f329ae905f88fd9df3ba4dd9a51c3f6216df2368 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Tue, 13 May 2025 15:45:30 +0800 Subject: [PATCH 038/104] fix: reenable summary as default --- packages/internal/shared/src/settings/defaults.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/internal/shared/src/settings/defaults.ts b/packages/internal/shared/src/settings/defaults.ts index 74ebf1c10..5fbccd335 100644 --- a/packages/internal/shared/src/settings/defaults.ts +++ b/packages/internal/shared/src/settings/defaults.ts @@ -6,7 +6,7 @@ export const defaultGeneralSettings: GeneralSettings = { language: "en", translation: false, translationMode: "bilingual", - summary: false, + summary: true, actionLanguage: "default", // mobile app From b06d8ff62e1f73ce2215e27bf6242887a6e34bed Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Tue, 13 May 2025 16:14:22 +0800 Subject: [PATCH 039/104] fix: bring back open in browser for social media --- .../src/modules/entry-column/Items/social-media-item.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/Items/social-media-item.tsx b/apps/desktop/layer/renderer/src/modules/entry-column/Items/social-media-item.tsx index f8c7c21b3..64762492f 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-column/Items/social-media-item.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-column/Items/social-media-item.tsx @@ -141,10 +141,7 @@ const ActionBar = ({ entryId }: { entryId: string }) => {
{entryActions .filter((item) => item instanceof MenuItemText) - .filter( - (item) => - item.id !== COMMAND_ID.entry.read && item.id !== COMMAND_ID.entry.openInBrowser, - ) + .filter((item) => item.id !== COMMAND_ID.entry.read) .map((item) => ( Date: Tue, 13 May 2025 16:47:01 +0800 Subject: [PATCH 040/104] fix: first ActionBar in social media being cut off --- .../modules/entry-column/wrapper.electron.tsx | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/wrapper.electron.tsx b/apps/desktop/layer/renderer/src/modules/entry-column/wrapper.electron.tsx index f8e2002e0..ce9dde5e7 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-column/wrapper.electron.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-column/wrapper.electron.tsx @@ -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,21 @@ export const EntryColumnWrapper = ({ ref, children, onScroll }: EntryColumnWrapp const isZenMode = useIsZenMode() return ( -
+
div]:grow flex", + // avoid ActionBar being cut off + view === FeedViewType.SocialMedia && "pt-6", + )} onScroll={onScroll} > {children} From 450b759318f7302c4f0cbeb5bb466252cbdbe8e3 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Tue, 13 May 2025 17:02:29 +0800 Subject: [PATCH 041/104] fix: no notification registration when no notification action enabled --- apps/desktop/layer/renderer/src/push-notification.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/apps/desktop/layer/renderer/src/push-notification.ts b/apps/desktop/layer/renderer/src/push-notification.ts index 07c9c82af..fbdb1ee71 100644 --- a/apps/desktop/layer/renderer/src/push-notification.ts +++ b/apps/desktop/layer/renderer/src/push-notification.ts @@ -12,6 +12,15 @@ export async function registerWebPushNotifications() { return } try { + const actions = await apiClient.actions.$get() + const rules = actions.data?.rules + const hasPushNotificationRule = rules?.some( + (rule) => rule.result.newEntryNotification && !rule.result.disabled, + ) + if (!hasPushNotificationRule) { + return + } + const existingRegistration = await navigator.serviceWorker.getRegistration() const registration = existingRegistration From 5d5314873867a26ef0c58509cdb5cecb64bcb657 Mon Sep 17 00:00:00 2001 From: Innei Date: Tue, 13 May 2025 17:46:43 +0800 Subject: [PATCH 042/104] fix: update title styling in list item template for better visibility, fixed #3634 Signed-off-by: Innei --- .../src/modules/entry-column/templates/list-item-template.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/templates/list-item-template.tsx b/apps/desktop/layer/renderer/src/modules/entry-column/templates/list-item-template.tsx index b77c63dd9..13b0c481a 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-column/templates/list-item-template.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-column/templates/list-item-template.tsx @@ -156,7 +156,7 @@ export function ListItem({ > {entry.entries.title ? ( From 93f19c4f6c304cbbbc041a1f5fc380b1a7a1507d Mon Sep 17 00:00:00 2001 From: lawvs <18554747+lawvs@users.noreply.github.com> Date: Tue, 13 May 2025 19:05:21 +0800 Subject: [PATCH 043/104] fix: handle image loading errors in FeedIcon component --- apps/mobile/src/components/ui/icon/feed-icon.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/apps/mobile/src/components/ui/icon/feed-icon.tsx b/apps/mobile/src/components/ui/icon/feed-icon.tsx index 6e4ad2a1b..6730f6036 100644 --- a/apps/mobile/src/components/ui/icon/feed-icon.tsx +++ b/apps/mobile/src/components/ui/icon/feed-icon.tsx @@ -1,12 +1,13 @@ import type { FeedViewType } from "@follow/constants" import type { ReactNode } from "react" -import { useMemo } from "react" +import { useCallback, useMemo, useState } from "react" import type { FeedSchema } from "@/src/database/schemas/types" import { getFeedIconSource } from "@/src/lib/image" import type { ImageProps } from "../image/Image" import { Image } from "../image/Image" +import { FallbackIcon } from "./fallback-icon" export type FeedIconRequiredFeed = Pick< FeedSchema, @@ -39,12 +40,15 @@ export function FeedIcon({ siteUrl, ...props }: FeedIconProps & ImageProps) { + const [isError, setIsError] = useState(false) const src = useMemo(() => { return getFeedIconSource(feed, siteUrl, fallback) }, [fallback, feed, siteUrl]) - if (!src) { - return null + const handleError = useCallback(() => setIsError(true), []) + + if (!src || isError) { + return } return ( ) From 262a7ce55e0057fcbf2d213873e3c75f9de7decd Mon Sep 17 00:00:00 2001 From: Innei Date: Tue, 13 May 2025 21:12:47 +0800 Subject: [PATCH 044/104] feat: enhance command binding and shortcut management - Introduced `useCommandBinding` to streamline the registration of command hotkeys. - Added new entry navigation commands for scrolling to the next and previous entries. - Updated existing components to utilize the new command binding approach, improving consistency and maintainability. - Refactored shortcut handling to support new entry render commands, enhancing user experience. This update aims to improve the overall efficiency of command and shortcut management in the application. Signed-off-by: Innei --- .../app-layout/feed-column/desktop.tsx | 6 +- .../modules/command/commands/entry-render.tsx | 42 +++++++++- .../src/modules/command/commands/id.ts | 2 + .../command/hooks/use-command-shortcut.ts | 40 +++++++++ .../command/hooks/use-register-hotkey.ts | 34 +++++--- .../EntryColumnShortcutHandler.tsx | 15 ++-- .../entry-content/actions/header-actions.tsx | 6 +- .../src/modules/entry-content/hooks.tsx | 19 +---- .../modules/entry-content/index.electron.tsx | 82 +++++++++---------- .../modules/entry-content/index.shared.tsx | 4 - .../src/modules/timeline-column/index.tsx | 9 +- .../src/providers/global-hotkeys-provider.tsx | 9 +- .../components/src/common/Focusable.tsx | 49 +++++------ packages/internal/utils/src/utils.ts | 10 +++ 14 files changed, 199 insertions(+), 128 deletions(-) create mode 100644 apps/desktop/layer/renderer/src/modules/command/hooks/use-command-shortcut.ts diff --git a/apps/desktop/layer/renderer/src/modules/app-layout/feed-column/desktop.tsx b/apps/desktop/layer/renderer/src/modules/app-layout/feed-column/desktop.tsx index 111ec8d80..5bc5ec81a 100644 --- a/apps/desktop/layer/renderer/src/modules/app-layout/feed-column/desktop.tsx +++ b/apps/desktop/layer/renderer/src/modules/app-layout/feed-column/desktop.tsx @@ -32,7 +32,6 @@ import { PlainModal } from "~/components/ui/modal/stacked/custom-modal" import { DeclarativeModal } from "~/components/ui/modal/stacked/declarative-modal" 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" @@ -40,7 +39,7 @@ 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 { useCommandHotkey } from "~/modules/command/hooks/use-register-hotkey" +import { useCommandBinding } from "~/modules/command/hooks/use-register-hotkey" import { DebugRegistry } from "~/modules/debug/registry" import { CmdF } from "~/modules/panel/cmdf" import { SearchCmdK } from "~/modules/panel/cmdk" @@ -244,9 +243,8 @@ const FeedResponsiveResizerContainer = ({ const activeScopes = useHotkeyScope() - useCommandHotkey({ + useCommandBinding({ commandId: COMMAND_ID.layout.toggleTimelineColumn, - shortcut: shortcuts.layout.toggleSidebar.key, when: activeScopes.includes(HotkeyScope.Home), }) diff --git a/apps/desktop/layer/renderer/src/modules/command/commands/entry-render.tsx b/apps/desktop/layer/renderer/src/modules/command/commands/entry-render.tsx index 1a2094f30..29682f14f 100644 --- a/apps/desktop/layer/renderer/src/modules/command/commands/entry-render.tsx +++ b/apps/desktop/layer/renderer/src/modules/command/commands/entry-render.tsx @@ -8,9 +8,13 @@ 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([ { @@ -18,7 +22,7 @@ export const useRegisterEntryRenderCommand = () => { run: () => { EventBus.dispatch(COMMAND_ID.entryRender.scrollDown) }, - category: "follow:entry-render", + category, label: `${LABEL_PREFIX}: Scroll down`, }, { @@ -26,9 +30,27 @@ export const useRegisterEntryRenderCommand = () => { run: () => { EventBus.dispatch(COMMAND_ID.entryRender.scrollUp) }, - category: "follow:entry-render", + 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`, + }, ]) } @@ -42,4 +64,18 @@ type EntryScrollUpCommand = Command<{ fn: () => void }> -export type EntryRenderCommand = EntryScrollDownCommand | EntryScrollUpCommand +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 diff --git a/apps/desktop/layer/renderer/src/modules/command/commands/id.ts b/apps/desktop/layer/renderer/src/modules/command/commands/id.ts index dd37fcea8..dc5264fb7 100644 --- a/apps/desktop/layer/renderer/src/modules/command/commands/id.ts +++ b/apps/desktop/layer/renderer/src/modules/command/commands/id.ts @@ -55,6 +55,8 @@ export const COMMAND_ID = { 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", diff --git a/apps/desktop/layer/renderer/src/modules/command/hooks/use-command-shortcut.ts b/apps/desktop/layer/renderer/src/modules/command/hooks/use-command-shortcut.ts new file mode 100644 index 000000000..939e1ed6b --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/command/hooks/use-command-shortcut.ts @@ -0,0 +1,40 @@ +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.switchTabToNext]: shortcuts.feeds.switchNextView.key, + [COMMAND_ID.subscription.switchTabToPrevious]: shortcuts.feeds.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, +} 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 +} diff --git a/apps/desktop/layer/renderer/src/modules/command/hooks/use-register-hotkey.ts b/apps/desktop/layer/renderer/src/modules/command/hooks/use-register-hotkey.ts index 9adc46c98..304310d5c 100644 --- a/apps/desktop/layer/renderer/src/modules/command/hooks/use-register-hotkey.ts +++ b/apps/desktop/layer/renderer/src/modules/command/hooks/use-register-hotkey.ts @@ -1,8 +1,10 @@ -import { useEffect, useRef } from "react" +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 { shortcut: string @@ -14,14 +16,15 @@ interface RegisterHotkeyOptions { export const useCommandHotkey = ({ shortcut, commandId, - when = true, + when, args, }: RegisterHotkeyOptions) => { - const unsubscribeRef = useRef<() => void>(void 0) - useEffect(() => { if (!when) { - unsubscribeRef.current?.() + return + } + + if (!shortcut) { return } @@ -55,10 +58,21 @@ export const useCommandHotkey = ({ } }) - unsubscribeRef.current = tinykeys(document.documentElement, keyMap) - - return () => { - unsubscribeRef.current?.() - } + return tinykeys(document.documentElement, keyMap) }, [shortcut, commandId, when, args]) } + +export const useCommandBinding = ({ + commandId, + when = true, + args, +}: Omit, "shortcut">) => { + const commandShortcut = useCommandShortcut(commandId) + + return useCommandHotkey({ + shortcut: commandShortcut, + commandId, + when, + args, + }) +} diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/EntryColumnShortcutHandler.tsx b/apps/desktop/layer/renderer/src/modules/entry-column/EntryColumnShortcutHandler.tsx index 17e918885..2d0119c89 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-column/EntryColumnShortcutHandler.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-column/EntryColumnShortcutHandler.tsx @@ -5,14 +5,13 @@ import { memo, useEffect, useLayoutEffect, useState } from "react" import { useMainContainerElement } from "~/atoms/dom" import { HotkeyScope } from "~/constants" -import { shortcuts } from "~/constants/shortcuts" 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 { useCommandHotkey } from "../command/hooks/use-register-hotkey" +import { useCommandBinding } from "../command/hooks/use-register-hotkey" export const EntryColumnShortcutHandler: FC<{ refetch: () => void @@ -26,26 +25,24 @@ export const EntryColumnShortcutHandler: FC<{ const when = activeScope.includes(HotkeyScope.Timeline) && !activeScope.includes(HotkeyScope.EntryRender) - useCommandHotkey({ - shortcut: shortcuts.entries.next.key, + useCommandBinding({ commandId: COMMAND_ID.timeline.switchToNext, when, }) - useCommandHotkey({ - shortcut: shortcuts.entries.previous.key, + useCommandBinding({ commandId: COMMAND_ID.timeline.switchToPrevious, when, }) - useCommandHotkey({ - shortcut: shortcuts.entries.refetch.key, + useCommandBinding({ commandId: COMMAND_ID.timeline.refetch, when, }) const currentEntryIdRef = useRefValue(useRouteEntryId()) const navigate = useNavigateEntry() + useEffect(() => { return EventBus.subscribe(COMMAND_ID.timeline.switchToNext, () => { const data = dataRef.current @@ -60,7 +57,7 @@ export const EntryColumnShortcutHandler: FC<{ entryId: nextId, }) }) - }, [currentEntryIdRef, dataRef, handleScrollTo, navigate]) + }, [currentEntryIdRef, dataRef, handleScrollTo, navigate, when]) useEffect(() => { return EventBus.subscribe(COMMAND_ID.timeline.switchToPrevious, () => { diff --git a/apps/desktop/layer/renderer/src/modules/entry-content/actions/header-actions.tsx b/apps/desktop/layer/renderer/src/modules/entry-content/actions/header-actions.tsx index dc08ac81a..a76305d51 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-content/actions/header-actions.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-content/actions/header-actions.tsx @@ -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 }], }) diff --git a/apps/desktop/layer/renderer/src/modules/entry-content/hooks.tsx b/apps/desktop/layer/renderer/src/modules/entry-content/hooks.tsx index 4087e8626..13cf41526 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-content/hooks.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-content/hooks.tsx @@ -1,6 +1,5 @@ import { tracker } from "@follow/tracker" -import { EventBus } from "@follow/utils/event-bus" -import { createElement, useCallback, useEffect } from "react" +import { createElement, useCallback } from "react" import { useTranslation } from "react-i18next" import { toast } from "sonner" @@ -8,22 +7,6 @@ import { useModalStack } from "~/components/ui/modal/stacked/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, -) => { - useEffect(() => { - return EventBus.subscribe("FOCUS_ENTRY_CONTAINER", () => { - ref.current?.focus() - }) - }, [ref]) -} - export const useGalleryModal = () => { const { present } = useModalStack() const { t } = useTranslation() diff --git a/apps/desktop/layer/renderer/src/modules/entry-content/index.electron.tsx b/apps/desktop/layer/renderer/src/modules/entry-content/index.electron.tsx index bb55f1fd8..2e6a0f1db 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-content/index.electron.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-content/index.electron.tsx @@ -1,3 +1,4 @@ +import { Focusable, useFocusable } from "@follow/components/common/Focusable.js" import { MemoedDangerousHTMLStyle } from "@follow/components/common/MemoedDangerousHTMLStyle.js" import { ScrollArea } from "@follow/components/ui/scroll-area/index.js" import type { FeedViewType } from "@follow/constants" @@ -6,10 +7,10 @@ import type { FeedModel, InboxModel } from "@follow/models/types" import { stopPropagation } from "@follow/utils/dom" import { EventBus } from "@follow/utils/event-bus" import { springScrollTo } from "@follow/utils/scroller" -import { cn } from "@follow/utils/utils" +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, @@ -21,7 +22,6 @@ 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 { shortcuts } from "~/constants/shortcuts" import { useRenderStyle } from "~/hooks/biz/useRenderStyle" import { useRouteParamsSelector } from "~/hooks/biz/useRouteParams" import { useAuthQuery, useConditionalHotkeyScope } from "~/hooks/common" @@ -35,7 +35,7 @@ import { useFeedById } from "~/store/feed" import { useInboxById } from "~/store/inbox" import { COMMAND_ID } from "../command/commands/id" -import { useCommandHotkey } from "../command/hooks/use-register-hotkey" +import { useCommandBinding } from "../command/hooks/use-register-hotkey" import { EntryContentHTMLRenderer } from "../renderer/html" import { AISummary } from "./AISummary" import { EntryTimelineSidebar } from "./components/EntryTimelineSidebar" @@ -43,7 +43,6 @@ import { EntryTitle } from "./components/EntryTitle" import { SourceContentPanel } from "./components/SourceContentView" import { SupportCreator } from "./components/SupportCreator" import { EntryHeader } from "./header" -import { useFocusEntryContainerSubscriptions } from "./hooks" import type { EntryContentProps } from "./index.shared" import { ContainerToc, @@ -82,13 +81,20 @@ export const EntryContent: Component = ({ const isInReadabilityMode = useEntryIsInReadability(entryId) const isReadabilitySuccess = useEntryIsInReadabilitySuccess(entryId) - const scrollerRef = useRef(null) - useEffect(() => { - scrollerRef.current?.scrollTo(0, 0) - scrollerRef.current?.focus() - }, [entryId]) - useFocusEntryContainerSubscriptions(scrollerRef) + const scrollerRef = useRef(null) + + 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), + ) + }, []) const safeUrl = useFeedSafeUrl(entryId) @@ -101,11 +107,7 @@ export const EntryContent: Component = ({ const isInPeekModal = useInPeekModal() - const { setIsUserInteraction } = useRegisterCommands({ - entryId, - scrollerRef, - }) - + const [isUserInteraction, setIsUserInteraction] = useState(false) if (!entry) return null const entryContent = isInReadabilityMode @@ -129,7 +131,15 @@ export const EntryContent: Component = ({ /> )} -
+ setIsUserInteraction(true)} + > +
= ({ >
setIsUserInteraction(true)} + onFocus={() => setIsUserInteraction(true)} data-testid="entry-render" onContextMenu={stopPropagation} className="@[950px]:max-w-[70ch] @7xl:max-w-[80ch] relative m-auto min-w-0 max-w-[550px]" @@ -202,7 +212,7 @@ export const EntryContent: Component = ({
-
+ ) } @@ -286,41 +296,37 @@ const Renderer: React.FC<{ ) }) -const useRegisterCommands = ({ - entryId, +const RegisterCommands = ({ scrollerRef, + isUserInteraction, }: { entryId: string scrollerRef: React.RefObject + isUserInteraction: boolean }) => { - const [isUserInteraction, setIsUserInteraction] = React.useState(false) - useConditionalHotkeyScope(HotkeyScope.EntryRender, isUserInteraction, true) + const containerFocused = useFocusable() + useConditionalHotkeyScope(HotkeyScope.EntryRender, isUserInteraction && containerFocused, true) const activeScope = useHotkeyScope() const when = activeScope.includes(HotkeyScope.EntryRender) - useCommandHotkey({ - shortcut: shortcuts.entry.scrollUp.key, + useCommandBinding({ commandId: COMMAND_ID.entryRender.scrollUp, when, }) - useCommandHotkey({ - shortcut: shortcuts.entry.scrollDown.key, + useCommandBinding({ commandId: COMMAND_ID.entryRender.scrollDown, when, }) - useCommandHotkey({ - shortcut: shortcuts.entry.nextEntry.key, - commandId: COMMAND_ID.timeline.switchToNext, - + useCommandBinding({ + commandId: COMMAND_ID.entryRender.nextEntry, when, }) - useCommandHotkey({ - shortcut: shortcuts.entry.previousEntry.key, - commandId: COMMAND_ID.timeline.switchToPrevious, + useCommandBinding({ + commandId: COMMAND_ID.entryRender.previousEntry, when, }) @@ -345,11 +351,5 @@ const useRegisterCommands = ({ }) }, [scrollerRef]) - useEffect(() => { - return () => setIsUserInteraction(false) - }, [entryId]) - - return { - setIsUserInteraction, - } + return null } diff --git a/apps/desktop/layer/renderer/src/modules/entry-content/index.shared.tsx b/apps/desktop/layer/renderer/src/modules/entry-content/index.shared.tsx index 012188cb8..4e911f811 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-content/index.shared.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-content/index.shared.tsx @@ -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" @@ -261,9 +260,6 @@ export const ContainerToc = memo(
{ - 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", diff --git a/apps/desktop/layer/renderer/src/modules/timeline-column/index.tsx b/apps/desktop/layer/renderer/src/modules/timeline-column/index.tsx index 952c4ecff..7ed859516 100644 --- a/apps/desktop/layer/renderer/src/modules/timeline-column/index.tsx +++ b/apps/desktop/layer/renderer/src/modules/timeline-column/index.tsx @@ -17,7 +17,6 @@ import { useRootContainerElement } from "~/atoms/dom" import { useUISettingKey } from "~/atoms/settings/ui" import { setTimelineColumnShow, useTimelineColumnShow } from "~/atoms/sidebar" import { HotkeyScope } from "~/constants" -import { shortcuts } from "~/constants/shortcuts" import { navigateEntry, useBackHome } from "~/hooks/biz/useNavigateEntry" import { useReduceMotion } from "~/hooks/biz/useReduceMotion" import { useRouteParamsSelector } from "~/hooks/biz/useRouteParams" @@ -27,7 +26,7 @@ import { useHotkeyScope } from "~/providers/hotkey-provider" import { WindowUnderBlur } from "../../components/ui/background" import { COMMAND_ID } from "../command/commands/id" -import { useCommandHotkey } from "../command/hooks/use-register-hotkey" +import { useCommandBinding } from "../command/hooks/use-register-hotkey" import { getSelectedFeedIds, resetSelectedFeedIds, setSelectedFeedIds } from "./atom" import { useShouldFreeUpSpace } from "./hook" import { TimelineColumnHeader } from "./TimelineColumnHeader" @@ -245,15 +244,13 @@ const useRegisterCommands = ({ const activeScope = useHotkeyScope() const when = activeScope.includes(HotkeyScope.SubscriptionList) || activeScope.includes(HotkeyScope.Timeline) - useCommandHotkey({ + useCommandBinding({ commandId: COMMAND_ID.subscription.switchTabToNext, - shortcut: shortcuts.feeds.switchNextView.key, when, }) - useCommandHotkey({ + useCommandBinding({ commandId: COMMAND_ID.subscription.switchTabToPrevious, - shortcut: shortcuts.feeds.switchPreviousView.key, when, }) diff --git a/apps/desktop/layer/renderer/src/providers/global-hotkeys-provider.tsx b/apps/desktop/layer/renderer/src/providers/global-hotkeys-provider.tsx index 7306820fe..7f6c85298 100644 --- a/apps/desktop/layer/renderer/src/providers/global-hotkeys-provider.tsx +++ b/apps/desktop/layer/renderer/src/providers/global-hotkeys-provider.tsx @@ -1,17 +1,16 @@ import { HotkeyScope } from "~/constants/hotkeys" -import { shortcuts } from "~/constants/shortcuts" 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 { useHotkeyScope } from "./hotkey-provider" export const GlobalHotkeysProvider = () => { const activeScopes = useHotkeyScope() - useCommandHotkey({ + useCommandBinding({ commandId: COMMAND_ID.global.showShortcuts, - shortcut: shortcuts.layout.showShortcuts.key, - when: activeScopes.includes(HotkeyScope.Home), + when: + activeScopes.includes(HotkeyScope.Home) && !activeScopes.includes(HotkeyScope.EntryRender), }) return null diff --git a/packages/internal/components/src/common/Focusable.tsx b/packages/internal/components/src/common/Focusable.tsx index 08e87fee1..50fbca1b6 100644 --- a/packages/internal/components/src/common/Focusable.tsx +++ b/packages/internal/components/src/common/Focusable.tsx @@ -1,40 +1,37 @@ -import type { FocusEvent } from "react" -import { createContext, use, useCallback, useState } from "react" +import { createContext, use, useImperativeHandle, useRef, useState } from "react" +import { useEventListener } from "usehooks-ts" // const const FocusableContext = createContext(false) +const FocusTargetRefContext = createContext>(null!) export const Focusable: Component< React.DetailedHTMLProps, HTMLDivElement> > = ({ ref, ...props }) => { const { onBlur, onFocus, ...rest } = props const [isFocusWithIn, setIsFocusWithIn] = useState(false) - const handleFocus = useCallback( - (e: FocusEvent) => { - onFocus?.(e) + const focusTargetRef = useRef(void 0) + + const containerRef = useRef(null) + useImperativeHandle(ref, () => containerRef.current!) + useEventListener("focusin", (e) => { + if (containerRef.current?.contains(e.target as Node)) { setIsFocusWithIn(true) - }, - [onFocus], - ) - const handleBlur = useCallback( - (e: FocusEvent) => { - onBlur?.(e) + } else { setIsFocusWithIn(false) - }, - [onBlur], - ) + } + }) + + // useEventListener("focusout", (e) => { + // if (!containerRef.current?.contains(e.target as Node)) { + // setIsFocusWithIn(false) + // } + // }) return ( -
+ +
+ ) } @@ -42,3 +39,7 @@ export const Focusable: Component< export const useFocusable = () => { return use(FocusableContext) } + +export const useFocusTargetRef = () => { + return use(FocusTargetRefContext) +} diff --git a/packages/internal/utils/src/utils.ts b/packages/internal/utils/src/utils.ts index 2db0c0f8e..71b7c7726 100644 --- a/packages/internal/utils/src/utils.ts +++ b/packages/internal/utils/src/utils.ts @@ -407,3 +407,13 @@ export function duplicateIfLengthLessThan(text: string, length: number) { ? text.repeat(Math.ceil(length / text.length)) : text } + +export function combineCleanupFunctions(...fns: Array void) | void>>) { + return () => { + fns.forEach((fn) => { + if (typeof fn === "function") { + fn() + } + }) + } +} From c2d8ffaa19f543bda6ff42e248bb6e50675cdbb7 Mon Sep 17 00:00:00 2001 From: Innei Date: Tue, 13 May 2025 22:35:26 +0800 Subject: [PATCH 045/104] feat: support vim keybinding navigation between entry content and timeline Signed-off-by: Innei --- .../ui/background/WindowUnderBlur.tsx | 2 +- .../src/components/ui/media/VideoPlayer.tsx | 2 +- .../src/modules/command/commands/id.ts | 2 + .../src/modules/command/commands/layout.tsx | 22 ++- .../src/modules/command/commands/timeline.tsx | 14 ++ .../EntryColumnShortcutHandler.tsx | 48 +++-- .../src/modules/entry-column/index.tsx | 5 +- .../modules/entry-content/index.electron.tsx | 66 ++++--- .../src/modules/player/corner-player.tsx | 2 +- .../src/common/{ => Focusable}/Focusable.tsx | 40 ++-- .../src/common/Focusable/context.ts | 7 + .../components/src/common/Focusable/hooks.ts | 15 ++ .../components/src/common/Focusable/index.ts | 2 + .../components/src/common/Focusable/utils.ts | 174 ++++++++++++++++++ .../src/ui/button/action-button.tsx | 2 +- 15 files changed, 328 insertions(+), 75 deletions(-) rename packages/internal/components/src/common/{ => Focusable}/Focusable.tsx (50%) create mode 100644 packages/internal/components/src/common/Focusable/context.ts create mode 100644 packages/internal/components/src/common/Focusable/hooks.ts create mode 100644 packages/internal/components/src/common/Focusable/index.ts create mode 100644 packages/internal/components/src/common/Focusable/utils.ts diff --git a/apps/desktop/layer/renderer/src/components/ui/background/WindowUnderBlur.tsx b/apps/desktop/layer/renderer/src/components/ui/background/WindowUnderBlur.tsx index 56899f9f4..6c4d03a08 100644 --- a/apps/desktop/layer/renderer/src/components/ui/background/WindowUnderBlur.tsx +++ b/apps/desktop/layer/renderer/src/components/ui/background/WindowUnderBlur.tsx @@ -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" diff --git a/apps/desktop/layer/renderer/src/components/ui/media/VideoPlayer.tsx b/apps/desktop/layer/renderer/src/components/ui/media/VideoPlayer.tsx index 14532ae82..afa8d7b64 100644 --- a/apps/desktop/layer/renderer/src/components/ui/media/VideoPlayer.tsx +++ b/apps/desktop/layer/renderer/src/components/ui/media/VideoPlayer.tsx @@ -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" diff --git a/apps/desktop/layer/renderer/src/modules/command/commands/id.ts b/apps/desktop/layer/renderer/src/modules/command/commands/id.ts index dc5264fb7..da71ab50d 100644 --- a/apps/desktop/layer/renderer/src/modules/command/commands/id.ts +++ b/apps/desktop/layer/renderer/src/modules/command/commands/id.ts @@ -46,11 +46,13 @@ export const COMMAND_ID = { }, layout: { toggleTimelineColumn: "layout:toggle-timeline-column", + focusToTimeline: "layout:focus-to-timeline", }, timeline: { switchToNext: "timeline:switch-to-next", switchToPrevious: "timeline:switch-to-previous", refetch: "timeline:refetch", + enter: "timeline:enter", }, entryRender: { scrollDown: "entry-render:scroll-down", diff --git a/apps/desktop/layer/renderer/src/modules/command/commands/layout.tsx b/apps/desktop/layer/renderer/src/modules/command/commands/layout.tsx index 309d3811f..00e3e6a64 100644 --- a/apps/desktop/layer/renderer/src/modules/command/commands/layout.tsx +++ b/apps/desktop/layer/renderer/src/modules/command/commands/layout.tsx @@ -1,9 +1,17 @@ +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 + } +} + export const useRegisterLayoutCommands = () => { useRegisterCommandEffect([ { @@ -13,6 +21,13 @@ export const useRegisterLayoutCommands = () => { setTimelineColumnShow((show) => !show) }, }, + { + id: COMMAND_ID.layout.focusToTimeline, + label: "Focus to timeline", + run: () => { + EventBus.dispatch(COMMAND_ID.layout.focusToTimeline) + }, + }, ]) } @@ -21,4 +36,9 @@ export type ToggleTimelineColumnCommand = Command<{ fn: () => void }> -export type LayoutCommand = ToggleTimelineColumnCommand +export type FocusToTimelineCommand = Command<{ + id: typeof COMMAND_ID.layout.focusToTimeline + fn: () => void +}> + +export type LayoutCommand = ToggleTimelineColumnCommand | FocusToTimelineCommand diff --git a/apps/desktop/layer/renderer/src/modules/command/commands/timeline.tsx b/apps/desktop/layer/renderer/src/modules/command/commands/timeline.tsx index f9e883cd3..3f6d9e809 100644 --- a/apps/desktop/layer/renderer/src/modules/command/commands/timeline.tsx +++ b/apps/desktop/layer/renderer/src/modules/command/commands/timeline.tsx @@ -9,6 +9,7 @@ declare module "@follow/utils/event-bus" { "timeline:switch-to-next": never "timeline:switch-to-previous": never "timeline:refetch": never + "timeline:enter": never } } export const useRegisterTimelineCommand = () => { @@ -35,6 +36,13 @@ export const useRegisterTimelineCommand = () => { EventBus.dispatch("timeline:refetch") }, }, + { + id: COMMAND_ID.timeline.enter, + label: "Enter Selected Entry", + run: () => { + EventBus.dispatch("timeline:enter") + }, + }, ]) } @@ -53,7 +61,13 @@ export type RefetchTimelineCommand = Command<{ fn: () => void }> +export type EnterTimelineCommand = Command<{ + id: typeof COMMAND_ID.timeline.enter + fn: () => void +}> + export type TimelineCommand = | SwitchToNextTimelineCommand | SwitchToPreviousTimelineCommand | RefetchTimelineCommand + | EnterTimelineCommand diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/EntryColumnShortcutHandler.tsx b/apps/desktop/layer/renderer/src/modules/entry-column/EntryColumnShortcutHandler.tsx index 2d0119c89..f8f710790 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-column/EntryColumnShortcutHandler.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-column/EntryColumnShortcutHandler.tsx @@ -1,9 +1,11 @@ +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, useEffect, useLayoutEffect, useState } from "react" +import { memo, useEffect } from "react" -import { useMainContainerElement } from "~/atoms/dom" import { HotkeyScope } from "~/constants" import { useNavigateEntry } from "~/hooks/biz/useNavigateEntry" import { useRouteEntryId } from "~/hooks/biz/useRouteParams" @@ -11,7 +13,7 @@ import { useConditionalHotkeyScope } from "~/hooks/common" import { useHotkeyScope } from "~/providers/hotkey-provider" import { COMMAND_ID } from "../command/commands/id" -import { useCommandBinding } from "../command/hooks/use-register-hotkey" +import { useCommandBinding, useCommandHotkey } from "../command/hooks/use-register-hotkey" export const EntryColumnShortcutHandler: FC<{ refetch: () => void @@ -40,6 +42,12 @@ export const EntryColumnShortcutHandler: FC<{ when, }) + useCommandHotkey({ + commandId: COMMAND_ID.timeline.enter, + shortcut: "Enter", + when, + }) + const currentEntryIdRef = useRefValue(useRouteEntryId()) const navigate = useNavigateEntry() @@ -82,32 +90,18 @@ export const EntryColumnShortcutHandler: FC<{ }) }, [refetch]) - const $mainContainer = useMainContainerElement() - const [isFocusIn, setIsFocusIn] = useState(false) + 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) - // 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 - - setIsFocusIn(isFocusIn) - } - - handler() - // NOTE: focusin event will bubble to the document - document.addEventListener("focusin", handler) - document.addEventListener("focusout", handler) - return () => { - document.removeEventListener("focusin", handler) - document.removeEventListener("focusout", handler) - } - }, [$mainContainer]) - return null }) diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/index.tsx b/apps/desktop/layer/renderer/src/modules/entry-column/index.tsx index c3d03232e..744a0acfb 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-column/index.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-column/index.tsx @@ -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 ( -
)} -
+ ) } diff --git a/apps/desktop/layer/renderer/src/modules/entry-content/index.electron.tsx b/apps/desktop/layer/renderer/src/modules/entry-content/index.electron.tsx index 2e6a0f1db..392258aa5 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-content/index.electron.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-content/index.electron.tsx @@ -1,10 +1,14 @@ -import { Focusable, useFocusable } from "@follow/components/common/Focusable.js" +import { + Focusable, + useFocusable, + useFocusActions, +} from "@follow/components/common/Focusable/index.js" import { MemoedDangerousHTMLStyle } from "@follow/components/common/MemoedDangerousHTMLStyle.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 { 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" @@ -35,7 +39,7 @@ import { useFeedById } from "~/store/feed" import { useInboxById } from "~/store/inbox" import { COMMAND_ID } from "../command/commands/id" -import { useCommandBinding } from "../command/hooks/use-register-hotkey" +import { useCommandBinding, useCommandHotkey } from "../command/hooks/use-register-hotkey" import { EntryContentHTMLRenderer } from "../renderer/html" import { AISummary } from "./AISummary" import { EntryTimelineSidebar } from "./components/EntryTimelineSidebar" @@ -136,9 +140,9 @@ export const EntryContent: Component = ({ onFocus={() => setIsUserInteraction(true)} > @@ -299,10 +303,11 @@ const Renderer: React.FC<{ const RegisterCommands = ({ scrollerRef, isUserInteraction, + setIsUserInteraction, }: { - entryId: string scrollerRef: React.RefObject isUserInteraction: boolean + setIsUserInteraction: (isUserInteraction: boolean) => void }) => { const containerFocused = useFocusable() useConditionalHotkeyScope(HotkeyScope.EntryRender, isUserInteraction && containerFocused, true) @@ -330,26 +335,43 @@ const RegisterCommands = ({ when, }) - useEffect(() => { - return EventBus.subscribe(COMMAND_ID.entryRender.scrollUp, () => { - const currentScroll = scrollerRef.current?.scrollTop - const delta = window.innerHeight + useCommandHotkey({ + commandId: COMMAND_ID.layout.focusToTimeline, + when, + shortcut: "Backspace", + }) - if (typeof currentScroll === "number" && delta) { - springScrollTo(currentScroll - delta, scrollerRef.current!) - } - }) - }, [scrollerRef]) + const { highlightBoundary } = useFocusActions() useEffect(() => { - return 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!) - } - }) - }, [scrollerRef]) + 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 } diff --git a/apps/desktop/layer/renderer/src/modules/player/corner-player.tsx b/apps/desktop/layer/renderer/src/modules/player/corner-player.tsx index fb578f721..5d0c9d3b9 100644 --- a/apps/desktop/layer/renderer/src/modules/player/corner-player.tsx +++ b/apps/desktop/layer/renderer/src/modules/player/corner-player.tsx @@ -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" diff --git a/packages/internal/components/src/common/Focusable.tsx b/packages/internal/components/src/common/Focusable/Focusable.tsx similarity index 50% rename from packages/internal/components/src/common/Focusable.tsx rename to packages/internal/components/src/common/Focusable/Focusable.tsx index 50fbca1b6..e04ba6bd5 100644 --- a/packages/internal/components/src/common/Focusable.tsx +++ b/packages/internal/components/src/common/Focusable/Focusable.tsx @@ -1,9 +1,9 @@ -import { createContext, use, useImperativeHandle, useRef, useState } from "react" +import { useCallback, useImperativeHandle, useMemo, useRef, useState } from "react" import { useEventListener } from "usehooks-ts" -// const -const FocusableContext = createContext(false) -const FocusTargetRefContext = createContext>(null!) +import { FocusableContext, FocusActionsContext, FocusTargetRefContext } from "./context" +import { highlightElement } from "./utils" + export const Focusable: Component< React.DetailedHTMLProps, HTMLDivElement> > = ({ ref, ...props }) => { @@ -13,33 +13,35 @@ export const Focusable: Component< const containerRef = useRef(null) useImperativeHandle(ref, () => containerRef.current!) + + const highlightBoundary = useCallback(() => { + if (!isFocusWithIn) return + const element = containerRef.current + if (!element) return + + highlightElement(element) + }, [isFocusWithIn]) + useEventListener("focusin", (e) => { if (containerRef.current?.contains(e.target as Node)) { setIsFocusWithIn(true) + focusTargetRef.current = e.target as HTMLElement + if (import.meta.env.DEV) { + highlightElement(containerRef.current!) + } } else { setIsFocusWithIn(false) + focusTargetRef.current = undefined } }) - // useEventListener("focusout", (e) => { - // if (!containerRef.current?.contains(e.target as Node)) { - // setIsFocusWithIn(false) - // } - // }) - return ( -
+ ({ highlightBoundary }), [highlightBoundary])}> +
+ ) } - -export const useFocusable = () => { - return use(FocusableContext) -} - -export const useFocusTargetRef = () => { - return use(FocusTargetRefContext) -} diff --git a/packages/internal/components/src/common/Focusable/context.ts b/packages/internal/components/src/common/Focusable/context.ts new file mode 100644 index 000000000..c50aa988d --- /dev/null +++ b/packages/internal/components/src/common/Focusable/context.ts @@ -0,0 +1,7 @@ +import { createContext } from "react" + +export const FocusableContext = createContext(false) +export const FocusTargetRefContext = createContext>(null!) +export const FocusActionsContext = createContext<{ + highlightBoundary: () => void +}>(null!) diff --git a/packages/internal/components/src/common/Focusable/hooks.ts b/packages/internal/components/src/common/Focusable/hooks.ts new file mode 100644 index 000000000..4a349a1e5 --- /dev/null +++ b/packages/internal/components/src/common/Focusable/hooks.ts @@ -0,0 +1,15 @@ +import { use } from "react" + +import { FocusableContext, FocusActionsContext, FocusTargetRefContext } from "./context" + +export const useFocusable = () => { + return use(FocusableContext) +} + +export const useFocusTargetRef = () => { + return use(FocusTargetRefContext) +} + +export const useFocusActions = () => { + return use(FocusActionsContext) +} diff --git a/packages/internal/components/src/common/Focusable/index.ts b/packages/internal/components/src/common/Focusable/index.ts new file mode 100644 index 000000000..b96ce5b87 --- /dev/null +++ b/packages/internal/components/src/common/Focusable/index.ts @@ -0,0 +1,2 @@ +export * from "./Focusable" +export * from "./hooks" diff --git a/packages/internal/components/src/common/Focusable/utils.ts b/packages/internal/components/src/common/Focusable/utils.ts new file mode 100644 index 000000000..fd95891f4 --- /dev/null +++ b/packages/internal/components/src/common/Focusable/utils.ts @@ -0,0 +1,174 @@ +// Grok AI +// Interface for keyframe properties +interface HighlightKeyframe { + shadowWidth: number + shadowOpacity: number + outlineWidth: number + outlineOpacity: number +} + +// Cubic-bezier easing function (0.4, 0, 0.2, 1) +function cubicBezier(t: number, p1x: number, p1y: number, p2x: number, p2y: number): number { + const cx: number = 3 * p1x + const bx: number = 3 * (p2x - p1x) - cx + const ax: number = 1 - cx - bx + const cy: number = 3 * p1y + const by: number = 3 * (p2y - p1y) - cy + const ay: number = 1 - cy - by + + function sampleCurveX(t: number): number { + return ((ax * t + bx) * t + cx) * t + } + function sampleCurveY(t: number): number { + return ((ay * t + by) * t + cy) * t + } + function solveCurveX(x: number): number { + let t2: number = x + for (let i = 0; i < 8; i++) { + const x2: number = sampleCurveX(t2) - x + if (Math.abs(x2) < 1e-6) return t2 + const d2: number = (3 * ax * t2 + 2 * bx) * t2 + cx + if (Math.abs(d2) < 1e-6) break + t2 -= x2 / d2 + } + return t2 + } + return sampleCurveY(solveCurveX(t)) +} + +export function highlightElement(element: HTMLElement | null): void { + if (!element) return + + // Get element's bounding rectangle + const rect: DOMRect = element.getBoundingClientRect() + const padding = 10 // Extra space for shadow effect + + // Create canvas + const canvas: HTMLCanvasElement = document.createElement("canvas") + canvas.style.position = "absolute" + canvas.style.top = `${rect.top + window.scrollY - padding}px` + canvas.style.left = `${rect.left + window.scrollX - padding}px` + canvas.width = rect.width + padding * 2 + canvas.height = rect.height + padding * 2 + + canvas.style.pointerEvents = "none" + document.body.append(canvas) + + const ctx: CanvasRenderingContext2D = canvas.getContext("2d")! + if (!ctx) { + canvas.remove() + return + } + + const duration = 1000 // 1000ms + const startTime: number = performance.now() + const borderRadius = 8 // Border radius for rounded corners + + // Keyframes (mimicking CSS) + const keyframes: HighlightKeyframe[] = [ + { + shadowWidth: 4, + shadowOpacity: 0.8, + outlineWidth: 1, + outlineOpacity: 0.5, + }, + { + shadowWidth: 2, + shadowOpacity: 0.4, + outlineWidth: 1, + outlineOpacity: 0.3, + }, + { + shadowWidth: 0, + shadowOpacity: 0, + outlineWidth: 0, + outlineOpacity: 0, + }, + ] + + function interpolate(start: number, end: number, factor: number): number { + return start + (end - start) * factor + } + + function animate(): void { + const elapsed: number = performance.now() - startTime + let t: number = elapsed / duration + if (t > 1) t = 1 + + // Apply cubic-bezier easing + t = cubicBezier(t, 0.4, 0, 0.2, 1) + + // Determine which keyframe segment we're in + const segmentDuration: number = 1 / (keyframes.length - 1) + const segmentIndex: number = Math.floor(t / segmentDuration) + const segmentT: number = (t - segmentIndex * segmentDuration) / segmentDuration + + if (segmentIndex >= keyframes.length - 1) { + // Animation complete, remove canvas + canvas.remove() + return + } + + const startFrame: HighlightKeyframe = keyframes[segmentIndex]! + const endFrame: HighlightKeyframe = keyframes[segmentIndex + 1]! + + // Interpolate values + const shadowWidth: number = interpolate(startFrame.shadowWidth, endFrame.shadowWidth, segmentT) + const shadowOpacity: number = interpolate( + startFrame.shadowOpacity, + endFrame.shadowOpacity, + segmentT, + ) + const outlineWidth: number = interpolate( + startFrame.outlineWidth, + endFrame.outlineWidth, + segmentT, + ) + const outlineOpacity: number = interpolate( + startFrame.outlineOpacity, + endFrame.outlineOpacity, + segmentT, + ) + + // Clear canvas + ctx.clearRect(0, 0, canvas.width, canvas.height) + + // Draw shadow (approximated as thick border with rounded corners) + if (shadowWidth > 0) { + ctx.strokeStyle = `rgba(255, 165, 0, ${shadowOpacity})` // Orange color + ctx.lineWidth = shadowWidth + ctx.beginPath() + ctx.roundRect( + padding - shadowWidth / 2, + padding - shadowWidth / 2, + rect.width + shadowWidth, + rect.height + shadowWidth, + borderRadius, + ) + ctx.stroke() + } + + // Draw outline (with rounded corners) + if (outlineWidth > 0) { + ctx.strokeStyle = `rgba(255, 165, 0, ${outlineOpacity})` + ctx.lineWidth = outlineWidth + ctx.beginPath() + ctx.roundRect( + padding - outlineWidth / 2, + padding - outlineWidth / 2, + rect.width + outlineWidth, + rect.height + outlineWidth, + borderRadius, + ) + ctx.stroke() + } + + if (t < 1) { + requestAnimationFrame(animate) + } else { + canvas.remove() + } + } + + requestAnimationFrame(animate) +} diff --git a/packages/internal/components/src/ui/button/action-button.tsx b/packages/internal/components/src/ui/button/action-button.tsx index 853f21a32..7ae2f4faf 100644 --- a/packages/internal/components/src/ui/button/action-button.tsx +++ b/packages/internal/components/src/ui/button/action-button.tsx @@ -1,4 +1,4 @@ -import { useFocusable } from "@follow/components/common/Focusable.jsx" +import { useFocusable } from "@follow/components/common/Focusable/index.js" import { stopPropagation } from "@follow/utils/dom" import { cn, getOS } from "@follow/utils/utils" import * as React from "react" From 42150751f2babfad66e7ba6f50a046e0a9eef687 Mon Sep 17 00:00:00 2001 From: Innei Date: Wed, 14 May 2025 00:22:06 +0800 Subject: [PATCH 046/104] feat: add navigation buttons for entry content with new icons - Implemented left and right navigation buttons in the entry content component to switch between entries. - Added SVG icons for left and right navigation. - Enhanced user interaction with hover effects for better visibility. This update improves the navigation experience within the entry content, aligning with recent command enhancements. Signed-off-by: Innei --- .../modules/entry-content/index.electron.tsx | 30 ++++++++++++++++++- icons/mgc/left_small_sharp.svg | 1 + icons/mgc/right_small_sharp.svg | 1 + 3 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 icons/mgc/left_small_sharp.svg create mode 100644 icons/mgc/right_small_sharp.svg diff --git a/apps/desktop/layer/renderer/src/modules/entry-content/index.electron.tsx b/apps/desktop/layer/renderer/src/modules/entry-content/index.electron.tsx index 392258aa5..3b5169879 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-content/index.electron.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-content/index.electron.tsx @@ -4,6 +4,7 @@ import { 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" @@ -21,7 +22,7 @@ import { useEntryIsInReadabilitySuccess, useEntryReadabilityContent, } from "~/atoms/readability" -import { useUISettingKey } from "~/atoms/settings/ui" +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" @@ -112,6 +113,7 @@ export const EntryContent: Component = ({ const isInPeekModal = useInPeekModal() const [isUserInteraction, setIsUserInteraction] = useState(false) + const isZenMode = useIsZenMode() if (!entry) return null const entryContent = isInReadabilityMode @@ -150,6 +152,32 @@ export const EntryContent: Component = ({ 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 && ( + <> +
+ { + EventBus.dispatch(COMMAND_ID.timeline.switchToPrevious) + }} + > + + +
+ +
+ { + EventBus.dispatch(COMMAND_ID.timeline.switchToNext) + }} + > + + +
+ + )} +
setIsUserInteraction(true)} diff --git a/icons/mgc/left_small_sharp.svg b/icons/mgc/left_small_sharp.svg new file mode 100644 index 000000000..e013d1608 --- /dev/null +++ b/icons/mgc/left_small_sharp.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/icons/mgc/right_small_sharp.svg b/icons/mgc/right_small_sharp.svg new file mode 100644 index 000000000..f2b635f36 --- /dev/null +++ b/icons/mgc/right_small_sharp.svg @@ -0,0 +1 @@ + \ No newline at end of file From 20049b01501f52f56fa8757095372019aa97d7c8 Mon Sep 17 00:00:00 2001 From: Innei Date: Wed, 14 May 2025 00:46:42 +0800 Subject: [PATCH 047/104] fix: update language display logic in Shiki code highlighter - Modified the language display condition to show an empty div for plaintext instead of rendering the language name and icon. - This change improves the clarity of the code highlighter by avoiding unnecessary elements for plaintext. Signed-off-by: Innei --- .../src/components/ui/code-highlighter/shiki/Shiki.tsx | 4 +++- .../renderer/src/modules/entry-content/index.electron.tsx | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/desktop/layer/renderer/src/components/ui/code-highlighter/shiki/Shiki.tsx b/apps/desktop/layer/renderer/src/components/ui/code-highlighter/shiki/Shiki.tsx index a78c1031c..3e2ea51fc 100644 --- a/apps/desktop/layer/renderer/src/components/ui/code-highlighter/shiki/Shiki.tsx +++ b/apps/desktop/layer/renderer/src/components/ui/code-highlighter/shiki/Shiki.tsx @@ -193,7 +193,9 @@ const ShikiCode: FC< )} >
- {language !== "plaintext" && ( + {language === "plaintext" ? ( +
+ ) : ( {getLanguageIcon(language)} {language} diff --git a/apps/desktop/layer/renderer/src/modules/entry-content/index.electron.tsx b/apps/desktop/layer/renderer/src/modules/entry-content/index.electron.tsx index 3b5169879..7e7f986ac 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-content/index.electron.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-content/index.electron.tsx @@ -99,7 +99,7 @@ export const EntryContent: Component = ({ EventBus.subscribe(COMMAND_ID.timeline.switchToNext, scrollAndFocus), EventBus.subscribe(COMMAND_ID.timeline.switchToPrevious, scrollAndFocus), ) - }, []) + }, [entryId]) const safeUrl = useFeedSafeUrl(entryId) From 56d65f473fcf7ea99bbcd90d6bdec2474bacc74e Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Wed, 14 May 2025 10:18:41 +0800 Subject: [PATCH 048/104] fix(mobile): separator style, clean useless code (#3723) --- apps/mobile/src/modules/screen/atoms.ts | 34 ++----------------- .../modules/subscription/CategoryGrouped.tsx | 9 ++--- .../subscription/SubscriptionLists.tsx | 4 +-- .../modules/subscription/UnGroupedList.tsx | 6 ++-- .../subscription/items/SubscriptionItem.tsx | 4 +-- 5 files changed, 14 insertions(+), 43 deletions(-) diff --git a/apps/mobile/src/modules/screen/atoms.ts b/apps/mobile/src/modules/screen/atoms.ts index 1a5fa50c8..abfb2b401 100644 --- a/apps/mobile/src/modules/screen/atoms.ts +++ b/apps/mobile/src/modules/screen/atoms.ts @@ -1,9 +1,9 @@ import { FeedViewType } from "@follow/constants" import { jotaiStore } from "@follow/utils" import { EventBus } from "@follow/utils/src/event-bus" -import { atom, useAtom, useAtomValue, useSetAtom } from "jotai" +import { atom, useAtomValue } from "jotai" import { selectAtom } from "jotai/utils" -import { createContext, use, useCallback, useMemo, useState } from "react" +import { createContext, use, useMemo, useState } from "react" import { useTranslation } from "react-i18next" import { views } from "@/src/constants/views" @@ -13,36 +13,6 @@ import { FEED_COLLECTION_LIST } from "@/src/store/entry/utils" import { useFeed } from "@/src/store/feed/hooks" import { useInbox } from "@/src/store/inbox/hooks" import { useList } from "@/src/store/list/hooks" -// drawer open state - -const drawerOpenAtom = atom(false) - -export function useFeedDrawer() { - const [state, setState] = useAtom(drawerOpenAtom) - - return { - isDrawerOpen: state, - openDrawer: useCallback(() => setState(true), [setState]), - closeDrawer: useCallback(() => setState(false), [setState]), - toggleDrawer: useCallback(() => setState(!state), [setState, state]), - } -} - -export const closeDrawer = () => jotaiStore.set(drawerOpenAtom, false) - -// is drawer swipe disabled - -const isDrawerSwipeDisabledAtom = atom(true) - -export function useIsDrawerSwipeDisabled() { - return useAtomValue(isDrawerSwipeDisabledAtom) -} - -export function useSetDrawerSwipeDisabled() { - return useSetAtom(isDrawerSwipeDisabledAtom) -} - -// feed panel selected state export type SelectedTimeline = { type: "view" diff --git a/apps/mobile/src/modules/subscription/CategoryGrouped.tsx b/apps/mobile/src/modules/subscription/CategoryGrouped.tsx index f592b9d3f..83baf1684 100644 --- a/apps/mobile/src/modules/subscription/CategoryGrouped.tsx +++ b/apps/mobile/src/modules/subscription/CategoryGrouped.tsx @@ -9,7 +9,7 @@ import { ItemPressable } from "@/src/components/ui/pressable/ItemPressable" import { NativePressable } from "@/src/components/ui/pressable/NativePressable" import { RightCuteFiIcon } from "@/src/icons/right_cute_fi" import { useNavigation } from "@/src/lib/navigation/hooks" -import { closeDrawer, selectFeed } from "@/src/modules/screen/atoms" +import { selectFeed } from "@/src/modules/screen/atoms" import { FeedScreen } from "@/src/screens/(stack)/feeds/[feedId]/FeedScreen" import { useUnreadCounts } from "@/src/store/unread/hooks" import { useColor } from "@/src/theme/colors" @@ -55,7 +55,7 @@ export const CategoryGrouped = memo( type: "category", categoryName: category, }) - closeDrawer() + navigation.pushControllerView(FeedScreen, { feedId: category, }) @@ -83,10 +83,11 @@ export const CategoryGrouped = memo( - {!isLast && } + {/* FIXME: This separator is not visible when expanded and will add a unexpected space under grouped list */} + {!isLast && !expanded && } {expanded && ( - + )} diff --git a/apps/mobile/src/modules/subscription/SubscriptionLists.tsx b/apps/mobile/src/modules/subscription/SubscriptionLists.tsx index bda15d575..c6011472a 100644 --- a/apps/mobile/src/modules/subscription/SubscriptionLists.tsx +++ b/apps/mobile/src/modules/subscription/SubscriptionLists.tsx @@ -19,7 +19,7 @@ import { ItemPressableStyle } from "@/src/components/ui/pressable/enum" import { ItemPressable } from "@/src/components/ui/pressable/ItemPressable" import { StarCuteFiIcon } from "@/src/icons/star_cute_fi" import { useNavigation } from "@/src/lib/navigation/hooks" -import { closeDrawer, selectFeed } from "@/src/modules/screen/atoms" +import { selectFeed } from "@/src/modules/screen/atoms" import { TimelineSelectorList } from "@/src/modules/screen/TimelineSelectorList" import { FeedScreen } from "@/src/screens/(stack)/feeds/[feedId]/FeedScreen" import { FEED_COLLECTION_LIST } from "@/src/store/entry/utils" @@ -237,7 +237,7 @@ const StarItem = () => { itemStyle={ItemPressableStyle.Grouped} onPress={() => { selectFeed({ type: "feed", feedId: FEED_COLLECTION_LIST }) - closeDrawer() + navigation.pushControllerView(FeedScreen, { feedId: FEED_COLLECTION_LIST, }) diff --git a/apps/mobile/src/modules/subscription/UnGroupedList.tsx b/apps/mobile/src/modules/subscription/UnGroupedList.tsx index c462f4935..0e70aabdd 100644 --- a/apps/mobile/src/modules/subscription/UnGroupedList.tsx +++ b/apps/mobile/src/modules/subscription/UnGroupedList.tsx @@ -7,8 +7,8 @@ import { SubscriptionItem } from "./items/SubscriptionItem" export const UnGroupedList: FC<{ subscriptionIds: string[] - isGroupLast?: boolean -}> = ({ subscriptionIds, isGroupLast }) => { + isLastGroup?: boolean +}> = ({ subscriptionIds, isLastGroup }) => { const sortBy = useFeedListSortMethod() const sortOrder = useFeedListSortOrder() const sortedSubscriptionIds = useSortedUngroupedSubscription(subscriptionIds, sortBy, sortOrder) @@ -18,7 +18,7 @@ export const UnGroupedList: FC<{ key={id} id={id} isFirst={false} - isLast={!!isGroupLast && index === sortedSubscriptionIds.length - 1} + isLast={!!isLastGroup && index === sortedSubscriptionIds.length - 1} /> )) } diff --git a/apps/mobile/src/modules/subscription/items/SubscriptionItem.tsx b/apps/mobile/src/modules/subscription/items/SubscriptionItem.tsx index 2dc217c51..9c053bdf6 100644 --- a/apps/mobile/src/modules/subscription/items/SubscriptionItem.tsx +++ b/apps/mobile/src/modules/subscription/items/SubscriptionItem.tsx @@ -12,7 +12,7 @@ import { ItemPressableStyle } from "@/src/components/ui/pressable/enum" import { ItemPressable } from "@/src/components/ui/pressable/ItemPressable" import { WifiOffCuteReIcon } from "@/src/icons/wifi_off_cute_re" import { useNavigation } from "@/src/lib/navigation/hooks" -import { closeDrawer, selectFeed } from "@/src/modules/screen/atoms" +import { selectFeed } from "@/src/modules/screen/atoms" import { FeedScreen } from "@/src/screens/(stack)/feeds/[feedId]/FeedScreen" import { useFeed, usePrefetchFeed } from "@/src/store/feed/hooks" import { useSubscription } from "@/src/store/subscription/hooks" @@ -69,7 +69,7 @@ export const SubscriptionItem = memo( type: "feed", feedId: id, }) - closeDrawer() + navigation.pushControllerView(FeedScreen, { feedId: id, }) From e26c30d6a4886c1c9556188ebe7308e64bd767b7 Mon Sep 17 00:00:00 2001 From: Cleves Date: Wed, 14 May 2025 13:05:54 +0800 Subject: [PATCH 049/104] fix(desktop): update keyboard shortcut styling for undo action (#3720) --- .../layer/renderer/src/hooks/biz/useSubscriptionActions.tsx | 2 +- .../src/modules/entry-column/components/mark-all-button.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/desktop/layer/renderer/src/hooks/biz/useSubscriptionActions.tsx b/apps/desktop/layer/renderer/src/hooks/biz/useSubscriptionActions.tsx index f45e18fe3..473dc81f3 100644 --- a/apps/desktop/layer/renderer/src/hooks/biz/useSubscriptionActions.tsx +++ b/apps/desktop/layer/renderer/src/hooks/biz/useSubscriptionActions.tsx @@ -64,7 +64,7 @@ export const useDeleteSubscription = ({ onSuccess }: { onSuccess?: () => void } label: ( {t("words.undo")} - + Meta+Z diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/components/mark-all-button.tsx b/apps/desktop/layer/renderer/src/modules/entry-column/components/mark-all-button.tsx index 7ecc22664..4284f665a 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-column/components/mark-all-button.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-column/components/mark-all-button.tsx @@ -50,7 +50,7 @@ export const MarkAllReadButton = ({ label: ( {t("mark_all_read_button.undo")} - + Meta+Z From 4119b9aece4c48b69cec99df7f987a1c85ad5b4e Mon Sep 17 00:00:00 2001 From: lawvs <18554747+lawvs@users.noreply.github.com> Date: Wed, 14 May 2025 15:45:05 +0800 Subject: [PATCH 050/104] fix(mobile): add fallback media display for video entries --- .../entry-list/templates/EntryVideoItem.tsx | 35 +++++++++++++------ 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/apps/mobile/src/modules/entry-list/templates/EntryVideoItem.tsx b/apps/mobile/src/modules/entry-list/templates/EntryVideoItem.tsx index c36826f0a..ecd524482 100644 --- a/apps/mobile/src/modules/entry-list/templates/EntryVideoItem.tsx +++ b/apps/mobile/src/modules/entry-list/templates/EntryVideoItem.tsx @@ -2,7 +2,7 @@ import { FeedViewType } from "@follow/constants" import { tracker } from "@follow/tracker" import { transformVideoUrl } from "@follow/utils" import { memo } from "react" -import { Linking, View } from "react-native" +import { Linking, Text, View } from "react-native" import { getGeneralSettings } from "@/src/atoms/settings/general" import { Image } from "@/src/components/ui/image/Image" @@ -19,10 +19,12 @@ import { EntryGridFooter } from "../../entry-content/EntryGridFooter" export const EntryVideoItem = memo(({ id }: { id: string }) => { const item = useEntry(id) - if (!item || !item.media) { + if (!item) { return null } + const imageUrl = item.media?.at(0)?.url + return ( @@ -42,14 +44,18 @@ export const EntryVideoItem = memo(({ id }: { id: string }) => { openVideo(item.url) }} > - + {imageUrl ? ( + + ) : ( + + )} @@ -59,6 +65,15 @@ export const EntryVideoItem = memo(({ id }: { id: string }) => { EntryVideoItem.displayName = "EntryVideoItem" +const FallbackMedia = () => ( + + No media available + +) + const parseSchemeLink = (url: string) => { let urlObject: URL try { From 72ed7df9a80ec2cb07285deeea734f2a6cbf2aa8 Mon Sep 17 00:00:00 2001 From: lawvs <18554747+lawvs@users.noreply.github.com> Date: Wed, 14 May 2025 18:21:47 +0800 Subject: [PATCH 051/104] fix(mobile): improve CJK character handling in FallbackIcon component --- apps/mobile/src/components/ui/icon/fallback-icon.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/components/ui/icon/fallback-icon.tsx b/apps/mobile/src/components/ui/icon/fallback-icon.tsx index 27ea4331a..c0b1e9be1 100644 --- a/apps/mobile/src/components/ui/icon/fallback-icon.tsx +++ b/apps/mobile/src/components/ui/icon/fallback-icon.tsx @@ -32,7 +32,8 @@ export const FallbackIcon = ({ const [, , , bgAccent, bgAccentLight, bgAccentUltraLight] = colors const renderedText = useMemo(() => { - const isCJK = isCJKChar(title[0]!) + const firstChar = title.at(0) + const isCJK = firstChar ? isCJKChar(firstChar) : false return ( {isCJK ? title[0] : title.slice(0, 2)} From 9b0691aacf501698bad3d42a8bd35deb1a3f7613 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Wed, 14 May 2025 18:38:07 +0800 Subject: [PATCH 052/104] fix(desktop): follow state for url --- .../layer/renderer/src/hooks/biz/useFollow.tsx | 12 +++++++----- apps/desktop/layer/renderer/src/store/feed/store.ts | 4 ++++ .../layer/renderer/src/store/subscription/getters.ts | 7 +++++++ 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/apps/desktop/layer/renderer/src/hooks/biz/useFollow.tsx b/apps/desktop/layer/renderer/src/hooks/biz/useFollow.tsx index cbb0df1e7..23e703020 100644 --- a/apps/desktop/layer/renderer/src/hooks/biz/useFollow.tsx +++ b/apps/desktop/layer/renderer/src/hooks/biz/useFollow.tsx @@ -13,7 +13,7 @@ import { FeedForm } from "~/modules/discover/FeedForm" import type { ListFormDataValuesType } from "~/modules/discover/ListForm" import { ListForm } from "~/modules/discover/ListForm" import { - getSubscriptionByFeedId, + getSubscriptionByFeedIdOrUrl, useFeedSubscriptionCount, useListSubscriptionCount, } from "~/store/subscription" @@ -73,10 +73,12 @@ export const useFollow = () => { } else { canFollowMoreInboxAndNotify("feed") } - let isFollowed = false - if (options?.id) { - isFollowed = !!getSubscriptionByFeedId(options.id) - } + + const subscription = getSubscriptionByFeedIdOrUrl({ + id: options?.id, + url: options?.url, + }) + const isFollowed = !!subscription present({ title: `${isFollowed ? `${t("common:words.edit")} ` : ""}${options?.isList ? t("words.lists") : t("words.feeds")}`, diff --git a/apps/desktop/layer/renderer/src/store/feed/store.ts b/apps/desktop/layer/renderer/src/store/feed/store.ts index c96f785a4..fa2066e5d 100644 --- a/apps/desktop/layer/renderer/src/store/feed/store.ts +++ b/apps/desktop/layer/renderer/src/store/feed/store.ts @@ -15,6 +15,7 @@ import { FeedService } from "~/services" import { getSubscriptionByFeedId } from "../subscription" import { userActions } from "../user" import { createImmerSetter, createTransaction, createZustandStore } from "../utils/helper" +import { feedByIdOrUrlSelector } from "./selector" import type { FeedQueryParams, FeedState } from "./types" export const useFeedStore = createZustandStore("feed")(() => ({ @@ -142,6 +143,9 @@ export const feedActions = new FeedActions() export const getFeedById = (feedId: string): Nullable => useFeedStore.getState().feeds[feedId] +export const getFeedByIdOrUrl = (feed: FeedQueryParams): Nullable => { + return feedByIdOrUrlSelector(feed)(get()) +} export const getPreferredTitle = ( feed?: Pick | null, diff --git a/apps/desktop/layer/renderer/src/store/subscription/getters.ts b/apps/desktop/layer/renderer/src/store/subscription/getters.ts index 5b31d43f5..d3d4a914e 100644 --- a/apps/desktop/layer/renderer/src/store/subscription/getters.ts +++ b/apps/desktop/layer/renderer/src/store/subscription/getters.ts @@ -1,5 +1,7 @@ import type { FeedViewType } from "@follow/constants" +import type { FeedQueryParams } from "../feed" +import { getFeedByIdOrUrl } from "../feed" import { folderFeedsByFeedIdSelector, subscriptionByViewSelector, @@ -12,6 +14,11 @@ export const getSubscriptionByFeedId = (feedId: FeedId) => { const state = get() return state.data[feedId] } +export const getSubscriptionByFeedIdOrUrl = (params: FeedQueryParams) => { + const feed = getFeedByIdOrUrl(params) + if (!feed) return + return getSubscriptionByFeedId(feed.id) +} export const isListSubscription = (feedId?: FeedId) => { if (!feedId) return false From bf26a3eabc96796c1f88fbbc4fd98a72dd29459c Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Wed, 14 May 2025 19:21:16 +0800 Subject: [PATCH 053/104] fix(desktop): fix default view in feed form --- .../layer/renderer/src/modules/discover/FeedForm.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/desktop/layer/renderer/src/modules/discover/FeedForm.tsx b/apps/desktop/layer/renderer/src/modules/discover/FeedForm.tsx index d990247c6..5ff736472 100644 --- a/apps/desktop/layer/renderer/src/modules/discover/FeedForm.tsx +++ b/apps/desktop/layer/renderer/src/modules/discover/FeedForm.tsx @@ -209,7 +209,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]) From 701ee30cf0db5a5be8b9a7eaec648d0f1e3d588b Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Wed, 14 May 2025 20:16:54 +0800 Subject: [PATCH 054/104] fix(desktop): check feed follow state correctly for slash redirect --- apps/desktop/layer/renderer/package.json | 1 + .../renderer/src/hooks/biz/useFollow.tsx | 20 ++++++++++++------- .../src/store/subscription/getters.ts | 6 +++--- pnpm-lock.yaml | 3 +++ 4 files changed, 20 insertions(+), 10 deletions(-) diff --git a/apps/desktop/layer/renderer/package.json b/apps/desktop/layer/renderer/package.json index 366ea43a8..fc4f70b11 100644 --- a/apps/desktop/layer/renderer/package.json +++ b/apps/desktop/layer/renderer/package.json @@ -90,6 +90,7 @@ "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", diff --git a/apps/desktop/layer/renderer/src/hooks/biz/useFollow.tsx b/apps/desktop/layer/renderer/src/hooks/biz/useFollow.tsx index 23e703020..1226a753c 100644 --- a/apps/desktop/layer/renderer/src/hooks/biz/useFollow.tsx +++ b/apps/desktop/layer/renderer/src/hooks/biz/useFollow.tsx @@ -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,8 +13,9 @@ 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 { - getSubscriptionByFeedIdOrUrl, + getSubscriptionByFeedId, useFeedSubscriptionCount, useListSubscriptionCount, } from "~/store/subscription" @@ -74,10 +76,14 @@ export const useFollow = () => { canFollowMoreInboxAndNotify("feed") } - const subscription = getSubscriptionByFeedIdOrUrl({ - id: options?.id, - url: options?.url, - }) + // 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({ @@ -96,8 +102,8 @@ export const useFollow = () => { /> ) : ( diff --git a/apps/desktop/layer/renderer/src/store/subscription/getters.ts b/apps/desktop/layer/renderer/src/store/subscription/getters.ts index d3d4a914e..cd3258cc8 100644 --- a/apps/desktop/layer/renderer/src/store/subscription/getters.ts +++ b/apps/desktop/layer/renderer/src/store/subscription/getters.ts @@ -10,9 +10,9 @@ import { import { useSubscriptionStore } from "./store" const get = useSubscriptionStore.getState -export const getSubscriptionByFeedId = (feedId: FeedId) => { - const state = get() - return state.data[feedId] +export const getSubscriptionByFeedId = (feedId?: FeedId) => { + if (!feedId) return + return get().data[feedId] } export const getSubscriptionByFeedIdOrUrl = (params: FeedQueryParams) => { const feed = getFeedByIdOrUrl(params) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 77a8001dc..96a1c7f38 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -683,6 +683,9 @@ importers: tldts: specifier: 7.0.4 version: 7.0.4 + ufo: + specifier: 1.6.1 + version: 1.6.1 use-context-selector: specifier: 2.0.0 version: 2.0.0(react@19.0.0)(scheduler@0.25.0) From e18f052112e6ff7c9aa14001dc6888ca430c69be Mon Sep 17 00:00:00 2001 From: Innei Date: Wed, 14 May 2025 20:24:12 +0800 Subject: [PATCH 055/104] fix(desktop): update icon styling in LoginModalContent for better dark mode support Signed-off-by: Innei --- .../layer/renderer/src/modules/auth/LoginModalContent.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/apps/desktop/layer/renderer/src/modules/auth/LoginModalContent.tsx b/apps/desktop/layer/renderer/src/modules/auth/LoginModalContent.tsx index aeb8e8003..44f1b81cf 100644 --- a/apps/desktop/layer/renderer/src/modules/auth/LoginModalContent.tsx +++ b/apps/desktop/layer/renderer/src/modules/auth/LoginModalContent.tsx @@ -79,10 +79,7 @@ export const LoginModalContent = (props: LoginModalContentProps) => { className="center hover:bg-material-medium relative w-full gap-2 rounded-xl border p-2.5 pl-5 font-semibold duration-200" > {t("login.continueWith", { provider: provider.name })} From 45f53f2055097464235662a9f84f7168b86ef70e Mon Sep 17 00:00:00 2001 From: Innei Date: Wed, 14 May 2025 20:32:16 +0800 Subject: [PATCH 056/104] fix(desktop): update keyboard shortcuts and CSS for improved functionality - Added additional key options for scrolling actions and next entry command. - Updated the command hotkey for focusing on the timeline to include Escape. - Modified CSS to prevent overflow on the HTML element. This enhances user experience by providing more flexible keyboard shortcuts and improving layout handling. Signed-off-by: Innei --- apps/desktop/layer/renderer/src/constants/shortcuts.ts | 4 ++-- .../renderer/src/modules/entry-content/index.electron.tsx | 2 +- apps/desktop/layer/renderer/src/styles/base.css | 4 ++++ 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/desktop/layer/renderer/src/constants/shortcuts.ts b/apps/desktop/layer/renderer/src/constants/shortcuts.ts index 7ec5793a7..1986460b9 100644 --- a/apps/desktop/layer/renderer/src/constants/shortcuts.ts +++ b/apps/desktop/layer/renderer/src/constants/shortcuts.ts @@ -102,11 +102,11 @@ const shortcutConfigs = { }, scrollUp: { name: tShortcuts("keys.entry.scrollUp"), - key: "K", + key: "K, ArrowUp", }, scrollDown: { name: tShortcuts("keys.entry.scrollDown"), - key: "J", + key: "J, ArrowDown", }, nextEntry: { name: tShortcuts("keys.entries.next"), diff --git a/apps/desktop/layer/renderer/src/modules/entry-content/index.electron.tsx b/apps/desktop/layer/renderer/src/modules/entry-content/index.electron.tsx index 7e7f986ac..6be78a6a7 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-content/index.electron.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-content/index.electron.tsx @@ -366,7 +366,7 @@ const RegisterCommands = ({ useCommandHotkey({ commandId: COMMAND_ID.layout.focusToTimeline, when, - shortcut: "Backspace", + shortcut: "Backspace, Escape", }) const { highlightBoundary } = useFocusActions() diff --git a/apps/desktop/layer/renderer/src/styles/base.css b/apps/desktop/layer/renderer/src/styles/base.css index 90aa0f495..723c9c82a 100644 --- a/apps/desktop/layer/renderer/src/styles/base.css +++ b/apps/desktop/layer/renderer/src/styles/base.css @@ -2,6 +2,10 @@ html, #shadow-html { --fo-font-family: "SN Pro", system-ui, sans-serif; } + +html { + overflow: hidden; +} :root { --sat: env(safe-area-inset-top); --sar: env(safe-area-inset-right); From 0605d2dddd32bb3fea46e11c46b976970a888eaa Mon Sep 17 00:00:00 2001 From: Innei Date: Wed, 14 May 2025 20:55:08 +0800 Subject: [PATCH 057/104] fix(desktop): enhance video handling in markdown parser and improve changelog loading - Added support for multiple video formats in the markdown parser. - Implemented dynamic loading of the changelog using Suspense for better performance. This improves the user experience by allowing for more versatile video content and optimizing changelog rendering. Signed-off-by: Innei --- .../layer/renderer/src/lib/parse-markdown.ts | 36 +++++++++++++++++-- .../src/modules/upgrade/container.tsx | 19 +++++++--- 2 files changed, 48 insertions(+), 7 deletions(-) diff --git a/apps/desktop/layer/renderer/src/lib/parse-markdown.ts b/apps/desktop/layer/renderer/src/lib/parse-markdown.ts index a6f2f9b5f..2450b96ca 100644 --- a/apps/desktop/layer/renderer/src/lib/parse-markdown.ts +++ b/apps/desktop/layer/renderer/src/lib/parse-markdown.ts @@ -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) => { + const videoExts = ["mp4", "webm"] return parseMarkdownImpl(content, { ...options, components: { @@ -13,9 +14,40 @@ export const parseMarkdown = (content: string, options?: Partial) 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, }) diff --git a/apps/desktop/layer/renderer/src/modules/upgrade/container.tsx b/apps/desktop/layer/renderer/src/modules/upgrade/container.tsx index 852c3ae92..32eca5824 100644 --- a/apps/desktop/layer/renderer/src/modules/upgrade/container.tsx +++ b/apps/desktop/layer/renderer/src/modules/upgrade/container.tsx @@ -4,7 +4,7 @@ import { nextFrame } from "@follow/utils/dom" import { getStorageNS } from "@follow/utils/ns" import { repository } from "@pkg" import type { FC } from "react" -import { useEffect, useRef } from "react" +import { Suspense, use, useEffect, useRef } from "react" import { toast } from "sonner" import { useServerConfigs } from "~/atoms/server-configs" @@ -85,7 +85,11 @@ const AppNotificationContainer: FC = () => { CustomModalComponent: ({ children }) => { return {children} }, - content: Changelog, + content: () => ( + + + + ), overlay: true, }) }) @@ -98,16 +102,21 @@ const AppNotificationContainer: FC = () => { setTimeout(toaster) } - DEV && DebugRegistry.add("simulate_app_upgraded_toast", toaster) + DEV && DebugRegistry.add("App Upgraded Toast", toaster) }) return null } - export default AppNotificationContainer +const changelogContext = (async () => { + if (import.meta.env.DEV) { + return import("../../../../../changelog/next.md?raw").then((m) => m.default) + } + return CHANGELOG_CONTENT +})() const Changelog = () => ( - {CHANGELOG_CONTENT} + {use(changelogContext)} ) From 178978441d4fe29d9d83ecd1f25ad79e1129cd2f Mon Sep 17 00:00:00 2001 From: Innei Date: Wed, 14 May 2025 21:38:14 +0800 Subject: [PATCH 058/104] feat(desktop): enhance subscription feature and Vim navigation support - Introduced a simplified subscription feature that hides fully read subscriptions with no updates. - Added Vim-like keyboard navigation for improved user experience in the Timeline and Entry Content. This update streamlines subscription management and enhances accessibility for Vim users. Signed-off-by: Innei --- apps/desktop/changelog/next.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/desktop/changelog/next.md b/apps/desktop/changelog/next.md index 62b641fa5..1ae0f0005 100644 --- a/apps/desktop/changelog/next.md +++ b/apps/desktop/changelog/next.md @@ -2,6 +2,14 @@ ## Shiny new things +- Simplified Subscription Feature: Automatically hides subscriptions that have no updates and are fully read. To enable, go to Settings - General Subscription - Hide Read. +- Vim users' expectations: Currently, it supports using the keyboard in the Timeline and entry to complete page turning and switching between entries. + + - When Focus in Timeline, you can use `j` and `k` (or `down` and `up` arrow keys) to navigate to previous and next entries, press `Enter` to change focus to the entry content. + - When Focus in Entry Content, you can use `h` and `l` (or `left` and `right` arrow keys) to navigate to previous and next entries, press `j` and `k` (or `down` and `up` arrow keys) to scroll down/up, press `Escape` or `Backspace` to change focus to the timeline. + +![Vim-like navigation](https://github.com/RSSNext/assets/raw/refs/heads/main/vim-navigation.mp4?ratio=1.833) + ## Improvements ## No longer broken From 0667b2bacb3c91ab058de1eea5fcb473d9a5f018 Mon Sep 17 00:00:00 2001 From: Innei Date: Wed, 14 May 2025 21:52:04 +0800 Subject: [PATCH 059/104] fix(desktop): improve UI elements and keyboard interaction - Adjusted CSS properties in BoostProgress for better alignment. - Implemented keyboard event listener in GlobalHotkeysProvider to highlight focused elements on Tab key press. - Enhanced highlightElement function with z-index for improved visibility. - Updated TooltipTrigger in RelativeTime to prevent focus issues. - Added tabIndex to RadioCard for better keyboard navigation. These changes enhance user experience by refining UI components and improving keyboard accessibility. Signed-off-by: Innei --- .../renderer/src/modules/boost/boost-progress.tsx | 4 ++-- .../src/providers/global-hotkeys-provider.tsx | 12 ++++++++++++ .../components/src/common/Focusable/utils.ts | 1 + .../internal/components/src/ui/datetime/index.tsx | 2 +- .../components/src/ui/radio-group/RadioCard.tsx | 1 + 5 files changed, 17 insertions(+), 3 deletions(-) diff --git a/apps/desktop/layer/renderer/src/modules/boost/boost-progress.tsx b/apps/desktop/layer/renderer/src/modules/boost/boost-progress.tsx index 2092db5fe..e915b2d7c 100644 --- a/apps/desktop/layer/renderer/src/modules/boost/boost-progress.tsx +++ b/apps/desktop/layer/renderer/src/modules/boost/boost-progress.tsx @@ -24,7 +24,7 @@ export const BoostProgress = ({ const nextLevel = level + 1 return (
-
+
diff --git a/apps/desktop/layer/renderer/src/providers/global-hotkeys-provider.tsx b/apps/desktop/layer/renderer/src/providers/global-hotkeys-provider.tsx index 7f6c85298..8061251d5 100644 --- a/apps/desktop/layer/renderer/src/providers/global-hotkeys-provider.tsx +++ b/apps/desktop/layer/renderer/src/providers/global-hotkeys-provider.tsx @@ -1,3 +1,7 @@ +import { highlightElement } from "@follow/components/common/Focusable/utils.js" +import { nextFrame } from "@follow/utils/dom" +import { useEventListener } from "usehooks-ts" + import { HotkeyScope } from "~/constants/hotkeys" import { COMMAND_ID } from "~/modules/command/commands/id" import { useCommandBinding } from "~/modules/command/hooks/use-register-hotkey" @@ -13,5 +17,13 @@ export const GlobalHotkeysProvider = () => { activeScopes.includes(HotkeyScope.Home) && !activeScopes.includes(HotkeyScope.EntryRender), }) + useEventListener("keydown", (e) => { + if (e.key === "Tab") { + nextFrame(() => { + highlightElement(document.activeElement as HTMLElement) + }) + } + }) + return null } diff --git a/packages/internal/components/src/common/Focusable/utils.ts b/packages/internal/components/src/common/Focusable/utils.ts index fd95891f4..513da24b0 100644 --- a/packages/internal/components/src/common/Focusable/utils.ts +++ b/packages/internal/components/src/common/Focusable/utils.ts @@ -52,6 +52,7 @@ export function highlightElement(element: HTMLElement | null): void { canvas.height = rect.height + padding * 2 canvas.style.pointerEvents = "none" + canvas.style.zIndex = "999999" document.body.append(canvas) const ctx: CanvasRenderingContext2D = canvas.getContext("2d")! diff --git a/packages/internal/components/src/ui/datetime/index.tsx b/packages/internal/components/src/ui/datetime/index.tsx index 92d5e52f5..a2658a5d5 100644 --- a/packages/internal/components/src/ui/datetime/index.tsx +++ b/packages/internal/components/src/ui/datetime/index.tsx @@ -59,7 +59,7 @@ export const RelativeTime: FC<{ return ( {/* https://github.com/radix-ui/primitives/issues/2248#issuecomment-2147056904 */} - + {relative} {t("space")} {t("words.ago")} diff --git a/packages/internal/components/src/ui/radio-group/RadioCard.tsx b/packages/internal/components/src/ui/radio-group/RadioCard.tsx index 999be33ae..680081e49 100644 --- a/packages/internal/components/src/ui/radio-group/RadioCard.tsx +++ b/packages/internal/components/src/ui/radio-group/RadioCard.tsx @@ -37,6 +37,7 @@ export const RadioCard: FC< selected && "border-accent bg-accent/5 font-medium outline-none ring-2", wrapperClassName, )} + tabIndex={1} > Date: Wed, 14 May 2025 22:02:01 +0800 Subject: [PATCH 060/104] fix(desktop): enhance error handling and focus management in components - Updated PageError component to allow pointer events for better user interaction. - Refactored FeedColumn to utilize useFocusable for improved keyboard navigation and removed unnecessary focus state management. - Enhanced highlightElement function to support customizable highlight colors and optimized canvas usage. These changes improve accessibility and user experience by refining error handling and focus management across components. Signed-off-by: Innei --- .../src/components/errors/PageError.tsx | 2 +- .../src/modules/timeline-column/index.tsx | 20 ++---- .../src/common/Focusable/Focusable.tsx | 2 +- .../components/src/common/Focusable/utils.ts | 66 +++++++++++++------ 4 files changed, 53 insertions(+), 37 deletions(-) diff --git a/apps/desktop/layer/renderer/src/components/errors/PageError.tsx b/apps/desktop/layer/renderer/src/components/errors/PageError.tsx index 9a05ee3aa..d698503f3 100644 --- a/apps/desktop/layer/renderer/src/components/errors/PageError.tsx +++ b/apps/desktop/layer/renderer/src/components/errors/PageError.tsx @@ -11,7 +11,7 @@ const PageErrorFallback: FC = (props) => { const { message, stack } = parseError(props.error) useResetErrorWhenRouteChange(props.resetError) return ( -
+
diff --git a/apps/desktop/layer/renderer/src/modules/timeline-column/index.tsx b/apps/desktop/layer/renderer/src/modules/timeline-column/index.tsx index 7ed859516..6d684db2b 100644 --- a/apps/desktop/layer/renderer/src/modules/timeline-column/index.tsx +++ b/apps/desktop/layer/renderer/src/modules/timeline-column/index.tsx @@ -1,3 +1,4 @@ +import { useFocusable } from "@follow/components/common/Focusable/hooks.js" import { ActionButton } from "@follow/components/ui/button/index.js" import { RootPortal } from "@follow/components/ui/portal/index.js" import { Routes } from "@follow/constants" @@ -105,17 +106,9 @@ export function FeedColumn({ children, className }: PropsWithChildren<{ classNam const feedColumnShow = useTimelineColumnShow() const rootContainerElement = useRootContainerElement() - const { setIsFocus } = useRegisterCommands({ setActive, timelineList }) - return ( { - setIsFocus(true) - }, [setIsFocus])} - onBlur={useCallback(() => { - setIsFocus(false) - }, [setIsFocus])} className={cn( "relative flex h-full flex-col pt-2.5", @@ -128,6 +121,7 @@ export function FeedColumn({ children, className }: PropsWithChildren<{ classNam } }, [navigateBackHome])} > + {!feedColumnShow && ( @@ -234,7 +228,7 @@ const SwipeWrapper: FC<{ active: string; children: React.JSX.Element[] }> = memo }, ) -const useRegisterCommands = ({ +const CommandsHandler = ({ setActive, timelineList, }: { @@ -266,10 +260,8 @@ const useRegisterCommands = ({ }) }, [activeScope, setActive, timelineList]) - const [isFocus, setIsFocus] = useState(false) + const focus = useFocusable() - useConditionalHotkeyScope(HotkeyScope.SubscriptionList, isFocus, true) - return { - setIsFocus, - } + useConditionalHotkeyScope(HotkeyScope.SubscriptionList, focus, true) + return null } diff --git a/packages/internal/components/src/common/Focusable/Focusable.tsx b/packages/internal/components/src/common/Focusable/Focusable.tsx index e04ba6bd5..3f84b34a9 100644 --- a/packages/internal/components/src/common/Focusable/Focusable.tsx +++ b/packages/internal/components/src/common/Focusable/Focusable.tsx @@ -27,7 +27,7 @@ export const Focusable: Component< setIsFocusWithIn(true) focusTargetRef.current = e.target as HTMLElement if (import.meta.env.DEV) { - highlightElement(containerRef.current!) + highlightElement(containerRef.current!, "14, 165, 233") } } else { setIsFocusWithIn(false) diff --git a/packages/internal/components/src/common/Focusable/utils.ts b/packages/internal/components/src/common/Focusable/utils.ts index 513da24b0..6702479d9 100644 --- a/packages/internal/components/src/common/Focusable/utils.ts +++ b/packages/internal/components/src/common/Focusable/utils.ts @@ -36,28 +36,41 @@ function cubicBezier(t: number, p1x: number, p1y: number, p2x: number, p2y: numb return sampleCurveY(solveCurveX(t)) } -export function highlightElement(element: HTMLElement | null): void { +// Shared canvas element for all highlight operations +let sharedCanvas: HTMLCanvasElement | null = null +let currentAnimationId: number | null = null + +export function highlightElement(element: HTMLElement | null, colorString = "255, 165, 0"): void { if (!element) return + // Cancel any ongoing animation + if (currentAnimationId !== null) { + cancelAnimationFrame(currentAnimationId) + currentAnimationId = null + } + // Get element's bounding rectangle const rect: DOMRect = element.getBoundingClientRect() const padding = 10 // Extra space for shadow effect - // Create canvas - const canvas: HTMLCanvasElement = document.createElement("canvas") - canvas.style.position = "absolute" - canvas.style.top = `${rect.top + window.scrollY - padding}px` - canvas.style.left = `${rect.left + window.scrollX - padding}px` - canvas.width = rect.width + padding * 2 - canvas.height = rect.height + padding * 2 + // Create or reuse canvas + if (!sharedCanvas) { + sharedCanvas = document.createElement("canvas") + sharedCanvas.style.position = "absolute" + sharedCanvas.style.pointerEvents = "none" + sharedCanvas.style.zIndex = "999999" + sharedCanvas.id = "follow-highlight-canvas" + document.body.append(sharedCanvas) + } - canvas.style.pointerEvents = "none" - canvas.style.zIndex = "999999" - document.body.append(canvas) + // Update canvas position and size + sharedCanvas.style.top = `${rect.top + window.scrollY - padding}px` + sharedCanvas.style.left = `${rect.left + window.scrollX - padding}px` + sharedCanvas.width = rect.width + padding * 2 + sharedCanvas.height = rect.height + padding * 2 - const ctx: CanvasRenderingContext2D = canvas.getContext("2d")! + const ctx: CanvasRenderingContext2D = sharedCanvas.getContext("2d")! if (!ctx) { - canvas.remove() return } @@ -105,8 +118,11 @@ export function highlightElement(element: HTMLElement | null): void { const segmentT: number = (t - segmentIndex * segmentDuration) / segmentDuration if (segmentIndex >= keyframes.length - 1) { - // Animation complete, remove canvas - canvas.remove() + // Animation complete, hide canvas + if (sharedCanvas) { + sharedCanvas.style.display = "none" + } + currentAnimationId = null return } @@ -132,11 +148,11 @@ export function highlightElement(element: HTMLElement | null): void { ) // Clear canvas - ctx.clearRect(0, 0, canvas.width, canvas.height) + ctx.clearRect(0, 0, sharedCanvas!.width, sharedCanvas!.height) // Draw shadow (approximated as thick border with rounded corners) if (shadowWidth > 0) { - ctx.strokeStyle = `rgba(255, 165, 0, ${shadowOpacity})` // Orange color + ctx.strokeStyle = `rgba(${colorString}, ${shadowOpacity})` // Orange color ctx.lineWidth = shadowWidth ctx.beginPath() ctx.roundRect( @@ -151,7 +167,7 @@ export function highlightElement(element: HTMLElement | null): void { // Draw outline (with rounded corners) if (outlineWidth > 0) { - ctx.strokeStyle = `rgba(255, 165, 0, ${outlineOpacity})` + ctx.strokeStyle = `rgba(${colorString}, ${outlineOpacity})` ctx.lineWidth = outlineWidth ctx.beginPath() ctx.roundRect( @@ -165,11 +181,19 @@ export function highlightElement(element: HTMLElement | null): void { } if (t < 1) { - requestAnimationFrame(animate) + currentAnimationId = requestAnimationFrame(animate) } else { - canvas.remove() + if (sharedCanvas) { + sharedCanvas.style.display = "none" + } + currentAnimationId = null } } - requestAnimationFrame(animate) + // Show canvas before starting animation + if (sharedCanvas) { + sharedCanvas.style.display = "block" + } + + currentAnimationId = requestAnimationFrame(animate) } From afb7d57b824d4cdde9e9c97d19d77713d0181822 Mon Sep 17 00:00:00 2001 From: DIYgod Date: Wed, 14 May 2025 22:54:57 +0800 Subject: [PATCH 061/104] feat(desktop): SubscribeModalOpened tracker event --- .../src/modules/discover/FeedForm.tsx | 10 + .../src/modules/discover/ListForm.tsx | 9 + packages/internal/shared/src/hono.ts | 198 +++++++++++++++++- packages/internal/tracker/src/points.ts | 10 + 4 files changed, 226 insertions(+), 1 deletion(-) diff --git a/apps/desktop/layer/renderer/src/modules/discover/FeedForm.tsx b/apps/desktop/layer/renderer/src/modules/discover/FeedForm.tsx index 5ff736472..c9c78cfea 100644 --- a/apps/desktop/layer/renderer/src/modules/discover/FeedForm.tsx +++ b/apps/desktop/layer/renderer/src/modules/discover/FeedForm.tsx @@ -71,6 +71,16 @@ export const FeedForm: Component<{ const isInModal = useIsInModal() const placeholderRef = useRef(null) + useEffect(() => { + if (!feedQuery.isLoading) { + tracker.subscribeModalOpened({ + feedId: id, + feedUrl: feedQuery.data?.feed.url || url, + isError: feedQuery.isError, + }) + } + }, [feedQuery.isLoading]) + return (
{ + if (!feedQuery.isLoading) { + tracker.subscribeModalOpened({ + listId: id, + isError: feedQuery.isError, + }) + } + }, [feedQuery.isLoading]) + return (
; + rsshubRoute: drizzle_orm_pg_core.PgColumn<{ + name: "rsshub_route"; + tableName: "feeds"; + dataType: "string"; + columnType: "PgText"; + data: string; + driverParam: string; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + rsshubNamespace: drizzle_orm_pg_core.PgColumn<{ + name: "rsshub_namespace"; + tableName: "feeds"; + dataType: "string"; + columnType: "PgText"; + data: string; + driverParam: string; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; }; dialect: "pg"; }>; @@ -3164,6 +3198,8 @@ declare const feedsOpenAPISchema: zod.ZodObject<{ ownerUserId: zod.ZodNullable; language: zod.ZodNullable; migrateTo: zod.ZodNullable; + rsshubRoute: zod.ZodNullable; + rsshubNamespace: zod.ZodNullable; }, zod.UnknownKeysParam, zod.ZodTypeAny, { id: string; image: string | null; @@ -3180,6 +3216,8 @@ declare const feedsOpenAPISchema: zod.ZodObject<{ ownerUserId: string | null; language: string | null; migrateTo: string | null; + rsshubRoute: string | null; + rsshubNamespace: string | null; }, { id: string; image: string | null; @@ -3196,6 +3234,8 @@ declare const feedsOpenAPISchema: zod.ZodObject<{ ownerUserId: string | null; language: string | null; migrateTo: string | null; + rsshubRoute: string | null; + rsshubNamespace: string | null; }>; declare const feedsRelations: drizzle_orm.Relations<"feeds", { subscriptions: drizzle_orm.Many<"subscriptions">; @@ -5354,6 +5394,138 @@ declare const rsshubUsageRelations: drizzle_orm.Relations<"rsshub_usage", { rsshub: drizzle_orm.One<"rsshub", true>; }>; +declare const rsshubAnalytics: drizzle_orm_pg_core.PgTableWithColumns<{ + name: "rsshub_analytics"; + schema: undefined; + columns: { + rsshubId: drizzle_orm_pg_core.PgColumn<{ + name: "rsshub_id"; + tableName: "rsshub_analytics"; + dataType: "string"; + columnType: "PgText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + rsshubRoute: drizzle_orm_pg_core.PgColumn<{ + name: "rsshub_route"; + tableName: "rsshub_analytics"; + dataType: "string"; + columnType: "PgText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + rsshubNamespace: drizzle_orm_pg_core.PgColumn<{ + name: "rsshub_namespace"; + tableName: "rsshub_analytics"; + dataType: "string"; + columnType: "PgText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + successCount: drizzle_orm_pg_core.PgColumn<{ + name: "success_count"; + tableName: "rsshub_analytics"; + dataType: "number"; + columnType: "PgInteger"; + data: number; + driverParam: string | number; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + errorCount: drizzle_orm_pg_core.PgColumn<{ + name: "error_count"; + tableName: "rsshub_analytics"; + dataType: "number"; + columnType: "PgInteger"; + data: number; + driverParam: string | number; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + timestamp: drizzle_orm_pg_core.PgColumn<{ + name: "timestamp"; + tableName: "rsshub_analytics"; + dataType: "date"; + columnType: "PgTimestamp"; + data: Date; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + }; + dialect: "pg"; +}>; +declare const rsshubAnalyticsOpenAPISchema: zod.ZodObject<{ + rsshubId: zod.ZodString; + rsshubRoute: zod.ZodString; + rsshubNamespace: zod.ZodString; + successCount: zod.ZodNumber; + errorCount: zod.ZodNumber; + timestamp: zod.ZodString; +}, zod.UnknownKeysParam, zod.ZodTypeAny, { + rsshubRoute: string; + rsshubNamespace: string; + rsshubId: string; + successCount: number; + errorCount: number; + timestamp: string; +}, { + rsshubRoute: string; + rsshubNamespace: string; + rsshubId: string; + successCount: number; + errorCount: number; + timestamp: string; +}>; + declare const settings: drizzle_orm_pg_core.PgTableWithColumns<{ name: "settings"; schema: undefined; @@ -15594,6 +15766,8 @@ declare const _routes: hono_hono_base.HonoBase | hono_types.MergeSchemaPath<{ "/": { $post: { @@ -17064,4 +17260,4 @@ declare const _routes: hono_hono_base.HonoBase, "/">; type AppType = typeof _routes; -export { type ActionItem, type ActionsModel, type AirdropActivity, type AppType, type AttachmentsModel, type AuthSession, type AuthUser, CommonEntryFields, type ConditionItem, type DetailModel, type EntriesModel, type ExtraModel, type FeedModel, type ListModel, type MediaModel, type MessagingData, MessagingType, type SettingsModel, type UrlReadsModel, account, achievements, achievementsOpenAPISchema, actions, actionsItemOpenAPISchema, actionsOpenAPISchema, actionsRelations, activityEnum, airdrops, airdropsOpenAPISchema, attachmentsZodSchema, authPlugins, boosts, captcha, collections, collectionsOpenAPISchema, collectionsRelations, detailModelSchema, entries, entriesOpenAPISchema, entriesRelations, extraZodSchema, feedAnalytics, feedAnalyticsOpenAPISchema, feedAnalyticsRelations, feedPowerTokens, feedPowerTokensOpenAPISchema, feedPowerTokensRelations, feeds, feedsOpenAPISchema, feedsRelations, inboxHandleSchema, inboxes, inboxesEntries, inboxesEntriesInsertOpenAPISchema, type inboxesEntriesModel, inboxesEntriesOpenAPISchema, inboxesEntriesRelations, inboxesOpenAPISchema, inboxesRelations, invitations, invitationsOpenAPISchema, invitationsRelations, languageSchema, levels, levelsOpenAPISchema, levelsRelations, lists, listsOpenAPISchema, listsRelations, listsSubscriptions, listsSubscriptionsOpenAPISchema, listsSubscriptionsRelations, lower, mediaZodSchema, messaging, messagingOpenAPISchema, messagingRelations, readabilities, rsshub, rsshubOpenAPISchema, rsshubPurchase, rsshubUsage, rsshubUsageOpenAPISchema, rsshubUsageRelations, session, settings, subscriptions, subscriptionsOpenAPISchema, subscriptionsRelations, timeline, timelineOpenAPISchema, timelineRelations, transactionType, transactions, transactionsOpenAPISchema, transactionsRelations, trendingFeeds, trendingFeedsOpenAPISchema, trendingFeedsRelations, twoFactor, uploads, urlReads, urlReadsOpenAPISchema, user, users, usersOpenApiSchema, usersRelations, verification, wallets, walletsOpenAPISchema, walletsRelations }; +export { type ActionItem, type ActionsModel, type AirdropActivity, type AppType, type AttachmentsModel, type AuthSession, type AuthUser, CommonEntryFields, type ConditionItem, type DetailModel, type EntriesModel, type ExtraModel, type FeedModel, type ListModel, type MediaModel, type MessagingData, MessagingType, type SettingsModel, type UrlReadsModel, account, achievements, achievementsOpenAPISchema, actions, actionsItemOpenAPISchema, actionsOpenAPISchema, actionsRelations, activityEnum, airdrops, airdropsOpenAPISchema, attachmentsZodSchema, authPlugins, boosts, captcha, collections, collectionsOpenAPISchema, collectionsRelations, detailModelSchema, entries, entriesOpenAPISchema, entriesRelations, extraZodSchema, feedAnalytics, feedAnalyticsOpenAPISchema, feedAnalyticsRelations, feedPowerTokens, feedPowerTokensOpenAPISchema, feedPowerTokensRelations, feeds, feedsOpenAPISchema, feedsRelations, inboxHandleSchema, inboxes, inboxesEntries, inboxesEntriesInsertOpenAPISchema, type inboxesEntriesModel, inboxesEntriesOpenAPISchema, inboxesEntriesRelations, inboxesOpenAPISchema, inboxesRelations, invitations, invitationsOpenAPISchema, invitationsRelations, languageSchema, levels, levelsOpenAPISchema, levelsRelations, lists, listsOpenAPISchema, listsRelations, listsSubscriptions, listsSubscriptionsOpenAPISchema, listsSubscriptionsRelations, lower, mediaZodSchema, messaging, messagingOpenAPISchema, messagingRelations, readabilities, rsshub, rsshubAnalytics, rsshubAnalyticsOpenAPISchema, rsshubOpenAPISchema, rsshubPurchase, rsshubUsage, rsshubUsageOpenAPISchema, rsshubUsageRelations, session, settings, subscriptions, subscriptionsOpenAPISchema, subscriptionsRelations, timeline, timelineOpenAPISchema, timelineRelations, transactionType, transactions, transactionsOpenAPISchema, transactionsRelations, trendingFeeds, trendingFeedsOpenAPISchema, trendingFeedsRelations, twoFactor, uploads, urlReads, urlReadsOpenAPISchema, user, users, usersOpenApiSchema, usersRelations, verification, wallets, walletsOpenAPISchema, walletsRelations }; diff --git a/packages/internal/tracker/src/points.ts b/packages/internal/tracker/src/points.ts index a4288a7be..deaeed95f 100644 --- a/packages/internal/tracker/src/points.ts +++ b/packages/internal/tracker/src/points.ts @@ -171,6 +171,7 @@ export enum TrackerMapper { FeedClaimed = 2012, DailyRewardClaimed = 2013, TipSent = 2014, + SubscribeModalOpened = 2015, // https://docs.google.com/spreadsheets/d/1XlUxTxiXWIQDHFYa2eoPBeuosR1t2h8VFIjXEOqmjhY/edit?gid=0#gid=0 Register = 3000, @@ -271,6 +272,15 @@ export class TrackerPoints { this.track(TrackerMapper.TipModalOpened, props) } + subscribeModalOpened(props: { + feedId?: string + listId?: string + feedUrl?: string + isError?: boolean + }) { + this.track(TrackerMapper.SubscribeModalOpened, props) + } + feedClaimed(props: { feedId: string }) { this.track(TrackerMapper.FeedClaimed, props) } From 74f7d52277b289324852a9503a387670a25cc750 Mon Sep 17 00:00:00 2001 From: DIYgod Date: Wed, 14 May 2025 22:58:03 +0800 Subject: [PATCH 062/104] fix(mobile): new feed in discover result --- .../mobile/src/modules/discover/search-tabs/SearchFeed.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/modules/discover/search-tabs/SearchFeed.tsx b/apps/mobile/src/modules/discover/search-tabs/SearchFeed.tsx index f470239ca..c3b9dfbe7 100644 --- a/apps/mobile/src/modules/discover/search-tabs/SearchFeed.tsx +++ b/apps/mobile/src/modules/discover/search-tabs/SearchFeed.tsx @@ -80,6 +80,11 @@ const SearchFeedItem = ({ item }: { item: SearchResultItem }) => { id: item.feed.id, type: "feed", }) + } else if (item.feed?.url) { + navigation.presentControllerView(FollowScreen, { + url: item.feed.url, + type: "url", + }) } }} > @@ -145,7 +150,7 @@ const SearchFeedItem = ({ item }: { item: SearchResultItem }) => { showsHorizontalScrollIndicator={false} contentContainerClassName="flex flex-row gap-4 pl-4" > - {item.entries?.map((entry) => )} + {item.entries?.map((entry) => )} From aff493f38f24e8850edacd77dce96b2dece6418e Mon Sep 17 00:00:00 2001 From: Innei Date: Thu, 15 May 2025 00:04:29 +0800 Subject: [PATCH 063/104] feat(desktop): integrate AvatarGroup component for enhanced user read history display - Added AvatarGroup component to encapsulate user avatars in EntryReadHistory for improved layout and interaction. - Refactored EntryReadHistory to utilize AvatarGroup, simplifying the rendering of user avatars. - Updated EntryUser component to enhance user profile interaction and tooltip display. These changes enhance the visual presentation and user experience of the read history feature. Signed-off-by: Innei --- .../components/EntryReadHistory.electron.tsx | 86 +++++-------------- .../components/EntryReadHistory.shared.tsx | 81 ++++------------- .../modules/entry-content/index.electron.tsx | 6 +- .../components/src/ui/avatar-group/index.tsx | 74 ++++++++++++++++ 4 files changed, 118 insertions(+), 129 deletions(-) create mode 100644 packages/internal/components/src/ui/avatar-group/index.tsx diff --git a/apps/desktop/layer/renderer/src/modules/entry-content/components/EntryReadHistory.electron.tsx b/apps/desktop/layer/renderer/src/modules/entry-content/components/EntryReadHistory.electron.tsx index bffb08e2f..937dc597c 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-content/components/EntryReadHistory.electron.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-content/components/EntryReadHistory.electron.tsx @@ -1,11 +1,4 @@ -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 { useWhoami } from "~/atoms/user" import { useAuthQuery } from "~/hooks/common" @@ -13,7 +6,7 @@ import { useAppLayoutGridContainerWidth } from "~/providers/app-grid-layout-cont import { Queries } from "~/queries" import { useEntryReadHistory } from "~/store/entry" -import { EntryUser, EntryUserRow, getLimit } from "./EntryReadHistory.shared" +import { EntryUser, getLimit } from "./EntryReadHistory.shared" export const EntryReadHistory: Component<{ entryId: string }> = ({ entryId }) => { const me = useWhoami() @@ -37,64 +30,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) + + {entryHistory.userIds + .filter((id) => id !== me?.id) + .slice(0, LIMIT) - .map((userId, i) => ( - - ))} + .map((userId) => ( + + ))} + - {!!totalCount && totalCount > LIMIT && ( - - - - - - {totalCount > LIMIT && ( - - - -
    - {entryHistory.userIds - .filter((id) => id !== me?.id) - .slice(LIMIT) - .map((userId) => ( - - ))} -
-
-
-
- )} -
- )} +
+ + +{Math.min(totalCount - LIMIT, 99)} + +
) } diff --git a/apps/desktop/layer/renderer/src/modules/entry-content/components/EntryReadHistory.shared.tsx b/apps/desktop/layer/renderer/src/modules/entry-content/components/EntryReadHistory.shared.tsx index 8b226fe3f..276807e1a 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-content/components/EntryReadHistory.shared.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-content/components/EntryReadHistory.shared.tsx @@ -1,11 +1,5 @@ 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 { TooltipContent, TooltipPortal } from "@follow/components/ui/tooltip/index.jsx" import { FeedViewType } from "@follow/constants" import { getNameInitials } from "@follow/utils/cjk" import { m } from "motion/react" @@ -36,74 +30,37 @@ export const getLimit = (width: number): number => { return 5 } -export const EntryUserRow: Component<{ userId: string }> = memo(({ userId }) => { - const user = useUserById(userId) - const presentUserProfile = usePresentUserProfileModal("drawer") - if (!user) return null - - return ( -
  • { - 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" - > - - - {getNameInitials(user.name || "")} - - - {user.name && ( - - {user.name} - - )} -
  • - ) -}) - export const EntryUser: Component<{ userId: string - i: number -}> = memo(({ userId, i }) => { + ref?: React.Ref +}> = memo(({ userId, ref }) => { const user = useUserById(userId) const { t } = useTranslation() const presentUserProfile = usePresentUserProfileModal("drawer") if (!user) return null return ( - - + { + presentUserProfile(userId) }} - asChild > - { - presentUserProfile(userId) - }} - > - - - {getNameInitials(user.name || "")} - - - + + + {getNameInitials(user.name || "")} + + {t("entry_actions.recent_reader")} {user.name} - +
    ) }) diff --git a/apps/desktop/layer/renderer/src/modules/entry-content/index.electron.tsx b/apps/desktop/layer/renderer/src/modules/entry-content/index.electron.tsx index 6be78a6a7..a44ffe380 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-content/index.electron.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-content/index.electron.tsx @@ -148,6 +148,7 @@ export const EntryContent: Component = ({ /> + {/* Indicator for the entry */}
    = ({ <>
    { EventBus.dispatch(COMMAND_ID.timeline.switchToPrevious) }} @@ -167,7 +169,7 @@ export const EntryContent: Component = ({
    { EventBus.dispatch(COMMAND_ID.timeline.switchToNext) }} diff --git a/packages/internal/components/src/ui/avatar-group/index.tsx b/packages/internal/components/src/ui/avatar-group/index.tsx new file mode 100644 index 000000000..4c1d25802 --- /dev/null +++ b/packages/internal/components/src/ui/avatar-group/index.tsx @@ -0,0 +1,74 @@ +import { Spring } from "@follow/components/constants/spring" +import { cn } from "@follow/utils/utils" +import type { Transition } from "motion/react" +import { m as motion } from "motion/react" +import * as React from "react" + +import { Tooltip, TooltipTrigger } from "../tooltip" + +type AvatarProps = { + children: React.ReactNode + zIndex: number + transition: Transition + translate: string | number +} + +const AvatarContainer = React.memo(({ children, zIndex, transition, translate }: AvatarProps) => { + return ( + + + + {children} + + + + ) +}) + +type AvatarGroupProps = Omit, "translate"> & { + children: React.ReactElement[] + transition?: Transition + translate?: string | number +} + +function AvatarGroup({ + ref, + children, + className, + transition = Spring.presets.smooth, + translate = "-30%", + + ...props +}: AvatarGroupProps) { + return ( +
    + {children?.map((child, index) => ( + + + {child} + + + ))} +
    + ) +} + +export { AvatarGroup, type AvatarGroupProps } From d359b74190eb98c70b38e2e04b95232432668d71 Mon Sep 17 00:00:00 2001 From: lawvs <18554747+lawvs@users.noreply.github.com> Date: Thu, 15 May 2025 01:10:43 +0800 Subject: [PATCH 064/104] fix: correct import path for Spring constant --- packages/internal/components/src/ui/avatar-group/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/internal/components/src/ui/avatar-group/index.tsx b/packages/internal/components/src/ui/avatar-group/index.tsx index 4c1d25802..104953299 100644 --- a/packages/internal/components/src/ui/avatar-group/index.tsx +++ b/packages/internal/components/src/ui/avatar-group/index.tsx @@ -1,9 +1,9 @@ -import { Spring } from "@follow/components/constants/spring" import { cn } from "@follow/utils/utils" import type { Transition } from "motion/react" import { m as motion } from "motion/react" import * as React from "react" +import { Spring } from "../../constants/spring" import { Tooltip, TooltipTrigger } from "../tooltip" type AvatarProps = { From 79fe6a616d00de9a309e19eca4af82161588b87d Mon Sep 17 00:00:00 2001 From: lawvs <18554747+lawvs@users.noreply.github.com> Date: Thu, 15 May 2025 01:24:15 +0800 Subject: [PATCH 065/104] fix(mobile): improve back navigation logic in useBackHandler hook --- apps/mobile/src/hooks/useBackHandler.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/hooks/useBackHandler.ts b/apps/mobile/src/hooks/useBackHandler.ts index 4e286af4a..c63babfc8 100644 --- a/apps/mobile/src/hooks/useBackHandler.ts +++ b/apps/mobile/src/hooks/useBackHandler.ts @@ -15,10 +15,12 @@ export const useBackHandler = () => { const listener = BackHandler.addEventListener("hardwareBackPress", () => { if (canDismiss) { navigation.dismiss() - } else { + return true + } else if (navigation.canGoBack()) { navigation.back() + return true } - return true + return false }) return () => { From 2244dc7884356198838e2dd2089061b21dac3bef Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Thu, 15 May 2025 09:53:13 +0800 Subject: [PATCH 066/104] fix(mobile): unread indicator for grid item --- apps/mobile/src/modules/entry-content/EntryGridFooter.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mobile/src/modules/entry-content/EntryGridFooter.tsx b/apps/mobile/src/modules/entry-content/EntryGridFooter.tsx index 31900bc68..6b04992d2 100644 --- a/apps/mobile/src/modules/entry-content/EntryGridFooter.tsx +++ b/apps/mobile/src/modules/entry-content/EntryGridFooter.tsx @@ -29,7 +29,7 @@ export const EntryGridFooter = ({ return ( - {!entry.read && } + {!entry.read && } {entry.title && ( Date: Thu, 15 May 2025 10:44:05 +0800 Subject: [PATCH 067/104] fix(web): readability support --- .../src/modules/entry-content/hooks.tsx | 71 ++++++++++++++++++- .../modules/entry-content/index.electron.tsx | 54 +++----------- .../modules/entry-content/index.mobile.tsx | 58 ++++++--------- 3 files changed, 100 insertions(+), 83 deletions(-) diff --git a/apps/desktop/layer/renderer/src/modules/entry-content/hooks.tsx b/apps/desktop/layer/renderer/src/modules/entry-content/hooks.tsx index 13cf41526..b20b6308c 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-content/hooks.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-content/hooks.tsx @@ -1,9 +1,19 @@ import { tracker } from "@follow/tracker" -import { createElement, useCallback } 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" @@ -30,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, + }, + ]) ?? [], + ), + ) +} diff --git a/apps/desktop/layer/renderer/src/modules/entry-content/index.electron.tsx b/apps/desktop/layer/renderer/src/modules/entry-content/index.electron.tsx index a44ffe380..6155a9183 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-content/index.electron.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-content/index.electron.tsx @@ -17,11 +17,7 @@ import { ErrorBoundary } from "@sentry/react" import * as React from "react" import { useEffect, useMemo, useRef, useState } from "react" -import { - useEntryIsInReadability, - useEntryIsInReadabilitySuccess, - useEntryReadabilityContent, -} from "~/atoms/readability" +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" @@ -29,12 +25,10 @@ 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, useConditionalHotkeyScope } 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" @@ -48,6 +42,7 @@ import { EntryTitle } from "./components/EntryTitle" import { SourceContentPanel } from "./components/SourceContentView" import { SupportCreator } from "./components/SupportCreator" import { EntryHeader } from "./header" +import { useEntryContent, useEntryMediaInfo } from "./hooks" import type { EntryContentProps } from "./index.shared" import { ContainerToc, @@ -73,20 +68,13 @@ export const EntryContent: Component = ({ 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(null) useEffect(() => { @@ -105,27 +93,13 @@ export const EntryContent: Component = ({ 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 && ( @@ -282,19 +256,7 @@ const Renderer: React.FC<{ noMedia?: boolean content?: Nullable }> = 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") diff --git a/apps/desktop/layer/renderer/src/modules/entry-content/index.mobile.tsx b/apps/desktop/layer/renderer/src/modules/entry-content/index.mobile.tsx index ba68cd73b..dafb817db 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-content/index.mobile.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-content/index.mobile.tsx @@ -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(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 ( @@ -162,6 +140,7 @@ export const EntryContent: Component<{ + {!!customCSS && ( {customCSS} @@ -184,6 +163,13 @@ export const EntryContent: Component<{
    + {entry.settings?.readability && ( + + )} + {!content && (
    {isPending ? ( From 1f3707aabda2e9b92db52c9eb33f69a26fbfbd60 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Thu, 15 May 2025 10:52:23 +0800 Subject: [PATCH 068/104] fix(web): only show view with subscription --- .../feed-column/float-bar.mobile.tsx | 34 +++++++++++-------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/apps/desktop/layer/renderer/src/modules/app-layout/feed-column/float-bar.mobile.tsx b/apps/desktop/layer/renderer/src/modules/app-layout/feed-column/float-bar.mobile.tsx index 1d011dcd8..4b8247572 100644 --- a/apps/desktop/layer/renderer/src/modules/app-layout/feed-column/float-bar.mobile.tsx +++ b/apps/desktop/layer/renderer/src/modules/app-layout/feed-column/float-bar.mobile.tsx @@ -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" @@ -101,6 +102,7 @@ export const MobileFloatBar = ({ } const ViewTabs = ({ onViewChange }: { onViewChange?: (view: number) => void }) => { + const viewsWithSubscription = useViewWithSubscription() const view = useRouteParamsSelector((s) => s.view) const navigate = useNavigateEntry() @@ -109,21 +111,23 @@ const ViewTabs = ({ onViewChange }: { onViewChange?: (view: number) => void }) = 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) => ( - { - navigate({ view: item.view }) - onViewChange?.(item.view) - }} - > -
    {item.icon}
    -
    - ))} + {views + .filter((item) => viewsWithSubscription.includes(item.view)) + .map((item) => ( + { + navigate({ view: item.view }) + onViewChange?.(item.view) + }} + > +
    {item.icon}
    +
    + ))}
    ) } From 9b8bd7db073d5b8576e9751aa7c8e238da2009c1 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Thu, 15 May 2025 10:59:13 +0800 Subject: [PATCH 069/104] fix(web): ignore customizeColumn on mobile --- .../src/modules/entry-column/Items/picture-masonry.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/Items/picture-masonry.tsx b/apps/desktop/layer/renderer/src/modules/entry-column/Items/picture-masonry.tsx index f31734ebd..a5eccaa0d 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-column/Items/picture-masonry.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-column/Items/picture-masonry.tsx @@ -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 = (props) => { const { data } = props + const isMobile = useMobile() const cacheMap = useState(() => new Map())[0] const [isInitDim, setIsInitDim] = useState(false) const [isInitLayout, setIsInitLayout] = useState(false) @@ -79,7 +81,7 @@ export const PictureMasonry: FC = (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], From e392e59c856652ab375431eeb17d4690ed6f5163 Mon Sep 17 00:00:00 2001 From: DIYgod Date: Thu, 15 May 2025 11:33:54 +0800 Subject: [PATCH 070/104] feat(mobile): optimize login process --- .../src/modules/auth/LoginModalContent.tsx | 8 +- apps/mobile/src/icons/left_small_sharp.tsx | 24 +++ apps/mobile/src/icons/right_small_sharp.tsx | 24 +++ apps/mobile/src/lib/auth.ts | 11 +- apps/mobile/src/modules/login/email.tsx | 166 ++++++++++++-- apps/mobile/src/modules/login/index.tsx | 94 +++++--- apps/mobile/src/modules/login/social.tsx | 154 ++++++------- .../src/screens/(modal)/SignUpScreen.tsx | 202 ------------------ apps/mobile/src/sitemap.tsx | 3 +- locales/app/en.json | 5 +- locales/app/zh-CN.json | 5 +- locales/external/en.json | 4 +- locales/external/zh-CN.json | 4 +- locales/mobile/default/en.json | 6 + locales/mobile/default/zh-CN.json | 6 + 15 files changed, 360 insertions(+), 356 deletions(-) create mode 100644 apps/mobile/src/icons/left_small_sharp.tsx create mode 100644 apps/mobile/src/icons/right_small_sharp.tsx delete mode 100644 apps/mobile/src/screens/(modal)/SignUpScreen.tsx diff --git a/apps/desktop/layer/renderer/src/modules/auth/LoginModalContent.tsx b/apps/desktop/layer/renderer/src/modules/auth/LoginModalContent.tsx index 44f1b81cf..5d2dfe9cf 100644 --- a/apps/desktop/layer/renderer/src/modules/auth/LoginModalContent.tsx +++ b/apps/desktop/layer/renderer/src/modules/auth/LoginModalContent.tsx @@ -76,7 +76,7 @@ export const LoginModalContent = (props: LoginModalContentProps) => { 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" + className="center hover:bg-material-medium relative w-full gap-2 rounded-xl border py-3 pl-5 font-semibold duration-200" > { )} {isEmail ? ( -
    setIsEmail(false)}> - Back +
    setIsEmail(false)}> + {t("login.back")}
    ) : ( -
    setIsRegister(!isRegister)}> +
    setIsRegister(!isRegister)}> { + return ( + + + + ) +} diff --git a/apps/mobile/src/icons/right_small_sharp.tsx b/apps/mobile/src/icons/right_small_sharp.tsx new file mode 100644 index 000000000..b66918ce0 --- /dev/null +++ b/apps/mobile/src/icons/right_small_sharp.tsx @@ -0,0 +1,24 @@ +import * as React from "react" +import Svg, { Path } from "react-native-svg" + +interface RightSmallSharpIconProps { + width?: number + height?: number + color?: string +} + +export const RightSmallSharpIcon = ({ + width = 24, + height = 24, + color = "#10161F", +}: RightSmallSharpIconProps) => { + return ( + + + + ) +} diff --git a/apps/mobile/src/lib/auth.ts b/apps/mobile/src/lib/auth.ts index 2507a66ad..12db90136 100644 --- a/apps/mobile/src/lib/auth.ts +++ b/apps/mobile/src/lib/auth.ts @@ -5,6 +5,7 @@ import { inferAdditionalFields, twoFactorClient } from "better-auth/client/plugi import { createAuthClient } from "better-auth/react" import type { BetterAuthClientPlugin } from "better-auth/types" import * as SecureStore from "expo-secure-store" +import { Platform } from "react-native" import { isNewUserQueryKey } from "../store/user/constants" import { whoamiQueryKey } from "../store/user/hooks" @@ -91,12 +92,20 @@ export interface AuthProvider { id: string color: string icon: string + icon64: string + iconDark64?: string } export const useAuthProviders = () => { return useQuery({ queryKey: ["providers"], - queryFn: async () => (await getProviders()).data, + queryFn: async () => { + const data = (await getProviders()).data as Record + if (Platform.OS !== "ios") { + delete data.apple + } + return data + }, }) } diff --git a/apps/mobile/src/modules/login/email.tsx b/apps/mobile/src/modules/login/email.tsx index cb370ab88..61068b438 100644 --- a/apps/mobile/src/modules/login/email.tsx +++ b/apps/mobile/src/modules/login/email.tsx @@ -1,18 +1,22 @@ import { tracker } from "@follow/tracker" +import { zodResolver } from "@hookform/resolvers/zod" import { useMutation } from "@tanstack/react-query" import { useCallback, useRef } from "react" +import type { Control } from "react-hook-form" +import { useController, useForm } from "react-hook-form" +import type { TextInputProps } from "react-native" import { Alert, Text, TouchableOpacity, View } from "react-native" import { KeyboardController } from "react-native-keyboard-controller" import { z } from "zod" import { SubmitButton } from "@/src/components/common/SubmitButton" import { PlainTextField } from "@/src/components/ui/form/TextField" -import { signIn } from "@/src/lib/auth" +import { signIn, signUp } from "@/src/lib/auth" import { useNavigation } from "@/src/lib/navigation/hooks" import { Navigation } from "@/src/lib/navigation/Navigation" +import { toast } from "@/src/lib/toast" import { getTokenHeaders } from "@/src/lib/token" import { ForgetPasswordScreen } from "@/src/screens/(modal)/ForgetPasswordScreen" -import { SignUpScreen } from "@/src/screens/(modal)/SignUpScreen" import { TwoFactorAuthScreen } from "@/src/screens/(modal)/TwoFactorAuthScreen" import { accentColor } from "@/src/theme/colors" @@ -117,24 +121,154 @@ export function EmailLogin() { - navigation.presentControllerView(SignUpScreen)} - > - Don't have an account? - - navigation.presentControllerView(ForgetPasswordScreen)} > - Forgot password? + Forgot password? + + + ) +} + +// Signup + +const signupFormSchema = z + .object({ + email: z.string().email(), + password: z.string().min(8).max(128), + confirmPassword: z.string(), + }) + .refine((data) => data.password === data.confirmPassword, { + message: "Passwords don't match", + path: ["confirmPassword"], + }) + +type SignupFormValue = z.infer + +function SignupInput({ + control, + name, + ...rest +}: TextInputProps & { + control: Control + name: keyof SignupFormValue +}) { + const { field } = useController({ + control, + name, + }) + return ( + + ) +} + +export function EmailSignUp() { + const { control, handleSubmit, formState } = useForm({ + resolver: zodResolver(signupFormSchema), + defaultValues: { + email: "", + password: "", + confirmPassword: "", + }, + }) + + const submitMutation = useMutation({ + mutationFn: async (values: SignupFormValue) => { + await signUp + .email( + { + email: values.email, + password: values.password, + name: values.email.split("@")[0] ?? "", + }, + { + headers: await getTokenHeaders(), + }, + ) + .then((res) => { + if (res.error?.message) { + toast.error(res.error.message) + } else { + toast.success("Sign up successful") + tracker.register({ type: "email" }) + Navigation.rootNavigation.back() + } + }) + }, + }) + + const signup = handleSubmit((values) => { + submitMutation.mutate(values) + }) + + return ( + + + + { + KeyboardController.setFocusTo("next") + }} + /> + + + + + + + + { + signup() + }} + /> + + + ) } diff --git a/apps/mobile/src/modules/login/index.tsx b/apps/mobile/src/modules/login/index.tsx index 7c405d150..5854eeef6 100644 --- a/apps/mobile/src/modules/login/index.tsx +++ b/apps/mobile/src/modules/login/index.tsx @@ -1,5 +1,6 @@ -import { useCallback } from "react" -import { Text, TouchableWithoutFeedback, View } from "react-native" +import { useCallback, useState } from "react" +import { Trans, useTranslation } from "react-i18next" +import { Text, TouchableOpacity, TouchableWithoutFeedback, View } from "react-native" import { KeyboardController } from "react-native-keyboard-controller" import Animated, { useAnimatedStyle, useSharedValue } from "react-native-reanimated" import { useSafeAreaInsets } from "react-native-safe-area-context" @@ -11,7 +12,7 @@ import { NavigationLink } from "@/src/lib/navigation/NavigationLink" import { useScaleHeight } from "@/src/lib/responsive" import { TermsMarkdown, TermsScreen } from "@/src/screens/(headless)/terms" -import { EmailLogin } from "./email" +import { EmailLogin, EmailSignUp } from "./email" import { SocialLogin } from "./social" export function Login() { @@ -21,46 +22,70 @@ export function Login() { const gapSize = scaledHeight(28) const fontSize = scaledHeight(28) const lineHeight = scaledHeight(32) + const { t } = useTranslation() + + const [isRegister, setIsRegister] = useState(true) + const [isEmail, setIsEmail] = useState(false) return ( - - { - KeyboardController.dismiss() + { + KeyboardController.dismiss() + }} + accessible={false} + > + - + - - - Sign in to Folo - - - - - - - - - or - - - + {`${isRegister ? t("signin.sign_up_to") : t("signin.sign_in_to")} `} + Folo + + {isEmail ? ( + isRegister ? ( + + ) : ( + + ) + ) : ( + setIsEmail(true)} /> + )} - + + + {isEmail ? ( + setIsEmail(false)} + > + {t("login.back")} + + ) : ( + setIsRegister(!isRegister)}> + + , + }} + /> + + + )} + ) } @@ -86,12 +111,13 @@ const TermsText = () => { + By continuing, you agree to our{" "} - Terms of Service + Terms of Service diff --git a/apps/mobile/src/modules/login/social.tsx b/apps/mobile/src/modules/login/social.tsx index 46f5c3fcd..a27a4bbaf 100644 --- a/apps/mobile/src/modules/login/social.tsx +++ b/apps/mobile/src/modules/login/social.tsx @@ -1,107 +1,83 @@ import { tracker } from "@follow/tracker" import * as AppleAuthentication from "expo-apple-authentication" import { useColorScheme } from "nativewind" -import { Platform, TouchableOpacity, View } from "react-native" +import { useTranslation } from "react-i18next" +import { Text, TouchableOpacity, View } from "react-native" -import { AppleCuteFiIcon } from "@/src/icons/apple_cute_fi" -import { GithubCuteFiIcon } from "@/src/icons/github_cute_fi" -import { GoogleCuteFiIcon } from "@/src/icons/google_cute_fi" +import { Image } from "@/src/components/ui/image/Image" import { signIn, useAuthProviders } from "@/src/lib/auth" -const provider: Record< - string, - { - id: string - color: string - darkColor: string - icon: (props: { width?: number; height?: number; color?: string }) => React.ReactNode - } -> = { - google: { - id: "google", - color: "#3b82f6", - darkColor: "#2563eb", - icon: GoogleCuteFiIcon, - }, - github: { - id: "github", - color: "#000000", - darkColor: "#ffffff", - icon: GithubCuteFiIcon, - }, - apple: { - id: "apple", - color: "#1f2937", - darkColor: "#ffffff", - icon: AppleCuteFiIcon, - }, -} - -export function SocialLogin() { - const { data } = useAuthProviders() +export function SocialLogin({ onPressEmail }: { onPressEmail: () => void }) { + const { data: authProviders } = useAuthProviders() const { colorScheme } = useColorScheme() + const providers = Object.entries(authProviders || []) + const { t } = useTranslation() return ( - - {Object.keys(provider) - .filter((key) => key !== "apple" || (Platform.OS === "ios" && key === "apple")) - .map((key) => { - const providerInfo = provider[key]! - return ( - { - if (!data?.[providerInfo.id]) return + + {providers.map(([key, provider]) => { + return ( + { + if (provider.id === "credential") { + onPressEmail() + return + } + if (provider.id === "apple") { + try { + const credential = await AppleAuthentication.signInAsync({ + requestedScopes: [ + AppleAuthentication.AppleAuthenticationScope.FULL_NAME, + AppleAuthentication.AppleAuthenticationScope.EMAIL, + ], + }) - if (providerInfo.id === "apple") { - try { - const credential = await AppleAuthentication.signInAsync({ - requestedScopes: [ - AppleAuthentication.AppleAuthenticationScope.FULL_NAME, - AppleAuthentication.AppleAuthenticationScope.EMAIL, - ], + if (credential.identityToken) { + await signIn.social({ + provider: "apple", + idToken: { + token: credential.identityToken, + }, }) - - if (credential.identityToken) { - await signIn.social({ - provider: "apple", - idToken: { - token: credential.identityToken, - }, - }) - tracker.userLogin({ - type: "social", - }) - } else { - throw new Error("No identityToken.") - } - } catch (e) { - console.error(e) - // handle errors + tracker.userLogin({ + type: "social", + }) + } else { + throw new Error("No identityToken.") } - return + } catch (e) { + console.error(e) + // handle errors } + return + } - await signIn.social({ - provider: providerInfo.id as any, - callbackURL: "/", - }) - tracker.userLogin({ - type: "social", - }) + await signIn.social({ + provider: provider.id as any, + callbackURL: "/", + }) + tracker.userLogin({ + type: "social", + }) + }} + > + - - - ) - })} + className="absolute left-9 size-6" + contentFit="contain" + /> + + {t("login.continueWith", { provider: provider.name })} + + + ) + })} ) } diff --git a/apps/mobile/src/screens/(modal)/SignUpScreen.tsx b/apps/mobile/src/screens/(modal)/SignUpScreen.tsx deleted file mode 100644 index 3a9a0d466..000000000 --- a/apps/mobile/src/screens/(modal)/SignUpScreen.tsx +++ /dev/null @@ -1,202 +0,0 @@ -import { tracker } from "@follow/tracker" -import { zodResolver } from "@hookform/resolvers/zod" -import { useMutation } from "@tanstack/react-query" -import type { Control } from "react-hook-form" -import { useController, useForm } from "react-hook-form" -import type { TextInputProps } from "react-native" -import { Text, TouchableWithoutFeedback, View } from "react-native" -import { KeyboardController } from "react-native-keyboard-controller" -import { useSafeAreaInsets } from "react-native-safe-area-context" -import { z } from "zod" - -import { SubmitButton } from "@/src/components/common/SubmitButton" -import { HeaderCloseOnly } from "@/src/components/layouts/header/HeaderElements" -import { PlainTextField } from "@/src/components/ui/form/TextField" -import { Logo } from "@/src/components/ui/logo" -import { signUp } from "@/src/lib/auth" -import { Navigation } from "@/src/lib/navigation/Navigation" -import type { NavigationControllerView } from "@/src/lib/navigation/types" -import { useScaleHeight } from "@/src/lib/responsive" -import { toast } from "@/src/lib/toast" -import { getTokenHeaders } from "@/src/lib/token" -import { accentColor } from "@/src/theme/colors" - -const formSchema = z - .object({ - email: z.string().email(), - password: z.string().min(8).max(128), - confirmPassword: z.string(), - }) - .refine((data) => data.password === data.confirmPassword, { - message: "Passwords don't match", - path: ["confirmPassword"], - }) - -type FormValue = z.infer - -async function onSubmit(values: FormValue) { - await signUp - .email( - { - email: values.email, - password: values.password, - name: values.email.split("@")[0] ?? "", - }, - { - headers: await getTokenHeaders(), - }, - ) - .then((res) => { - if (res.error?.message) { - toast.error(res.error.message) - } else { - toast.success("Sign up successful") - tracker.register({ type: "email" }) - Navigation.rootNavigation.back() - } - }) -} - -function Input({ - control, - name, - ...rest -}: TextInputProps & { - control: Control - name: keyof FormValue -}) { - const { field } = useController({ - control, - name, - }) - return ( - - ) -} - -function EmailSignUp() { - const { control, handleSubmit, formState } = useForm({ - resolver: zodResolver(formSchema), - defaultValues: { - email: "", - password: "", - confirmPassword: "", - }, - }) - - const submitMutation = useMutation({ - mutationFn: onSubmit, - }) - - const signup = handleSubmit((values) => { - submitMutation.mutate(values) - }) - - return ( - - - - { - KeyboardController.setFocusTo("next") - }} - /> - - - - - - - - { - signup() - }} - /> - - - - - ) -} - -export const SignUpScreen: NavigationControllerView = () => { - const insets = useSafeAreaInsets() - const scaledHeight = useScaleHeight() - const logoSize = scaledHeight(80) - const gapSize = scaledHeight(28) - const fontSize = scaledHeight(28) - const lineHeight = scaledHeight(32) - return ( - - - { - KeyboardController.dismiss() - }} - accessible={false} - > - - - - Sign up to Folo - - - - - - ) -} diff --git a/apps/mobile/src/sitemap.tsx b/apps/mobile/src/sitemap.tsx index 7a02091f9..f1eeeb8c0 100644 --- a/apps/mobile/src/sitemap.tsx +++ b/apps/mobile/src/sitemap.tsx @@ -10,7 +10,6 @@ import { TermsScreen } from "./screens/(headless)/terms" import { ForgetPasswordScreen } from "./screens/(modal)/ForgetPasswordScreen" import { InvitationScreen } from "./screens/(modal)/InvitationScreen" import { LoginScreen } from "./screens/(modal)/LoginScreen" -import { SignUpScreen } from "./screens/(modal)/SignUpScreen" import { TwoFactorAuthScreen } from "./screens/(modal)/TwoFactorAuthScreen" import { OnboardingScreen } from "./screens/OnboardingScreen" @@ -18,7 +17,7 @@ export function registerSitemap() { ;[TermsScreen].forEach((Component) => { NavigationSitemapRegistry.registerByComponent(Component) }) - ;[LoginScreen, InvitationScreen, SignUpScreen, ForgetPasswordScreen, TwoFactorAuthScreen].forEach( + ;[LoginScreen, InvitationScreen, ForgetPasswordScreen, TwoFactorAuthScreen].forEach( (Component) => { NavigationSitemapRegistry.registerByComponent(Component, void 0, { stackPresentation: "modal", diff --git a/locales/app/en.json b/locales/app/en.json index 6becda0d6..932f8a664 100644 --- a/locales/app/en.json +++ b/locales/app/en.json @@ -224,12 +224,13 @@ "feed_item.claimed_list": "Claimed List", "feed_item.error_since": "Error since", "feed_item.not_publicly_visible": "Not publicly visible on your profile page", + "login.back": "Back", "login.confirm_password.label": "Confirm Password", "login.continueWith": "Continue with {{provider}}", "login.email": "Email", "login.forget_password.note": "Forgot your password?", - "login.have_account": "Already have an account? Sign in →", - "login.no_account": "Don't have an account? Sign up →", + "login.have_account": "Already have an account? Sign in", + "login.no_account": "Don't have an account? Sign up", "login.or": "OR", "login.password": "Password", "login.signUp": "Sign up with email", diff --git a/locales/app/zh-CN.json b/locales/app/zh-CN.json index eb542dc8c..878eba4e6 100644 --- a/locales/app/zh-CN.json +++ b/locales/app/zh-CN.json @@ -222,12 +222,13 @@ "feed_item.claimed_list": "已认证列表", "feed_item.error_since": "源失效:", "feed_item.not_publicly_visible": "在个人页面上隐藏", + "login.back": "返回", "login.confirm_password.label": "确认密码", "login.continueWith": "使用 {{provider}} 继续", "login.email": "邮件地址", "login.forget_password.note": "忘记了密码?", - "login.have_account": "已有账户?登录 →", - "login.no_account": "没有账户?注册 →", + "login.have_account": "已有账户?登录", + "login.no_account": "没有账户?注册", "login.or": "或", "login.password": "密码", "login.signUp": "使用邮件地址注册", diff --git a/locales/external/en.json b/locales/external/en.json index 062aaea5c..45c2f4ca6 100644 --- a/locales/external/en.json +++ b/locales/external/en.json @@ -48,11 +48,11 @@ "login.forget_password.label": "Forget Password", "login.forget_password.note": "Forgot your password?", "login.forget_password.success": "Email has been sent successfully", - "login.have_account": "Already have an account? Sign in →", + "login.have_account": "Already have an account? Sign in", "login.logInTo": "Sign in to", "login.logInWithEmail": "Sign in with email", "login.new_password.label": "New Password", - "login.no_account": "Don't have an account? Sign up →", + "login.no_account": "Don't have an account? Sign up", "login.openApp": "Open App", "login.or": "Or", "login.password": "Password", diff --git a/locales/external/zh-CN.json b/locales/external/zh-CN.json index fe3892c07..47fa1f3fa 100644 --- a/locales/external/zh-CN.json +++ b/locales/external/zh-CN.json @@ -48,11 +48,11 @@ "login.forget_password.label": "忘记密码", "login.forget_password.note": "忘记了密码?", "login.forget_password.success": "邮件已成功发送。", - "login.have_account": "已有账户?登录 →", + "login.have_account": "已有账户?登录", "login.logInTo": "登录 ", "login.logInWithEmail": "使用邮件地址登录", "login.new_password.label": "新密码", - "login.no_account": "没有账户?注册 →", + "login.no_account": "没有账户?注册", "login.openApp": "打开应用", "login.or": "或", "login.password": "密码", diff --git a/locales/mobile/default/en.json b/locales/mobile/default/en.json index c163d1df3..d0483d855 100644 --- a/locales/mobile/default/en.json +++ b/locales/mobile/default/en.json @@ -1,4 +1,8 @@ { + "login.back": "Back", + "login.continueWith": "Continue with {{provider}}", + "login.have_account": "Already have an account? Sign in", + "login.no_account": "Don't have an account? Sign up", "onboarding.edit_profile": "Edit Profile", "onboarding.edit_profile_description": "Change your name, email, and profile picture", "onboarding.finished_description": "You have completed the guide. Enjoy your journey!", @@ -50,6 +54,8 @@ "operation.unstar": "Unstar", "profile.title": "{{name}}'s Profile", "profile.uncategorized_feeds": "Uncategorized feeds", + "signin.sign_in_to": "Sign in to", + "signin.sign_up_to": "Sign up to", "subscription_form.category": "Category", "subscription_form.category_description": "By default, your follows will be grouped by website.", "subscription_form.private_follow": "Private Follow", diff --git a/locales/mobile/default/zh-CN.json b/locales/mobile/default/zh-CN.json index 617779d52..cb2f1554d 100644 --- a/locales/mobile/default/zh-CN.json +++ b/locales/mobile/default/zh-CN.json @@ -1,4 +1,8 @@ { + "login.back": "返回", + "login.continueWith": "使用 {{provider}} 继续", + "login.have_account": "已有账户?登录", + "login.no_account": "没有账户?注册", "onboarding.edit_profile": "编辑个人资料", "onboarding.edit_profile_description": "更改你的姓名、电子邮件和头像", "onboarding.finished_description": "你已完成所有设置步骤。开始享受 Folo 带来的精彩体验吧!", @@ -49,6 +53,8 @@ "operation.unstar": "取消收藏", "profile.title": "{{name}}的个人资料", "profile.uncategorized_feeds": "未分类的订阅源", + "signin.sign_in_to": "登录", + "signin.sign_up_to": "注册", "subscription_form.category": "分类", "subscription_form.category_description": "默认情况下,你的订阅将按网站域名分组。", "subscription_form.private_follow": "私密订阅", From 689dac695d557324c86b6a8e20fe84379a901db9 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Thu, 15 May 2025 12:37:57 +0800 Subject: [PATCH 071/104] chore: update lan-network to get correct expo dev server url this can be removed after expo 53.0.9 --- package.json | 1 + pnpm-lock.yaml | 9 +++++---- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index 5ab3b9902..03ba052dc 100644 --- a/package.json +++ b/package.json @@ -84,6 +84,7 @@ "@electron/node-gyp": "10.2.0-electron.2", "is-core-module": "npm:@nolyfill/is-core-module@1.0.39", "isarray": "npm:@nolyfill/isarray@1.0.44", + "lan-network@<0.1.6": "0.1.6", "react": "19.0.0", "react-dom": "19.0.0", "react-native-ios-context-menu": "3.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 96a1c7f38..2a82e9f2d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,7 @@ overrides: '@electron/node-gyp': 10.2.0-electron.2 is-core-module: npm:@nolyfill/is-core-module@1.0.39 isarray: npm:@nolyfill/isarray@1.0.44 + lan-network@<0.1.6: 0.1.6 react: 19.0.0 react-dom: 19.0.0 react-native-ios-context-menu: 3.1.1 @@ -10921,8 +10922,8 @@ packages: resolution: {integrity: sha512-FIyV/64EkKhJmjgC0g2hygpBv5RNWVPyNCqSAD7eTCv6eFWNIi4PN1UvdSJGicN/o35bnevgis4Y0UDC0qi8jQ==} engines: {node: '>=14.0.0'} - lan-network@0.1.4: - resolution: {integrity: sha512-9EzcRaFzlj3nSwcn2VOdxm30uHBBCgkX8/xzhSIlZXvQ1N1QV2cSwSrL+4bJC/WI+3k1f9SNJuPt4/F/9Au6hQ==} + lan-network@0.1.6: + resolution: {integrity: sha512-0qPYjNoD89v+bfhkIqFBYGBAof1xhxLqjX8bkNN1kQdP81UHpZw5TDXgEjwB+X2iCFGQmzF8TRmvg4vQcykyDA==} hasBin: true launch-ide@1.0.7: @@ -17607,7 +17608,7 @@ snapshots: freeport-async: 2.0.0 getenv: 1.0.0 glob: 10.4.5 - lan-network: 0.1.4 + lan-network: 0.1.6 minimatch: 9.0.5 node-forge: 1.3.1 npm-package-arg: 11.0.3 @@ -27062,7 +27063,7 @@ snapshots: kysely@0.27.6: {} - lan-network@0.1.4: {} + lan-network@0.1.6: {} launch-ide@1.0.7: dependencies: From 7a6d627cb4c0ece542d3111573496cdfa86d417b Mon Sep 17 00:00:00 2001 From: Innei Date: Thu, 15 May 2025 13:29:53 +0800 Subject: [PATCH 072/104] fix(desktop): adjust styling for list item and entry header components - Changed font weight from 'semibold' to 'medium' in ListItem component for better visual consistency. - Updated icon class in EntryHeader component to ensure proper styling and alignment of elements. These adjustments improve the overall appearance and readability of the entry components. Signed-off-by: Innei --- .../src/modules/entry-column/templates/list-item-template.tsx | 2 +- .../renderer/src/modules/entry-content/header.electron.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/templates/list-item-template.tsx b/apps/desktop/layer/renderer/src/modules/entry-column/templates/list-item-template.tsx index 13b0c481a..daffa41c8 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-column/templates/list-item-template.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-column/templates/list-item-template.tsx @@ -156,7 +156,7 @@ export function ListItem({ > {entry.entries.title ? ( diff --git a/apps/desktop/layer/renderer/src/modules/entry-content/header.electron.tsx b/apps/desktop/layer/renderer/src/modules/entry-content/header.electron.tsx index 023e878a8..9c02df5b1 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-content/header.electron.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-content/header.electron.tsx @@ -60,8 +60,8 @@ function EntryHeaderImpl({ view, entryId, className, compact }: EntryHeaderProps {entryTitleMeta.title} - - + + {entryTitleMeta.description} From 13120c7b0dd3b5acc540225f62858c04064931fc Mon Sep 17 00:00:00 2001 From: Innei Date: Thu, 15 May 2025 14:55:06 +0800 Subject: [PATCH 073/104] feat(desktop): enhance subscription navigation and shortcuts - Renamed and updated shortcut configurations from "feeds" to "subscriptions" for better clarity and organization. - Introduced new commands for navigating to the next and previous subscriptions. - Implemented keyboard shortcuts for subscription navigation and focused subscription view. - Updated localization files to reflect changes in shortcut keys and labels. These enhancements improve user experience by streamlining subscription management and navigation. Signed-off-by: Innei --- .../layer/renderer/src/constants/shortcuts.ts | 20 +++-- .../src/modules/command/commands/id.ts | 4 + .../src/modules/command/commands/layout.tsx | 19 ++++- .../modules/command/commands/subscription.tsx | 29 +++++++ .../command/hooks/use-command-shortcut.ts | 6 +- .../EntryColumnShortcutHandler.tsx | 6 ++ .../components/EntryReadHistory.electron.tsx | 22 ++++- .../components/EntryReadHistory.shared.tsx | 20 ----- .../modules/timeline-column/FeedCategory.tsx | 13 +-- .../src/modules/timeline-column/FeedItem.tsx | 13 +-- .../timeline-column/FeedList.electron.tsx | 82 ++++++++++++++++++- .../timeline-column/FeedList.shared.tsx | 1 + .../src/modules/timeline-column/index.tsx | 8 ++ .../src/providers/global-hotkeys-provider.tsx | 6 ++ locales/shortcuts/en.json | 14 ++-- locales/shortcuts/ja.json | 8 +- locales/shortcuts/zh-CN.json | 8 +- locales/shortcuts/zh-TW.json | 8 +- .../src/common/Focusable/Focusable.tsx | 24 ++++-- .../src/common/Focusable/context.ts | 3 + .../components/src/common/Focusable/hooks.ts | 11 ++- 21 files changed, 253 insertions(+), 72 deletions(-) diff --git a/apps/desktop/layer/renderer/src/constants/shortcuts.ts b/apps/desktop/layer/renderer/src/constants/shortcuts.ts index 1986460b9..71ab26461 100644 --- a/apps/desktop/layer/renderer/src/constants/shortcuts.ts +++ b/apps/desktop/layer/renderer/src/constants/shortcuts.ts @@ -8,23 +8,31 @@ type Shortcuts = Record< > const shortcutConfigs = { - feeds: { + subscriptions: { add: { - name: tShortcuts("keys.feeds.add"), + name: tShortcuts("keys.subscriptions.add"), key: "Meta+T", }, switchToView: { - name: tShortcuts("keys.feeds.switchToView"), + name: tShortcuts("keys.subscriptions.switchToView"), key: "1, 2, 3, 4, 5, 6", }, switchNextView: { - name: tShortcuts("keys.feeds.switchNextView"), + name: tShortcuts("keys.subscriptions.switchNextView"), key: "Tab, ArrowRight", }, switchPreviousView: { - name: tShortcuts("keys.feeds.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", + }, }, layout: { toggleSidebar: { @@ -147,7 +155,7 @@ function transformShortcuts(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", diff --git a/apps/desktop/layer/renderer/src/modules/command/commands/id.ts b/apps/desktop/layer/renderer/src/modules/command/commands/id.ts index da71ab50d..4ee810f8f 100644 --- a/apps/desktop/layer/renderer/src/modules/command/commands/id.ts +++ b/apps/desktop/layer/renderer/src/modules/command/commands/id.ts @@ -47,6 +47,7 @@ export const COMMAND_ID = { layout: { toggleTimelineColumn: "layout:toggle-timeline-column", focusToTimeline: "layout:focus-to-timeline", + focusToSubscription: "layout:focus-to-subscription", }, timeline: { switchToNext: "timeline:switch-to-next", @@ -69,5 +70,8 @@ export const COMMAND_ID = { switchTabToVideo: "subscription:switch-tab-to-video", switchTabToAudio: "subscription:switch-tab-to-audio", switchTabToNotification: "subscription:switch-tab-to-notification", + + nextSubscription: "subscription:next", + previousSubscription: "subscription:previous", }, } as const diff --git a/apps/desktop/layer/renderer/src/modules/command/commands/layout.tsx b/apps/desktop/layer/renderer/src/modules/command/commands/layout.tsx index 00e3e6a64..9c7aadd09 100644 --- a/apps/desktop/layer/renderer/src/modules/command/commands/layout.tsx +++ b/apps/desktop/layer/renderer/src/modules/command/commands/layout.tsx @@ -9,6 +9,7 @@ import { COMMAND_ID } from "./id" declare module "@follow/utils/event-bus" { interface EventBusMap { "layout:focus-to-timeline": never + "layout:focus-to-subscription": never } } @@ -28,9 +29,21 @@ export const useRegisterLayoutCommands = () => { 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 @@ -40,5 +53,7 @@ export type FocusToTimelineCommand = Command<{ id: typeof COMMAND_ID.layout.focusToTimeline fn: () => void }> - -export type LayoutCommand = ToggleTimelineColumnCommand | FocusToTimelineCommand +export type LayoutCommand = + | ToggleTimelineColumnCommand + | FocusToTimelineCommand + | FocusToSubscriptionCommand diff --git a/apps/desktop/layer/renderer/src/modules/command/commands/subscription.tsx b/apps/desktop/layer/renderer/src/modules/command/commands/subscription.tsx index 03be9e054..959a8becc 100644 --- a/apps/desktop/layer/renderer/src/modules/command/commands/subscription.tsx +++ b/apps/desktop/layer/renderer/src/modules/command/commands/subscription.tsx @@ -14,6 +14,9 @@ declare module "@follow/utils/event-bus" { "subscription:switch-tab-to-video": never "subscription:switch-tab-to-audio": never "subscription:switch-tab-to-notification": never + + "subscription:next": never + "subscription:previous": never } } const LABEL_PREFIX = "Subscription" @@ -75,6 +78,20 @@ export const useRegisterSubscriptionCommands = () => { 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) + }, + }, ]) } @@ -118,6 +135,16 @@ type SwitchTabToNotificationCommand = Command<{ fn: () => void }> +type NextSubscriptionCommand = Command<{ + id: typeof COMMAND_ID.subscription.nextSubscription + fn: () => void +}> + +type PreviousSubscriptionCommand = Command<{ + id: typeof COMMAND_ID.subscription.previousSubscription + fn: () => void +}> + export type SubscriptionCommand = | SwitchTabToNextCommand | SwitchTabToPreviousCommand @@ -127,3 +154,5 @@ export type SubscriptionCommand = | SwitchTabToVideoCommand | SwitchTabToAudioCommand | SwitchTabToNotificationCommand + | NextSubscriptionCommand + | PreviousSubscriptionCommand diff --git a/apps/desktop/layer/renderer/src/modules/command/hooks/use-command-shortcut.ts b/apps/desktop/layer/renderer/src/modules/command/hooks/use-command-shortcut.ts index 939e1ed6b..b56e22a33 100644 --- a/apps/desktop/layer/renderer/src/modules/command/hooks/use-command-shortcut.ts +++ b/apps/desktop/layer/renderer/src/modules/command/hooks/use-command-shortcut.ts @@ -21,8 +21,10 @@ const defaultCommandShortcuts = { [COMMAND_ID.layout.toggleTimelineColumn]: shortcuts.layout.toggleSidebar.key, - [COMMAND_ID.subscription.switchTabToNext]: shortcuts.feeds.switchNextView.key, - [COMMAND_ID.subscription.switchTabToPrevious]: shortcuts.feeds.switchPreviousView.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, diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/EntryColumnShortcutHandler.tsx b/apps/desktop/layer/renderer/src/modules/entry-column/EntryColumnShortcutHandler.tsx index f8f710790..3f1cc5528 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-column/EntryColumnShortcutHandler.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-column/EntryColumnShortcutHandler.tsx @@ -48,6 +48,12 @@ export const EntryColumnShortcutHandler: FC<{ when, }) + useCommandHotkey({ + commandId: COMMAND_ID.layout.focusToSubscription, + shortcut: "Backspace, Escape", + when, + }) + const currentEntryIdRef = useRefValue(useRouteEntryId()) const navigate = useNavigateEntry() diff --git a/apps/desktop/layer/renderer/src/modules/entry-content/components/EntryReadHistory.electron.tsx b/apps/desktop/layer/renderer/src/modules/entry-content/components/EntryReadHistory.electron.tsx index 937dc597c..c72e1e515 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-content/components/EntryReadHistory.electron.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-content/components/EntryReadHistory.electron.tsx @@ -1,12 +1,32 @@ 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, 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() diff --git a/apps/desktop/layer/renderer/src/modules/entry-content/components/EntryReadHistory.shared.tsx b/apps/desktop/layer/renderer/src/modules/entry-content/components/EntryReadHistory.shared.tsx index 276807e1a..b8f58855d 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-content/components/EntryReadHistory.shared.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-content/components/EntryReadHistory.shared.tsx @@ -1,35 +1,15 @@ import { Avatar, AvatarFallback, AvatarImage } from "@follow/components/ui/avatar/index.jsx" import { TooltipContent, TooltipPortal } from "@follow/components/ui/tooltip/index.jsx" -import { FeedViewType } from "@follow/constants" 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 EntryUser: Component<{ userId: string ref?: React.Ref diff --git a/apps/desktop/layer/renderer/src/modules/timeline-column/FeedCategory.tsx b/apps/desktop/layer/renderer/src/modules/timeline-column/FeedCategory.tsx index 2c597f3e9..727a02131 100644 --- a/apps/desktop/layer/renderer/src/modules/timeline-column/FeedCategory.tsx +++ b/apps/desktop/layer/renderer/src/modules/timeline-column/FeedCategory.tsx @@ -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) => { @@ -270,7 +270,7 @@ function FeedCategoryImpl({ data: ids, view, categoryOpenStateData }: FeedCatego }) return (
    - {!!showCollapse && ( + {!!isCategory && (
    { e.stopPropagation() if (!isCategoryEditing) { @@ -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 > diff --git a/apps/desktop/layer/renderer/src/modules/timeline-column/FeedItem.tsx b/apps/desktop/layer/renderer/src/modules/timeline-column/FeedItem.tsx index f269283fd..20b2584e9 100644 --- a/apps/desktop/layer/renderer/src/modules/timeline-column/FeedItem.tsx +++ b/apps/desktop/layer/renderer/src/modules/timeline-column/FeedItem.tsx @@ -9,13 +9,11 @@ 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 { 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 { useGeneralSettingKey } from "~/atoms/settings/general" import { ErrorTooltip } from "~/components/common/ErrorTooltip" import { useFeedActions, useInboxActions, useListActions } from "~/hooks/biz/useFeedActions" @@ -106,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], ) @@ -167,6 +161,7 @@ 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() - }) }, [listId, navigate, view], ) @@ -296,6 +287,7 @@ const ListItemImpl: Component = ({ return (
    = ({ view, inboxId, className, ic return (
    { useFeedQuery({ id: isFeedPreview ? feedId : undefined }) useList({ id: isListPreview ? listId : undefined }) + useRegisterCommand() + return (
    @@ -285,3 +296,72 @@ const FeedListImpl = ({ ref, className, view }: FeedListProps) => { FeedListImpl.displayName = "FeedListImpl" export const FeedList = memo(FeedListImpl) + +const useRegisterCommand = () => { + const isFocus = useFocusable() + const focusableContainerRef = useFocusableContainerRef() + const focusActions = useFocusActions() + + useCommandBinding({ + commandId: COMMAND_ID.subscription.nextSubscription, + when: isFocus, + }) + + useCommandBinding({ + commandId: COMMAND_ID.subscription.previousSubscription, + when: isFocus, + }) + + useCommandHotkey({ + commandId: COMMAND_ID.layout.focusToTimeline, + when: isFocus, + shortcut: "Enter", + }) + + useEffect(() => { + const handleSubscriptionNavigation = (direction: "next" | "previous") => { + const container = focusableContainerRef.current + if (!container) return + + const allSubscriptions = Array.from(container.querySelectorAll("[data-sub]")) + if (allSubscriptions.length === 0) return + + const currentActive = container.querySelector("[data-active=true]") + + if (!currentActive) { + // If no active item, select first or last based on direction + const defaultIndex = direction === "next" ? 0 : -1 + ;(allSubscriptions.at(defaultIndex) as HTMLElement)?.click() + return + } + + const currentIndex = allSubscriptions.indexOf(currentActive) + let targetIndex: number + + if (direction === "next") { + targetIndex = (currentIndex + 1) % allSubscriptions.length + } else { + targetIndex = (currentIndex - 1 + allSubscriptions.length) % allSubscriptions.length + } + + const targetElement = allSubscriptions[targetIndex] as HTMLElement | null + + targetElement?.click() + } + + return combineCleanupFunctions( + EventBus.subscribe(COMMAND_ID.subscription.nextSubscription, () => { + handleSubscriptionNavigation("next") + }), + EventBus.subscribe(COMMAND_ID.subscription.previousSubscription, () => { + handleSubscriptionNavigation("previous") + }), + EventBus.subscribe(COMMAND_ID.layout.focusToSubscription, () => { + focusableContainerRef.current?.focus() + nextFrame(() => { + focusActions.highlightBoundary() + }) + }), + ) + }, [focusableContainerRef, focusActions]) +} diff --git a/apps/desktop/layer/renderer/src/modules/timeline-column/FeedList.shared.tsx b/apps/desktop/layer/renderer/src/modules/timeline-column/FeedList.shared.tsx index b5fa1931b..e5f36223f 100644 --- a/apps/desktop/layer/renderer/src/modules/timeline-column/FeedList.shared.tsx +++ b/apps/desktop/layer/renderer/src/modules/timeline-column/FeedList.shared.tsx @@ -214,6 +214,7 @@ export const StarredItem = memo(({ view }: { view: number }) => { return (
    (null) + + useEffect(() => { + if (!focusableContainerRef.current) return + focusableContainerRef.current.focus() + }, []) + return ( { if (document.hasFocus()) { navigateBackHome() diff --git a/apps/desktop/layer/renderer/src/providers/global-hotkeys-provider.tsx b/apps/desktop/layer/renderer/src/providers/global-hotkeys-provider.tsx index 8061251d5..d8e7971d3 100644 --- a/apps/desktop/layer/renderer/src/providers/global-hotkeys-provider.tsx +++ b/apps/desktop/layer/renderer/src/providers/global-hotkeys-provider.tsx @@ -20,6 +20,12 @@ export const GlobalHotkeysProvider = () => { useEventListener("keydown", (e) => { if (e.key === "Tab") { nextFrame(() => { + if ( + document.activeElement instanceof HTMLInputElement || + document.activeElement instanceof HTMLTextAreaElement + ) { + return + } highlightElement(document.activeElement as HTMLElement) }) } diff --git a/locales/shortcuts/en.json b/locales/shortcuts/en.json index 9aa03b7ce..be20faf7e 100644 --- a/locales/shortcuts/en.json +++ b/locales/shortcuts/en.json @@ -16,21 +16,23 @@ "keys.entry.toggleRead": "Toggle Read", "keys.entry.toggleStarred": "Toggle Starred", "keys.entry.tts": "Play TTS", - "keys.feeds.add": "Add Subscription", - "keys.feeds.switchBetweenViews": "Switch Between Views", - "keys.feeds.switchNextView": "Switch to Next View", - "keys.feeds.switchPreviousView": "Switch to Previous View", - "keys.feeds.switchToView": "Switch to View", "keys.layout.showShortcuts": "Show/Hide Shortcuts", "keys.layout.toggleSidebar": "Show/Hide Feed Sidebar", "keys.layout.toggleWideMode": "Toggle Wide Mode", "keys.layout.zenMode": "Zen Mode", "keys.misc.quickSearch": "Quick Search", + "keys.subscriptions.add": "Add Subscription", + "keys.subscriptions.nextSubscription": "Next Subscription", + "keys.subscriptions.previousSubscription": "Previous Subscription", + "keys.subscriptions.switchBetweenViews": "Switch Between Views", + "keys.subscriptions.switchNextView": "Switch to Next View", + "keys.subscriptions.switchPreviousView": "Switch to Previous View", + "keys.subscriptions.switchToView": "Switch to View", "keys.type.audio": "audio", "keys.type.entries": "entries", "keys.type.entry": "entry", - "keys.type.feeds": "feeds", "keys.type.layout": "layout", "keys.type.misc": "misc", + "keys.type.subscriptions": "subscriptions", "sidebar_title": "Shortcuts" } diff --git a/locales/shortcuts/ja.json b/locales/shortcuts/ja.json index 5d1afc079..55440e457 100644 --- a/locales/shortcuts/ja.json +++ b/locales/shortcuts/ja.json @@ -16,19 +16,19 @@ "keys.entry.toggleRead": "既読/未読の切り替え", "keys.entry.toggleStarred": "スターの切り替え", "keys.entry.tts": "TTS を再生", - "keys.feeds.add": "購読を追加", - "keys.feeds.switchBetweenViews": "ビューを切り替え", - "keys.feeds.switchToView": "ビューの切り替え", "keys.layout.showShortcuts": "ショートカットを表示/非表示", "keys.layout.toggleSidebar": "フィードサイドバーの表示/非表示", "keys.layout.toggleWideMode": "ワイドモードに切り替え", "keys.layout.zenMode": "Zen モード", "keys.misc.quickSearch": "クイック検索", + "keys.subscriptions.add": "購読を追加", + "keys.subscriptions.switchBetweenViews": "ビューを切り替え", + "keys.subscriptions.switchToView": "ビューの切り替え", "keys.type.audio": "音声", "keys.type.entries": "エントリー", "keys.type.entry": "エントリー", - "keys.type.feeds": "フィード", "keys.type.layout": "レイアウト", "keys.type.misc": "その他", + "keys.type.subscriptions": "フィード", "sidebar_title": "ショートカット" } diff --git a/locales/shortcuts/zh-CN.json b/locales/shortcuts/zh-CN.json index 61490418d..f3185a195 100644 --- a/locales/shortcuts/zh-CN.json +++ b/locales/shortcuts/zh-CN.json @@ -16,19 +16,19 @@ "keys.entry.toggleRead": "标记为已读/未读", "keys.entry.toggleStarred": "加入收藏/取消收藏", "keys.entry.tts": "文本转语音", - "keys.feeds.add": "添加订阅源", - "keys.feeds.switchBetweenViews": "在类型之间切换", - "keys.feeds.switchToView": "切换到指定类型", "keys.layout.showShortcuts": "显示/隐藏快捷键", "keys.layout.toggleSidebar": "显示/隐藏侧边栏", "keys.layout.toggleWideMode": "切换宽屏模式", "keys.layout.zenMode": "禅定模式", "keys.misc.quickSearch": "快速搜索", + "keys.subscriptions.add": "添加订阅源", + "keys.subscriptions.switchBetweenViews": "在类型之间切换", + "keys.subscriptions.switchToView": "切换到指定类型", "keys.type.audio": "音频", "keys.type.entries": "条目列表", "keys.type.entry": "条目", - "keys.type.feeds": "订阅源", "keys.type.layout": "布局", "keys.type.misc": "杂项", + "keys.type.subscriptions": "订阅源", "sidebar_title": "快捷键" } diff --git a/locales/shortcuts/zh-TW.json b/locales/shortcuts/zh-TW.json index a88b0dd67..e812ce4f7 100644 --- a/locales/shortcuts/zh-TW.json +++ b/locales/shortcuts/zh-TW.json @@ -16,19 +16,19 @@ "keys.entry.toggleRead": "切換標記為已讀/未讀", "keys.entry.toggleStarred": "切換收藏/取消收藏", "keys.entry.tts": "播放文字轉語音", - "keys.feeds.add": "新增 RSS 摘要", - "keys.feeds.switchBetweenViews": "在類別之間切換", - "keys.feeds.switchToView": "切換到指定類別", "keys.layout.showShortcuts": "顯示/隱藏快捷鍵", "keys.layout.toggleSidebar": "顯示/隱藏摘要側邊欄", "keys.layout.toggleWideMode": "切換寬螢幕模式", "keys.layout.zenMode": "禪定模式", "keys.misc.quickSearch": "快速搜尋", + "keys.subscriptions.add": "新增 RSS 摘要", + "keys.subscriptions.switchBetweenViews": "在類別之間切換", + "keys.subscriptions.switchToView": "切換到指定類別", "keys.type.audio": "音訊", "keys.type.entries": "條目列表", "keys.type.entry": "條目", - "keys.type.feeds": "RSS 摘要", "keys.type.layout": "佈局", "keys.type.misc": "雜項", + "keys.type.subscriptions": "RSS 摘要", "sidebar_title": "快捷鍵" } diff --git a/packages/internal/components/src/common/Focusable/Focusable.tsx b/packages/internal/components/src/common/Focusable/Focusable.tsx index 3f84b34a9..6b90e749f 100644 --- a/packages/internal/components/src/common/Focusable/Focusable.tsx +++ b/packages/internal/components/src/common/Focusable/Focusable.tsx @@ -1,7 +1,12 @@ -import { useCallback, useImperativeHandle, useMemo, useRef, useState } from "react" +import { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react" import { useEventListener } from "usehooks-ts" -import { FocusableContext, FocusActionsContext, FocusTargetRefContext } from "./context" +import { + FocusableContainerRefContext, + FocusableContext, + FocusActionsContext, + FocusTargetRefContext, +} from "./context" import { highlightElement } from "./utils" export const Focusable: Component< @@ -15,12 +20,15 @@ export const Focusable: Component< useImperativeHandle(ref, () => containerRef.current!) const highlightBoundary = useCallback(() => { - if (!isFocusWithIn) return + const { activeElement } = document + if (!containerRef.current?.contains(activeElement as Node)) { + return + } const element = containerRef.current if (!element) return highlightElement(element) - }, [isFocusWithIn]) + }, []) useEventListener("focusin", (e) => { if (containerRef.current?.contains(e.target as Node)) { @@ -34,12 +42,18 @@ export const Focusable: Component< focusTargetRef.current = undefined } }) + useEffect(() => { + if (!containerRef.current) return + setIsFocusWithIn(containerRef.current.contains(document.activeElement as Node)) + }, [containerRef]) return ( ({ highlightBoundary }), [highlightBoundary])}> -
    + +
    + diff --git a/packages/internal/components/src/common/Focusable/context.ts b/packages/internal/components/src/common/Focusable/context.ts index c50aa988d..735349658 100644 --- a/packages/internal/components/src/common/Focusable/context.ts +++ b/packages/internal/components/src/common/Focusable/context.ts @@ -2,6 +2,9 @@ import { createContext } from "react" export const FocusableContext = createContext(false) export const FocusTargetRefContext = createContext>(null!) +export const FocusableContainerRefContext = createContext>( + null!, +) export const FocusActionsContext = createContext<{ highlightBoundary: () => void }>(null!) diff --git a/packages/internal/components/src/common/Focusable/hooks.ts b/packages/internal/components/src/common/Focusable/hooks.ts index 4a349a1e5..df8799b8e 100644 --- a/packages/internal/components/src/common/Focusable/hooks.ts +++ b/packages/internal/components/src/common/Focusable/hooks.ts @@ -1,6 +1,11 @@ import { use } from "react" -import { FocusableContext, FocusActionsContext, FocusTargetRefContext } from "./context" +import { + FocusableContainerRefContext, + FocusableContext, + FocusActionsContext, + FocusTargetRefContext, +} from "./context" export const useFocusable = () => { return use(FocusableContext) @@ -13,3 +18,7 @@ export const useFocusTargetRef = () => { export const useFocusActions = () => { return use(FocusActionsContext) } + +export const useFocusableContainerRef = () => { + return use(FocusableContainerRefContext) +} From a9172317b7ecf3565765b761777ba9c429db91db Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Thu, 15 May 2025 17:53:34 +0800 Subject: [PATCH 074/104] fix(desktop): hide actions on header for social media --- .../entry-column/Items/social-media-item.tsx | 19 +++++-------------- .../modules/entry-content/header.electron.tsx | 12 +++++++----- 2 files changed, 12 insertions(+), 19 deletions(-) diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/Items/social-media-item.tsx b/apps/desktop/layer/renderer/src/modules/entry-column/Items/social-media-item.tsx index 64762492f..105ad82e1 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-column/Items/social-media-item.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-column/Items/social-media-item.tsx @@ -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" @@ -139,17 +139,8 @@ const ActionBar = ({ entryId }: { entryId: string }) => { return (
    - {entryActions - .filter((item) => item instanceof MenuItemText) - .filter((item) => item.id !== COMMAND_ID.entry.read) - .map((item) => ( - - ))} + +
    ) diff --git a/apps/desktop/layer/renderer/src/modules/entry-content/header.electron.tsx b/apps/desktop/layer/renderer/src/modules/entry-content/header.electron.tsx index 9c02df5b1..1f8449d09 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-content/header.electron.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-content/header.electron.tsx @@ -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" @@ -69,10 +69,12 @@ function EntryHeaderImpl({ view, entryId, className, compact }: EntryHeaderProps
    -
    - - -
    + {view !== FeedViewType.SocialMedia && ( +
    + + +
    + )}
    ) From 75102f4e129c0576e7da90eb396dd66a8d6cdf75 Mon Sep 17 00:00:00 2001 From: DIYgod Date: Thu, 15 May 2025 15:57:03 +0800 Subject: [PATCH 075/104] feat(desktop): trending api types --- apps/desktop/layer/renderer/src/api/trending.ts | 17 ----------------- .../renderer/src/modules/trending/index.tsx | 16 ++++------------ packages/internal/shared/src/hono.ts | 4 ++-- 3 files changed, 6 insertions(+), 31 deletions(-) delete mode 100644 apps/desktop/layer/renderer/src/api/trending.ts diff --git a/apps/desktop/layer/renderer/src/api/trending.ts b/apps/desktop/layer/renderer/src/api/trending.ts deleted file mode 100644 index 061491e0a..000000000 --- a/apps/desktop/layer/renderer/src/api/trending.ts +++ /dev/null @@ -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( - `${v1ApiPrefix}/trendings?language=${params.language}`, - { - params: { - language: params.language, - }, - }, - ) - return camelcaseKeys(data as any, { deep: true }) as Models.TrendingAggregates -} diff --git a/apps/desktop/layer/renderer/src/modules/trending/index.tsx b/apps/desktop/layer/renderer/src/modules/trending/index.tsx index c041c2793..3110ecd5b 100644 --- a/apps/desktop/layer/renderer/src/modules/trending/index.tsx +++ b/apps/desktop/layer/renderer/src/modules/trending/index.tsx @@ -1,14 +1,13 @@ import { ResponsiveSelect } from "@follow/components/ui/select/responsive.js" import { Skeleton } from "@follow/components/ui/skeleton/index.jsx" import { views } from "@follow/constants" -import type { FeedModel } from "@follow/models" import { cn } from "@follow/utils/utils" import { useQuery } from "@tanstack/react-query" import { useState } from "react" import { useTranslation } from "react-i18next" import { useGeneralSettingKey } from "~/atoms/settings/general" -import { apiFetch } from "~/lib/api-fetch" +import { apiClient } from "~/lib/api-fetch" import { TrendingFeedCard } from "../discover/TrendingFeedCard" @@ -27,7 +26,7 @@ const LanguageOptions = [ }, ] -type Language = (typeof LanguageOptions)[number]["value"] +type Language = "all" | "eng" | "cmn" const viewOptions = [ { @@ -61,15 +60,8 @@ export function Trending({ const { data, isLoading } = useQuery({ queryKey: ["trending", selectedLang, selectedView], queryFn: async () => { - return await apiFetch<{ - data: { - feed: FeedModel - view: number - subscriptionCount: number - }[] - }>("/trending/feeds", { - method: "GET", - params: { + return await apiClient.trending.feeds.$get({ + query: { language: selectedLang === "all" ? undefined : selectedLang, view: selectedView === "all" ? undefined : Number(selectedView), limit, diff --git a/packages/internal/shared/src/hono.ts b/packages/internal/shared/src/hono.ts index 7538257be..d0e244a29 100644 --- a/packages/internal/shared/src/hono.ts +++ b/packages/internal/shared/src/hono.ts @@ -17243,14 +17243,14 @@ declare const _routes: hono_hono_base.HonoBase Date: Thu, 15 May 2025 17:42:07 +0800 Subject: [PATCH 076/104] feat(mobile): feed summary component --- .../src/modules/discover/FeedSummary.tsx | 137 ++++++++++++++ .../discover/search-tabs/SearchFeed.tsx | 177 +----------------- .../discover/search-tabs/SearchFeedCard.tsx | 68 +++++++ apps/mobile/src/modules/feed/FollowFeed.tsx | 22 +-- 4 files changed, 218 insertions(+), 186 deletions(-) create mode 100644 apps/mobile/src/modules/discover/FeedSummary.tsx create mode 100644 apps/mobile/src/modules/discover/search-tabs/SearchFeedCard.tsx diff --git a/apps/mobile/src/modules/discover/FeedSummary.tsx b/apps/mobile/src/modules/discover/FeedSummary.tsx new file mode 100644 index 000000000..049f7a4ad --- /dev/null +++ b/apps/mobile/src/modules/discover/FeedSummary.tsx @@ -0,0 +1,137 @@ +import { FeedViewType } from "@follow/constants" +import { getBackgroundGradient } from "@follow/utils" +import { LinearGradient } from "expo-linear-gradient" +import { Text, View } from "react-native" +import { ScrollView } from "react-native-gesture-handler" + +import { RelativeDateTime } from "@/src/components/ui/datetime/RelativeDateTime" +import { FeedIcon } from "@/src/components/ui/icon/feed-icon" +import { Image } from "@/src/components/ui/image/Image" +import { ItemPressableStyle } from "@/src/components/ui/pressable/enum" +import { ItemPressable } from "@/src/components/ui/pressable/ItemPressable" +import type { apiClient } from "@/src/lib/api-fetch" +import { useNavigation } from "@/src/lib/navigation/hooks" +import { FollowScreen } from "@/src/screens/(modal)/FollowScreen" + +type SearchResultItem = Awaited>["data"][number] + +export const FeedSummary = ({ + item, + children, + className, +}: { + item: SearchResultItem + children?: React.ReactNode + className?: string +}) => { + const navigation = useNavigation() + + return ( + { + if (item.feed?.id) { + navigation.presentControllerView(FollowScreen, { + id: item.feed.id, + type: "feed", + }) + } else if (item.feed?.url) { + navigation.presentControllerView(FollowScreen, { + url: item.feed.url, + type: "url", + }) + } + }} + className={className} + > + {/* Headline */} + + + + + + + {item.feed?.title} + + {item.feed?.url} + + + {!!item.feed?.description && ( + + {item.feed?.description} + + )} + + {/* Preview */} + + + {item.entries?.map((entry) => )} + + + {children} + + ) +} + +const PreviewItem = ({ entry }: { entry: NonNullable[number] }) => { + const firstMedia = entry.media?.[0] + const [, , , bgAccent, bgAccentLight] = getBackgroundGradient( + entry.title || entry.url || "Untitled", + ) + + return ( + + {firstMedia ? ( + + ) : ( + + {entry.title?.[0]} + + )} + + + {entry.title} + + + + + ) +} diff --git a/apps/mobile/src/modules/discover/search-tabs/SearchFeed.tsx b/apps/mobile/src/modules/discover/search-tabs/SearchFeed.tsx index c3b9dfbe7..103f81b23 100644 --- a/apps/mobile/src/modules/discover/search-tabs/SearchFeed.tsx +++ b/apps/mobile/src/modules/discover/search-tabs/SearchFeed.tsx @@ -1,31 +1,13 @@ -import { FeedViewType } from "@follow/constants" -import { getBackgroundGradient } from "@follow/utils" import { useQuery } from "@tanstack/react-query" -import { LinearGradient } from "expo-linear-gradient" import { useAtomValue } from "jotai" import { Text, useWindowDimensions, View } from "react-native" -import { ScrollView } from "react-native-gesture-handler" -import { RelativeDateTime } from "@/src/components/ui/datetime/RelativeDateTime" -import { FeedIcon } from "@/src/components/ui/icon/feed-icon" -import { Image } from "@/src/components/ui/image/Image" -import { ItemPressableStyle } from "@/src/components/ui/pressable/enum" -import { ItemPressable } from "@/src/components/ui/pressable/ItemPressable" -import { RightCuteReIcon } from "@/src/icons/right_cute_re" -import { SafeAlertCuteReIcon } from "@/src/icons/safe_alert_cute_re" -import { SafetyCertificateCuteReIcon } from "@/src/icons/safety_certificate_cute_re" -import { User3CuteReIcon } from "@/src/icons/user_3_cute_re" import { apiClient } from "@/src/lib/api-fetch" -import { useNavigation } from "@/src/lib/navigation/hooks" -import { FollowScreen } from "@/src/screens/(modal)/FollowScreen" -import { useSubscriptionByFeedId } from "@/src/store/subscription/hooks" -import { useColor } from "@/src/theme/colors" import { useSearchPageContext } from "../ctx" import { ItemSeparator } from "./__base" import { useDataSkeleton } from "./hooks" - -type SearchResultItem = Awaited>["data"][number] +import { SearchFeedCard } from "./SearchFeedCard" export const SearchFeed = () => { const { searchValueAtom } = useSearchPageContext() @@ -56,7 +38,7 @@ export const SearchFeed = () => { {data.data?.map((item) => ( - + ))} @@ -64,158 +46,3 @@ export const SearchFeed = () => { ) } - -const SearchFeedItem = ({ item }: { item: SearchResultItem }) => { - const isSubscribed = useSubscriptionByFeedId(item.feed?.id ?? "") - const navigation = useNavigation() - const iconColor = useColor("text") - - return ( - { - if (item.feed?.id) { - navigation.presentControllerView(FollowScreen, { - id: item.feed.id, - type: "feed", - }) - } else if (item.feed?.url) { - navigation.presentControllerView(FollowScreen, { - url: item.feed.url, - type: "url", - }) - } - }} - > - {/* Headline */} - - - - - - - {item.feed?.title} - - {!!item.feed?.description && ( - - {item.feed?.description} - - )} - - {/* Subscribe */} - {isSubscribed ? ( - - - Followed - - - ) : ( - - - Follow - - - )} - - - - - {item.feed?.url} - - - {/* Preview */} - - - {item.entries?.map((entry) => )} - - - - - - {item.subscriptionCount} followers - - - {item.updatesPerWeek ? ( - <> - - {item.updatesPerWeek} entries/week - - ) : item.entries?.[0]?.publishedAt ? ( - <> - - Updated - - - ) : null} - - - - ) -} - -const PreviewItem = ({ entry }: { entry: NonNullable[number] }) => { - const firstMedia = entry.media?.[0] - const [, , , bgAccent, bgAccentLight] = getBackgroundGradient( - entry.title || entry.url || "Untitled", - ) - - return ( - - {firstMedia ? ( - - ) : ( - - {entry.title?.[0]} - - )} - - - {entry.title} - - - - - ) -} diff --git a/apps/mobile/src/modules/discover/search-tabs/SearchFeedCard.tsx b/apps/mobile/src/modules/discover/search-tabs/SearchFeedCard.tsx new file mode 100644 index 000000000..b1d4929f8 --- /dev/null +++ b/apps/mobile/src/modules/discover/search-tabs/SearchFeedCard.tsx @@ -0,0 +1,68 @@ +import { Text, View } from "react-native" + +import { RelativeDateTime } from "@/src/components/ui/datetime/RelativeDateTime" +import { SafeAlertCuteReIcon } from "@/src/icons/safe_alert_cute_re" +import { SafetyCertificateCuteReIcon } from "@/src/icons/safety_certificate_cute_re" +import { User3CuteReIcon } from "@/src/icons/user_3_cute_re" +import type { apiClient } from "@/src/lib/api-fetch" +import { useSubscriptionByFeedId } from "@/src/store/subscription/hooks" +import { useColor } from "@/src/theme/colors" + +import { FeedSummary } from "../FeedSummary" + +type SearchResultItem = Awaited>["data"][number] + +export const SearchFeedCard = ({ item }: { item: SearchResultItem }) => { + const isSubscribed = useSubscriptionByFeedId(item.feed?.id ?? "") + const iconColor = useColor("text") + + return ( + + + <> + + + + + {item.analytics?.subscriptionCount} followers + + + + {item.analytics?.updatesPerWeek ? ( + <> + + + {item.analytics.updatesPerWeek} entries/week + + + ) : item.analytics?.latestEntryPublishedAt ? ( + <> + + Updated + + + ) : null} + + + {/* Subscribe */} + {isSubscribed ? ( + + + Followed + + + ) : ( + + + Follow + + + )} + + + + ) +} diff --git a/apps/mobile/src/modules/feed/FollowFeed.tsx b/apps/mobile/src/modules/feed/FollowFeed.tsx index 6ae42762f..601ed301b 100644 --- a/apps/mobile/src/modules/feed/FollowFeed.tsx +++ b/apps/mobile/src/modules/feed/FollowFeed.tsx @@ -17,10 +17,10 @@ import { FormLabel } from "@/src/components/ui/form/Label" import { FormSwitch } from "@/src/components/ui/form/Switch" import { TextField } from "@/src/components/ui/form/TextField" import { GroupedInsetListCard } from "@/src/components/ui/grouped/GroupedList" -import { FeedIcon } from "@/src/components/ui/icon/feed-icon" import { PlatformActivityIndicator } from "@/src/components/ui/loading/PlatformActivityIndicator" import { useCanDismiss, useNavigation } from "@/src/lib/navigation/hooks" import { useSetModalScreenOptions } from "@/src/lib/navigation/ScreenOptionsContext" +import { FeedSummary } from "@/src/modules/discover/FeedSummary" import { FeedViewSelector } from "@/src/modules/feed/view-selector" import { useFeed, usePrefetchFeed, usePrefetchFeedByUrl } from "@/src/store/feed/hooks" import { useSubscriptionByFeedId } from "@/src/store/subscription/hooks" @@ -163,16 +163,16 @@ function FollowImpl(props: { feedId: string }) { } > {/* Group 1 */} - - - - - - - {feed?.title} - {feed?.description} - - + + {/* Group 2 */} From fc7b37d14cd792bab3e26401da8d9c634cfc0912 Mon Sep 17 00:00:00 2001 From: DIYgod Date: Thu, 15 May 2025 17:56:32 +0800 Subject: [PATCH 077/104] feat(mobile): replace good luck with trending feed --- .../src/modules/discover/DiscoverContent.tsx | 6 +- .../src/modules/discover/FeedSummary.tsx | 28 ++++--- apps/mobile/src/modules/discover/GoodLuck.tsx | 53 ------------- apps/mobile/src/modules/discover/Trending.tsx | 29 +++++++ .../src/modules/discover/TrendingFeedCard.tsx | 37 +++++++++ apps/mobile/src/modules/discover/api.ts | 18 +++++ .../discover/search-tabs/SearchFeedCard.tsx | 76 +++++++++---------- 7 files changed, 138 insertions(+), 109 deletions(-) delete mode 100644 apps/mobile/src/modules/discover/GoodLuck.tsx create mode 100644 apps/mobile/src/modules/discover/Trending.tsx create mode 100644 apps/mobile/src/modules/discover/TrendingFeedCard.tsx diff --git a/apps/mobile/src/modules/discover/DiscoverContent.tsx b/apps/mobile/src/modules/discover/DiscoverContent.tsx index ac6fb9730..245d81886 100644 --- a/apps/mobile/src/modules/discover/DiscoverContent.tsx +++ b/apps/mobile/src/modules/discover/DiscoverContent.tsx @@ -7,18 +7,18 @@ import { Pressable, StyleSheet, Text, View } from "react-native" import { Grid } from "@/src/components/ui/grid" import { useNavigation } from "@/src/lib/navigation/hooks" -import { GoodLuck } from "@/src/modules/discover/GoodLuck" import { Recommendations } from "@/src/modules/discover/Recommendations" +import { Trending } from "@/src/modules/discover/Trending" import { RecommendationCategoryScreen } from "@/src/screens/(stack)/recommendation/RecommendationCategoryScreen" export function DiscoverContent() { return ( - Good Luck + Trending - + Categories diff --git a/apps/mobile/src/modules/discover/FeedSummary.tsx b/apps/mobile/src/modules/discover/FeedSummary.tsx index 049f7a4ad..3e98b363f 100644 --- a/apps/mobile/src/modules/discover/FeedSummary.tsx +++ b/apps/mobile/src/modules/discover/FeedSummary.tsx @@ -19,10 +19,12 @@ export const FeedSummary = ({ item, children, className, + simple, }: { item: SearchResultItem children?: React.ReactNode className?: string + simple?: boolean }) => { const navigation = useNavigation() @@ -75,22 +77,24 @@ export const FeedSummary = ({ {item.feed?.url} - {!!item.feed?.description && ( + {!simple && !!item.feed?.description && ( {item.feed?.description} )} - - {/* Preview */} - - - {item.entries?.map((entry) => )} - - + {!simple && !!item.entries && ( + + + {item.entries?.map((entry) => ( + + ))} + + + )} {children} ) diff --git a/apps/mobile/src/modules/discover/GoodLuck.tsx b/apps/mobile/src/modules/discover/GoodLuck.tsx deleted file mode 100644 index c38068837..000000000 --- a/apps/mobile/src/modules/discover/GoodLuck.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { RSSHubCategories } from "@follow/constants" -import type { RSSHubRouteDeclaration } from "@follow/models/src/rsshub" -import { useQuery } from "@tanstack/react-query" -import { shuffle } from "es-toolkit/compat" -import { useMemo } from "react" -import { View } from "react-native" - -import { PlatformActivityIndicator } from "@/src/components/ui/loading/PlatformActivityIndicator" - -import { fetchRsshubPopular } from "./api" -import { RecommendationListItem } from "./RecommendationListItem" - -export const GoodLuck = () => { - const randomTab = useMemo( - () => RSSHubCategories[Math.floor(Math.random() * RSSHubCategories.length)]!, - [], - ) - const { data, isLoading } = useQuery({ - queryKey: ["rsshub-popular", "cache", randomTab], - queryFn: () => fetchRsshubPopular(randomTab, "all").then((res) => res.data), - }) - - const arr = useMemo(() => { - if (!data) return [] - const keys = shuffle(Object.keys(data)).slice(0, 2) - - return keys.map((key) => { - const route = data[key] - return { - key, - route, - } - }) - }, [data]) - - return ( - - {isLoading ? ( - - - - ) : ( - arr.map((item) => ( - - )) - )} - - ) -} diff --git a/apps/mobile/src/modules/discover/Trending.tsx b/apps/mobile/src/modules/discover/Trending.tsx new file mode 100644 index 000000000..4405e5ba7 --- /dev/null +++ b/apps/mobile/src/modules/discover/Trending.tsx @@ -0,0 +1,29 @@ +import { useQuery } from "@tanstack/react-query" +import { View } from "react-native" + +import { PlatformActivityIndicator } from "@/src/components/ui/loading/PlatformActivityIndicator" + +import { fetchFeedTrending } from "./api" +import { TrendingFeedCard } from "./TrendingFeedCard" + +export const Trending = () => { + const { data, isLoading } = useQuery({ + queryKey: ["trending", "feeds"], + queryFn: () => + fetchFeedTrending({ + limit: 20, + }).then((res) => res.data), + }) + + return ( + + {isLoading ? ( + + + + ) : ( + data?.map((item) => ) + )} + + ) +} diff --git a/apps/mobile/src/modules/discover/TrendingFeedCard.tsx b/apps/mobile/src/modules/discover/TrendingFeedCard.tsx new file mode 100644 index 000000000..883f377f4 --- /dev/null +++ b/apps/mobile/src/modules/discover/TrendingFeedCard.tsx @@ -0,0 +1,37 @@ +import { Text, View } from "react-native" + +import { User3CuteReIcon } from "@/src/icons/user_3_cute_re" +import type { apiClient } from "@/src/lib/api-fetch" +import { useSubscriptionByFeedId } from "@/src/store/subscription/hooks" +import { useColor } from "@/src/theme/colors" + +import { FeedSummary } from "./FeedSummary" + +type SearchResultItem = Awaited>["data"][number] + +export const TrendingFeedCard = ({ item }: { item: SearchResultItem }) => { + const isSubscribed = useSubscriptionByFeedId(item.feed?.id ?? "") + const iconColor = useColor("text") + + return ( + + + + + {item.analytics?.subscriptionCount} followers + + + {isSubscribed ? ( + + Followed + + ) : ( + + Follow + + )} + + + + ) +} diff --git a/apps/mobile/src/modules/discover/api.ts b/apps/mobile/src/modules/discover/api.ts index 5deeabe55..b1a538a98 100644 --- a/apps/mobile/src/modules/discover/api.ts +++ b/apps/mobile/src/modules/discover/api.ts @@ -11,3 +11,21 @@ export const fetchRsshubPopular = (category: DiscoverCategories, lang: Language) }, }) } + +export const fetchFeedTrending = ({ + lang, + view, + limit, +}: { + lang?: "eng" | "cmn" + view?: number + limit: number +}) => { + return apiClient.trending.feeds.$get({ + query: { + language: lang, + view, + limit, + }, + }) +} diff --git a/apps/mobile/src/modules/discover/search-tabs/SearchFeedCard.tsx b/apps/mobile/src/modules/discover/search-tabs/SearchFeedCard.tsx index b1d4929f8..4147e398f 100644 --- a/apps/mobile/src/modules/discover/search-tabs/SearchFeedCard.tsx +++ b/apps/mobile/src/modules/discover/search-tabs/SearchFeedCard.tsx @@ -17,52 +17,46 @@ export const SearchFeedCard = ({ item }: { item: SearchResultItem }) => { const iconColor = useColor("text") return ( - - - <> - - - - - {item.analytics?.subscriptionCount} followers - - - - {item.analytics?.updatesPerWeek ? ( - <> - - - {item.analytics.updatesPerWeek} entries/week - - - ) : item.analytics?.latestEntryPublishedAt ? ( - <> - - Updated - - - ) : null} - + + <> + + + + {item.analytics?.subscriptionCount} followers - {/* Subscribe */} + + {item.analytics?.updatesPerWeek ? ( + <> + + + {item.analytics.updatesPerWeek} entries/week + + + ) : item.analytics?.latestEntryPublishedAt ? ( + <> + + Updated + + + ) : null} + + + {/* Subscribe */} + {isSubscribed ? ( - - - Followed - + + Followed ) : ( - - - Follow - + + Follow )} - - - + + + ) } From 04b5d02afab0f17ae32b1bd039fea213778fbdd6 Mon Sep 17 00:00:00 2001 From: DIYgod Date: Thu, 15 May 2025 18:48:07 +0800 Subject: [PATCH 078/104] feat(mobile): trending index --- apps/mobile/src/modules/discover/Trending.tsx | 26 +++++++++++++++++-- .../src/modules/discover/TrendingFeedCard.tsx | 2 +- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/apps/mobile/src/modules/discover/Trending.tsx b/apps/mobile/src/modules/discover/Trending.tsx index 4405e5ba7..d6adf84ad 100644 --- a/apps/mobile/src/modules/discover/Trending.tsx +++ b/apps/mobile/src/modules/discover/Trending.tsx @@ -1,5 +1,6 @@ +import { cn } from "@follow/utils" import { useQuery } from "@tanstack/react-query" -import { View } from "react-native" +import { Text, View } from "react-native" import { PlatformActivityIndicator } from "@/src/components/ui/loading/PlatformActivityIndicator" @@ -22,7 +23,28 @@ export const Trending = () => { ) : ( - data?.map((item) => ) + data?.map((item, index) => ( + + + + + {index + 1} + + + + )) )} ) diff --git a/apps/mobile/src/modules/discover/TrendingFeedCard.tsx b/apps/mobile/src/modules/discover/TrendingFeedCard.tsx index 883f377f4..000f45680 100644 --- a/apps/mobile/src/modules/discover/TrendingFeedCard.tsx +++ b/apps/mobile/src/modules/discover/TrendingFeedCard.tsx @@ -14,7 +14,7 @@ export const TrendingFeedCard = ({ item }: { item: SearchResultItem }) => { const iconColor = useColor("text") return ( - + From 1284832e8026368432d3ad05cd9461951fe6c261 Mon Sep 17 00:00:00 2001 From: Cleves Date: Thu, 15 May 2025 18:51:35 +0800 Subject: [PATCH 079/104] fix: prevent event propagation on double click for navigation buttons in SwipeMedia (#3732) --- .../layer/renderer/src/components/ui/media/SwipeMedia.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/desktop/layer/renderer/src/components/ui/media/SwipeMedia.tsx b/apps/desktop/layer/renderer/src/components/ui/media/SwipeMedia.tsx index 186b4f362..6ae7d2acd 100644 --- a/apps/desktop/layer/renderer/src/components/ui/media/SwipeMedia.tsx +++ b/apps/desktop/layer/renderer/src/components/ui/media/SwipeMedia.tsx @@ -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} > @@ -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} > From a5d8455f54630b8a5445fcd80c888dd36681a845 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Thu, 15 May 2025 19:35:54 +0800 Subject: [PATCH 080/104] feat(desktop): hideAllReadSubscriptions option for category --- .../modules/timeline-column/FeedCategory.tsx | 22 ++++++++++++++++++- .../src/modules/timeline-column/FeedItem.tsx | 4 ++-- .../sort-by/SortByAlphabeticalList.tsx | 4 ++-- .../sort-by/SortByUnreadList.tsx | 4 ++-- 4 files changed, 27 insertions(+), 7 deletions(-) diff --git a/apps/desktop/layer/renderer/src/modules/timeline-column/FeedCategory.tsx b/apps/desktop/layer/renderer/src/modules/timeline-column/FeedCategory.tsx index 727a02131..d0f885acc 100644 --- a/apps/desktop/layer/renderer/src/modules/timeline-column/FeedCategory.tsx +++ b/apps/desktop/layer/renderer/src/modules/timeline-column/FeedCategory.tsx @@ -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 { useGeneralSettingKey, useGeneralSettingSelector } from "~/atoms/settings/general" import { ROUTE_FEED_IN_FOLDER } from "~/constants" import { useAddFeedToFeedList } from "~/hooks/biz/useFeedActions" import { useNavigateEntry } from "~/hooks/biz/useNavigateEntry" @@ -362,8 +362,28 @@ function FeedCategoryImpl({ data: ids, view, categoryOpenStateData }: FeedCatego ) } +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 +} + export const FeedCategory = memo(FeedCategoryImpl) +export function FeedCategoryAutoHideUnread(props: FeedCategoryProps) { + const hideAllReadSubscriptions = useGeneralSettingKey("hideAllReadSubscriptions") + const unreadOnly = useGeneralSettingKey("unreadOnly") + if (hideAllReadSubscriptions && unreadOnly) { + return + } + return +} + const RenameCategoryForm: FC<{ currentCategory: string onFinished: () => void diff --git a/apps/desktop/layer/renderer/src/modules/timeline-column/FeedItem.tsx b/apps/desktop/layer/renderer/src/modules/timeline-column/FeedItem.tsx index 20b2584e9..07fb2c8a6 100644 --- a/apps/desktop/layer/renderer/src/modules/timeline-column/FeedItem.tsx +++ b/apps/desktop/layer/renderer/src/modules/timeline-column/FeedItem.tsx @@ -230,8 +230,8 @@ export const FeedItem = memo(FeedItemImpl) export const FeedItemAutoHideUnread: Component = memo((props) => { const hideAllReadSubscriptions = useGeneralSettingKey("hideAllReadSubscriptions") - - if (hideAllReadSubscriptions) return createElement(FilterReadFeedItem, props) + const unreadOnly = useGeneralSettingKey("unreadOnly") + if (hideAllReadSubscriptions && unreadOnly) return createElement(FilterReadFeedItem, props) return createElement(FeedItemImpl, props) }) diff --git a/apps/desktop/layer/renderer/src/modules/timeline-column/sort-by/SortByAlphabeticalList.tsx b/apps/desktop/layer/renderer/src/modules/timeline-column/sort-by/SortByAlphabeticalList.tsx index 7a2f8f2be..8adac2b61 100644 --- a/apps/desktop/layer/renderer/src/modules/timeline-column/sort-by/SortByAlphabeticalList.tsx +++ b/apps/desktop/layer/renderer/src/modules/timeline-column/sort-by/SortByAlphabeticalList.tsx @@ -6,7 +6,7 @@ import { getPreferredTitle, useFeedStore } from "~/store/feed" import { useSubscriptionStore } from "~/store/subscription" import { useFeedListSortSelector } from "../atom" -import { FeedCategory } from "../FeedCategory" +import { FeedCategoryAutoHideUnread } from "../FeedCategory" import { InboxItem, ListItemAutoHideUnread } from "../FeedItem" import type { FeedListProps, ListListProps } from "./types" @@ -82,7 +82,7 @@ export const SortByAlphabeticalFeedList = ({ return ( {sortedByAlphabetical.map((category) => ( - { @@ -41,7 +41,7 @@ export const SortByUnreadFeedList = ({ view, data, categoryOpenStateData }: Feed return ( {sortedByUnread?.map(([category, ids]) => ( - Date: Thu, 15 May 2025 20:20:15 +0800 Subject: [PATCH 081/104] feat(mobile): useHideAllReadSubscriptions option --- apps/mobile/src/atoms/settings/general.ts | 6 ++ .../screen/TimelineSelectorProvider.tsx | 31 ++++------ .../src/modules/settings/routes/General.tsx | 14 +++++ apps/mobile/src/store/subscription/hooks.ts | 57 ++++++++++++------- 4 files changed, 66 insertions(+), 42 deletions(-) diff --git a/apps/mobile/src/atoms/settings/general.ts b/apps/mobile/src/atoms/settings/general.ts index 5f7754e90..522de8c41 100644 --- a/apps/mobile/src/atoms/settings/general.ts +++ b/apps/mobile/src/atoms/settings/general.ts @@ -45,3 +45,9 @@ export function getActionLanguage() { const { actionLanguage, language } = getGeneralSettings() return (actionLanguage === "default" ? language : actionLanguage) as SupportedLanguages } + +export function useHideAllReadSubscriptions() { + const hideAllReadSubscriptions = useGeneralSettingKey("hideAllReadSubscriptions") + const unreadOnly = useGeneralSettingKey("unreadOnly") + return hideAllReadSubscriptions && unreadOnly +} diff --git a/apps/mobile/src/modules/screen/TimelineSelectorProvider.tsx b/apps/mobile/src/modules/screen/TimelineSelectorProvider.tsx index f62f8466c..6185f75ee 100644 --- a/apps/mobile/src/modules/screen/TimelineSelectorProvider.tsx +++ b/apps/mobile/src/modules/screen/TimelineSelectorProvider.tsx @@ -37,27 +37,16 @@ export function TimelineHeader({ feedId }: { feedId?: string }) { [isTimeline, isSubscriptions], )} headerRight={useMemo(() => { - const Component = (() => { - if (isTimeline || isFeed) - return () => ( - - - - - - ) - if (isSubscriptions) - return () => ( - - - - ) - })() - - if (Component) - return () => {Component()} - return - }, [isFeed, isTimeline, isSubscriptions, feedId])} + return () => ( + + + + + + + + ) + }, [feedId])} headerHideableBottom={isTimeline || isSubscriptions ? TimelineViewSelector : undefined} headerHideableBottomHeight={TIMELINE_VIEW_SELECTOR_HEIGHT} /> diff --git a/apps/mobile/src/modules/settings/routes/General.tsx b/apps/mobile/src/modules/settings/routes/General.tsx index fc163f957..7dd9dec9b 100644 --- a/apps/mobile/src/modules/settings/routes/General.tsx +++ b/apps/mobile/src/modules/settings/routes/General.tsx @@ -110,6 +110,7 @@ export const GeneralScreen: NavigationControllerView = () => { const translation = useGeneralSettingKey("translation") const summary = useGeneralSettingKey("summary") const autoGroup = useGeneralSettingKey("autoGroup") + const hideAllReadSubscriptions = useGeneralSettingKey("hideAllReadSubscriptions") const showUnreadOnLaunch = useGeneralSettingKey("unreadOnly") // const groupByDate = useGeneralSettingKey("groupByDate") const expandLongSocialMedia = useGeneralSettingKey("autoExpandLongSocialMedia") @@ -170,6 +171,19 @@ export const GeneralScreen: NavigationControllerView = () => { }} /> + + + { + setGeneralSetting("hideAllReadSubscriptions", value) + }} + /> + {/* Timeline */} diff --git a/apps/mobile/src/store/subscription/hooks.ts b/apps/mobile/src/store/subscription/hooks.ts index a373cb9d2..aeb13daa3 100644 --- a/apps/mobile/src/store/subscription/hooks.ts +++ b/apps/mobile/src/store/subscription/hooks.ts @@ -3,7 +3,7 @@ import { sortByAlphabet } from "@follow/utils" import { useQuery } from "@tanstack/react-query" import { useCallback } from "react" -import { getGeneralSettings } from "@/src/atoms/settings/general" +import { getGeneralSettings, useHideAllReadSubscriptions } from "@/src/atoms/settings/general" import { views } from "@/src/constants/views" import { getFeed } from "../feed/getter" @@ -142,6 +142,7 @@ export const useSortedGroupedSubscription = ( sortBy: "alphabet" | "count", sortOrder: "asc" | "desc", ) => { + const hideAllReadSubscriptions = useHideAllReadSubscriptions() return useSubscriptionStore( useCallback(() => { const categories = Object.keys(grouped) @@ -152,10 +153,12 @@ export const useSortedGroupedSubscription = ( }) const sortedList = [] as { category: string; subscriptionIds: string[] }[] for (const category of sortedCategories) { - sortedList.push({ category, subscriptionIds: grouped[category]! }) + if (!hideAllReadSubscriptions || grouped[category]?.some((id) => getUnreadCount(id) > 0)) { + sortedList.push({ category, subscriptionIds: grouped[category]! }) + } } return sortedList - }, [grouped, sortBy, sortOrder, view]), + }, [grouped, sortBy, sortOrder, view, hideAllReadSubscriptions]), ) } @@ -164,15 +167,21 @@ export const useSortedUngroupedSubscription = ( sortBy: "alphabet" | "count", sortOrder: "asc" | "desc", ) => { + const hideAllReadSubscriptions = useHideAllReadSubscriptions() + return useSubscriptionStore( useCallback(() => { - return ids.sort((a, b) => { - const sortMethod = - sortBy === "alphabet" ? sortUngroupedSubscriptionByAlphabet : sortByUnread - const result = sortMethod(a, b) - return sortOrder === "asc" ? result : -result - }) - }, [ids.toString(), sortBy, sortOrder]), + return ids + .filter((id) => { + return !hideAllReadSubscriptions || getUnreadCount(id) > 0 + }) + .sort((a, b) => { + const sortMethod = + sortBy === "alphabet" ? sortUngroupedSubscriptionByAlphabet : sortByUnread + const result = sortMethod(a, b) + return sortOrder === "asc" ? result : -result + }) + }, [ids.toString(), sortBy, sortOrder, hideAllReadSubscriptions]), ) } @@ -215,17 +224,23 @@ export const useListSubscription = (view: FeedViewType) => { } export const useSortedListSubscription = (ids: string[], sortBy: "alphabet" | "unread") => { - return useSubscriptionStore(() => { - return ids.concat().sort((a, b) => { - const leftList = getList(a) - const rightList = getList(b) - if (!leftList || !rightList) return 0 - if (sortBy === "alphabet") { - return sortByAlphabet(leftList.title, rightList.title) - } - return sortByUnread(a, b) - }) - }) + const hideAllReadSubscriptions = useHideAllReadSubscriptions() + return useSubscriptionStore( + useCallback(() => { + return ids + .concat() + .filter((id) => !hideAllReadSubscriptions || getUnreadCount(id) > 0) + .sort((a, b) => { + const leftList = getList(a) + const rightList = getList(b) + if (!leftList || !rightList) return 0 + if (sortBy === "alphabet") { + return sortByAlphabet(leftList.title, rightList.title) + } + return sortByUnread(a, b) + }) + }, [ids.toString(), sortBy, hideAllReadSubscriptions]), + ) } export const useInboxSubscription = (view: FeedViewType) => { From 180933b70915cd6cb107f4da4ddea6cfb6117d5a Mon Sep 17 00:00:00 2001 From: DIYgod Date: Thu, 15 May 2025 21:23:27 +0800 Subject: [PATCH 082/104] feat(deskotp): remember discover language --- .../renderer/src/atoms/settings/general.ts | 23 ++-------------- .../layer/renderer/src/atoms/settings/ui.ts | 4 +++ .../layer/renderer/src/lib/load-language.ts | 27 +++++++++++++++++-- .../src/modules/discover/recommendations.tsx | 24 ++++++++++------- .../renderer/src/modules/trending/index.tsx | 13 +++++---- .../internal/shared/src/settings/defaults.ts | 3 +++ .../internal/shared/src/settings/interface.ts | 3 +++ 7 files changed, 57 insertions(+), 40 deletions(-) diff --git a/apps/desktop/layer/renderer/src/atoms/settings/general.ts b/apps/desktop/layer/renderer/src/atoms/settings/general.ts index 959d6085e..b747bfa20 100644 --- a/apps/desktop/layer/renderer/src/atoms/settings/general.ts +++ b/apps/desktop/layer/renderer/src/atoms/settings/general.ts @@ -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/load-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 { diff --git a/apps/desktop/layer/renderer/src/atoms/settings/ui.ts b/apps/desktop/layer/renderer/src/atoms/settings/ui.ts index cfb79636a..482bcb5ee 100644 --- a/apps/desktop/layer/renderer/src/atoms/settings/ui.ts +++ b/apps/desktop/layer/renderer/src/atoms/settings/ui.ts @@ -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/load-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) diff --git a/apps/desktop/layer/renderer/src/lib/load-language.ts b/apps/desktop/layer/renderer/src/lib/load-language.ts index 138a8a335..a364c9a8b 100644 --- a/apps/desktop/layer/renderer/src/lib/load-language.ts +++ b/apps/desktop/layer/renderer/src/lib/load-language.ts @@ -3,18 +3,41 @@ import { EventBus } from "@follow/utils/event-bus" import { isEmptyObject } from "@follow/utils/utils" import dayjs from "dayjs" import i18next from "i18next" +import LanguageDetector from "i18next-browser-languagedetector" import { toast } from "sonner" import { currentSupportedLanguages, dayjsLocaleImportMap } from "~/@types/constants" import { defaultResources } from "~/@types/default-resource" -import { fallbackLanguage, i18nAtom, langChain, LocaleCache } from "~/i18n" +import { I18N_LOCALE_KEY } from "~/constants" +import { i18nAtom, langChain, LocaleCache } from "~/i18n" import { jotaiStore } from "~/lib/jotai" import { tipcClient } from "./client" import { appLog } from "./log" const loadingLangLock = new Set() -const loadedLangs = new Set([fallbackLanguage]) +const loadedLangs = new Set(["en"]) + +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 +} export const loadLanguageAndApply = async (lang: string) => { const dayjsImport = dayjsLocaleImportMap[lang] diff --git a/apps/desktop/layer/renderer/src/modules/discover/recommendations.tsx b/apps/desktop/layer/renderer/src/modules/discover/recommendations.tsx index dbbcfb61c..d953ec8ce 100644 --- a/apps/desktop/layer/renderer/src/modules/discover/recommendations.tsx +++ b/apps/desktop/layer/renderer/src/modules/discover/recommendations.tsx @@ -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("all") - const [selectedLang, setSelectedLang] = useState(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() {
    {t("words.language")} (lang.startsWith("zh") ? "all" : "eng") const [selectedView, setSelectedView] = useState("all") const { data, isLoading } = useQuery({ - queryKey: ["trending", selectedLang, selectedView], + queryKey: ["trending", lang, selectedView], queryFn: async () => { return await apiClient.trending.feeds.$get({ query: { - language: selectedLang === "all" ? undefined : selectedLang, + language: lang === "all" ? undefined : lang, view: selectedView === "all" ? undefined : Number(selectedView), limit, }, @@ -86,9 +85,9 @@ export function Trending({
    {t("words.language")}: { - setSelectedLang(value as Language) + setUISetting("discoverLanguage", value as Language) }} triggerClassName="h-8 rounded border-0" size="sm" diff --git a/packages/internal/shared/src/settings/defaults.ts b/packages/internal/shared/src/settings/defaults.ts index 5fbccd335..2ec0979e9 100644 --- a/packages/internal/shared/src/settings/defaults.ts +++ b/packages/internal/shared/src/settings/defaults.ts @@ -89,6 +89,9 @@ export const defaultUISettings: UISettings = { showUnreadCountViewAndSubscriptionMobile: false, showUnreadCountBadgeMobile: false, + + // Discover + discoverLanguage: "all", } export const defaultIntegrationSettings: IntegrationSettings = { diff --git a/packages/internal/shared/src/settings/interface.ts b/packages/internal/shared/src/settings/interface.ts index 74e08c78b..ea47f5535 100644 --- a/packages/internal/shared/src/settings/interface.ts +++ b/packages/internal/shared/src/settings/interface.ts @@ -75,6 +75,9 @@ export interface UISettings { // @mobile showUnreadCountViewAndSubscriptionMobile: boolean showUnreadCountBadgeMobile: boolean + + // Discover + discoverLanguage: "all" | "eng" | "cmn" } export interface IntegrationSettings { From 331f8d80220e03f0a09b6668c813b2dcb60c3b3e Mon Sep 17 00:00:00 2001 From: Innei Date: Thu, 15 May 2025 21:38:44 +0800 Subject: [PATCH 083/104] refactor(mobile): update SearchFeedCard and login components for improved styling and loading state - Changed icon color usage in SearchFeedCard to align with design standards. - Adjusted layout and spacing in SearchFeedCard for better visual consistency. - Enhanced login text styling for better emphasis in the Login component. - Added loading state handling in SocialLogin component to improve user experience during authentication. These changes enhance the overall UI and UX of the mobile application. Signed-off-by: Innei --- .../discover/search-tabs/SearchFeedCard.tsx | 63 +++++++++---------- apps/mobile/src/modules/login/index.tsx | 2 +- apps/mobile/src/modules/login/social.tsx | 11 +++- 3 files changed, 42 insertions(+), 34 deletions(-) diff --git a/apps/mobile/src/modules/discover/search-tabs/SearchFeedCard.tsx b/apps/mobile/src/modules/discover/search-tabs/SearchFeedCard.tsx index 4147e398f..acb7f0509 100644 --- a/apps/mobile/src/modules/discover/search-tabs/SearchFeedCard.tsx +++ b/apps/mobile/src/modules/discover/search-tabs/SearchFeedCard.tsx @@ -14,49 +14,48 @@ type SearchResultItem = Awaited>["da export const SearchFeedCard = ({ item }: { item: SearchResultItem }) => { const isSubscribed = useSubscriptionByFeedId(item.feed?.id ?? "") - const iconColor = useColor("text") + const iconColor = useColor("secondaryLabel") return ( - - <> - - - - {item.analytics?.subscriptionCount} followers - - - {item.analytics?.updatesPerWeek ? ( - <> - - - {item.analytics.updatesPerWeek} entries/week - - - ) : item.analytics?.latestEntryPublishedAt ? ( - <> - - Updated - - - ) : null} - + + + + + + {item.analytics?.subscriptionCount} followers + + + + {item.analytics?.updatesPerWeek ? ( + <> + + + {item.analytics.updatesPerWeek} entries/week + + + ) : item.analytics?.latestEntryPublishedAt ? ( + <> + + Updated + + + ) : null} - {/* Subscribe */} {isSubscribed ? ( - - Followed + + Followed ) : ( - + Follow )} - + ) } diff --git a/apps/mobile/src/modules/login/index.tsx b/apps/mobile/src/modules/login/index.tsx index 5854eeef6..a52b60e8d 100644 --- a/apps/mobile/src/modules/login/index.tsx +++ b/apps/mobile/src/modules/login/index.tsx @@ -49,7 +49,7 @@ export function Login() { lineHeight, }} > - {`${isRegister ? t("signin.sign_up_to") : t("signin.sign_in_to")} `} + {`${isRegister ? t("signin.sign_up_to") : t("signin.sign_in_to")} `} Folo {isEmail ? ( diff --git a/apps/mobile/src/modules/login/social.tsx b/apps/mobile/src/modules/login/social.tsx index a27a4bbaf..2b5555e7b 100644 --- a/apps/mobile/src/modules/login/social.tsx +++ b/apps/mobile/src/modules/login/social.tsx @@ -5,14 +5,23 @@ import { useTranslation } from "react-i18next" import { Text, TouchableOpacity, View } from "react-native" import { Image } from "@/src/components/ui/image/Image" +import { PlatformActivityIndicator } from "@/src/components/ui/loading/PlatformActivityIndicator" import { signIn, useAuthProviders } from "@/src/lib/auth" export function SocialLogin({ onPressEmail }: { onPressEmail: () => void }) { - const { data: authProviders } = useAuthProviders() + const { data: authProviders, isLoading } = useAuthProviders() const { colorScheme } = useColorScheme() const providers = Object.entries(authProviders || []) const { t } = useTranslation() + if (isLoading) { + return ( + + + + ) + } + return ( {providers.map(([key, provider]) => { From 1e71cd5a59da7ac23e57014f74976a8e2479ecc9 Mon Sep 17 00:00:00 2001 From: Innei Date: Thu, 15 May 2025 22:07:02 +0800 Subject: [PATCH 084/104] fix(navigation): use keyboard to navigation select between feed and folder - Removed unused export of FeedCategory to streamline the component structure. - Added import for target from happy-dom in FeedList for improved functionality. - Implemented cleanup of selected feed IDs when a category or folder is targeted in FeedList. These changes enhance code clarity and maintainability in the timeline column components. Signed-off-by: Innei --- .../renderer/src/modules/timeline-column/FeedCategory.tsx | 3 --- .../src/modules/timeline-column/FeedList.electron.tsx | 6 ++++++ 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/desktop/layer/renderer/src/modules/timeline-column/FeedCategory.tsx b/apps/desktop/layer/renderer/src/modules/timeline-column/FeedCategory.tsx index d0f885acc..36e8a50b6 100644 --- a/apps/desktop/layer/renderer/src/modules/timeline-column/FeedCategory.tsx +++ b/apps/desktop/layer/renderer/src/modules/timeline-column/FeedCategory.tsx @@ -164,7 +164,6 @@ function FeedCategoryImpl({ data: ids, view, categoryOpenStateData }: FeedCatego }) const [isCategoryEditing, setIsCategoryEditing] = useState(false) - const [isContextMenuOpen, setIsContextMenuOpen] = useState(false) const isCategoryIsWaiting = isChangePending @@ -373,8 +372,6 @@ function FilterReadFeedCategory(props: FeedCategoryProps) { return } -export const FeedCategory = memo(FeedCategoryImpl) - export function FeedCategoryAutoHideUnread(props: FeedCategoryProps) { const hideAllReadSubscriptions = useGeneralSettingKey("hideAllReadSubscriptions") const unreadOnly = useGeneralSettingKey("unreadOnly") diff --git a/apps/desktop/layer/renderer/src/modules/timeline-column/FeedList.electron.tsx b/apps/desktop/layer/renderer/src/modules/timeline-column/FeedList.electron.tsx index a4cd7f249..5316b0014 100644 --- a/apps/desktop/layer/renderer/src/modules/timeline-column/FeedList.electron.tsx +++ b/apps/desktop/layer/renderer/src/modules/timeline-column/FeedList.electron.tsx @@ -31,6 +31,7 @@ import { useIsPreviewFeed } from "../entry-column/hooks/useIsPreviewFeed" import { resetSelectedFeedIds, setFeedAreaScrollProgressValue, + setSelectedFeedIds, useSelectedFeedIdsState, } from "./atom" import { DraggableContext } from "./context" @@ -346,6 +347,11 @@ const useRegisterCommand = () => { const targetElement = allSubscriptions[targetIndex] as HTMLElement | null + // Cleanup selected feed + const targetIsCategoryOrFolder = targetElement?.dataset.sub?.startsWith("feed-category-") + if (targetIsCategoryOrFolder) { + setSelectedFeedIds([]) + } targetElement?.click() } From b42c53fdd502311c3d0919584272f11ea52ba3f5 Mon Sep 17 00:00:00 2001 From: Innei Date: Thu, 15 May 2025 22:16:11 +0800 Subject: [PATCH 085/104] feat(shortcuts): add toggle folder collapse shortcut and command - Introduced a new keyboard shortcut for toggling folder collapse with the key "Z". - Updated command IDs to include the new toggleFolderCollapse command. - Enhanced the subscription command registration to handle the new toggle functionality. - Updated localization files to include the new shortcut description. These changes improve user navigation and interaction with subscription folders in the application. Signed-off-by: Innei --- .../layer/renderer/src/constants/shortcuts.ts | 4 ++ .../src/modules/command/commands/id.ts | 2 + .../modules/command/commands/subscription.tsx | 14 ++++++ .../command/hooks/use-command-shortcut.ts | 2 + .../modules/timeline-column/FeedCategory.tsx | 1 + .../timeline-column/FeedList.electron.tsx | 45 +++++++++++++++---- locales/shortcuts/en.json | 1 + 7 files changed, 60 insertions(+), 9 deletions(-) diff --git a/apps/desktop/layer/renderer/src/constants/shortcuts.ts b/apps/desktop/layer/renderer/src/constants/shortcuts.ts index 71ab26461..008638b4a 100644 --- a/apps/desktop/layer/renderer/src/constants/shortcuts.ts +++ b/apps/desktop/layer/renderer/src/constants/shortcuts.ts @@ -33,6 +33,10 @@ const shortcutConfigs = { name: tShortcuts("keys.subscriptions.previousSubscription"), key: "K, ArrowUp", }, + toggleFolderCollapse: { + name: tShortcuts("keys.subscriptions.toggleFolderCollapse"), + key: "Z", + }, }, layout: { toggleSidebar: { diff --git a/apps/desktop/layer/renderer/src/modules/command/commands/id.ts b/apps/desktop/layer/renderer/src/modules/command/commands/id.ts index 4ee810f8f..629e08af5 100644 --- a/apps/desktop/layer/renderer/src/modules/command/commands/id.ts +++ b/apps/desktop/layer/renderer/src/modules/command/commands/id.ts @@ -73,5 +73,7 @@ export const COMMAND_ID = { nextSubscription: "subscription:next", previousSubscription: "subscription:previous", + + toggleFolderCollapse: "subscription:toggle-folder-collapse", }, } as const diff --git a/apps/desktop/layer/renderer/src/modules/command/commands/subscription.tsx b/apps/desktop/layer/renderer/src/modules/command/commands/subscription.tsx index 959a8becc..ba93f3cdd 100644 --- a/apps/desktop/layer/renderer/src/modules/command/commands/subscription.tsx +++ b/apps/desktop/layer/renderer/src/modules/command/commands/subscription.tsx @@ -17,6 +17,7 @@ declare module "@follow/utils/event-bus" { "subscription:next": never "subscription:previous": never + "subscription:toggle-folder-collapse": never } } const LABEL_PREFIX = "Subscription" @@ -92,6 +93,13 @@ export const useRegisterSubscriptionCommands = () => { EventBus.dispatch(COMMAND_ID.subscription.previousSubscription) }, }, + { + id: COMMAND_ID.subscription.toggleFolderCollapse, + label: `${LABEL_PREFIX}: Toggle Folder Collapse`, + run: () => { + EventBus.dispatch(COMMAND_ID.subscription.toggleFolderCollapse) + }, + }, ]) } @@ -145,6 +153,11 @@ type PreviousSubscriptionCommand = Command<{ fn: () => void }> +type ToggleFolderCollapseCommand = Command<{ + id: typeof COMMAND_ID.subscription.toggleFolderCollapse + fn: () => void +}> + export type SubscriptionCommand = | SwitchTabToNextCommand | SwitchTabToPreviousCommand @@ -156,3 +169,4 @@ export type SubscriptionCommand = | SwitchTabToNotificationCommand | NextSubscriptionCommand | PreviousSubscriptionCommand + | ToggleFolderCollapseCommand diff --git a/apps/desktop/layer/renderer/src/modules/command/hooks/use-command-shortcut.ts b/apps/desktop/layer/renderer/src/modules/command/hooks/use-command-shortcut.ts index b56e22a33..7921d8ac4 100644 --- a/apps/desktop/layer/renderer/src/modules/command/hooks/use-command-shortcut.ts +++ b/apps/desktop/layer/renderer/src/modules/command/hooks/use-command-shortcut.ts @@ -30,6 +30,8 @@ const defaultCommandShortcuts = { [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 diff --git a/apps/desktop/layer/renderer/src/modules/timeline-column/FeedCategory.tsx b/apps/desktop/layer/renderer/src/modules/timeline-column/FeedCategory.tsx index 36e8a50b6..9a8ddbe54 100644 --- a/apps/desktop/layer/renderer/src/modules/timeline-column/FeedCategory.tsx +++ b/apps/desktop/layer/renderer/src/modules/timeline-column/FeedCategory.tsx @@ -289,6 +289,7 @@ function FeedCategoryImpl({ data: ids, view, categoryOpenStateData }: FeedCatego >