diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index 4695f9bda..091cbd31d 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -7,6 +7,7 @@ on: paths: - "apps/desktop/**" - "pnpm-lock.yaml" + - ".github/workflows/build-desktop.yml" workflow_dispatch: inputs: tag_version: @@ -124,6 +125,11 @@ jobs: - name: Install dependencies run: pnpm i + + - name: Prebuild packages (Windows) + if: runner.os == 'windows' + run: pnpm run build:packages + - name: Update main hash working-directory: apps/desktop run: pnpm update:main-hash diff --git a/README.md b/README.md index d11ef6629..e5f2a3e9e 100644 --- a/README.md +++ b/README.md @@ -18,8 +18,8 @@
- +
@@ -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). diff --git a/apps/desktop/changelog/0.4.5.md b/apps/desktop/changelog/0.4.5.md index 476b5f305..853646241 100644 --- a/apps/desktop/changelog/0.4.5.md +++ b/apps/desktop/changelog/0.4.5.md @@ -29,3 +29,7 @@ - Picture and video view sizes no longer interfere with each other (#3645) - Correct sorting icon in the feed list (#3675) - Custom fonts containing spaces now render properly (#3674) + +## Thanks + +Special thanks to external contributors @kovsu @ericyzhu @cuikaipeng @grtsinry43 @cscnk52 for their valuable contributions diff --git a/apps/desktop/changelog/0.4.6.md b/apps/desktop/changelog/0.4.6.md new file mode 100644 index 000000000..3be482b93 --- /dev/null +++ b/apps/desktop/changelog/0.4.6.md @@ -0,0 +1,29 @@ +# What's new in v0.4.6 + +## Shiny new things + +- Hide read subscriptions. You can now hide subscriptions with no unread items. Enable it in Settings → General → Subscriptions → Hide Read. (1b88d72) +- Vim-style keyboard navigation with smarter focus detection: (262a7ce c2d8ffa) + - Timeline focus: `j`/`k` or ↓/↑ to move between entries; `Enter` to open the selected entry. + - Entry focus: `h`/`l` or ←/→ to jump to previous/next entry, `j`/`k` or ↓/↑ to scroll, `Esc` or `Backspace` to return to the Timeline. + - Subscription list focus: `j`/`k` or ↓/↑ to move, `Enter` to open the Timeline, `Z` to collapse/expand a folder. + +## Improvements + +- Clearer RSSHub error messages. (ed95fb6b) +- AI summary is now enabled by default. (f329ae9) +- Social-media view shows a richer action bar. (b06d8ff) +- No more notification-permission prompts when your actions don’t need them. (450b759) +- Discover page now remembers your language preference. (180933b) + +## No longer broken + +- Fixed incorrect language display in Shiki code blocks. (20049b0) +- Fixed occasional subscription-status errors. (9b0691a) +- Fixed default view in the subscription dialog. (bf26a3e) +- Fixed login dialog styling in dark mode. (e18f052) +- Fixed duplicated action bar in social-media view. (a917231) + +## Thanks + +Special thanks to external contributors @cleves0315 @grtsinry43 for their valuable contributions diff --git a/apps/desktop/changelog/next.md b/apps/desktop/changelog/next.md index 62b641fa5..eb0db6436 100644 --- a/apps/desktop/changelog/next.md +++ b/apps/desktop/changelog/next.md @@ -5,3 +5,7 @@ ## Improvements ## No longer broken + +## Thanks + +Special thanks to external contributors @ for their valuable contributions diff --git a/apps/desktop/changelog/next.template.md b/apps/desktop/changelog/next.template.md index 62b641fa5..eb0db6436 100644 --- a/apps/desktop/changelog/next.template.md +++ b/apps/desktop/changelog/next.template.md @@ -5,3 +5,7 @@ ## Improvements ## No longer broken + +## Thanks + +Special thanks to external contributors @ for their valuable contributions diff --git a/apps/desktop/configs/vite.render.config.ts b/apps/desktop/configs/vite.render.config.ts index b58841e1a..4178ee333 100644 --- a/apps/desktop/configs/vite.render.config.ts +++ b/apps/desktop/configs/vite.render.config.ts @@ -52,7 +52,6 @@ export const viteRenderBaseConfig = { codeInspectorPlugin({ bundler: "vite", editor: "cursor", - hotKeys: ["metaKey"], }), sentryVitePlugin({ org: "follow-rg", diff --git a/apps/desktop/layer/renderer/package.json b/apps/desktop/layer/renderer/package.json index 0897a59ae..fc4f70b11 100644 --- a/apps/desktop/layer/renderer/package.json +++ b/apps/desktop/layer/renderer/package.json @@ -88,7 +88,9 @@ "react-zoom-pan-pinch": "3.7.0", "shiki": "3.3.0", "sonner": "2.0.3", + "tinykeys": "3.0.0", "tldts": "7.0.4", + "ufo": "1.6.1", "use-context-selector": "2.0.0", "use-sync-external-store": "1.5.0", "usehooks-ts": "3.1.1", 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/atoms/settings/general.ts b/apps/desktop/layer/renderer/src/atoms/settings/general.ts index 959d6085e..eca51a9fd 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/language" export const DEFAULT_ACTION_LANGUAGE = "default" -let language = "en" -const languageDetector = new LanguageDetector(null, { - order: ["querystring", "localStorage", "navigator"], - lookupQuerystring: "lng", - lookupLocalStorage: I18N_LOCALE_KEY, - caches: ["localStorage"], -}) - -const userLang = languageDetector.detect() -if (userLang) { - const firstUserLang = Array.isArray(userLang) ? userLang[0]! : userLang - const currentLang = currentSupportedLanguages.find((lang) => lang.includes(firstUserLang)) - if (currentLang) { - language = currentLang - } -} - const createDefaultSettings = (): GeneralSettings => ({ ...defaultGeneralSettings, - language, + language: getDefaultLanguage(), }) const { @@ -208,6 +189,12 @@ export function getActionLanguage() { ) as SupportedLanguages } +export function useHideAllReadSubscriptions() { + const hideAllReadSubscriptions = useGeneralSettingKey("hideAllReadSubscriptions") + const unreadOnly = useGeneralSettingKey("unreadOnly") + return hideAllReadSubscriptions && unreadOnly +} + export const subscribeShouldUseIndexedDB = (callback: (value: boolean) => void) => jotaiStore.sub(__generalSettingAtom, () => callback(getGeneralSettingsInternal().dataPersist)) diff --git a/apps/desktop/layer/renderer/src/atoms/settings/ui.ts b/apps/desktop/layer/renderer/src/atoms/settings/ui.ts index cfb79636a..9075a7e4a 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/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/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/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/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/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/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 ( @@ -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} > 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/components/ui/modal/stacked/context.tsx b/apps/desktop/layer/renderer/src/components/ui/modal/stacked/context.tsx index 63c1cf637..bf556f8ff 100644 --- a/apps/desktop/layer/renderer/src/components/ui/modal/stacked/context.tsx +++ b/apps/desktop/layer/renderer/src/components/ui/modal/stacked/context.tsx @@ -2,6 +2,8 @@ import type { FC, RefObject } from "react" import { createContext as reactCreateContext } from "react" import { createContext as createContextSelector } from "use-context-selector" +import type { ModalProps } from "./types" + export type CurrentModalContentProps = ModalActionsInternal & { ref: RefObject modalElementRef: RefObject @@ -37,3 +39,10 @@ export type ModalActionsInternal = { setClickOutSideToDismiss: (value: boolean) => void getIndex: () => number } + +type Disposer = () => void +type PresentModalContextInternalFn = (props: ModalProps & { id?: string }) => Disposer +export const PresentModalContextInternal = reactCreateContext(() => { + warnNoProvider() + return () => {} +}) diff --git a/apps/desktop/layer/renderer/src/components/ui/modal/stacked/hooks.tsx b/apps/desktop/layer/renderer/src/components/ui/modal/stacked/hooks.tsx index 6eb115ead..1d5ca0ded 100644 --- a/apps/desktop/layer/renderer/src/components/ui/modal/stacked/hooks.tsx +++ b/apps/desktop/layer/renderer/src/components/ui/modal/stacked/hooks.tsx @@ -1,75 +1,30 @@ import { Button } from "@follow/components/ui/button/index.js" -import { nextFrame } from "@follow/utils/dom" import { atom, useAtomValue } from "jotai" import type { DragControls } from "motion/react" import type { ResizeCallback, ResizeStartCallback } from "re-resizable" -import { use, useId, useRef, useState } from "react" +import { use, useState } from "react" import { flushSync } from "react-dom" import { useTranslation } from "react-i18next" import { useContextSelector } from "use-context-selector" import { useEventCallback } from "usehooks-ts" -import { getUISettings } from "~/atoms/settings/ui" import { jotaiStore } from "~/lib/jotai" import { modalStackAtom } from "./atom" import { ModalEventBus } from "./bus" -import { CurrentModalContext, CurrentModalStateContext } from "./context" -import type { DialogInstance, ModalProps, ModalStackOptions } from "./types" +import { + CurrentModalContext, + CurrentModalStateContext, + PresentModalContextInternal, +} from "./context" +import type { DialogInstance, ModalProps } from "./types" export const modalIdToPropsMap = {} as Record -export const useModalStack = (options?: ModalStackOptions) => { - const id = useId() - const currentCount = useRef(0) - const { wrapper } = options || {} +export const useModalStack = () => { + const present = use(PresentModalContextInternal) return { - present: useEventCallback((props: ModalProps & { id?: string }) => { - const presentSync = (props: ModalProps & { id?: string }) => { - const fallbackModelId = `${id}-${++currentCount.current}` - const modalId = props.id ?? fallbackModelId - - const currentStack = jotaiStore.get(modalStackAtom) - - const existingModal = currentStack.find((item) => item.id === modalId) - - if (existingModal) { - // Move to top - jotaiStore.set(modalStackAtom, (p) => { - const index = p.indexOf(existingModal) - return [...p.slice(0, index), ...p.slice(index + 1), existingModal] - }) - } else { - // NOTE: The props of the Command Modal are immutable, so we'll just take the store value and inject it. - // There is no need to inject `overlay` props, this is rendered responsively based on ui changes. - const uiSettings = getUISettings() - const modalConfig: Partial = { - draggable: uiSettings.modalDraggable, - modal: true, - } - jotaiStore.set(modalStackAtom, (p) => { - const modalProps: ModalProps = { - ...modalConfig, - ...props, - - wrapper, - } - modalIdToPropsMap[modalId] = modalProps - return p.concat({ - id: modalId, - ...modalProps, - }) - }) - } - - return () => { - jotaiStore.set(modalStackAtom, (p) => p.filter((item) => item.id !== modalId)) - } - } - - return nextFrame(() => presentSync(props)) - }), - + present, ...actions, } } diff --git a/apps/desktop/layer/renderer/src/components/ui/modal/stacked/provider.tsx b/apps/desktop/layer/renderer/src/components/ui/modal/stacked/provider.tsx index fcffdb1cc..b30506f3a 100644 --- a/apps/desktop/layer/renderer/src/components/ui/modal/stacked/provider.tsx +++ b/apps/desktop/layer/renderer/src/components/ui/modal/stacked/provider.tsx @@ -1,17 +1,73 @@ +import { nextFrame } from "@follow/utils/dom" import type { FC, PropsWithChildren } from "react" +import { useId, useRef } from "react" +import { useEventCallback } from "usehooks-ts" +import { getUISettings } from "~/atoms/settings/ui" +import { jotaiStore } from "~/lib/jotai" + +import { modalStackAtom } from "./atom" +import { PresentModalContextInternal } from "./context" +import { modalIdToPropsMap } from "./hooks" import { ModalStack } from "./modal-stack" import type { ModalProps } from "./types" declare global { interface Window { - presentModal: (modal: ModalProps) => void + presentModal: (modal: ModalProps & { id?: string }) => void } } -export const ModalStackProvider: FC = ({ children }) => ( - <> - {children} - - -) +export const ModalStackProvider: FC = ({ children }) => { + const id = useId() + const currentCount = useRef(0) + const presentModal = useEventCallback((props: ModalProps & { id?: string }) => { + const fallbackModelId = `${id}-${++currentCount.current}` + const modalId = props.id ?? fallbackModelId + const presentSync = (props: ModalProps & { id?: string }) => { + const currentStack = jotaiStore.get(modalStackAtom) + + const existingModal = currentStack.find((item) => item.id === modalId) + + if (existingModal) { + // Move to top + jotaiStore.set(modalStackAtom, (p) => { + const index = p.indexOf(existingModal) + return [...p.slice(0, index), ...p.slice(index + 1), existingModal] + }) + } else { + // NOTE: The props of the Command Modal are immutable, so we'll just take the store value and inject it. + // There is no need to inject `overlay` props, this is rendered responsively based on ui changes. + const uiSettings = getUISettings() + const modalConfig: Partial = { + draggable: uiSettings.modalDraggable, + modal: true, + } + jotaiStore.set(modalStackAtom, (p) => { + const modalProps: ModalProps = { + ...modalConfig, + ...props, + } + modalIdToPropsMap[modalId] = modalProps + return p.concat({ + id: modalId, + ...modalProps, + }) + }) + } + } + + nextFrame(() => presentSync(props)) + + return () => { + jotaiStore.set(modalStackAtom, (p) => p.filter((item) => item.id !== modalId)) + } + }) + + return ( + + {children} + + + ) +} diff --git a/apps/desktop/layer/renderer/src/components/ui/modal/stacked/types.tsx b/apps/desktop/layer/renderer/src/components/ui/modal/stacked/types.tsx index 2fb4c03bf..269e5c32a 100644 --- a/apps/desktop/layer/renderer/src/components/ui/modal/stacked/types.tsx +++ b/apps/desktop/layer/renderer/src/components/ui/modal/stacked/types.tsx @@ -32,9 +32,6 @@ export interface ModalProps { autoFocus?: boolean } -export interface ModalStackOptions { - wrapper?: FC -} export interface DialogInstance { ask: (options: { diff --git a/apps/desktop/layer/renderer/src/constants/hotkeys.ts b/apps/desktop/layer/renderer/src/constants/hotkeys.ts index 90a797b8b..bf62b4f95 100644 --- a/apps/desktop/layer/renderer/src/constants/hotkeys.ts +++ b/apps/desktop/layer/renderer/src/constants/hotkeys.ts @@ -1,6 +1,11 @@ -export const HotKeyScopeMap = { - Home: ["home"], - Menu: ["menu"], - Modal: ["modal"], - DropdownMenu: ["dropdown-menu"], +export enum HotkeyScope { + Home = "home", + Menu = "menu", + Modal = "modal", + DropdownMenu = "dropdown-menu", + + // Atom Scope + Timeline = "timeline", + EntryRender = "entry-render", + SubscriptionList = "subscription-list", } diff --git a/apps/desktop/layer/renderer/src/constants/shortcuts.ts b/apps/desktop/layer/renderer/src/constants/shortcuts.ts index bb49770d2..008638b4a 100644 --- a/apps/desktop/layer/renderer/src/constants/shortcuts.ts +++ b/apps/desktop/layer/renderer/src/constants/shortcuts.ts @@ -8,67 +8,83 @@ type Shortcuts = Record< > const shortcutConfigs = { - feeds: { + subscriptions: { add: { - name: "keys.feeds.add", + name: tShortcuts("keys.subscriptions.add"), key: "Meta+T", }, switchToView: { - name: "keys.feeds.switchToView", + name: tShortcuts("keys.subscriptions.switchToView"), key: "1, 2, 3, 4, 5, 6", }, - switchBetweenViews: { - name: "keys.feeds.switchBetweenViews", - key: "Tab, Left, Right", + switchNextView: { + name: tShortcuts("keys.subscriptions.switchNextView"), + key: "Tab, ArrowRight", + }, + switchPreviousView: { + name: tShortcuts("keys.subscriptions.switchPreviousView"), + key: "Shift+Tab, ArrowLeft", + }, + nextSubscription: { + name: tShortcuts("keys.subscriptions.nextSubscription"), + key: "J, ArrowDown", + }, + previousSubscription: { + name: tShortcuts("keys.subscriptions.previousSubscription"), + key: "K, ArrowUp", + }, + toggleFolderCollapse: { + name: tShortcuts("keys.subscriptions.toggleFolderCollapse"), + key: "Z", }, }, layout: { toggleSidebar: { - name: "keys.layout.toggleSidebar", + name: tShortcuts("keys.layout.toggleSidebar"), key: "Meta+B, [", }, showShortcuts: { - name: "keys.layout.showShortcuts", + name: tShortcuts("keys.layout.showShortcuts"), key: "H", }, toggleWideMode: { - name: "keys.layout.toggleWideMode", + name: tShortcuts("keys.layout.toggleWideMode"), key: "Meta+[", }, zenMode: { - name: "keys.layout.zenMode", + name: tShortcuts("keys.layout.zenMode"), key: "Ctrl+Shift+Z", }, }, entries: { refetch: { - name: "keys.entries.refetch", + name: tShortcuts("keys.entries.refetch"), key: "R", }, previous: { - name: "keys.entries.previous", - key: "K, Up", + name: tShortcuts("keys.entries.previous"), + key: "K, ArrowUp", }, next: { - name: "keys.entries.next", - key: "J, Down", + name: tShortcuts("keys.entries.next"), + key: "J, ArrowDown", }, markAllAsRead: { - name: "keys.entries.markAllAsRead", + name: tShortcuts("keys.entries.markAllAsRead"), key: "Shift+Meta+A", }, toggleUnreadOnly: { - name: "keys.entries.toggleUnreadOnly", + name: tShortcuts("keys.entries.toggleUnreadOnly"), key: "U", }, }, entry: { toggleRead: { - name: "keys.entry.toggleRead", + name: tShortcuts("keys.entry.toggleRead"), key: "M", }, toggleStarred: { - name: "keys.entry.toggleStarred", + name: tShortcuts("keys.entry.toggleStarred"), key: "S", }, openInBrowser: { @@ -77,43 +93,51 @@ const shortcutConfigs = { extra: "Double Click", }, tts: { - name: "keys.entry.tts", + name: tShortcuts("keys.entry.tts"), key: "Shift+Meta+V", }, copyLink: { - name: "keys.entry.copyLink", + name: tShortcuts("keys.entry.copyLink"), key: "Shift+Meta+C", }, copyTitle: { - name: "keys.entry.copyTitle", + name: tShortcuts("keys.entry.copyTitle"), key: "Shift+Meta+B", }, tip: { - name: "keys.entry.tip", + name: tShortcuts("keys.entry.tip"), key: "Shift+Meta+T", }, share: { - name: "keys.entry.share", + name: tShortcuts("keys.entry.share"), key: "Meta+Alt+S", }, scrollUp: { - name: "keys.entry.scrollUp", - key: "PageUp", + name: tShortcuts("keys.entry.scrollUp"), + key: "K, ArrowUp", }, scrollDown: { - name: "keys.entry.scrollDown", - key: "PageDown", + name: tShortcuts("keys.entry.scrollDown"), + key: "J, ArrowDown", + }, + nextEntry: { + name: tShortcuts("keys.entries.next"), + key: "L, ArrowRight", + }, + previousEntry: { + name: tShortcuts("keys.entries.previous"), + key: "H, ArrowLeft", }, }, audio: { "play/pause": { - name: "keys.audio.playPause", + name: tShortcuts("keys.audio.playPause"), key: "Space", }, }, misc: { quickSearch: { - name: "keys.misc.quickSearch", + name: tShortcuts("keys.misc.quickSearch"), key: "Meta+K", }, }, @@ -135,7 +159,7 @@ function transformShortcuts(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/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/hooks/biz/useEntryActions.tsx b/apps/desktop/layer/renderer/src/hooks/biz/useEntryActions.tsx index 7d7dc68f0..fed28b417 100644 --- a/apps/desktop/layer/renderer/src/hooks/biz/useEntryActions.tsx +++ b/apps/desktop/layer/renderer/src/hooks/biz/useEntryActions.tsx @@ -410,6 +410,7 @@ export const useSortedEntryActions = ({ return false } const order = orderMap.get(item.id) + if (!order) return false return order.type === "main" }) diff --git a/apps/desktop/layer/renderer/src/hooks/biz/useFeedActions.tsx b/apps/desktop/layer/renderer/src/hooks/biz/useFeedActions.tsx index 2b7251cec..563dcd484 100644 --- a/apps/desktop/layer/renderer/src/hooks/biz/useFeedActions.tsx +++ b/apps/desktop/layer/renderer/src/hooks/biz/useFeedActions.tsx @@ -49,7 +49,7 @@ const ConfirmDestroyModalContent = ({ onConfirm }: { onConfirm: () => void }) => {t("sidebar.feed_actions.unfollow_feed_many_warning")}
-
diff --git a/apps/desktop/layer/renderer/src/hooks/biz/useFollow.tsx b/apps/desktop/layer/renderer/src/hooks/biz/useFollow.tsx index cbb0df1e7..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,6 +13,7 @@ import type { FeedFormDataValuesType } from "~/modules/discover/FeedForm" import { FeedForm } from "~/modules/discover/FeedForm" import type { ListFormDataValuesType } from "~/modules/discover/ListForm" import { ListForm } from "~/modules/discover/ListForm" +import { getFeedByIdOrUrl } from "~/store/feed" import { getSubscriptionByFeedId, useFeedSubscriptionCount, @@ -73,10 +75,16 @@ export const useFollow = () => { } else { canFollowMoreInboxAndNotify("feed") } - let isFollowed = false - if (options?.id) { - isFollowed = !!getSubscriptionByFeedId(options.id) - } + + // Some feeds redirect xxx.com/feed to xxx.com/feed/ + // Try to get a valid feed, then we can check isFollowed correctly + const feed = + getFeedByIdOrUrl({ id: options?.id, url: withTrailingSlash(options?.url) }) ?? + getFeedByIdOrUrl({ id: options?.id, url: withoutTrailingSlash(options?.url) }) + const id = options?.id || feed?.id + const url = feed?.type === "feed" ? feed.url : options?.url + const subscription = getSubscriptionByFeedId(id) + const isFollowed = !!subscription present({ title: `${isFollowed ? `${t("common:words.edit")} ` : ""}${options?.isList ? t("words.lists") : t("words.feeds")}`, @@ -94,8 +102,8 @@ export const useFollow = () => { /> ) : ( diff --git a/apps/desktop/layer/renderer/src/hooks/biz/useNavigateEntry.ts b/apps/desktop/layer/renderer/src/hooks/biz/useNavigateEntry.ts index e71ba3256..7d5340c9a 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}` } @@ -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/hooks/biz/useSubscriptionActions.tsx b/apps/desktop/layer/renderer/src/hooks/biz/useSubscriptionActions.tsx index e43b7b8ae..473dc81f3 100644 --- a/apps/desktop/layer/renderer/src/hooks/biz/useSubscriptionActions.tsx +++ b/apps/desktop/layer/renderer/src/hooks/biz/useSubscriptionActions.tsx @@ -4,7 +4,7 @@ import { useHotkeys } from "react-hotkeys-hook" import { Trans, useTranslation } from "react-i18next" import { toast } from "sonner" -import { HotKeyScopeMap } from "~/constants" +import { HotkeyScope } from "~/constants" import { apiClient } from "~/lib/api-fetch" import { subscription as subscriptionQuery } from "~/queries/subscriptions" import type { SubscriptionFlatModel } from "~/store/subscription" @@ -64,7 +64,7 @@ export const useDeleteSubscription = ({ onSuccess }: { onSuccess?: () => void } label: ( {t("words.undo")} - + Meta+Z @@ -92,7 +92,7 @@ export const useDeleteSubscription = ({ onSuccess }: { onSuccess?: () => void } const UnfollowInfo = ({ title, undo }: { title: string; undo: () => any }) => { useHotkeys("ctrl+z,meta+z", undo, { - scopes: HotKeyScopeMap.Home, + scopes: HotkeyScope.Home, preventDefault: true, }) return ( diff --git a/apps/desktop/layer/renderer/src/hooks/common/useSwitchHotkeyScope.ts b/apps/desktop/layer/renderer/src/hooks/common/useSwitchHotkeyScope.ts index b24c298a3..a0f821a63 100644 --- a/apps/desktop/layer/renderer/src/hooks/common/useSwitchHotkeyScope.ts +++ b/apps/desktop/layer/renderer/src/hooks/common/useSwitchHotkeyScope.ts @@ -1,55 +1,54 @@ import { useCallback, useLayoutEffect, useRef } from "react" import { useHotkeysContext } from "react-hotkeys-hook" -import { HotKeyScopeMap } from "~/constants" +import { HotkeyScope } from "~/constants" -const allScopes = Object.keys(HotKeyScopeMap).reduce((acc, key) => { - acc.push(...HotKeyScopeMap[key]) +const allScopes = Object.keys(HotkeyScope).reduce((acc, key) => { + acc.push(HotkeyScope[key]) return acc }, [] as string[]) + export const useSwitchHotKeyScope = () => { const { enableScope, disableScope } = useHotkeysContext() return useCallback( - (scope: keyof typeof HotKeyScopeMap) => { - const nextScope = HotKeyScopeMap[scope] + (scope: keyof typeof HotkeyScope) => { + const nextScope = HotkeyScope[scope] if (!nextScope) return for (const key of allScopes) { disableScope(key) } - for (const key of nextScope) { - enableScope(key) - } + enableScope(nextScope) }, [disableScope, enableScope], ) } -export const useHotkeyScopeFn = (scope: keyof typeof HotKeyScopeMap) => { +export const useConditionalHotkeyScopeFn = (scope: HotkeyScope, replaceAll = true) => { const { enableScope, disableScope, activeScopes } = useHotkeysContext() const currentScopeRef = useRef(activeScopes) return useCallback(() => { const currentScope = currentScopeRef.current - for (const key of allScopes) { - disableScope(key) - } - for (const key of scope) { - enableScope(key) - } - return () => { - for (const key of scope) { + if (replaceAll) { + for (const key of allScopes) { disableScope(key) } - for (const scope of currentScope) { - enableScope(scope) + } + enableScope(scope) + return () => { + disableScope(scope) + if (replaceAll) { + for (const key of currentScope) { + enableScope(key) + } } } - }, [enableScope, disableScope, scope]) + }, [enableScope, disableScope, scope, replaceAll]) } -export const useHotkeyScope = (scope: keyof typeof HotKeyScopeMap, when: boolean) => { - const fn = useHotkeyScopeFn(scope) +export const useConditionalHotkeyScope = (scope: HotkeyScope, when: boolean, append = false) => { + const fn = useConditionalHotkeyScopeFn(scope, !append) useLayoutEffect(() => { if (when) { return fn() diff --git a/apps/desktop/layer/renderer/src/lib/language.ts b/apps/desktop/layer/renderer/src/lib/language.ts new file mode 100644 index 000000000..48f538e93 --- /dev/null +++ b/apps/desktop/layer/renderer/src/lib/language.ts @@ -0,0 +1,25 @@ +import LanguageDetector from "i18next-browser-languagedetector" + +import { currentSupportedLanguages } from "~/@types/constants" +import { I18N_LOCALE_KEY } from "~/constants" + +let defaultLanguage = "en" +const languageDetector = new LanguageDetector(null, { + order: ["querystring", "localStorage", "navigator"], + lookupQuerystring: "lng", + lookupLocalStorage: I18N_LOCALE_KEY, + caches: ["localStorage"], +}) + +const userLang = languageDetector.detect() +if (userLang) { + const firstUserLang = Array.isArray(userLang) ? userLang[0]! : userLang + const currentLang = currentSupportedLanguages.find((lang) => lang.includes(firstUserLang)) + if (currentLang) { + defaultLanguage = currentLang + } +} + +export const getDefaultLanguage = () => { + return defaultLanguage +} diff --git a/apps/desktop/layer/renderer/src/lib/load-language.ts b/apps/desktop/layer/renderer/src/lib/load-language.ts index 138a8a335..14ee3884a 100644 --- a/apps/desktop/layer/renderer/src/lib/load-language.ts +++ b/apps/desktop/layer/renderer/src/lib/load-language.ts @@ -7,14 +7,14 @@ import { toast } from "sonner" import { currentSupportedLanguages, dayjsLocaleImportMap } from "~/@types/constants" import { defaultResources } from "~/@types/default-resource" -import { fallbackLanguage, i18nAtom, langChain, LocaleCache } from "~/i18n" +import { i18nAtom, langChain, LocaleCache } from "~/i18n" import { jotaiStore } from "~/lib/jotai" import { tipcClient } from "./client" import { appLog } from "./log" const loadingLangLock = new Set() -const loadedLangs = new Set([fallbackLanguage]) +const loadedLangs = new Set(["en"]) export const loadLanguageAndApply = async (lang: string) => { const dayjsImport = dayjsLocaleImportMap[lang] 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/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/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/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 1d94a8f57..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 @@ -12,7 +12,6 @@ import { debounce } from "es-toolkit/compat" import type { PropsWithChildren } from "react" import * as React from "react" import { Suspense, useEffect, useRef, useState } from "react" -import { useHotkeys } from "react-hotkeys-hook" import { Trans } from "react-i18next" import { useResizable } from "react-resizable-layout" import { Outlet } from "react-router" @@ -31,17 +30,17 @@ import { AppErrorBoundary } from "~/components/common/AppErrorBoundary" import { ErrorComponentType } from "~/components/errors/enum" import { PlainModal } from "~/components/ui/modal/stacked/custom-modal" import { DeclarativeModal } from "~/components/ui/modal/stacked/declarative-modal" -import { HotKeyScopeMap } from "~/constants" +import { HotkeyScope } from "~/constants" import { ROOT_CONTAINER_ID } from "~/constants/dom" -import { shortcuts } from "~/constants/shortcuts" import { useDailyTask } from "~/hooks/biz/useDailyTask" import { useBatchUpdateSubscription } from "~/hooks/biz/useSubscriptionActions" import { useI18n } from "~/hooks/common" import { EnvironmentIndicator } from "~/modules/app/EnvironmentIndicator" import { NetworkStatusIndicator } from "~/modules/app/NetworkStatusIndicator" import { LoginModalContent } from "~/modules/auth/LoginModalContent" +import { COMMAND_ID } from "~/modules/command/commands/id" +import { useCommandBinding } from "~/modules/command/hooks/use-register-hotkey" import { DebugRegistry } from "~/modules/debug/registry" -import { useShortcutsModal } from "~/modules/modal/shortcuts" import { CmdF } from "~/modules/panel/cmdf" import { SearchCmdK } from "~/modules/panel/cmdk" import { CmdNTrigger } from "~/modules/panel/cmdn" @@ -51,6 +50,7 @@ import { getSelectedFeedIds, resetSelectedFeedIds } from "~/modules/timeline-col import { UpdateNotice } from "~/modules/update-notice/UpdateNotice" import { AppNotificationContainer } from "~/modules/upgrade/lazy/index" import { AppLayoutGridContainerProvider } from "~/providers/app-grid-layout-container-provider" +import { useHotkeyScope } from "~/providers/hotkey-provider" import { NewUserGuide } from "./index.shared" @@ -241,17 +241,12 @@ const FeedResponsiveResizerContainer = ({ } }, [feedColumnShow]) - useHotkeys( - shortcuts.layout.toggleSidebar.key, - () => { - setTimelineColumnShow(!feedColumnShow) - }, - { - scopes: HotKeyScopeMap.Home, - }, - ) - const showShortcuts = useShortcutsModal() - useHotkeys(shortcuts.layout.showShortcuts.key, showShortcuts) + const activeScopes = useHotkeyScope() + + useCommandBinding({ + commandId: COMMAND_ID.layout.toggleTimelineColumn, + when: activeScopes.includes(HotkeyScope.Home), + }) const [delayShowSplitter, setDelayShowSplitter] = useState(feedColumnShow) 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..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" @@ -73,53 +74,60 @@ export const MobileFloatBar = ({ return (
- - +
+ + +
- - +
+ + +
) } const ViewTabs = ({ onViewChange }: { onViewChange?: (view: number) => void }) => { + const viewsWithSubscription = useViewWithSubscription() const view = useRouteParamsSelector((s) => s.view) const navigate = useNavigateEntry() return (
- {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}
+
+ ))}
) } 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/auth/LegalModal.tsx b/apps/desktop/layer/renderer/src/modules/auth/LegalModal.tsx new file mode 100644 index 000000000..c30f451aa --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/auth/LegalModal.tsx @@ -0,0 +1,25 @@ +import { legalHtml } from "@follow/legal" +import { stopPropagation } from "@follow/utils/dom" +import { m } from "motion/react" +import type { FC } from "react" + +type LegalModalProps = { + type: "privacy" | "tos" +} + +export const LegalModalContent: FC = ({ type }) => { + const content = type === "privacy" ? legalHtml.privacy : legalHtml.tos + + return ( + +
+