release(desktop): Release v0.4.6
release(desktop): Release v0.4.6
This commit is contained in:
commit
e40f44487b
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -18,8 +18,8 @@
|
|||
<a href="https://discord.gg/followapp" target="_blank"><img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fdiscord.com%2Fapi%2Finvites%2Ffollowapp%3Fwith_counts%3Dtrue&query=approximate_member_count&color=5865F2&label=Discord&labelColor=black&logo=discord&logoColor=white&style=flat-square"/></a>
|
||||
<br />
|
||||
<a href="https://github.com/RSSNext/Folo/releases"><img src="https://img.shields.io/github/package-json/v/RSSNext/Folo?filename=%2Fapps%2Fmobile%2Fpackage.json&style=flat-square&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB3aWR0aD0iNTUiIGhlaWdodD0iNTUiIHZpZXdCb3g9IjAgMCA1NSA1NSIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTQxLjc5NDQgMTBIMTguNjc4NkMxNS44ODMzIDEwIDEzLjYxNzIgMTIuMTk2NSAxMy42MTcyIDE0LjkwNTlDMTMuNjE3MiAxNy42MTU0IDE1Ljg4MzMgMTkuODExOSAxOC42Nzg2IDE5LjgxMTlINDEuNzk0NEM0NC41ODk5IDE5LjgxMTkgNDYuODU1OSAxNy42MTU0IDQ2Ljg1NTkgMTQuOTA1OUM0Ni44NTU5IDEyLjE5NjUgNDQuNTg5OSAxMCA0MS43OTQ0IDEwWiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZD0iTTI5LjQ2ODMgMjIuNzU1OUgxNC4wNjE1QzExLjI2NjEgMjIuNzU1OSA5IDI0Ljk1MjMgOSAyNy42NjE4QzkgMzAuMzcxMyAxMS4yNjYxIDMyLjU2NzcgMTQuMDYxNSAzMi41Njc3SDI5LjQ2ODNDMzIuMjYzNyAzMi41Njc3IDM0LjUyOTggMzAuMzcxMyAzNC41Mjk4IDI3LjY2MThDMzQuNTI5OCAyNC45NTIzIDMyLjI2MzcgMjIuNzU1OSAyOS40NjgzIDIyLjc1NTlaIiBmaWxsPSJ3aGl0ZSIvPgo8cGF0aCBkPSJNMzAuOTU4OSA0MC40MjcyQzMwLjk1ODkgMzcuNzE3OSAyOC42OTI4IDM1LjUyMTUgMjUuODk3NCAzNS41MjE1QzIzLjEwMjEgMzUuNTIxNSAyMC44MzU5IDM3LjcxNzkgMjAuODM1OSA0MC40MjcyQzIwLjgzNTkgNDMuMTM3MSAyMy4xMDIxIDQ1LjMzMzIgMjUuODk3NCA0NS4zMzMyQzI4LjY5MjggNDUuMzMzMiAzMC45NTg5IDQzLjEzNzEgMzAuOTU4OSA0MC40MjcyWiIgZmlsbD0id2hpdGUiLz4KPC9zdmc%2BCg%3D%3D&label=Mobile&labelColor=black&color=FF5E01" /></a>
|
||||
<a href="https://github.com/RSSNext/Folo/releases"><img src="https://img.shields.io/github/package-json/v/RSSNext/Folo?filename=%2Fapps%2Fdesktop%2Fpackage.json&style=flat-square&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB3aWR0aD0iNTUiIGhlaWdodD0iNTUiIHZpZXdCb3g9IjAgMCA1NSA1NSIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTQxLjc5NDQgMTBIMTguNjc4NkMxNS44ODMzIDEwIDEzLjYxNzIgMTIuMTk2NSAxMy42MTcyIDE0LjkwNTlDMTMuNjE3MiAxNy42MTU0IDE1Ljg4MzMgMTkuODExOSAxOC42Nzg2IDE5LjgxMTlINDEuNzk0NEM0NC41ODk5IDE5LjgxMTkgNDYuODU1OSAxNy42MTU0IDQ2Ljg1NTkgMTQuOTA1OUM0Ni44NTU5IDEyLjE5NjUgNDQuNTg5OSAxMCA0MS43OTQ0IDEwWiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZD0iTTI5LjQ2ODMgMjIuNzU1OUgxNC4wNjE1QzExLjI2NjEgMjIuNzU1OSA5IDI0Ljk1MjMgOSAyNy42NjE4QzkgMzAuMzcxMyAxMS4yNjYxIDMyLjU2NzcgMTQuMDYxNSAzMi41Njc3SDI5LjQ2ODNDMzIuMjYzNyAzMi41Njc3IDM0LjUyOTggMzAuMzcxMyAzNC41Mjk4IDI3LjY2MThDMzQuNTI5OCAyNC45NTIzIDMyLjI2MzcgMjIuNzU1OSAyOS40NjgzIDIyLjc1NTlaIiBmaWxsPSJ3aGl0ZSIvPgo8cGF0aCBkPSJNMzAuOTU4OSA0MC40MjcyQzMwLjk1ODkgMzcuNzE3OSAyOC42OTI4IDM1LjUyMTUgMjUuODk3NCAzNS41MjE1QzIzLjEwMjEgMzUuNTIxNSAyMC44MzU5IDM3LjcxNzkgMjAuODM1OSA0MC40MjcyQzIwLjgzNTkgNDMuMTM3MSAyMy4xMDIxIDQ1LjMzMzIgMjUuODk3NCA0NS4zMzMyQzI4LjY5MjggNDUuMzMzMiAzMC45NTg5IDQzLjEzNzEgMzAuOTU4OSA0MC40MjcyWiIgZmlsbD0id2hpdGUiLz4KPC9zdmc%2BCg%3D%3D&label=Desktop&labelColor=black&color=FF5E01" /></a>
|
||||
<a href="https://apps.apple.com/us/app/folo-follow-everything/id6739802604"><img src="https://img.shields.io/itunes/v/6739802604?style=flat-square&logo=apple&label=App%20Store&color=FF5C00&labelColor=black" /></a>
|
||||
<a href="https://github.com/RSSNext/Folo/releases"><img src="https://img.shields.io/github/package-json/v/RSSNext/Folo?filename=%2Fapps%2Fdesktop%2Fpackage.json&style=flat-square&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB3aWR0aD0iNTUiIGhlaWdodD0iNTUiIHZpZXdCb3g9IjAgMCA1NSA1NSIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTQxLjc5NDQgMTBIMTguNjc4NkMxNS44ODMzIDEwIDEzLjYxNzIgMTIuMTk2NSAxMy42MTcyIDE0LjkwNTlDMTMuNjE3MiAxNy42MTU0IDE1Ljg4MzMgMTkuODExOSAxOC42Nzg2IDE5LjgxMTlINDEuNzk0NEM0NC41ODk5IDE5LjgxMTkgNDYuODU1OSAxNy42MTU0IDQ2Ljg1NTkgMTQuOTA1OUM0Ni44NTU5IDEyLjE5NjUgNDQuNTg5OSAxMCA0MS43OTQ0IDEwWiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZD0iTTI5LjQ2ODMgMjIuNzU1OUgxNC4wNjE1QzExLjI2NjEgMjIuNzU1OSA5IDI0Ljk1MjMgOSAyNy42NjE4QzkgMzAuMzcxMyAxMS4yNjYxIDMyLjU2NzcgMTQuMDYxNSAzMi41Njc3SDI5LjQ2ODNDMzIuMjYzNyAzMi41Njc3IDM0LjUyOTggMzAuMzcxMyAzNC41Mjk4IDI3LjY2MThDMzQuNTI5OCAyNC45NTIzIDMyLjI2MzcgMjIuNzU1OSAyOS40NjgzIDIyLjc1NTlaIiBmaWxsPSJ3aGl0ZSIvPgo8cGF0aCBkPSJNMzAuOTU4OSA0MC40MjcyQzMwLjk1ODkgMzcuNzE3OSAyOC42OTI4IDM1LjUyMTUgMjUuODk3NCAzNS41MjE1QzIzLjEwMjEgMzUuNTIxNSAyMC44MzU5IDM3LjcxNzkgMjAuODM1OSA0MC40MjcyQzIwLjgzNTkgNDMuMTM3MSAyMy4xMDIxIDQ1LjMzMzIgMjUuODk3NCA0NS4zMzMyQzI4LjY5MjggNDUuMzMzMiAzMC45NTg5IDQzLjEzNzEgMzAuOTU4OSA0MC40MjcyWiIgZmlsbD0id2hpdGUiLz4KPC9zdmc%2BCg%3D%3D&label=Desktop&labelColor=black&color=FF5E01" /></a>
|
||||
<a href="https://apps.apple.com/us/app/folo-follow-everything/id6739802604"><img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Ffolo-mac-app-store-version.rss3.workers.dev%2F&query=version&prefix=v&style=flat-square&logo=apple&label=Mac%20App%20Store&labelColor=black&color=FF5E01&cacheSeconds=3600" /></a>
|
||||
<a href="https://apps.microsoft.com/detail/9nvfzpv0v0ht?mode=direct"><img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Ffolo-microsoft-store-version.rss3.workers.dev%2F&query=version&style=flat-square&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIj48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMyAzaDguNTN2OC41M0gzek0xMi40NjkgM2g4LjUzdjguNTNoLTguNTN6TTMgMTIuNDdoOC41M1YyMUgzek0xMi40NjkgMTIuNDdoOC41M1YyMWgtOC41M3oiLz48L3N2Zz4%3D&logoColor=white&label=Microsoft%20Store&labelColor=black&color=FF5E01&cacheSeconds=3600&prefix=v" /></a>
|
||||
<br />
|
||||
|
|
@ -52,7 +52,7 @@ Feel free to try it using the following methods:
|
|||
|
||||
You can also install using the following methods maintained by our community:
|
||||
|
||||
- If you are using Arch Linux, you can install package [follow-appimage](https://aur.archlinux.org/packages/follow-appimage) that maintained by [timochan](https://github.com/ttimochan).
|
||||
- If you are using Arch Linux, you can install package [folo-appimage](https://aur.archlinux.org/packages/folo-appimage) that maintained by [timochan](https://github.com/ttimochan) and [grtsinry43](https://github.com/grtsinry43).
|
||||
- If you are using Nix, you can install package [follow](https://github.com/NixOS/nixpkgs/blob/master/pkgs/by-name/fo/follow/package.nix) that maintained by [iosmanthus](https://github.com/iosmanthus).
|
||||
- If you are using macOS with [Homebrew](https://brew.sh), you can install cask [folo](https://formulae.brew.sh/cask/folo) that maintained by [realSunyz](https://github.com/realSunyz).
|
||||
- If you are using Windows with [Scoop](https://scoop.sh), you can install manifest [folo](https://github.com/cscnk52/cetacea/blob/master/bucket/folo.json) that maintained by [cscnk52](https://github.com/cscnk52).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -5,3 +5,7 @@
|
|||
## Improvements
|
||||
|
||||
## No longer broken
|
||||
|
||||
## Thanks
|
||||
|
||||
Special thanks to external contributors @ for their valuable contributions
|
||||
|
|
|
|||
|
|
@ -5,3 +5,7 @@
|
|||
## Improvements
|
||||
|
||||
## No longer broken
|
||||
|
||||
## Thanks
|
||||
|
||||
Special thanks to external contributors @ for their valuable contributions
|
||||
|
|
|
|||
|
|
@ -52,7 +52,6 @@ export const viteRenderBaseConfig = {
|
|||
codeInspectorPlugin({
|
||||
bundler: "vite",
|
||||
editor: "cursor",
|
||||
hotKeys: ["metaKey"],
|
||||
}),
|
||||
sentryVitePlugin({
|
||||
org: "follow-rg",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -1,17 +0,0 @@
|
|||
import type { Models } from "@follow/models"
|
||||
import camelcaseKeys from "camelcase-keys"
|
||||
|
||||
import { apiFetch } from "~/lib/api-fetch"
|
||||
|
||||
const v1ApiPrefix = "/v1"
|
||||
export const getTrendingAggregates = async (params: { language: string }) => {
|
||||
const data = await apiFetch<Models.TrendingAggregates>(
|
||||
`${v1ApiPrefix}/trendings?language=${params.language}`,
|
||||
{
|
||||
params: {
|
||||
language: params.language,
|
||||
},
|
||||
},
|
||||
)
|
||||
return camelcaseKeys(data as any, { deep: true }) as Models.TrendingAggregates
|
||||
}
|
||||
|
|
@ -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))
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,45 @@
|
|||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipPortal,
|
||||
TooltipTrigger,
|
||||
} from "@follow/components/ui/tooltip/index.js"
|
||||
import dayjs from "dayjs"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
export function ErrorTooltip({
|
||||
errorAt,
|
||||
errorMessage,
|
||||
children,
|
||||
showWhenError = false,
|
||||
}: {
|
||||
errorMessage?: string | null
|
||||
errorAt?: string | null
|
||||
children: React.ReactNode
|
||||
showWhenError?: boolean
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
if (!errorAt || !errorMessage) {
|
||||
return showWhenError ? children : null
|
||||
}
|
||||
return (
|
||||
<Tooltip delayDuration={300}>
|
||||
<TooltipTrigger asChild>{children}</TooltipTrigger>
|
||||
<TooltipPortal>
|
||||
<TooltipContent>
|
||||
<div className="flex items-center gap-1">
|
||||
<i className="i-mgc-time-cute-re" />
|
||||
{t("feed_item.error_since")}{" "}
|
||||
{dayjs.duration(dayjs(errorAt).diff(dayjs(), "minute"), "minute").humanize(true)}
|
||||
</div>
|
||||
{!!errorMessage && (
|
||||
<div className="flex items-center gap-1">
|
||||
<i className="i-mgc-bug-cute-re" />
|
||||
{errorMessage}
|
||||
</div>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
|
@ -11,7 +11,7 @@ const PageErrorFallback: FC<AppErrorFallbackProps> = (props) => {
|
|||
const { message, stack } = parseError(props.error)
|
||||
useResetErrorWhenRouteChange(props.resetError)
|
||||
return (
|
||||
<div className="bg-theme-background flex w-full flex-col items-center justify-center rounded-md p-2">
|
||||
<div className="bg-theme-background pointer-events-auto flex w-full flex-col items-center justify-center rounded-md p-2">
|
||||
<div className="m-auto max-w-prose text-center">
|
||||
<div className="mb-4">
|
||||
<i className="i-mgc-bug-cute-re text-4xl text-red-500" />
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -193,7 +193,9 @@ const ShikiCode: FC<
|
|||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b p-2">
|
||||
{language !== "plaintext" && (
|
||||
{language === "plaintext" ? (
|
||||
<div />
|
||||
) : (
|
||||
<span className="center flex gap-1 text-xs uppercase opacity-80 dark:text-white">
|
||||
<span className="center [&_svg]:size-4">{getLanguageIcon(language)}</span>
|
||||
<span>{language}</span>
|
||||
|
|
|
|||
|
|
@ -5,11 +5,12 @@ import { cn } from "@follow/utils/utils"
|
|||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import * as React from "react"
|
||||
|
||||
import { useHotkeyScope } from "~/hooks/common"
|
||||
import { HotkeyScope } from "~/constants"
|
||||
import { useConditionalHotkeyScope } from "~/hooks/common"
|
||||
|
||||
const DropdownMenu: typeof DropdownMenuPrimitive.Root = (props) => {
|
||||
const [open, setOpen] = React.useState(!!props.open)
|
||||
useHotkeyScope("DropdownMenu", open)
|
||||
useConditionalHotkeyScope(HotkeyScope.DropdownMenu, open)
|
||||
|
||||
return (
|
||||
<DropdownMenuPrimitive.Root
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { MediaModel } from "@follow/shared/hono"
|
||||
import { stopPropagation } from "@follow/utils/dom"
|
||||
import { cn } from "@follow/utils/utils"
|
||||
import useEmblaCarousel from "embla-carousel-react"
|
||||
import { WheelGesturesPlugin } from "embla-carousel-wheel-gestures"
|
||||
|
|
@ -84,6 +85,7 @@ export function SwipeMedia({
|
|||
type="button"
|
||||
className="center absolute left-2 top-1/2 size-6 -translate-y-1/2 rounded-full bg-gray-800 text-white opacity-0 duration-200 group-hover:opacity-100"
|
||||
onClick={scrollPrev}
|
||||
onDoubleClick={stopPropagation}
|
||||
>
|
||||
<i className="i-mingcute-arrow-left-line" />
|
||||
</button>
|
||||
|
|
@ -93,6 +95,7 @@ export function SwipeMedia({
|
|||
type="button"
|
||||
className="center absolute right-2 top-1/2 size-6 -translate-y-1/2 rounded-full bg-gray-800 text-white opacity-0 duration-200 group-hover:opacity-100"
|
||||
onClick={scrollNext}
|
||||
onDoubleClick={stopPropagation}
|
||||
>
|
||||
<i className="i-mingcute-arrow-right-line" />
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ import type { FC, RefObject } from "react"
|
|||
import { createContext as reactCreateContext } from "react"
|
||||
import { createContext as createContextSelector } from "use-context-selector"
|
||||
|
||||
import type { ModalProps } from "./types"
|
||||
|
||||
export type CurrentModalContentProps = ModalActionsInternal & {
|
||||
ref: RefObject<HTMLElement | null>
|
||||
modalElementRef: RefObject<HTMLElement | null>
|
||||
|
|
@ -37,3 +39,10 @@ export type ModalActionsInternal = {
|
|||
setClickOutSideToDismiss: (value: boolean) => void
|
||||
getIndex: () => number
|
||||
}
|
||||
|
||||
type Disposer = () => void
|
||||
type PresentModalContextInternalFn = (props: ModalProps & { id?: string }) => Disposer
|
||||
export const PresentModalContextInternal = reactCreateContext<PresentModalContextInternalFn>(() => {
|
||||
warnNoProvider()
|
||||
return () => {}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,75 +1,30 @@
|
|||
import { Button } from "@follow/components/ui/button/index.js"
|
||||
import { nextFrame } from "@follow/utils/dom"
|
||||
import { atom, useAtomValue } from "jotai"
|
||||
import type { DragControls } from "motion/react"
|
||||
import type { ResizeCallback, ResizeStartCallback } from "re-resizable"
|
||||
import { use, useId, useRef, useState } from "react"
|
||||
import { use, useState } from "react"
|
||||
import { flushSync } from "react-dom"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { useContextSelector } from "use-context-selector"
|
||||
import { useEventCallback } from "usehooks-ts"
|
||||
|
||||
import { getUISettings } from "~/atoms/settings/ui"
|
||||
import { jotaiStore } from "~/lib/jotai"
|
||||
|
||||
import { modalStackAtom } from "./atom"
|
||||
import { ModalEventBus } from "./bus"
|
||||
import { CurrentModalContext, CurrentModalStateContext } from "./context"
|
||||
import type { DialogInstance, ModalProps, ModalStackOptions } from "./types"
|
||||
import {
|
||||
CurrentModalContext,
|
||||
CurrentModalStateContext,
|
||||
PresentModalContextInternal,
|
||||
} from "./context"
|
||||
import type { DialogInstance, ModalProps } from "./types"
|
||||
|
||||
export const modalIdToPropsMap = {} as Record<string, ModalProps>
|
||||
export const useModalStack = (options?: ModalStackOptions) => {
|
||||
const id = useId()
|
||||
const currentCount = useRef(0)
|
||||
const { wrapper } = options || {}
|
||||
export const useModalStack = () => {
|
||||
const present = use(PresentModalContextInternal)
|
||||
|
||||
return {
|
||||
present: useEventCallback((props: ModalProps & { id?: string }) => {
|
||||
const presentSync = (props: ModalProps & { id?: string }) => {
|
||||
const fallbackModelId = `${id}-${++currentCount.current}`
|
||||
const modalId = props.id ?? fallbackModelId
|
||||
|
||||
const currentStack = jotaiStore.get(modalStackAtom)
|
||||
|
||||
const existingModal = currentStack.find((item) => item.id === modalId)
|
||||
|
||||
if (existingModal) {
|
||||
// Move to top
|
||||
jotaiStore.set(modalStackAtom, (p) => {
|
||||
const index = p.indexOf(existingModal)
|
||||
return [...p.slice(0, index), ...p.slice(index + 1), existingModal]
|
||||
})
|
||||
} else {
|
||||
// NOTE: The props of the Command Modal are immutable, so we'll just take the store value and inject it.
|
||||
// There is no need to inject `overlay` props, this is rendered responsively based on ui changes.
|
||||
const uiSettings = getUISettings()
|
||||
const modalConfig: Partial<ModalProps> = {
|
||||
draggable: uiSettings.modalDraggable,
|
||||
modal: true,
|
||||
}
|
||||
jotaiStore.set(modalStackAtom, (p) => {
|
||||
const modalProps: ModalProps = {
|
||||
...modalConfig,
|
||||
...props,
|
||||
|
||||
wrapper,
|
||||
}
|
||||
modalIdToPropsMap[modalId] = modalProps
|
||||
return p.concat({
|
||||
id: modalId,
|
||||
...modalProps,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return () => {
|
||||
jotaiStore.set(modalStackAtom, (p) => p.filter((item) => item.id !== modalId))
|
||||
}
|
||||
}
|
||||
|
||||
return nextFrame(() => presentSync(props))
|
||||
}),
|
||||
|
||||
present,
|
||||
...actions,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,17 +1,73 @@
|
|||
import { nextFrame } from "@follow/utils/dom"
|
||||
import type { FC, PropsWithChildren } from "react"
|
||||
import { useId, useRef } from "react"
|
||||
import { useEventCallback } from "usehooks-ts"
|
||||
|
||||
import { getUISettings } from "~/atoms/settings/ui"
|
||||
import { jotaiStore } from "~/lib/jotai"
|
||||
|
||||
import { modalStackAtom } from "./atom"
|
||||
import { PresentModalContextInternal } from "./context"
|
||||
import { modalIdToPropsMap } from "./hooks"
|
||||
import { ModalStack } from "./modal-stack"
|
||||
import type { ModalProps } from "./types"
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
presentModal: (modal: ModalProps) => void
|
||||
presentModal: (modal: ModalProps & { id?: string }) => void
|
||||
}
|
||||
}
|
||||
|
||||
export const ModalStackProvider: FC<PropsWithChildren> = ({ children }) => (
|
||||
<>
|
||||
{children}
|
||||
<ModalStack />
|
||||
</>
|
||||
)
|
||||
export const ModalStackProvider: FC<PropsWithChildren> = ({ children }) => {
|
||||
const id = useId()
|
||||
const currentCount = useRef(0)
|
||||
const presentModal = useEventCallback((props: ModalProps & { id?: string }) => {
|
||||
const fallbackModelId = `${id}-${++currentCount.current}`
|
||||
const modalId = props.id ?? fallbackModelId
|
||||
const presentSync = (props: ModalProps & { id?: string }) => {
|
||||
const currentStack = jotaiStore.get(modalStackAtom)
|
||||
|
||||
const existingModal = currentStack.find((item) => item.id === modalId)
|
||||
|
||||
if (existingModal) {
|
||||
// Move to top
|
||||
jotaiStore.set(modalStackAtom, (p) => {
|
||||
const index = p.indexOf(existingModal)
|
||||
return [...p.slice(0, index), ...p.slice(index + 1), existingModal]
|
||||
})
|
||||
} else {
|
||||
// NOTE: The props of the Command Modal are immutable, so we'll just take the store value and inject it.
|
||||
// There is no need to inject `overlay` props, this is rendered responsively based on ui changes.
|
||||
const uiSettings = getUISettings()
|
||||
const modalConfig: Partial<ModalProps> = {
|
||||
draggable: uiSettings.modalDraggable,
|
||||
modal: true,
|
||||
}
|
||||
jotaiStore.set(modalStackAtom, (p) => {
|
||||
const modalProps: ModalProps = {
|
||||
...modalConfig,
|
||||
...props,
|
||||
}
|
||||
modalIdToPropsMap[modalId] = modalProps
|
||||
return p.concat({
|
||||
id: modalId,
|
||||
...modalProps,
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
nextFrame(() => presentSync(props))
|
||||
|
||||
return () => {
|
||||
jotaiStore.set(modalStackAtom, (p) => p.filter((item) => item.id !== modalId))
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<PresentModalContextInternal value={presentModal}>
|
||||
{children}
|
||||
<ModalStack />
|
||||
</PresentModalContextInternal>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,9 +32,6 @@ export interface ModalProps {
|
|||
|
||||
autoFocus?: boolean
|
||||
}
|
||||
export interface ModalStackOptions {
|
||||
wrapper?: FC
|
||||
}
|
||||
|
||||
export interface DialogInstance {
|
||||
ask: (options: {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,67 +8,83 @@ type Shortcuts = Record<
|
|||
>
|
||||
|
||||
const shortcutConfigs = {
|
||||
feeds: {
|
||||
subscriptions: {
|
||||
add: {
|
||||
name: "keys.feeds.add",
|
||||
name: tShortcuts("keys.subscriptions.add"),
|
||||
key: "Meta+T",
|
||||
},
|
||||
switchToView: {
|
||||
name: "keys.feeds.switchToView",
|
||||
name: tShortcuts("keys.subscriptions.switchToView"),
|
||||
key: "1, 2, 3, 4, 5, 6",
|
||||
},
|
||||
switchBetweenViews: {
|
||||
name: "keys.feeds.switchBetweenViews",
|
||||
key: "Tab, Left, Right",
|
||||
switchNextView: {
|
||||
name: tShortcuts("keys.subscriptions.switchNextView"),
|
||||
key: "Tab, ArrowRight",
|
||||
},
|
||||
switchPreviousView: {
|
||||
name: tShortcuts("keys.subscriptions.switchPreviousView"),
|
||||
key: "Shift+Tab, ArrowLeft",
|
||||
},
|
||||
nextSubscription: {
|
||||
name: tShortcuts("keys.subscriptions.nextSubscription"),
|
||||
key: "J, ArrowDown",
|
||||
},
|
||||
previousSubscription: {
|
||||
name: tShortcuts("keys.subscriptions.previousSubscription"),
|
||||
key: "K, ArrowUp",
|
||||
},
|
||||
toggleFolderCollapse: {
|
||||
name: tShortcuts("keys.subscriptions.toggleFolderCollapse"),
|
||||
key: "Z",
|
||||
},
|
||||
},
|
||||
layout: {
|
||||
toggleSidebar: {
|
||||
name: "keys.layout.toggleSidebar",
|
||||
name: tShortcuts("keys.layout.toggleSidebar"),
|
||||
key: "Meta+B, [",
|
||||
},
|
||||
showShortcuts: {
|
||||
name: "keys.layout.showShortcuts",
|
||||
name: tShortcuts("keys.layout.showShortcuts"),
|
||||
key: "H",
|
||||
},
|
||||
toggleWideMode: {
|
||||
name: "keys.layout.toggleWideMode",
|
||||
name: tShortcuts("keys.layout.toggleWideMode"),
|
||||
key: "Meta+[",
|
||||
},
|
||||
zenMode: {
|
||||
name: "keys.layout.zenMode",
|
||||
name: tShortcuts("keys.layout.zenMode"),
|
||||
key: "Ctrl+Shift+Z",
|
||||
},
|
||||
},
|
||||
entries: {
|
||||
refetch: {
|
||||
name: "keys.entries.refetch",
|
||||
name: tShortcuts("keys.entries.refetch"),
|
||||
key: "R",
|
||||
},
|
||||
previous: {
|
||||
name: "keys.entries.previous",
|
||||
key: "K, Up",
|
||||
name: tShortcuts("keys.entries.previous"),
|
||||
key: "K, ArrowUp",
|
||||
},
|
||||
next: {
|
||||
name: "keys.entries.next",
|
||||
key: "J, Down",
|
||||
name: tShortcuts("keys.entries.next"),
|
||||
key: "J, ArrowDown",
|
||||
},
|
||||
markAllAsRead: {
|
||||
name: "keys.entries.markAllAsRead",
|
||||
name: tShortcuts("keys.entries.markAllAsRead"),
|
||||
key: "Shift+Meta+A",
|
||||
},
|
||||
toggleUnreadOnly: {
|
||||
name: "keys.entries.toggleUnreadOnly",
|
||||
name: tShortcuts("keys.entries.toggleUnreadOnly"),
|
||||
key: "U",
|
||||
},
|
||||
},
|
||||
entry: {
|
||||
toggleRead: {
|
||||
name: "keys.entry.toggleRead",
|
||||
name: tShortcuts("keys.entry.toggleRead"),
|
||||
key: "M",
|
||||
},
|
||||
toggleStarred: {
|
||||
name: "keys.entry.toggleStarred",
|
||||
name: tShortcuts("keys.entry.toggleStarred"),
|
||||
key: "S",
|
||||
},
|
||||
openInBrowser: {
|
||||
|
|
@ -77,43 +93,51 @@ const shortcutConfigs = {
|
|||
extra: "Double Click",
|
||||
},
|
||||
tts: {
|
||||
name: "keys.entry.tts",
|
||||
name: tShortcuts("keys.entry.tts"),
|
||||
key: "Shift+Meta+V",
|
||||
},
|
||||
copyLink: {
|
||||
name: "keys.entry.copyLink",
|
||||
name: tShortcuts("keys.entry.copyLink"),
|
||||
key: "Shift+Meta+C",
|
||||
},
|
||||
copyTitle: {
|
||||
name: "keys.entry.copyTitle",
|
||||
name: tShortcuts("keys.entry.copyTitle"),
|
||||
key: "Shift+Meta+B",
|
||||
},
|
||||
tip: {
|
||||
name: "keys.entry.tip",
|
||||
name: tShortcuts("keys.entry.tip"),
|
||||
key: "Shift+Meta+T",
|
||||
},
|
||||
share: {
|
||||
name: "keys.entry.share",
|
||||
name: tShortcuts("keys.entry.share"),
|
||||
key: "Meta+Alt+S",
|
||||
},
|
||||
scrollUp: {
|
||||
name: "keys.entry.scrollUp",
|
||||
key: "PageUp",
|
||||
name: tShortcuts("keys.entry.scrollUp"),
|
||||
key: "K, ArrowUp",
|
||||
},
|
||||
scrollDown: {
|
||||
name: "keys.entry.scrollDown",
|
||||
key: "PageDown",
|
||||
name: tShortcuts("keys.entry.scrollDown"),
|
||||
key: "J, ArrowDown",
|
||||
},
|
||||
nextEntry: {
|
||||
name: tShortcuts("keys.entries.next"),
|
||||
key: "L, ArrowRight",
|
||||
},
|
||||
previousEntry: {
|
||||
name: tShortcuts("keys.entries.previous"),
|
||||
key: "H, ArrowLeft",
|
||||
},
|
||||
},
|
||||
audio: {
|
||||
"play/pause": {
|
||||
name: "keys.audio.playPause",
|
||||
name: tShortcuts("keys.audio.playPause"),
|
||||
key: "Space",
|
||||
},
|
||||
},
|
||||
misc: {
|
||||
quickSearch: {
|
||||
name: "keys.misc.quickSearch",
|
||||
name: tShortcuts("keys.misc.quickSearch"),
|
||||
key: "Meta+K",
|
||||
},
|
||||
},
|
||||
|
|
@ -135,7 +159,7 @@ function transformShortcuts<T extends Shortcuts>(configs: T) {
|
|||
export const shortcuts = transformShortcuts(shortcutConfigs) satisfies Shortcuts
|
||||
|
||||
export const shortcutsType: { [key in keyof typeof shortcuts]: I18nKeysForShortcuts } = {
|
||||
feeds: "keys.type.feeds",
|
||||
subscriptions: "keys.type.subscriptions",
|
||||
layout: "keys.type.layout",
|
||||
entries: "keys.type.entries",
|
||||
entry: "keys.type.entry",
|
||||
|
|
|
|||
|
|
@ -34,6 +34,21 @@ declare global {
|
|||
export type I18nKeys = OmitStringType<Parameters<typeof t>[0]>
|
||||
export type I18nKeysForSettings = OmitStringType<Parameters<typeof settingsT>[0]>
|
||||
export type I18nKeysForShortcuts = OmitStringType<Parameters<typeof shortcutsT>[0]>
|
||||
|
||||
// MACROS
|
||||
|
||||
/**
|
||||
* This function is a macro, will replace in the build stage.
|
||||
*/
|
||||
export function tShortcuts(key: I18nKeysForShortcuts): I18nKeysForShortcuts
|
||||
/**
|
||||
* This function is a macro, will replace in the build stage.
|
||||
*/
|
||||
export function tSettings(key: I18nKeysForSettings): I18nKeysForSettings
|
||||
/**
|
||||
* This function is a macro, will replace in the build stage.
|
||||
*/
|
||||
export function t_(key: I18nKeys): I18nKeys
|
||||
}
|
||||
|
||||
export {}
|
||||
|
|
|
|||
|
|
@ -410,6 +410,7 @@ export const useSortedEntryActions = ({
|
|||
return false
|
||||
}
|
||||
const order = orderMap.get(item.id)
|
||||
|
||||
if (!order) return false
|
||||
return order.type === "main"
|
||||
})
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ const ConfirmDestroyModalContent = ({ onConfirm }: { onConfirm: () => void }) =>
|
|||
{t("sidebar.feed_actions.unfollow_feed_many_warning")}
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button className="bg-red-600" onClick={onConfirm}>
|
||||
<Button buttonClassName="bg-red-600" onClick={onConfirm}>
|
||||
{t("words.confirm")}
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { UserRole } from "@follow/constants"
|
||||
import { t } from "i18next"
|
||||
import { useCallback } from "react"
|
||||
import { withoutTrailingSlash, withTrailingSlash } from "ufo"
|
||||
import { useEventCallback } from "usehooks-ts"
|
||||
|
||||
import { useServerConfigs } from "~/atoms/server-configs"
|
||||
|
|
@ -12,6 +13,7 @@ import type { FeedFormDataValuesType } from "~/modules/discover/FeedForm"
|
|||
import { FeedForm } from "~/modules/discover/FeedForm"
|
||||
import type { ListFormDataValuesType } from "~/modules/discover/ListForm"
|
||||
import { ListForm } from "~/modules/discover/ListForm"
|
||||
import { getFeedByIdOrUrl } from "~/store/feed"
|
||||
import {
|
||||
getSubscriptionByFeedId,
|
||||
useFeedSubscriptionCount,
|
||||
|
|
@ -73,10 +75,16 @@ export const useFollow = () => {
|
|||
} else {
|
||||
canFollowMoreInboxAndNotify("feed")
|
||||
}
|
||||
let isFollowed = false
|
||||
if (options?.id) {
|
||||
isFollowed = !!getSubscriptionByFeedId(options.id)
|
||||
}
|
||||
|
||||
// Some feeds redirect xxx.com/feed to xxx.com/feed/
|
||||
// Try to get a valid feed, then we can check isFollowed correctly
|
||||
const feed =
|
||||
getFeedByIdOrUrl({ id: options?.id, url: withTrailingSlash(options?.url) }) ??
|
||||
getFeedByIdOrUrl({ id: options?.id, url: withoutTrailingSlash(options?.url) })
|
||||
const id = options?.id || feed?.id
|
||||
const url = feed?.type === "feed" ? feed.url : options?.url
|
||||
const subscription = getSubscriptionByFeedId(id)
|
||||
const isFollowed = !!subscription
|
||||
|
||||
present({
|
||||
title: `${isFollowed ? `${t("common:words.edit")} ` : ""}${options?.isList ? t("words.lists") : t("words.feeds")}`,
|
||||
|
|
@ -94,8 +102,8 @@ export const useFollow = () => {
|
|||
/>
|
||||
) : (
|
||||
<FeedForm
|
||||
id={options?.id}
|
||||
url={options?.url}
|
||||
id={id}
|
||||
url={url}
|
||||
defaultValues={options?.defaultValues as FeedFormDataValuesType}
|
||||
onSuccess={onSuccess}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { useHotkeys } from "react-hotkeys-hook"
|
|||
import { Trans, useTranslation } from "react-i18next"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { HotKeyScopeMap } from "~/constants"
|
||||
import { HotkeyScope } from "~/constants"
|
||||
import { apiClient } from "~/lib/api-fetch"
|
||||
import { subscription as subscriptionQuery } from "~/queries/subscriptions"
|
||||
import type { SubscriptionFlatModel } from "~/store/subscription"
|
||||
|
|
@ -64,7 +64,7 @@ export const useDeleteSubscription = ({ onSuccess }: { onSuccess?: () => void }
|
|||
label: (
|
||||
<span className="flex items-center gap-1">
|
||||
{t("words.undo")}
|
||||
<Kbd className="border-border inline-flex items-center border bg-transparent dark:text-white">
|
||||
<Kbd className="border-border inline-flex items-center border bg-transparent text-white">
|
||||
Meta+Z
|
||||
</Kbd>
|
||||
</span>
|
||||
|
|
@ -92,7 +92,7 @@ export const useDeleteSubscription = ({ onSuccess }: { onSuccess?: () => void }
|
|||
|
||||
const UnfollowInfo = ({ title, undo }: { title: string; undo: () => any }) => {
|
||||
useHotkeys("ctrl+z,meta+z", undo, {
|
||||
scopes: HotKeyScopeMap.Home,
|
||||
scopes: HotkeyScope.Home,
|
||||
preventDefault: true,
|
||||
})
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -7,14 +7,14 @@ import { toast } from "sonner"
|
|||
|
||||
import { currentSupportedLanguages, dayjsLocaleImportMap } from "~/@types/constants"
|
||||
import { defaultResources } from "~/@types/default-resource"
|
||||
import { fallbackLanguage, i18nAtom, langChain, LocaleCache } from "~/i18n"
|
||||
import { i18nAtom, langChain, LocaleCache } from "~/i18n"
|
||||
import { jotaiStore } from "~/lib/jotai"
|
||||
|
||||
import { tipcClient } from "./client"
|
||||
import { appLog } from "./log"
|
||||
|
||||
const loadingLangLock = new Set<string>()
|
||||
const loadedLangs = new Set<string>([fallbackLanguage])
|
||||
const loadedLangs = new Set<string>(["en"])
|
||||
|
||||
export const loadLanguageAndApply = async (lang: string) => {
|
||||
const dayjsImport = dayjsLocaleImportMap[lang]
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { MarkdownLink } from "~/components/ui/markdown/renderers/MarkdownLink"
|
|||
import { VideoPlayer } from "~/components/ui/media/VideoPlayer"
|
||||
|
||||
export const parseMarkdown = (content: string, options?: Partial<RemarkOptions>) => {
|
||||
const videoExts = ["mp4", "webm"]
|
||||
return parseMarkdownImpl(content, {
|
||||
...options,
|
||||
components: {
|
||||
|
|
@ -13,9 +14,40 @@ export const parseMarkdown = (content: string, options?: Partial<RemarkOptions>)
|
|||
img: ({ node, ...props }) => {
|
||||
const { src } = props
|
||||
try {
|
||||
const path = new URL(src || "").pathname
|
||||
const isVideo = path.endsWith(".mp4")
|
||||
const url = new URL(src || "")
|
||||
const path = url.pathname
|
||||
const search = url.searchParams
|
||||
const ratio = search.get("ratio")
|
||||
|
||||
const isVideo = videoExts.some((ext) => path.endsWith(ext))
|
||||
if (isVideo) {
|
||||
if (ratio) {
|
||||
return createElement(
|
||||
"div",
|
||||
{
|
||||
style: {
|
||||
width: "100%",
|
||||
paddingTop: `${(1 / Number(ratio)) * 100}%`,
|
||||
position: "relative",
|
||||
},
|
||||
},
|
||||
createElement(
|
||||
"div",
|
||||
{
|
||||
style: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
},
|
||||
},
|
||||
createElement(VideoPlayer, {
|
||||
src: src as string,
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
return createElement(VideoPlayer, {
|
||||
src: src as string,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -9,14 +9,6 @@ import {
|
|||
SelectValue,
|
||||
} from "@follow/components/ui/select/index.jsx"
|
||||
import { ResponsiveSelect } from "@follow/components/ui/select/responsive.js"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@follow/components/ui/table/index.jsx"
|
||||
import type { ActionFeedField, ActionOperation } from "@follow/models/types"
|
||||
import { cn } from "@follow/utils/utils"
|
||||
import { Fragment, useMemo } from "react"
|
||||
|
|
@ -111,9 +103,9 @@ export const FeedFilter = ({ index }: { index: number }) => {
|
|||
|
||||
{condition.length > 0 && (
|
||||
<div className="pl-6">
|
||||
<Table>
|
||||
<FieldTableHeader />
|
||||
<TableBody>
|
||||
<div className="w-full">
|
||||
<GridHeader />
|
||||
<div className="mt-2">
|
||||
{condition.flatMap((orConditions, orConditionIdx) => {
|
||||
return orConditions.map((condition, conditionIdx) => {
|
||||
const change = (key: string, value: string | number) => {
|
||||
|
|
@ -129,17 +121,14 @@ export const FeedFilter = ({ index }: { index: number }) => {
|
|||
return (
|
||||
<Fragment key={`${orConditionIdx}${conditionIdx}`}>
|
||||
{conditionIdx === 0 && orConditionIdx !== 0 && (
|
||||
<TableRow className="flex h-16 items-center">
|
||||
<Button disabled variant="outline">
|
||||
<div className="flex h-16 items-center justify-center">
|
||||
<span className="text-text-secondary text-sm uppercase">
|
||||
{t("actions.action_card.or")}
|
||||
</Button>
|
||||
</TableRow>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<TableRow className="max-sm:flex max-sm:flex-col">
|
||||
<TableCell
|
||||
size="sm"
|
||||
className="max-sm:flex max-sm:items-center max-sm:justify-between max-sm:pr-0"
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-4 sm:gap-4">
|
||||
<div className="flex items-center justify-between max-sm:flex-row sm:justify-start">
|
||||
<span className="sm:hidden">{t("actions.action_card.field")}</span>
|
||||
<ResponsiveSelect
|
||||
placeholder="Select Field"
|
||||
|
|
@ -147,78 +136,106 @@ export const FeedFilter = ({ index }: { index: number }) => {
|
|||
value={condition.field}
|
||||
onValueChange={(value) => change("field", value as ActionFeedField)}
|
||||
items={FeedOptions}
|
||||
triggerClassName="max-sm:w-fit"
|
||||
triggerClassName="max-sm:w-fit h-8"
|
||||
/>
|
||||
</TableCell>
|
||||
<OperationTableCell
|
||||
type={type}
|
||||
disabled={disabled}
|
||||
value={condition.operator}
|
||||
onValueChange={(value) => change("operator", value)}
|
||||
/>
|
||||
<TableCell
|
||||
size="sm"
|
||||
className="max-sm:flex max-sm:items-center max-sm:justify-between max-sm:gap-4 max-sm:pr-0"
|
||||
>
|
||||
</div>
|
||||
<div className="flex items-center justify-between max-sm:flex-row sm:justify-start">
|
||||
<span className="sm:hidden">{t("actions.action_card.operator")}</span>
|
||||
<OperationSelect
|
||||
type={type}
|
||||
disabled={disabled}
|
||||
value={condition.operator}
|
||||
onValueChange={(value) => change("operator", value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4 max-sm:flex-row sm:justify-start">
|
||||
<span className="sm:hidden">{t("actions.action_card.value")}</span>
|
||||
<ValueTableCell
|
||||
<ValueInput
|
||||
type={type}
|
||||
value={condition.value}
|
||||
onChange={(value) => change("value", value)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</TableCell>
|
||||
</div>
|
||||
|
||||
<ActionTableCell
|
||||
disabled={disabled}
|
||||
onAnd={() => {
|
||||
onChange((data) => {
|
||||
data.condition[orConditionIdx]!.push({})
|
||||
})
|
||||
}}
|
||||
onDelete={() => {
|
||||
onChange((data) => {
|
||||
if (data.condition[orConditionIdx]!.length === 1) {
|
||||
data.condition.splice(orConditionIdx, 1)
|
||||
} else {
|
||||
data.condition[orConditionIdx]!.splice(conditionIdx, 1)
|
||||
}
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</TableRow>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
buttonClassName="w-full sm:hidden"
|
||||
variant="outline"
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
onChange((data) => {
|
||||
data.condition[orConditionIdx]!.push({})
|
||||
})
|
||||
}}
|
||||
>
|
||||
{t("actions.action_card.and")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
buttonClassName="w-full max-sm:hidden"
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
onChange((data) => {
|
||||
data.condition[orConditionIdx]!.push({})
|
||||
})
|
||||
}}
|
||||
>
|
||||
{t("actions.action_card.and")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
onChange((data) => {
|
||||
if (data.condition[orConditionIdx]!.length === 1) {
|
||||
data.condition.splice(orConditionIdx, 1)
|
||||
} else {
|
||||
data.condition[orConditionIdx]!.splice(conditionIdx, 1)
|
||||
}
|
||||
})
|
||||
}}
|
||||
>
|
||||
<i className="i-mgc-delete-2-cute-re size-5 text-zinc-600" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{conditionIdx !== orConditions.length - 1 && (
|
||||
<TableRow className="relative flex items-center">
|
||||
<div className="relative my-4 flex w-full items-center justify-center">
|
||||
<div className="relative">
|
||||
<Button disabled variant="outline">
|
||||
<span className="text-text-secondary text-sm uppercase">
|
||||
{t("actions.action_card.and")}
|
||||
</Button>
|
||||
<div className="bg-theme-disabled absolute left-1/2 h-[5px] w-px" />
|
||||
<div className="bg-theme-disabled absolute left-1/2 top-0 h-[5px] w-px -translate-y-full" />
|
||||
</span>
|
||||
<div className="bg-border absolute left-1/2 h-[10px] w-px" />
|
||||
<div className="bg-border absolute left-1/2 top-0 h-[10px] w-px -translate-y-full" />
|
||||
</div>
|
||||
</TableRow>
|
||||
</div>
|
||||
)}
|
||||
</Fragment>
|
||||
)
|
||||
})
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<OrTableRow
|
||||
disabled={disabled}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
buttonClassName="mt-4 w-full gap-1 py-1"
|
||||
onClick={() => {
|
||||
onChange((data) => {
|
||||
data.condition.push([{}])
|
||||
})
|
||||
}}
|
||||
/>
|
||||
disabled={disabled}
|
||||
>
|
||||
{t("actions.action_card.or")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const OperationTableCell = ({
|
||||
const OperationSelect = ({
|
||||
type,
|
||||
value,
|
||||
onValueChange,
|
||||
|
|
@ -276,24 +293,18 @@ const OperationTableCell = ({
|
|||
onValueChange?.(options[0]!.value as ActionOperation)
|
||||
}
|
||||
return (
|
||||
<TableCell
|
||||
size="sm"
|
||||
className="max-sm:flex max-sm:items-center max-sm:justify-between max-sm:pr-0"
|
||||
>
|
||||
<span className="sm:hidden">{t("actions.action_card.operator")}</span>
|
||||
<ResponsiveSelect
|
||||
placeholder="Select Field"
|
||||
disabled={disabled}
|
||||
value={value}
|
||||
onValueChange={(value) => onValueChange?.(value as ActionOperation)}
|
||||
items={options}
|
||||
triggerClassName="h-8 max-sm:w-fit"
|
||||
/>
|
||||
</TableCell>
|
||||
<ResponsiveSelect
|
||||
placeholder="Select Operation"
|
||||
disabled={disabled}
|
||||
value={value}
|
||||
onValueChange={(value) => onValueChange?.(value as ActionOperation)}
|
||||
items={options}
|
||||
triggerClassName="h-8 max-sm:w-fit"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const ValueTableCell = ({
|
||||
const ValueInput = ({
|
||||
type,
|
||||
value,
|
||||
onChange,
|
||||
|
|
@ -360,60 +371,14 @@ const CommonSelectTrigger = ({ className }: { className?: string }) => (
|
|||
</SelectTrigger>
|
||||
)
|
||||
|
||||
const ActionTableCell = ({
|
||||
disabled,
|
||||
onAnd,
|
||||
onDelete,
|
||||
}: {
|
||||
disabled?: boolean
|
||||
onAnd?: () => void
|
||||
onDelete?: () => void
|
||||
}) => {
|
||||
const GridHeader = () => {
|
||||
const { t } = useTranslation("settings")
|
||||
return (
|
||||
<>
|
||||
<TableCell size="sm" className="max-sm:hidden">
|
||||
<Button variant="outline" className="w-full" disabled={disabled} onClick={onAnd}>
|
||||
{t("actions.action_card.and")}
|
||||
</Button>
|
||||
</TableCell>
|
||||
<TableCell size="sm" className="w-full max-sm:flex max-sm:space-x-2 max-sm:pr-0 lg:w-8">
|
||||
<Button className="w-full sm:hidden" variant="outline" disabled={disabled} onClick={onAnd}>
|
||||
{t("actions.action_card.and")}
|
||||
</Button>
|
||||
<Button variant="ghost" disabled={disabled} onClick={onDelete}>
|
||||
<i className="i-mgc-delete-2-cute-re size-5 text-zinc-600" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const OrTableRow = ({ onClick, disabled }: { onClick?: () => void; disabled?: boolean }) => {
|
||||
const { t } = useTranslation("settings")
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="mt-1 w-full gap-1"
|
||||
buttonClassName="py-1"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
>
|
||||
{t("actions.action_card.or")}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
const FieldTableHeader = () => {
|
||||
const { t } = useTranslation("settings")
|
||||
return (
|
||||
<TableHeader className="max-sm:hidden">
|
||||
<TableRow>
|
||||
<TableHead size="sm">{t("actions.action_card.field")}</TableHead>
|
||||
<TableHead size="sm">{t("actions.action_card.operator")}</TableHead>
|
||||
<TableHead size="sm">{t("actions.action_card.value")}</TableHead>
|
||||
<TableHead size="sm" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<div className="text-text-secondary grid grid-cols-4 gap-4 pb-2 text-sm font-medium max-sm:hidden">
|
||||
<div className="pl-1">{t("actions.action_card.field")}</div>
|
||||
<div className="pl-1">{t("actions.action_card.operator")}</div>
|
||||
<div className="pl-1">{t("actions.action_card.value")}</div>
|
||||
<div />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,8 +36,8 @@ const AddTableRow = ({ onClick, disabled }: { onClick?: () => void; disabled?: b
|
|||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="mt-1 w-full gap-1"
|
||||
buttonClassName="py-1"
|
||||
textClassName="w-full"
|
||||
buttonClassName="py-1 mt-1 gap-1 w-full"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -16,7 +16,10 @@ export const FeedDailyModalContent = () => {
|
|||
<TabsList className="w-full">
|
||||
{tabs.map((tab: any) => (
|
||||
<TabsTrigger key={tab} value={tab}>
|
||||
<DailyReportTitle {...(tab === DayOf.Today ? today : yesterday)} />
|
||||
<DailyReportTitle
|
||||
containerClassName="pb-0"
|
||||
{...(tab === DayOf.Today ? today : yesterday)}
|
||||
/>
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
|
|
|||
|
|
@ -44,7 +44,6 @@ import { apiClient } from "~/lib/api-fetch"
|
|||
import { defineQuery } from "~/lib/defineQuery"
|
||||
import { COMMAND_ID } from "~/modules/command/commands/id"
|
||||
import { hasCommand } from "~/modules/command/hooks/use-command"
|
||||
import { FlatMarkAllReadButton } from "~/modules/entry-column/components/mark-all-button"
|
||||
import { StarIcon } from "~/modules/entry-column/star-icon"
|
||||
import { EntryContent } from "~/modules/entry-content"
|
||||
import { CommandDropdownMenuItem } from "~/modules/entry-content/actions/more-actions"
|
||||
|
|
@ -92,10 +91,12 @@ export const DailyReportTitle = ({
|
|||
endDate,
|
||||
startDate,
|
||||
title,
|
||||
containerClassName,
|
||||
}: {
|
||||
title: string
|
||||
startDate: number
|
||||
endDate: number
|
||||
containerClassName?: string
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const language = useGeneralSettingSelector((s) => s.language)
|
||||
|
|
@ -109,7 +110,7 @@ export const DailyReportTitle = ({
|
|||
|
||||
return (
|
||||
<m.div
|
||||
className="flex items-center justify-center gap-2 pb-6 text-base"
|
||||
className={cn("flex items-center justify-center gap-2 pb-6 text-base", containerClassName)}
|
||||
layoutId={`daily-report-title-${title}`}
|
||||
transition={Spring.presets.smooth}
|
||||
>
|
||||
|
|
@ -268,16 +269,6 @@ export const DailyReportModalContent: Component<DailyReportContentProps> = ({
|
|||
</Markdown>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{!!content.data && (
|
||||
<FlatMarkAllReadButton
|
||||
className="ml-auto shrink-0"
|
||||
filter={{
|
||||
startTime: startDate,
|
||||
endTime: endDate,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,7 +35,11 @@ export const EntryColumnMobile = () => {
|
|||
<MobileFloatBar
|
||||
scrollContainer={scrollContainer}
|
||||
onLogoClick={() => {
|
||||
;(document.querySelector(`#${LOGO_MOBILE_ID}`) as HTMLElement)?.click()
|
||||
const logo = document.querySelector(`#${LOGO_MOBILE_ID}`) as HTMLElement
|
||||
if (logo) {
|
||||
logo.click()
|
||||
logo.blur()
|
||||
}
|
||||
}}
|
||||
onViewChange={(view) => {
|
||||
navigateEntry({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { useRouteParamsSelector } from "~/hooks/biz/useRouteParams"
|
|||
import { useEntry } from "~/store/entry"
|
||||
import { useFeedById } from "~/store/feed"
|
||||
import { feedIconSelector } from "~/store/feed/selector"
|
||||
import { useViewWithSubscription } from "~/store/subscription/hooks"
|
||||
|
||||
import { ProfileButton } from "../../user/ProfileButton"
|
||||
import { PodcastButton } from "./components/PodcastButton"
|
||||
|
|
@ -73,53 +74,60 @@ export const MobileFloatBar = ({
|
|||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
"pb-safe-offset-6 pointer-events-none absolute inset-x-0 bottom-0 flex h-36 items-end",
|
||||
"pb-safe-offset-2 pointer-events-none absolute inset-x-0 bottom-0 flex h-40 items-end",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<m.div
|
||||
className={clsx(
|
||||
"bg-background mx-1 inline-flex h-10 w-full min-w-0 items-center rounded-full border border-neutral-200 pl-4 pr-2 shadow-sm shadow-zinc-100 dark:border-neutral-800",
|
||||
"[box-shadow:0px_8px_30px_rgba(122,122,122,0.2)] dark:[box-shadow:0px_8px_30px_rgba(122,122,122,0.2)]",
|
||||
"pointer-events-auto",
|
||||
"bg-background/90 mx-3 inline-flex h-12 w-full min-w-0 items-center justify-between rounded-2xl border border-neutral-200/60 px-4 backdrop-blur-md dark:border-neutral-800/60",
|
||||
"shadow-lg shadow-zinc-200/40 dark:shadow-zinc-900/40",
|
||||
"pointer-events-auto transition-all duration-200",
|
||||
)}
|
||||
transition={{ type: "spring" }}
|
||||
transition={{ type: "spring", damping: 20 }}
|
||||
animate={animateController}
|
||||
>
|
||||
<PlayerIcon isScrollDown={isScrollDown} onLogoClick={onLogoClick} />
|
||||
<DividerVertical className="h-3/4 shrink-0" />
|
||||
<div className="flex items-center gap-3">
|
||||
<PlayerIcon isScrollDown={isScrollDown} onLogoClick={onLogoClick} />
|
||||
<DividerVertical className="h-5 shrink-0 opacity-60" />
|
||||
</div>
|
||||
<ViewTabs onViewChange={onViewChange} />
|
||||
<DividerVertical className="h-3/4 shrink-0" />
|
||||
<ProfileButton />
|
||||
<div className="flex items-center gap-3">
|
||||
<DividerVertical className="h-5 shrink-0 opacity-60" />
|
||||
<ProfileButton />
|
||||
</div>
|
||||
</m.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ViewTabs = ({ onViewChange }: { onViewChange?: (view: number) => void }) => {
|
||||
const viewsWithSubscription = useViewWithSubscription()
|
||||
const view = useRouteParamsSelector((s) => s.view)
|
||||
const navigate = useNavigateEntry()
|
||||
|
||||
return (
|
||||
<div
|
||||
className="text-text-secondary flex w-full shrink items-center justify-between gap-4 overflow-x-auto overflow-y-hidden text-xl"
|
||||
className="text-text-secondary scrollbar-none flex shrink items-center justify-center gap-6 overflow-x-auto overflow-y-hidden text-xl"
|
||||
onClick={stopPropagation}
|
||||
>
|
||||
{views.map((item) => (
|
||||
<MotionButtonBase
|
||||
className={clsx(
|
||||
"center flex transition-colors duration-200",
|
||||
view === item.view && item.className,
|
||||
)}
|
||||
key={item.name}
|
||||
onClick={() => {
|
||||
navigate({ view: item.view })
|
||||
onViewChange?.(item.view)
|
||||
}}
|
||||
>
|
||||
<div className="relative flex flex-col items-center">{item.icon}</div>
|
||||
</MotionButtonBase>
|
||||
))}
|
||||
{views
|
||||
.filter((item) => viewsWithSubscription.includes(item.view))
|
||||
.map((item) => (
|
||||
<MotionButtonBase
|
||||
className={clsx(
|
||||
"center flex transition-colors duration-200",
|
||||
view === item.view && item.className,
|
||||
)}
|
||||
key={item.name}
|
||||
onClick={() => {
|
||||
navigate({ view: item.view })
|
||||
onViewChange?.(item.view)
|
||||
}}
|
||||
>
|
||||
<div className="relative flex flex-col items-center">{item.icon}</div>
|
||||
</MotionButtonBase>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -230,7 +230,7 @@ export function RegisterForm() {
|
|||
)}
|
||||
/>
|
||||
<ReCAPTCHA ref={recaptchaRef} sitekey={env.VITE_RECAPTCHA_V2_SITE_KEY} size="invisible" />
|
||||
<Button disabled={!isValid} type="submit" className="w-full" size="lg">
|
||||
<Button disabled={!isValid} type="submit" buttonClassName="w-full" size="lg">
|
||||
{t("register.submit")}
|
||||
</Button>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
import { legalHtml } from "@follow/legal"
|
||||
import { stopPropagation } from "@follow/utils/dom"
|
||||
import { m } from "motion/react"
|
||||
import type { FC } from "react"
|
||||
|
||||
type LegalModalProps = {
|
||||
type: "privacy" | "tos"
|
||||
}
|
||||
|
||||
export const LegalModalContent: FC<LegalModalProps> = ({ type }) => {
|
||||
const content = type === "privacy" ? legalHtml.privacy : legalHtml.tos
|
||||
|
||||
return (
|
||||
<m.div className="size-full overflow-hidden">
|
||||
<div className="bg-background size-full overflow-auto rounded-lg" onClick={stopPropagation}>
|
||||
<iframe
|
||||
sandbox="allow-scripts"
|
||||
srcDoc={content}
|
||||
title={type === "privacy" ? "Privacy Policy" : "Terms of Service"}
|
||||
className="size-full border-0"
|
||||
/>
|
||||
</div>
|
||||
</m.div>
|
||||
)
|
||||
}
|
||||
|
|
@ -10,11 +10,12 @@ import { m } from "motion/react"
|
|||
import { useState } from "react"
|
||||
import { Trans, useTranslation } from "react-i18next"
|
||||
|
||||
import { useCurrentModal } from "~/components/ui/modal/stacked/hooks"
|
||||
import { useCurrentModal, useModalStack } from "~/components/ui/modal/stacked/hooks"
|
||||
import { loginHandler } from "~/lib/auth"
|
||||
import { useAuthProviders } from "~/queries/users"
|
||||
|
||||
import { LoginWithPassword, RegisterForm } from "./Form"
|
||||
import { LegalModalContent } from "./LegalModal"
|
||||
|
||||
interface LoginModalContentProps {
|
||||
runtime: LoginRuntime
|
||||
|
|
@ -23,11 +24,12 @@ interface LoginModalContentProps {
|
|||
|
||||
export const LoginModalContent = (props: LoginModalContentProps) => {
|
||||
const modal = useCurrentModal()
|
||||
const { present } = useModalStack()
|
||||
|
||||
const { canClose = true, runtime } = props
|
||||
|
||||
const { t } = useTranslation()
|
||||
const { data: authProviders } = useAuthProviders()
|
||||
const { data: authProviders, isLoading } = useAuthProviders()
|
||||
|
||||
const isMobile = useMobile()
|
||||
|
||||
|
|
@ -36,6 +38,17 @@ export const LoginModalContent = (props: LoginModalContentProps) => {
|
|||
const [isRegister, setIsRegister] = useState(true)
|
||||
const [isEmail, setIsEmail] = useState(false)
|
||||
|
||||
const handleOpenLegal = (type: "privacy" | "tos") => {
|
||||
present({
|
||||
id: `legal-${type}`,
|
||||
title: type === "privacy" ? t("login.privacy") : t("login.terms"),
|
||||
content: () => <LegalModalContent type={type} />,
|
||||
resizeable: true,
|
||||
clickOutsideToDismiss: true,
|
||||
max: true,
|
||||
})
|
||||
}
|
||||
|
||||
const Inner = (
|
||||
<>
|
||||
<div className="-mt-9 mb-4 flex items-center justify-center">
|
||||
|
|
@ -66,37 +79,44 @@ export const LoginModalContent = (props: LoginModalContentProps) => {
|
|||
)
|
||||
) : (
|
||||
<div className="mb-3 flex flex-col items-center justify-center gap-4">
|
||||
{providers.map(([key, provider]) => (
|
||||
<MotionButtonBase
|
||||
key={key}
|
||||
onClick={() => {
|
||||
if (key === "credential") {
|
||||
setIsEmail(true)
|
||||
} else {
|
||||
loginHandler(key, "app")
|
||||
}
|
||||
}}
|
||||
className="center hover:bg-material-medium relative w-full gap-2 rounded-xl border p-2.5 pl-5 font-semibold duration-200"
|
||||
>
|
||||
<img
|
||||
className="absolute left-9 h-5"
|
||||
style={{
|
||||
color: provider.color,
|
||||
}}
|
||||
src={provider.icon64}
|
||||
/>
|
||||
<span>{t("login.continueWith", { provider: provider.name })}</span>
|
||||
</MotionButtonBase>
|
||||
))}
|
||||
{isLoading
|
||||
? // Skeleton loaders to prevent CLS
|
||||
Array.from({ length: 4 })
|
||||
.fill(0)
|
||||
.map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="bg-material-ultra-thick border-material-medium relative h-12 w-full animate-pulse rounded-xl border"
|
||||
/>
|
||||
))
|
||||
: providers.map(([key, provider]) => (
|
||||
<MotionButtonBase
|
||||
key={key}
|
||||
onClick={() => {
|
||||
if (key === "credential") {
|
||||
setIsEmail(true)
|
||||
} else {
|
||||
loginHandler(key, "app")
|
||||
}
|
||||
}}
|
||||
className="center hover:bg-material-medium relative w-full gap-2 rounded-xl border py-3 pl-5 font-semibold duration-200"
|
||||
>
|
||||
<img
|
||||
className="absolute left-9 h-5 dark:brightness-[0.85] dark:hue-rotate-180 dark:invert"
|
||||
src={provider.icon64}
|
||||
/>
|
||||
<span>{t("login.continueWith", { provider: provider.name })}</span>
|
||||
</MotionButtonBase>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<Divider className="mb-5 mt-6" />
|
||||
{isEmail ? (
|
||||
<div className="pb-2 text-center" onClick={() => setIsEmail(false)}>
|
||||
Back
|
||||
<div className="pb-2 text-center font-medium" onClick={() => setIsEmail(false)}>
|
||||
{t("login.back")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="pb-2 text-center" onClick={() => setIsRegister(!isRegister)}>
|
||||
<div className="pb-2 text-center font-medium" onClick={() => setIsRegister(!isRegister)}>
|
||||
<Trans
|
||||
t={t}
|
||||
i18nKey={isRegister ? "login.have_account" : "login.no_account"}
|
||||
|
|
@ -106,6 +126,23 @@ export const LoginModalContent = (props: LoginModalContentProps) => {
|
|||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-text-secondary mt-3 text-center text-xs leading-5">
|
||||
<span>{t("login.agree_to")}</span> <br />
|
||||
<a
|
||||
onClick={() => handleOpenLegal("tos")}
|
||||
className="text-accent cursor-pointer hover:underline"
|
||||
>
|
||||
{t("login.terms")}
|
||||
</a>{" "}
|
||||
&{" "}
|
||||
<a
|
||||
onClick={() => handleOpenLegal("privacy")}
|
||||
className="text-accent cursor-pointer hover:underline"
|
||||
>
|
||||
{t("login.privacy")}
|
||||
</a>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
if (isMobile) {
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ export const BoostProgress = ({
|
|||
const nextLevel = level + 1
|
||||
return (
|
||||
<div className="flex w-full flex-col px-2">
|
||||
<div className="relative -mx-2 pt-12">
|
||||
<div className="relative -mx-1 pt-12">
|
||||
<span
|
||||
className={cn(
|
||||
"absolute -bottom-3 mb-10 flex h-7 -translate-x-1/2 items-center justify-center whitespace-nowrap rounded-full p-2 text-sm font-bold text-white transition-all duration-500 ease-out after:absolute after:bottom-[-5px] after:left-1/2 after:-z-10 after:flex after:size-3 after:-translate-x-1/2 after:rotate-45",
|
||||
|
|
@ -43,7 +43,7 @@ export const BoostProgress = ({
|
|||
aria-valuemin={0}
|
||||
aria-valuemax={remainingBoostsToLevelUp}
|
||||
style={{ width: `calc(${percentage}% + 0.5rem)` }}
|
||||
className="bg-accent flex h-full -translate-x-2 items-center justify-center rounded-3xl text-white transition-all duration-500 ease-out"
|
||||
className="bg-accent flex h-full -translate-x-1 items-center justify-center rounded-3xl text-white transition-all duration-500 ease-out"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,16 +1,22 @@
|
|||
import { useRegisterEntryCommands } from "./commands/entry"
|
||||
import { useRegisterEntryRenderCommand } from "./commands/entry-render"
|
||||
import { useRegisterGlobalCommands } from "./commands/global"
|
||||
import { useRegisterIntegrationCommands } from "./commands/integration"
|
||||
import { useRegisterLayoutCommands } from "./commands/layout"
|
||||
import { useRegisterListCommands } from "./commands/list"
|
||||
import { useRegisterSettingsCommands } from "./commands/settings"
|
||||
import { useRegisterSubscriptionCommands } from "./commands/subscription"
|
||||
import { useRegisterTimelineCommand } from "./commands/timeline"
|
||||
|
||||
export function useRegisterFollowCommands() {
|
||||
export const FollowCommandManager = () => {
|
||||
useRegisterSettingsCommands()
|
||||
useRegisterListCommands()
|
||||
useRegisterEntryCommands()
|
||||
useRegisterIntegrationCommands()
|
||||
}
|
||||
|
||||
export const FollowCommandManager = () => {
|
||||
useRegisterFollowCommands()
|
||||
useRegisterGlobalCommands()
|
||||
useRegisterLayoutCommands()
|
||||
useRegisterTimelineCommand()
|
||||
useRegisterEntryRenderCommand()
|
||||
useRegisterSubscriptionCommands()
|
||||
return null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,81 @@
|
|||
import { EventBus } from "@follow/utils/event-bus"
|
||||
|
||||
import { useRegisterCommandEffect } from "../hooks/use-register-command"
|
||||
import type { Command } from "../types"
|
||||
import { COMMAND_ID } from "./id"
|
||||
|
||||
declare module "@follow/utils/event-bus" {
|
||||
interface EventBusMap {
|
||||
"entry-render:scroll-down": never
|
||||
"entry-render:scroll-up": never
|
||||
"entry-render:next-entry": never
|
||||
"entry-render:previous-entry": never
|
||||
}
|
||||
}
|
||||
const LABEL_PREFIX = "Entry Render"
|
||||
|
||||
const category = "follow:entry-render"
|
||||
export const useRegisterEntryRenderCommand = () => {
|
||||
useRegisterCommandEffect([
|
||||
{
|
||||
id: COMMAND_ID.entryRender.scrollDown,
|
||||
run: () => {
|
||||
EventBus.dispatch(COMMAND_ID.entryRender.scrollDown)
|
||||
},
|
||||
category,
|
||||
label: `${LABEL_PREFIX}: Scroll down`,
|
||||
},
|
||||
{
|
||||
id: COMMAND_ID.entryRender.scrollUp,
|
||||
run: () => {
|
||||
EventBus.dispatch(COMMAND_ID.entryRender.scrollUp)
|
||||
},
|
||||
category,
|
||||
label: `${LABEL_PREFIX}: Scroll up`,
|
||||
},
|
||||
{
|
||||
id: COMMAND_ID.entryRender.nextEntry,
|
||||
run: () => {
|
||||
EventBus.dispatch(COMMAND_ID.timeline.switchToNext)
|
||||
EventBus.dispatch(COMMAND_ID.entryRender.nextEntry)
|
||||
},
|
||||
category,
|
||||
label: `${LABEL_PREFIX}: Next entry`,
|
||||
},
|
||||
{
|
||||
id: COMMAND_ID.entryRender.previousEntry,
|
||||
run: () => {
|
||||
EventBus.dispatch(COMMAND_ID.timeline.switchToPrevious)
|
||||
EventBus.dispatch(COMMAND_ID.entryRender.previousEntry)
|
||||
},
|
||||
category,
|
||||
label: `${LABEL_PREFIX}: Previous entry`,
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
type EntryScrollDownCommand = Command<{
|
||||
id: typeof COMMAND_ID.entryRender.scrollDown
|
||||
fn: () => void
|
||||
}>
|
||||
|
||||
type EntryScrollUpCommand = Command<{
|
||||
id: typeof COMMAND_ID.entryRender.scrollUp
|
||||
fn: () => void
|
||||
}>
|
||||
|
||||
type EntryNextEntryCommand = Command<{
|
||||
id: typeof COMMAND_ID.entryRender.nextEntry
|
||||
fn: () => void
|
||||
}>
|
||||
|
||||
type EntryPreviousEntryCommand = Command<{
|
||||
id: typeof COMMAND_ID.entryRender.previousEntry
|
||||
fn: () => void
|
||||
}>
|
||||
|
||||
export type EntryRenderCommand =
|
||||
| EntryScrollDownCommand
|
||||
| EntryScrollUpCommand
|
||||
| EntryNextEntryCommand
|
||||
| EntryPreviousEntryCommand
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -41,4 +41,39 @@ export const COMMAND_ID = {
|
|||
changeThemeToLight: "follow:change-color-mode-to-light",
|
||||
customizeToolbar: "follow:customize-toolbar",
|
||||
},
|
||||
global: {
|
||||
showShortcuts: "global:show-shortcuts",
|
||||
},
|
||||
layout: {
|
||||
toggleTimelineColumn: "layout:toggle-timeline-column",
|
||||
focusToTimeline: "layout:focus-to-timeline",
|
||||
focusToSubscription: "layout:focus-to-subscription",
|
||||
},
|
||||
timeline: {
|
||||
switchToNext: "timeline:switch-to-next",
|
||||
switchToPrevious: "timeline:switch-to-previous",
|
||||
refetch: "timeline:refetch",
|
||||
enter: "timeline:enter",
|
||||
},
|
||||
entryRender: {
|
||||
scrollDown: "entry-render:scroll-down",
|
||||
scrollUp: "entry-render:scroll-up",
|
||||
nextEntry: "entry-render:next-entry",
|
||||
previousEntry: "entry-render:previous-entry",
|
||||
},
|
||||
subscription: {
|
||||
switchTabToNext: "subscription:switch-tab-to-next",
|
||||
switchTabToPrevious: "subscription:switch-tab-to-previous",
|
||||
switchTabToArticle: "subscription:switch-tab-to-article",
|
||||
switchTabToSocial: "subscription:switch-tab-to-social",
|
||||
switchTabToPicture: "subscription:switch-tab-to-picture",
|
||||
switchTabToVideo: "subscription:switch-tab-to-video",
|
||||
switchTabToAudio: "subscription:switch-tab-to-audio",
|
||||
switchTabToNotification: "subscription:switch-tab-to-notification",
|
||||
|
||||
nextSubscription: "subscription:next",
|
||||
previousSubscription: "subscription:previous",
|
||||
|
||||
toggleFolderCollapse: "subscription:toggle-folder-collapse",
|
||||
},
|
||||
} as const
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,59 @@
|
|||
import { EventBus } from "@follow/utils/event-bus"
|
||||
|
||||
import { setTimelineColumnShow } from "~/atoms/sidebar"
|
||||
|
||||
import { useRegisterCommandEffect } from "../hooks/use-register-command"
|
||||
import type { Command } from "../types"
|
||||
import { COMMAND_ID } from "./id"
|
||||
|
||||
declare module "@follow/utils/event-bus" {
|
||||
interface EventBusMap {
|
||||
"layout:focus-to-timeline": never
|
||||
"layout:focus-to-subscription": never
|
||||
}
|
||||
}
|
||||
|
||||
export const useRegisterLayoutCommands = () => {
|
||||
useRegisterCommandEffect([
|
||||
{
|
||||
id: COMMAND_ID.layout.toggleTimelineColumn,
|
||||
label: "Toggle timeline column",
|
||||
run: () => {
|
||||
setTimelineColumnShow((show) => !show)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: COMMAND_ID.layout.focusToTimeline,
|
||||
label: "Focus to timeline",
|
||||
run: () => {
|
||||
EventBus.dispatch(COMMAND_ID.layout.focusToTimeline)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: COMMAND_ID.layout.focusToSubscription,
|
||||
label: "Focus to subscription",
|
||||
run: () => {
|
||||
EventBus.dispatch(COMMAND_ID.layout.focusToSubscription)
|
||||
},
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
export type FocusToSubscriptionCommand = Command<{
|
||||
id: typeof COMMAND_ID.layout.focusToSubscription
|
||||
fn: () => void
|
||||
}>
|
||||
|
||||
export type ToggleTimelineColumnCommand = Command<{
|
||||
id: typeof COMMAND_ID.layout.toggleTimelineColumn
|
||||
fn: () => void
|
||||
}>
|
||||
|
||||
export type FocusToTimelineCommand = Command<{
|
||||
id: typeof COMMAND_ID.layout.focusToTimeline
|
||||
fn: () => void
|
||||
}>
|
||||
export type LayoutCommand =
|
||||
| ToggleTimelineColumnCommand
|
||||
| FocusToTimelineCommand
|
||||
| FocusToSubscriptionCommand
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,172 @@
|
|||
import { EventBus } from "@follow/utils/event-bus"
|
||||
|
||||
import { useRegisterCommandEffect } from "../hooks/use-register-command"
|
||||
import type { Command } from "../types"
|
||||
import { COMMAND_ID } from "./id"
|
||||
|
||||
declare module "@follow/utils/event-bus" {
|
||||
interface EventBusMap {
|
||||
"subscription:switch-tab-to-next": never
|
||||
"subscription:switch-tab-to-previous": never
|
||||
"subscription:switch-tab-to-article": never
|
||||
"subscription:switch-tab-to-social": never
|
||||
"subscription:switch-tab-to-picture": never
|
||||
"subscription:switch-tab-to-video": never
|
||||
"subscription:switch-tab-to-audio": never
|
||||
"subscription:switch-tab-to-notification": never
|
||||
|
||||
"subscription:next": never
|
||||
"subscription:previous": never
|
||||
"subscription:toggle-folder-collapse": never
|
||||
}
|
||||
}
|
||||
const LABEL_PREFIX = "Subscription"
|
||||
export const useRegisterSubscriptionCommands = () => {
|
||||
useRegisterCommandEffect([
|
||||
{
|
||||
id: COMMAND_ID.subscription.switchTabToNext,
|
||||
label: `${LABEL_PREFIX}: Switch to next tab`,
|
||||
run: () => {
|
||||
EventBus.dispatch(COMMAND_ID.subscription.switchTabToNext)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: COMMAND_ID.subscription.switchTabToPrevious,
|
||||
label: `${LABEL_PREFIX}: Switch to previous tab`,
|
||||
run: () => {
|
||||
EventBus.dispatch(COMMAND_ID.subscription.switchTabToPrevious)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: COMMAND_ID.subscription.switchTabToArticle,
|
||||
label: `${LABEL_PREFIX}: Switch to article tab`,
|
||||
run: () => {
|
||||
EventBus.dispatch(COMMAND_ID.subscription.switchTabToArticle)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: COMMAND_ID.subscription.switchTabToSocial,
|
||||
label: `${LABEL_PREFIX}: Switch to social tab`,
|
||||
run: () => {
|
||||
EventBus.dispatch(COMMAND_ID.subscription.switchTabToSocial)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: COMMAND_ID.subscription.switchTabToPicture,
|
||||
label: `${LABEL_PREFIX}: Switch to picture tab`,
|
||||
run: () => {
|
||||
EventBus.dispatch(COMMAND_ID.subscription.switchTabToPicture)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: COMMAND_ID.subscription.switchTabToVideo,
|
||||
label: `${LABEL_PREFIX}: Switch to video tab`,
|
||||
run: () => {
|
||||
EventBus.dispatch(COMMAND_ID.subscription.switchTabToVideo)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: COMMAND_ID.subscription.switchTabToAudio,
|
||||
label: `${LABEL_PREFIX}: Switch to audio tab`,
|
||||
run: () => {
|
||||
EventBus.dispatch(COMMAND_ID.subscription.switchTabToAudio)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: COMMAND_ID.subscription.switchTabToNotification,
|
||||
label: `${LABEL_PREFIX}: Switch to notification tab`,
|
||||
run: () => {
|
||||
EventBus.dispatch(COMMAND_ID.subscription.switchTabToNotification)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: COMMAND_ID.subscription.nextSubscription,
|
||||
label: `${LABEL_PREFIX}: Next Subscription`,
|
||||
run: () => {
|
||||
EventBus.dispatch(COMMAND_ID.subscription.nextSubscription)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: COMMAND_ID.subscription.previousSubscription,
|
||||
label: `${LABEL_PREFIX}: Previous Subscription`,
|
||||
run: () => {
|
||||
EventBus.dispatch(COMMAND_ID.subscription.previousSubscription)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: COMMAND_ID.subscription.toggleFolderCollapse,
|
||||
label: `${LABEL_PREFIX}: Toggle Folder Collapse`,
|
||||
run: () => {
|
||||
EventBus.dispatch(COMMAND_ID.subscription.toggleFolderCollapse)
|
||||
},
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
type SwitchTabToNextCommand = Command<{
|
||||
id: typeof COMMAND_ID.subscription.switchTabToNext
|
||||
fn: () => void
|
||||
}>
|
||||
|
||||
type SwitchTabToPreviousCommand = Command<{
|
||||
id: typeof COMMAND_ID.subscription.switchTabToPrevious
|
||||
fn: () => void
|
||||
}>
|
||||
|
||||
type SwitchTabToArticleCommand = Command<{
|
||||
id: typeof COMMAND_ID.subscription.switchTabToArticle
|
||||
fn: () => void
|
||||
}>
|
||||
|
||||
type SwitchTabToSocialCommand = Command<{
|
||||
id: typeof COMMAND_ID.subscription.switchTabToSocial
|
||||
fn: () => void
|
||||
}>
|
||||
|
||||
type SwitchTabToPictureCommand = Command<{
|
||||
id: typeof COMMAND_ID.subscription.switchTabToPicture
|
||||
fn: () => void
|
||||
}>
|
||||
|
||||
type SwitchTabToVideoCommand = Command<{
|
||||
id: typeof COMMAND_ID.subscription.switchTabToVideo
|
||||
fn: () => void
|
||||
}>
|
||||
|
||||
type SwitchTabToAudioCommand = Command<{
|
||||
id: typeof COMMAND_ID.subscription.switchTabToAudio
|
||||
fn: () => void
|
||||
}>
|
||||
|
||||
type SwitchTabToNotificationCommand = Command<{
|
||||
id: typeof COMMAND_ID.subscription.switchTabToNotification
|
||||
fn: () => void
|
||||
}>
|
||||
|
||||
type NextSubscriptionCommand = Command<{
|
||||
id: typeof COMMAND_ID.subscription.nextSubscription
|
||||
fn: () => void
|
||||
}>
|
||||
|
||||
type PreviousSubscriptionCommand = Command<{
|
||||
id: typeof COMMAND_ID.subscription.previousSubscription
|
||||
fn: () => void
|
||||
}>
|
||||
|
||||
type ToggleFolderCollapseCommand = Command<{
|
||||
id: typeof COMMAND_ID.subscription.toggleFolderCollapse
|
||||
fn: () => void
|
||||
}>
|
||||
|
||||
export type SubscriptionCommand =
|
||||
| SwitchTabToNextCommand
|
||||
| SwitchTabToPreviousCommand
|
||||
| SwitchTabToArticleCommand
|
||||
| SwitchTabToSocialCommand
|
||||
| SwitchTabToPictureCommand
|
||||
| SwitchTabToVideoCommand
|
||||
| SwitchTabToAudioCommand
|
||||
| SwitchTabToNotificationCommand
|
||||
| NextSubscriptionCommand
|
||||
| PreviousSubscriptionCommand
|
||||
| ToggleFolderCollapseCommand
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
import { EventBus } from "@follow/utils/event-bus"
|
||||
|
||||
import { useRegisterCommandEffect } from "../hooks/use-register-command"
|
||||
import type { Command } from "../types"
|
||||
import { COMMAND_ID } from "./id"
|
||||
|
||||
declare module "@follow/utils/event-bus" {
|
||||
interface EventBusMap {
|
||||
"timeline:switch-to-next": never
|
||||
"timeline:switch-to-previous": never
|
||||
"timeline:refetch": never
|
||||
"timeline:enter": never
|
||||
}
|
||||
}
|
||||
export const useRegisterTimelineCommand = () => {
|
||||
useRegisterCommandEffect([
|
||||
{
|
||||
id: COMMAND_ID.timeline.switchToNext,
|
||||
label: "Switch to next timeline",
|
||||
|
||||
run: () => {
|
||||
EventBus.dispatch("timeline:switch-to-next")
|
||||
},
|
||||
},
|
||||
{
|
||||
id: COMMAND_ID.timeline.switchToPrevious,
|
||||
label: "Switch to previous timeline",
|
||||
run: () => {
|
||||
EventBus.dispatch("timeline:switch-to-previous")
|
||||
},
|
||||
},
|
||||
{
|
||||
id: COMMAND_ID.timeline.refetch,
|
||||
label: "Refetch timeline",
|
||||
run: () => {
|
||||
EventBus.dispatch("timeline:refetch")
|
||||
},
|
||||
},
|
||||
{
|
||||
id: COMMAND_ID.timeline.enter,
|
||||
label: "Enter Selected Entry",
|
||||
run: () => {
|
||||
EventBus.dispatch("timeline:enter")
|
||||
},
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
export type SwitchToNextTimelineCommand = Command<{
|
||||
id: typeof COMMAND_ID.timeline.switchToNext
|
||||
fn: () => void
|
||||
}>
|
||||
|
||||
export type SwitchToPreviousTimelineCommand = Command<{
|
||||
id: typeof COMMAND_ID.timeline.switchToPrevious
|
||||
fn: () => void
|
||||
}>
|
||||
|
||||
export type RefetchTimelineCommand = Command<{
|
||||
id: typeof COMMAND_ID.timeline.refetch
|
||||
fn: () => void
|
||||
}>
|
||||
|
||||
export type EnterTimelineCommand = Command<{
|
||||
id: typeof COMMAND_ID.timeline.enter
|
||||
fn: () => void
|
||||
}>
|
||||
|
||||
export type TimelineCommand =
|
||||
| SwitchToNextTimelineCommand
|
||||
| SwitchToPreviousTimelineCommand
|
||||
| RefetchTimelineCommand
|
||||
| EnterTimelineCommand
|
||||
|
|
@ -1,167 +1,20 @@
|
|||
// Entry commands
|
||||
|
||||
import type { FeedViewType } from "@follow/constants"
|
||||
import type { EntryCommand } from "./entry"
|
||||
import type { EntryRenderCommand } from "./entry-render"
|
||||
import type { GlobalCommand } from "./global"
|
||||
import type { IntegrationCommand } from "./integration"
|
||||
import type { LayoutCommand } from "./layout"
|
||||
import type { SettingsCommand } from "./settings"
|
||||
import type { SubscriptionCommand } from "./subscription"
|
||||
import type { TimelineCommand } from "./timeline"
|
||||
|
||||
import type { Command } from "../types"
|
||||
import type { COMMAND_ID } from "./id"
|
||||
|
||||
export type TipCommand = Command<{
|
||||
id: typeof COMMAND_ID.entry.tip
|
||||
fn: (data: { userId?: string | null; feedId?: string; entryId?: string }) => void
|
||||
}>
|
||||
|
||||
export type StarCommand = Command<{
|
||||
id: typeof COMMAND_ID.entry.star
|
||||
fn: (data: { entryId: string; view?: FeedViewType }) => void
|
||||
}>
|
||||
|
||||
export type DeleteCommand = Command<{
|
||||
id: typeof COMMAND_ID.entry.delete
|
||||
fn: (data: { entryId: string }) => void
|
||||
}>
|
||||
|
||||
export type CopyLinkCommand = Command<{
|
||||
id: typeof COMMAND_ID.entry.copyLink
|
||||
fn: (data: { entryId: string }) => void
|
||||
}>
|
||||
|
||||
export type ExportAsPDFCommand = Command<{
|
||||
id: typeof COMMAND_ID.entry.exportAsPDF
|
||||
fn: (data: { entryId: string }) => void
|
||||
}>
|
||||
|
||||
export type CopyTitleCommand = Command<{
|
||||
id: typeof COMMAND_ID.entry.copyTitle
|
||||
fn: (data: { entryId: string }) => void
|
||||
}>
|
||||
|
||||
export type OpenInBrowserCommand = Command<{
|
||||
id: typeof COMMAND_ID.entry.openInBrowser
|
||||
fn: (data: { entryId: string }) => void
|
||||
}>
|
||||
|
||||
export type ViewSourceContentCommand = Command<{
|
||||
id: typeof COMMAND_ID.entry.viewSourceContent
|
||||
fn: (data: { entryId: string; siteUrl?: string | null | undefined }) => void
|
||||
}>
|
||||
|
||||
export type ShareCommand = Command<{
|
||||
id: typeof COMMAND_ID.entry.share
|
||||
fn: (data: { entryId: string }) => void
|
||||
}>
|
||||
|
||||
export type ReadCommand = Command<{
|
||||
id: typeof COMMAND_ID.entry.read
|
||||
fn: (data: { entryId: string }) => void
|
||||
}>
|
||||
|
||||
export type ReadAboveCommand = Command<{
|
||||
id: typeof COMMAND_ID.entry.readAbove
|
||||
fn: (data: { publishedAt: string }) => void
|
||||
}>
|
||||
|
||||
export type ReadBelowCommand = Command<{
|
||||
id: typeof COMMAND_ID.entry.readBelow
|
||||
fn: (data: { publishedAt: string }) => void
|
||||
}>
|
||||
|
||||
export type ToggleAISummaryCommand = Command<{
|
||||
id: typeof COMMAND_ID.entry.toggleAISummary
|
||||
fn: () => void
|
||||
}>
|
||||
|
||||
export type ToggleAITranslationCommand = Command<{
|
||||
id: typeof COMMAND_ID.entry.toggleAITranslation
|
||||
fn: () => void
|
||||
}>
|
||||
|
||||
export type ImageGalleryCommand = Command<{
|
||||
id: typeof COMMAND_ID.entry.imageGallery
|
||||
fn: (data: { entryId: string }) => void
|
||||
}>
|
||||
|
||||
export type TTSCommand = Command<{
|
||||
id: typeof COMMAND_ID.entry.tts
|
||||
fn: (data: { entryId: string; entryContent: string }) => void
|
||||
}>
|
||||
|
||||
export type ReadabilityCommand = Command<{
|
||||
id: typeof COMMAND_ID.entry.readability
|
||||
fn: (data: { entryId: string; entryUrl: string }) => void
|
||||
}>
|
||||
|
||||
export type EntryCommand =
|
||||
| TipCommand
|
||||
| StarCommand
|
||||
| DeleteCommand
|
||||
| CopyLinkCommand
|
||||
| ExportAsPDFCommand
|
||||
| CopyTitleCommand
|
||||
| OpenInBrowserCommand
|
||||
| ViewSourceContentCommand
|
||||
| ShareCommand
|
||||
| ReadCommand
|
||||
| ReadAboveCommand
|
||||
| ReadBelowCommand
|
||||
| ToggleAISummaryCommand
|
||||
| ToggleAITranslationCommand
|
||||
| ImageGalleryCommand
|
||||
| TTSCommand
|
||||
| ReadabilityCommand
|
||||
|
||||
// Settings commands
|
||||
|
||||
export type CustomizeToolbarCommand = Command<{
|
||||
id: typeof COMMAND_ID.settings.customizeToolbar
|
||||
fn: () => void
|
||||
}>
|
||||
|
||||
export type SettingsCommand = CustomizeToolbarCommand
|
||||
|
||||
// Integration commands
|
||||
|
||||
export type SaveToEagleCommand = Command<{
|
||||
id: typeof COMMAND_ID.integration.saveToEagle
|
||||
fn: (payload: { entryId: string }) => void
|
||||
}>
|
||||
|
||||
export type SaveToReadwiseCommand = Command<{
|
||||
id: typeof COMMAND_ID.integration.saveToReadwise
|
||||
fn: (payload: { entryId: string }) => void
|
||||
}>
|
||||
|
||||
export type SaveToInstapaperCommand = Command<{
|
||||
id: typeof COMMAND_ID.integration.saveToInstapaper
|
||||
fn: (payload: { entryId: string }) => void
|
||||
}>
|
||||
|
||||
export type SaveToObsidianCommand = Command<{
|
||||
id: typeof COMMAND_ID.integration.saveToObsidian
|
||||
fn: (payload: { entryId: string }) => void
|
||||
}>
|
||||
|
||||
export type SaveToOutlineCommand = Command<{
|
||||
id: typeof COMMAND_ID.integration.saveToOutline
|
||||
fn: (payload: { entryId: string }) => void
|
||||
}>
|
||||
|
||||
export type SaveToReadeckCommand = Command<{
|
||||
id: typeof COMMAND_ID.integration.saveToReadeck
|
||||
fn: (payload: { entryId: string }) => void
|
||||
}>
|
||||
|
||||
export type SaveToCuboxCommand = Command<{
|
||||
id: typeof COMMAND_ID.integration.saveToCubox
|
||||
fn: (payload: { entryId: string }) => void
|
||||
}>
|
||||
|
||||
export type IntegrationCommand =
|
||||
| SaveToEagleCommand
|
||||
| SaveToReadwiseCommand
|
||||
| SaveToInstapaperCommand
|
||||
| SaveToObsidianCommand
|
||||
| SaveToOutlineCommand
|
||||
| SaveToReadeckCommand
|
||||
| SaveToCuboxCommand
|
||||
|
||||
export type BasicCommand = EntryCommand | SettingsCommand | IntegrationCommand
|
||||
export type BasicCommand =
|
||||
| EntryCommand
|
||||
| SettingsCommand
|
||||
| IntegrationCommand
|
||||
| GlobalCommand
|
||||
| LayoutCommand
|
||||
| TimelineCommand
|
||||
| EntryRenderCommand
|
||||
| SubscriptionCommand
|
||||
|
|
|
|||
|
|
@ -0,0 +1,44 @@
|
|||
import { shortcuts } from "~/constants/shortcuts"
|
||||
|
||||
import { COMMAND_ID } from "../commands/id"
|
||||
|
||||
const defaultCommandShortcuts = {
|
||||
[COMMAND_ID.entry.read]: shortcuts.entry.toggleRead.key,
|
||||
[COMMAND_ID.entry.openInBrowser]: shortcuts.entry.openInBrowser.key,
|
||||
[COMMAND_ID.entry.star]: shortcuts.entry.toggleStarred.key,
|
||||
[COMMAND_ID.entry.copyLink]: shortcuts.entry.copyLink.key,
|
||||
[COMMAND_ID.entry.copyTitle]: shortcuts.entry.copyTitle.key,
|
||||
[COMMAND_ID.entry.tts]: shortcuts.entry.tts.key,
|
||||
[COMMAND_ID.entry.tip]: shortcuts.entry.tip.key,
|
||||
[COMMAND_ID.entry.share]: shortcuts.entry.share.key,
|
||||
|
||||
[COMMAND_ID.entryRender.scrollUp]: shortcuts.entry.scrollUp.key,
|
||||
[COMMAND_ID.entryRender.scrollDown]: shortcuts.entry.scrollDown.key,
|
||||
|
||||
[COMMAND_ID.timeline.switchToNext]: shortcuts.entries.next.key,
|
||||
[COMMAND_ID.timeline.switchToPrevious]: shortcuts.entries.previous.key,
|
||||
[COMMAND_ID.timeline.refetch]: shortcuts.entries.refetch.key,
|
||||
|
||||
[COMMAND_ID.layout.toggleTimelineColumn]: shortcuts.layout.toggleSidebar.key,
|
||||
|
||||
[COMMAND_ID.subscription.nextSubscription]: shortcuts.subscriptions.nextSubscription.key,
|
||||
[COMMAND_ID.subscription.previousSubscription]: shortcuts.subscriptions.previousSubscription.key,
|
||||
[COMMAND_ID.subscription.switchTabToNext]: shortcuts.subscriptions.switchNextView.key,
|
||||
[COMMAND_ID.subscription.switchTabToPrevious]: shortcuts.subscriptions.switchPreviousView.key,
|
||||
|
||||
[COMMAND_ID.global.showShortcuts]: shortcuts.layout.showShortcuts.key,
|
||||
|
||||
[COMMAND_ID.entryRender.nextEntry]: shortcuts.entry.nextEntry.key,
|
||||
[COMMAND_ID.entryRender.previousEntry]: shortcuts.entry.previousEntry.key,
|
||||
|
||||
[COMMAND_ID.subscription.toggleFolderCollapse]: shortcuts.subscriptions.toggleFolderCollapse.key,
|
||||
} as const
|
||||
|
||||
export type BindingCommandId = keyof typeof defaultCommandShortcuts
|
||||
|
||||
// eslint-disable-next-line @eslint-react/hooks-extra/no-unnecessary-use-prefix, @eslint-react/hooks-extra/ensure-custom-hooks-using-other-hooks
|
||||
export const useCommandShortcut = (commandId: BindingCommandId): string => {
|
||||
const commandShortcut = defaultCommandShortcuts[commandId]
|
||||
|
||||
return commandShortcut
|
||||
}
|
||||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -1,14 +1,16 @@
|
|||
import { useHotkeys } from "react-hotkeys-hook"
|
||||
import { useEffect } from "react"
|
||||
import { tinykeys } from "tinykeys"
|
||||
|
||||
import type { FollowCommand, FollowCommandId } from "../types"
|
||||
import { getCommand } from "./use-command"
|
||||
import type { BindingCommandId } from "./use-command-shortcut"
|
||||
import { useCommandShortcut } from "./use-command-shortcut"
|
||||
|
||||
interface RegisterHotkeyOptions<T extends FollowCommandId> {
|
||||
shortcut: string
|
||||
commandId: T
|
||||
args?: Parameters<Extract<FollowCommand, { id: T }>["run"]>
|
||||
when?: boolean
|
||||
// hotkeyOptions?: Options
|
||||
}
|
||||
|
||||
export const useCommandHotkey = <T extends FollowCommandId>({
|
||||
|
|
@ -17,27 +19,60 @@ export const useCommandHotkey = <T extends FollowCommandId>({
|
|||
when,
|
||||
args,
|
||||
}: RegisterHotkeyOptions<T>) => {
|
||||
useHotkeys(
|
||||
shortcut,
|
||||
() => {
|
||||
const command = getCommand(commandId)
|
||||
useEffect(() => {
|
||||
if (!when) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!command) return
|
||||
if (Array.isArray(args)) {
|
||||
// It should be safe to spread the args here because we are checking if it is an array
|
||||
// @ts-expect-error - A spread argument must either have a tuple type or be passed to a rest parameter.ts(2556)
|
||||
command.run(...args)
|
||||
return
|
||||
if (!shortcut) {
|
||||
return
|
||||
}
|
||||
|
||||
// Handle comma-separated shortcuts
|
||||
const shortcuts = shortcut.split(",").map((s) => s.trim())
|
||||
const keyMap: Record<string, (event: KeyboardEvent) => void> = {}
|
||||
|
||||
// Create a handler for each shortcut
|
||||
shortcuts.forEach((key) => {
|
||||
keyMap[key] = (event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
const command = getCommand(commandId)
|
||||
if (!command) return
|
||||
|
||||
if (Array.isArray(args)) {
|
||||
// It should be safe to spread the args here because we are checking if it is an array
|
||||
// @ts-expect-error - A spread argument must either have a tuple type or be passed to a rest parameter.ts(2556)
|
||||
command.run(...args)
|
||||
return
|
||||
}
|
||||
|
||||
if (args === undefined) {
|
||||
// @ts-expect-error
|
||||
command.run()
|
||||
return
|
||||
}
|
||||
|
||||
console.error("Invalid args", typeof args, args)
|
||||
}
|
||||
if (args === undefined) {
|
||||
// @ts-expect-error
|
||||
command.run()
|
||||
return
|
||||
}
|
||||
console.error("Invalid args", typeof args, args)
|
||||
},
|
||||
{
|
||||
enabled: when,
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
return tinykeys(document.documentElement, keyMap)
|
||||
}, [shortcut, commandId, when, args])
|
||||
}
|
||||
|
||||
export const useCommandBinding = <T extends BindingCommandId>({
|
||||
commandId,
|
||||
when = true,
|
||||
args,
|
||||
}: Omit<RegisterHotkeyOptions<T>, "shortcut">) => {
|
||||
const commandShortcut = useCommandShortcut(commandId)
|
||||
|
||||
return useCommandHotkey({
|
||||
shortcut: commandShortcut,
|
||||
commandId,
|
||||
when,
|
||||
args,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ export type CommandCategory =
|
|||
| "follow:updates"
|
||||
| "follow:help"
|
||||
| "follow:general"
|
||||
| "follow:entry-render"
|
||||
|
||||
export interface KeybindingOptions {
|
||||
binding: string
|
||||
|
|
@ -17,36 +18,6 @@ export interface KeybindingOptions {
|
|||
// skipRegister?: boolean
|
||||
}
|
||||
|
||||
export interface CommandKeybindingOptions<
|
||||
ID extends string,
|
||||
T extends (...args: any[]) => unknown = (...args: unknown[]) => unknown,
|
||||
> {
|
||||
/**
|
||||
* the command id.
|
||||
*/
|
||||
commandId: ID
|
||||
/**
|
||||
* a set of predefined precondition strategies.
|
||||
*
|
||||
* note: this only used for keybinding and command menu.
|
||||
* command will always available when called directly.
|
||||
*/
|
||||
when?: boolean
|
||||
/**
|
||||
* we use https://github.com/jamiebuilds/tinykeys so that we can use the same keybinding definition
|
||||
* for both mac and windows.
|
||||
*
|
||||
* Use `$mod` for `Cmd` on Mac and `Ctrl` on Windows and Linux.
|
||||
*/
|
||||
keyBinding?: KeybindingOptions | string
|
||||
/**
|
||||
* additional arguments for the command.
|
||||
*
|
||||
* Only used when the command is called from a keybinding.
|
||||
*/
|
||||
args?: Parameters<T>
|
||||
}
|
||||
|
||||
export interface Command<
|
||||
T extends { id: string; fn: (...args: any[]) => unknown } = {
|
||||
id: string
|
||||
|
|
@ -61,9 +32,6 @@ export interface Command<
|
|||
readonly icon?: ReactNode | ((props?: { isActive?: boolean }) => ReactNode)
|
||||
readonly category: CommandCategory
|
||||
readonly run: T["fn"]
|
||||
|
||||
// readonly when: boolean
|
||||
// readonly keyBinding?: KeybindingOptions
|
||||
}
|
||||
|
||||
export type SimpleCommand<T extends string> = Command<{ id: T; fn: () => void }>
|
||||
|
|
@ -87,7 +55,6 @@ export interface CommandOptions<
|
|||
run: T["fn"]
|
||||
|
||||
when?: boolean
|
||||
keyBinding?: T["fn"] extends () => void ? KeybindingOptions | string : never
|
||||
}
|
||||
|
||||
export type FollowCommandMap = {
|
||||
|
|
|
|||
|
|
@ -7,24 +7,29 @@ export interface ToolbarActionOrder {
|
|||
more: UniqueIdentifier[]
|
||||
}
|
||||
|
||||
const entryItemInMore = new Set<string>([
|
||||
COMMAND_ID.entry.copyLink,
|
||||
COMMAND_ID.entry.openInBrowser,
|
||||
COMMAND_ID.entry.exportAsPDF,
|
||||
COMMAND_ID.entry.read,
|
||||
COMMAND_ID.entry.tts,
|
||||
])
|
||||
|
||||
export const entryItemHideInHeader = new Set<string>([
|
||||
export const ENTRY_ITEM_HIDE_IN_HEADER = new Set<UniqueIdentifier>([
|
||||
COMMAND_ID.entry.readAbove,
|
||||
COMMAND_ID.entry.readBelow,
|
||||
])
|
||||
|
||||
const MAIN_ACTIONS = [
|
||||
COMMAND_ID.entry.readability,
|
||||
COMMAND_ID.entry.tts,
|
||||
COMMAND_ID.entry.star,
|
||||
|
||||
COMMAND_ID.entry.toggleAISummary,
|
||||
COMMAND_ID.entry.toggleAITranslation,
|
||||
|
||||
COMMAND_ID.entry.imageGallery,
|
||||
COMMAND_ID.entry.share,
|
||||
]
|
||||
const MAIN_ACTIONS_SET = new Set<UniqueIdentifier>(MAIN_ACTIONS)
|
||||
|
||||
export const DEFAULT_ACTION_ORDER: ToolbarActionOrder = {
|
||||
main: Object.values(COMMAND_ID.entry).filter((id) => !entryItemInMore.has(id)),
|
||||
main: MAIN_ACTIONS,
|
||||
more: [
|
||||
...Object.values(COMMAND_ID.integration),
|
||||
...Object.values(COMMAND_ID.entry).filter((id) => entryItemInMore.has(id)),
|
||||
...Object.values(COMMAND_ID.entry).filter((id) => !MAIN_ACTIONS_SET.has(id)),
|
||||
COMMAND_ID.settings.customizeToolbar,
|
||||
],
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,10 +3,11 @@ import { useMemo } from "react"
|
|||
|
||||
import { useUISettingSelector } from "~/atoms/settings/ui"
|
||||
|
||||
import { DEFAULT_ACTION_ORDER, entryItemHideInHeader } from "./constant"
|
||||
import { DEFAULT_ACTION_ORDER, ENTRY_ITEM_HIDE_IN_HEADER } from "./constant"
|
||||
|
||||
export const useActionOrder = () => {
|
||||
const actionOrderSetting = useUISettingSelector((s) => s.toolbarOrder)
|
||||
|
||||
return useMemo(() => {
|
||||
const { main, more } = actionOrderSetting
|
||||
const missingMainActions = DEFAULT_ACTION_ORDER.main.filter(
|
||||
|
|
@ -18,10 +19,10 @@ export const useActionOrder = () => {
|
|||
|
||||
return {
|
||||
main: [...actionOrderSetting.main, ...missingMainActions].filter(
|
||||
(id) => !entryItemHideInHeader.has(id as string),
|
||||
(id) => !ENTRY_ITEM_HIDE_IN_HEADER.has(id as string),
|
||||
),
|
||||
more: [...actionOrderSetting.more, ...missingMoreActions].filter(
|
||||
(id) => !entryItemHideInHeader.has(id as string),
|
||||
(id) => !ENTRY_ITEM_HIDE_IN_HEADER.has(id as string),
|
||||
),
|
||||
}
|
||||
}, [actionOrderSetting])
|
||||
|
|
@ -52,5 +53,6 @@ export const useToolbarOrderMap = () => {
|
|||
)
|
||||
return actionOrderMap
|
||||
}, [actionOrder])
|
||||
|
||||
return actionOrderMap
|
||||
}
|
||||
|
|
|
|||
|
|
@ -114,7 +114,7 @@ export const DiscoverFeedCard: FC<DiscoverFeedCardProps> = memo(
|
|||
{item.docs ? (
|
||||
<CardContent className="p-0">
|
||||
<a href={item.docs} target="_blank" rel="noreferrer">
|
||||
<Button className="rounded-full bg-zinc-900 px-6 text-white transition-opacity hover:opacity-90 dark:bg-white dark:text-zinc-900">
|
||||
<Button buttonClassName="rounded-full bg-zinc-900 px-6 text-white transition-opacity hover:opacity-90 dark:bg-white dark:text-zinc-900">
|
||||
View Docs
|
||||
</Button>
|
||||
</a>
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ export function DiscoverInboxList() {
|
|||
<div className="center mt-4 flex">
|
||||
{/* New Inbox */}
|
||||
<Button
|
||||
className="flex items-center gap-2"
|
||||
textClassName="flex items-center gap-2"
|
||||
onClick={() =>
|
||||
preCheck() &&
|
||||
present({
|
||||
|
|
|
|||
|
|
@ -71,6 +71,16 @@ export const FeedForm: Component<{
|
|||
const isInModal = useIsInModal()
|
||||
const placeholderRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!feedQuery.isLoading) {
|
||||
tracker.subscribeModalOpened({
|
||||
feedId: id,
|
||||
feedUrl: feedQuery.data?.feed.url || url,
|
||||
isError: feedQuery.isError,
|
||||
})
|
||||
}
|
||||
}, [feedQuery.isLoading])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
|
|
@ -209,7 +219,11 @@ const FeedInnerForm = ({
|
|||
}, [subscription])
|
||||
|
||||
useEffect(() => {
|
||||
if (analytics?.view !== undefined && !subscription && defaultValues?.view === undefined) {
|
||||
if (
|
||||
typeof analytics?.view === "number" &&
|
||||
!subscription &&
|
||||
typeof defaultValues?.view !== "number"
|
||||
) {
|
||||
form.setValue("view", `${analytics.view}`)
|
||||
}
|
||||
}, [analytics, subscription, defaultValues?.view])
|
||||
|
|
|
|||
|
|
@ -97,7 +97,7 @@ const ConfirmDestroyModalContent = ({ id }: { id: string }) => {
|
|||
{t("discover.inbox_destroy_warning")}
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button className="bg-red-600" onClick={() => mutationDestroy.mutate(id)}>
|
||||
<Button buttonClassName="bg-red-600" onClick={() => mutationDestroy.mutate(id)}>
|
||||
{t("words.confirm")}
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -66,6 +66,15 @@ export const ListForm: Component<{
|
|||
|
||||
const { t } = useTranslation()
|
||||
|
||||
useEffect(() => {
|
||||
if (!feedQuery.isLoading) {
|
||||
tracker.subscribeModalOpened({
|
||||
listId: id,
|
||||
isError: feedQuery.isError,
|
||||
})
|
||||
}
|
||||
}, [feedQuery.isLoading])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"
|
|||
import { flushSync } from "react-dom"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import { useGeneralSettingKey } from "~/atoms/settings/general"
|
||||
import { setUISetting, useUISettingKey } from "~/atoms/settings/ui"
|
||||
import { DrawerModalLayout } from "~/components/ui/modal/stacked/custom-modal"
|
||||
import { useCurrentModal, useModalStack } from "~/components/ui/modal/stacked/hooks"
|
||||
import { useAuthQuery } from "~/hooks/common"
|
||||
|
|
@ -29,35 +29,39 @@ const LanguageOptions = [
|
|||
},
|
||||
{
|
||||
label: "English",
|
||||
value: "en",
|
||||
value: "eng",
|
||||
},
|
||||
{
|
||||
label: "中文",
|
||||
value: "zh-CN",
|
||||
value: "cmn",
|
||||
},
|
||||
] as const
|
||||
|
||||
type Language = (typeof LanguageOptions)[number]["value"]
|
||||
type DiscoverCategories = (typeof RSSHubCategories)[number] | string
|
||||
|
||||
const LanguageMap = {
|
||||
all: "all",
|
||||
eng: "en",
|
||||
cmn: "zh-CN",
|
||||
} as const
|
||||
|
||||
const fetchRsshubPopular = (category: DiscoverCategories, lang: Language) => {
|
||||
return Queries.discover.rsshubCategory({
|
||||
category: "popular",
|
||||
categories: category === "all" ? "popular" : `popular,${category}`,
|
||||
lang,
|
||||
lang: LanguageMap[lang],
|
||||
})
|
||||
}
|
||||
let firstLoad = true
|
||||
export function Recommendations() {
|
||||
const { t } = useTranslation()
|
||||
const lang = useGeneralSettingKey("language")
|
||||
const { present } = useModalStack()
|
||||
|
||||
const defaultLang = !lang || ["zh-CN", "zh-HK", "zh-TW"].includes(lang) ? "all" : "en"
|
||||
const [category, setCategory] = useState<DiscoverCategories>("all")
|
||||
const [selectedLang, setSelectedLang] = useState<Language>(defaultLang)
|
||||
const lang = useUISettingKey("discoverLanguage")
|
||||
|
||||
const rsshubPopular = useAuthQuery(fetchRsshubPopular(category, selectedLang), {
|
||||
const rsshubPopular = useAuthQuery(fetchRsshubPopular(category, lang), {
|
||||
meta: {
|
||||
persist: true,
|
||||
},
|
||||
|
|
@ -107,7 +111,7 @@ export function Recommendations() {
|
|||
const handleLangChange = useCallback(
|
||||
(value: string) => {
|
||||
flushSync(() => {
|
||||
setSelectedLang(value as Language)
|
||||
setUISetting("discoverLanguage", value as Language)
|
||||
})
|
||||
rsshubPopular.refetch()
|
||||
},
|
||||
|
|
@ -152,7 +156,7 @@ export function Recommendations() {
|
|||
<div className="flex items-center gap-2">
|
||||
<span className="text-text shrink-0 text-sm font-medium">{t("words.language")}</span>
|
||||
<ResponsiveSelect
|
||||
value={selectedLang}
|
||||
value={lang}
|
||||
onValueChange={handleLangChange}
|
||||
triggerClassName="h-8 rounded border-0"
|
||||
size="sm"
|
||||
|
|
|
|||
|
|
@ -1,13 +1,19 @@
|
|||
import { useFocusable, useFocusActions } from "@follow/components/common/Focusable/hooks.js"
|
||||
import { useScrollViewElement } from "@follow/components/ui/scroll-area/hooks.js"
|
||||
import { useRefValue } from "@follow/hooks"
|
||||
import { nextFrame } from "@follow/utils/dom"
|
||||
import { EventBus } from "@follow/utils/event-bus"
|
||||
import type { FC } from "react"
|
||||
import { memo, useLayoutEffect, useState } from "react"
|
||||
import { useHotkeys } from "react-hotkeys-hook"
|
||||
import { memo, useEffect } from "react"
|
||||
|
||||
import { useMainContainerElement } from "~/atoms/dom"
|
||||
import { HotKeyScopeMap } from "~/constants"
|
||||
import { shortcuts } from "~/constants/shortcuts"
|
||||
import { HotkeyScope } from "~/constants"
|
||||
import { useNavigateEntry } from "~/hooks/biz/useNavigateEntry"
|
||||
import { useRouteEntryId } from "~/hooks/biz/useRouteParams"
|
||||
import { useConditionalHotkeyScope } from "~/hooks/common"
|
||||
import { useHotkeyScope } from "~/providers/hotkey-provider"
|
||||
|
||||
import { COMMAND_ID } from "../command/commands/id"
|
||||
import { useCommandBinding, useCommandHotkey } from "../command/hooks/use-register-hotkey"
|
||||
|
||||
export const EntryColumnShortcutHandler: FC<{
|
||||
refetch: () => void
|
||||
|
|
@ -16,42 +22,43 @@ export const EntryColumnShortcutHandler: FC<{
|
|||
}> = memo(({ data, refetch, handleScrollTo }) => {
|
||||
const dataRef = useRefValue(data!)
|
||||
|
||||
useHotkeys(
|
||||
shortcuts.entries.refetch.key,
|
||||
() => {
|
||||
refetch()
|
||||
},
|
||||
{ scopes: HotKeyScopeMap.Home },
|
||||
)
|
||||
const currentEntryIdRef = useRefValue(useRouteEntryId())
|
||||
const activeScope = useHotkeyScope()
|
||||
|
||||
const when =
|
||||
activeScope.includes(HotkeyScope.Timeline) && !activeScope.includes(HotkeyScope.EntryRender)
|
||||
|
||||
useCommandBinding({
|
||||
commandId: COMMAND_ID.timeline.switchToNext,
|
||||
when,
|
||||
})
|
||||
|
||||
useCommandBinding({
|
||||
commandId: COMMAND_ID.timeline.switchToPrevious,
|
||||
when,
|
||||
})
|
||||
|
||||
useCommandBinding({
|
||||
commandId: COMMAND_ID.timeline.refetch,
|
||||
when,
|
||||
})
|
||||
|
||||
useCommandHotkey({
|
||||
commandId: COMMAND_ID.timeline.enter,
|
||||
shortcut: "Enter",
|
||||
when,
|
||||
})
|
||||
|
||||
useCommandHotkey({
|
||||
commandId: COMMAND_ID.layout.focusToSubscription,
|
||||
shortcut: "Backspace, Escape",
|
||||
when,
|
||||
})
|
||||
|
||||
const currentEntryIdRef = useRefValue(useRouteEntryId())
|
||||
const navigate = useNavigateEntry()
|
||||
|
||||
const $mainContainer = useMainContainerElement()
|
||||
const [enabledArrowKey, setEnabledArrowKey] = useState(false)
|
||||
|
||||
// Enable arrow key navigation shortcuts only when focus is on entryContent or entryList,
|
||||
// entryList shortcuts should not be triggered in the feed col
|
||||
useLayoutEffect(() => {
|
||||
if (!$mainContainer) return
|
||||
const handler = () => {
|
||||
const target = document.activeElement
|
||||
const isFocusIn = $mainContainer.contains(target) || $mainContainer === target
|
||||
|
||||
setEnabledArrowKey(isFocusIn)
|
||||
}
|
||||
|
||||
handler()
|
||||
// NOTE: focusin event will bubble to the document
|
||||
document.addEventListener("focusin", handler)
|
||||
return () => {
|
||||
document.removeEventListener("focusin", handler)
|
||||
}
|
||||
}, [$mainContainer])
|
||||
|
||||
useHotkeys(
|
||||
shortcuts.entries.next.key,
|
||||
() => {
|
||||
useEffect(() => {
|
||||
return EventBus.subscribe(COMMAND_ID.timeline.switchToNext, () => {
|
||||
const data = dataRef.current
|
||||
const currentActiveEntryIndex = data.indexOf(currentEntryIdRef.current || "")
|
||||
|
||||
|
|
@ -63,12 +70,11 @@ export const EntryColumnShortcutHandler: FC<{
|
|||
navigate({
|
||||
entryId: nextId,
|
||||
})
|
||||
},
|
||||
{ scopes: HotKeyScopeMap.Home, enabled: enabledArrowKey, preventDefault: true },
|
||||
)
|
||||
useHotkeys(
|
||||
shortcuts.entries.previous.key,
|
||||
() => {
|
||||
})
|
||||
}, [currentEntryIdRef, dataRef, handleScrollTo, navigate, when])
|
||||
|
||||
useEffect(() => {
|
||||
return EventBus.subscribe(COMMAND_ID.timeline.switchToPrevious, () => {
|
||||
const data = dataRef.current
|
||||
const currentActiveEntryIndex = data.indexOf(currentEntryIdRef.current || "")
|
||||
|
||||
|
|
@ -81,8 +87,27 @@ export const EntryColumnShortcutHandler: FC<{
|
|||
navigate({
|
||||
entryId: nextId,
|
||||
})
|
||||
},
|
||||
{ scopes: HotKeyScopeMap.Home, enabled: enabledArrowKey, preventDefault: true },
|
||||
)
|
||||
})
|
||||
}, [currentEntryIdRef, dataRef, handleScrollTo, navigate])
|
||||
|
||||
useEffect(() => {
|
||||
return EventBus.subscribe(COMMAND_ID.timeline.refetch, () => {
|
||||
refetch()
|
||||
})
|
||||
}, [refetch])
|
||||
|
||||
const $scrollArea = useScrollViewElement()
|
||||
const { highlightBoundary } = useFocusActions()
|
||||
useEffect(() => {
|
||||
return EventBus.subscribe(COMMAND_ID.layout.focusToTimeline, () => {
|
||||
$scrollArea?.focus()
|
||||
nextFrame(highlightBoundary)
|
||||
})
|
||||
}, [$scrollArea, highlightBoundary])
|
||||
|
||||
const isFocusIn = useFocusable()
|
||||
|
||||
useConditionalHotkeyScope(HotkeyScope.Timeline, isFocusIn, true)
|
||||
|
||||
return null
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { useMobile } from "@follow/components/hooks/useMobile.js"
|
||||
import {
|
||||
MasonryIntersectionContext,
|
||||
MasonryItemsAspectRatioContext,
|
||||
|
|
@ -46,6 +47,7 @@ const gutter = 24
|
|||
|
||||
export const PictureMasonry: FC<MasonryProps> = (props) => {
|
||||
const { data } = props
|
||||
const isMobile = useMobile()
|
||||
const cacheMap = useState(() => new Map<string, object>())[0]
|
||||
const [isInitDim, setIsInitDim] = useState(false)
|
||||
const [isInitLayout, setIsInitLayout] = useState(false)
|
||||
|
|
@ -79,7 +81,7 @@ export const PictureMasonry: FC<MasonryProps> = (props) => {
|
|||
},
|
||||
)
|
||||
|
||||
const finalColumn = customizeColumn !== -1 ? customizeColumn : currentColumn
|
||||
const finalColumn = customizeColumn !== -1 && !isMobile ? customizeColumn : currentColumn
|
||||
const finalItemWidth = useMemo(
|
||||
() => (customizeColumn !== -1 ? calcItemWidth(finalColumn) : currentItemWidth),
|
||||
[calcItemWidth, currentItemWidth, customizeColumn, finalColumn],
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { PassviseFragment } from "@follow/components/common/Fragment.js"
|
|||
import { useMobile } from "@follow/components/hooks/useMobile.js"
|
||||
import { AutoResizeHeight } from "@follow/components/ui/auto-resize-height/index.js"
|
||||
import { Skeleton } from "@follow/components/ui/skeleton/index.jsx"
|
||||
import { FeedViewType } from "@follow/constants"
|
||||
import type { MediaModel } from "@follow/shared/hono"
|
||||
import { getImageProxyUrl } from "@follow/utils/img-proxy"
|
||||
import { LRUCache } from "@follow/utils/lru-cache"
|
||||
|
|
@ -10,9 +11,7 @@ import { atom } from "jotai"
|
|||
import { useLayoutEffect, useMemo, useRef, useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import { MenuItemText } from "~/atoms/context-menu"
|
||||
import { useGeneralSettingKey } from "~/atoms/settings/general"
|
||||
import { CommandActionButton } from "~/components/ui/button/CommandActionButton"
|
||||
import { RelativeTime } from "~/components/ui/datetime"
|
||||
import { HTML } from "~/components/ui/markdown/HTML"
|
||||
import { Media } from "~/components/ui/media"
|
||||
|
|
@ -22,7 +21,8 @@ import { useSortedEntryActions } from "~/hooks/biz/useEntryActions"
|
|||
import { useRenderStyle } from "~/hooks/biz/useRenderStyle"
|
||||
import { jotaiStore } from "~/lib/jotai"
|
||||
import { parseSocialMedia } from "~/lib/parsers"
|
||||
import { COMMAND_ID } from "~/modules/command/commands/id"
|
||||
import { EntryHeaderActions } from "~/modules/entry-content/actions/header-actions"
|
||||
import { MoreActions } from "~/modules/entry-content/actions/more-actions"
|
||||
import { FeedIcon } from "~/modules/feed/feed-icon"
|
||||
import { FeedTitle } from "~/modules/feed/feed-title"
|
||||
import { useEntry } from "~/store/entry/hooks"
|
||||
|
|
@ -73,7 +73,7 @@ export const SocialMediaItem: EntryListItemFC = ({ entryId, entryPreview, transl
|
|||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
className={cn(
|
||||
"relative flex px-5 py-4 lg:px-8",
|
||||
"relative flex px-5 py-4 first:mt-6 lg:px-8",
|
||||
"group",
|
||||
!asRead &&
|
||||
"before:bg-accent before:absolute before:left-1 before:top-8 before:block before:size-2 before:rounded-full md:before:-left-2 lg:before:left-2",
|
||||
|
|
@ -139,20 +139,8 @@ const ActionBar = ({ entryId }: { entryId: string }) => {
|
|||
return (
|
||||
<div className="absolute right-1 top-0 -translate-y-1/2 rounded-lg border border-gray-200 bg-white/90 p-1 shadow-sm backdrop-blur-sm dark:border-neutral-900 dark:bg-neutral-900">
|
||||
<div className="flex items-center gap-1">
|
||||
{entryActions
|
||||
.filter((item) => item instanceof MenuItemText)
|
||||
.filter(
|
||||
(item) =>
|
||||
item.id !== COMMAND_ID.entry.read && item.id !== COMMAND_ID.entry.openInBrowser,
|
||||
)
|
||||
.map((item) => (
|
||||
<CommandActionButton
|
||||
commandId={item.id}
|
||||
active={item.active}
|
||||
onClick={item.click!}
|
||||
key={item.id}
|
||||
/>
|
||||
))}
|
||||
<EntryHeaderActions entryId={entryId} view={FeedViewType.SocialMedia} />
|
||||
<MoreActions entryId={entryId} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { useHotkeys } from "react-hotkeys-hook"
|
|||
import { Trans, useTranslation } from "react-i18next"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { HotKeyScopeMap } from "~/constants"
|
||||
import { HotkeyScope } from "~/constants"
|
||||
import { shortcuts } from "~/constants/shortcuts"
|
||||
import { useI18n } from "~/hooks/common"
|
||||
|
||||
|
|
@ -50,7 +50,7 @@ export const MarkAllReadButton = ({
|
|||
label: (
|
||||
<span className="flex items-center gap-1">
|
||||
{t("mark_all_read_button.undo")}
|
||||
<Kbd className="border-border inline-flex items-center border bg-transparent dark:text-white">
|
||||
<Kbd className="border-border inline-flex items-center border bg-transparent text-white">
|
||||
Meta+Z
|
||||
</Kbd>
|
||||
</span>
|
||||
|
|
@ -61,7 +61,7 @@ export const MarkAllReadButton = ({
|
|||
},
|
||||
{
|
||||
preventDefault: true,
|
||||
scopes: HotKeyScopeMap.Home,
|
||||
scopes: HotkeyScope.Home,
|
||||
},
|
||||
)
|
||||
|
||||
|
|
@ -100,7 +100,7 @@ const ConfirmMarkAllReadInfo = ({ undo }: { undo: () => any }) => {
|
|||
const [countdown] = useCountdown({ countStart: 3 })
|
||||
|
||||
useHotkeys("ctrl+z,meta+z", undo, {
|
||||
scopes: HotKeyScopeMap.Home,
|
||||
scopes: HotkeyScope.Home,
|
||||
preventDefault: true,
|
||||
})
|
||||
|
||||
|
|
@ -137,7 +137,7 @@ export const FlatMarkAllReadButton: FC<
|
|||
variant="ghost"
|
||||
disabled={status === "done"}
|
||||
buttonClassName={buttonClassName}
|
||||
className={cn(
|
||||
textClassName={cn(
|
||||
"center relative flex h-auto gap-1",
|
||||
|
||||
className,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { Focusable } from "@follow/components/common/Focusable/index.js"
|
||||
import { useMobile } from "@follow/components/hooks/useMobile.js"
|
||||
import { FeedViewType, views } from "@follow/constants"
|
||||
import { useTitle } from "@follow/hooks"
|
||||
|
|
@ -116,7 +117,7 @@ function EntryColumnImpl() {
|
|||
|
||||
const ListComponent = views[view]!.gridMode ? EntryColumnGrid : EntryList
|
||||
return (
|
||||
<div
|
||||
<Focusable
|
||||
data-hide-in-print
|
||||
className="@container relative flex h-full flex-1 flex-col"
|
||||
onClick={
|
||||
|
|
@ -164,7 +165,7 @@ function EntryColumnImpl() {
|
|||
/>
|
||||
)}
|
||||
</EntryColumnWrapper>
|
||||
</div>
|
||||
</Focusable>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -175,7 +175,7 @@ const PreviewHeaderInfoWrapper: Component = ({ children }) => {
|
|||
|
||||
<button
|
||||
type="button"
|
||||
className="text-accent cursor-button from-accent/10 via-accent/15 to-accent/20 hover:bg-accent animate-gradient-x -mx-4 mt-2 flex place-items-center justify-center gap-1 bg-gradient-to-r px-3 py-2 font-semibold transition-all duration-300 hover:text-white"
|
||||
className="text-accent cursor-button from-accent/10 via-accent/15 to-accent/20 hover:bg-accent animate-gradient-x -mx-4 mt-3.5 flex place-items-center justify-center gap-1 bg-gradient-to-r px-3 py-2 font-semibold transition-all duration-300 hover:text-white"
|
||||
onClick={() => {
|
||||
const { feedId, listId } = getRouteParams()
|
||||
if (!feedId) return
|
||||
|
|
|
|||
|
|
@ -163,7 +163,7 @@ const FollowSubscriptionButton = () => {
|
|||
<ActionButton
|
||||
id={LOGO_MOBILE_ID}
|
||||
tooltip="Subscription"
|
||||
className="absolute left-3 translate-y-px text-zinc-500"
|
||||
className="text-text-secondary absolute left-3 translate-y-px"
|
||||
>
|
||||
<FollowIcon className="size-4" />
|
||||
</ActionButton>
|
||||
|
|
|
|||
|
|
@ -156,7 +156,7 @@ export function ListItem({
|
|||
>
|
||||
{entry.entries.title ? (
|
||||
<EntryTranslation
|
||||
className={cn("hyphens-auto", lineClamp.title)}
|
||||
className={cn("hyphens-auto font-medium", lineClamp.title)}
|
||||
source={entry.entries.title}
|
||||
target={translation?.title}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { ScrollArea } from "@follow/components/ui/scroll-area/ScrollArea.js"
|
||||
import { views } from "@follow/constants"
|
||||
import { clsx } from "clsx"
|
||||
import { FeedViewType, views } from "@follow/constants"
|
||||
import { cn } from "@follow/utils/utils"
|
||||
|
||||
import { useIsZenMode } from "~/atoms/settings/ui"
|
||||
import { useRouteParamsSelector } from "~/hooks/biz/useRouteParams"
|
||||
|
|
@ -14,17 +14,17 @@ export const EntryColumnWrapper = ({ ref, children, onScroll }: EntryColumnWrapp
|
|||
const isZenMode = useIsZenMode()
|
||||
|
||||
return (
|
||||
<div className={clsx(styles, animationStyles, "mt-2")}>
|
||||
<div className={cn(styles, animationStyles, view !== FeedViewType.SocialMedia && "mt-2")}>
|
||||
<ScrollArea
|
||||
scrollbarClassName={clsx(!views[view]!.wideMode ? "w-[5px] p-0" : "", "z-[3]")}
|
||||
scrollbarClassName={cn(!views[view]!.wideMode ? "w-[5px] p-0" : "", "z-[3]")}
|
||||
mask={false}
|
||||
ref={ref}
|
||||
rootClassName={clsx(
|
||||
rootClassName={cn(
|
||||
"h-full",
|
||||
views[view]!.wideMode ? "mt-2" : "",
|
||||
isZenMode ? "max-w-[80ch] mx-auto" : "",
|
||||
)}
|
||||
viewportClassName="[&>div]:grow flex"
|
||||
viewportClassName={"[&>div]:grow flex"}
|
||||
onScroll={onScroll}
|
||||
>
|
||||
{children}
|
||||
|
|
|
|||
|
|
@ -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 }],
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,19 +1,32 @@
|
|||
import { ScrollArea } from "@follow/components/ui/scroll-area/index.js"
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardPortal,
|
||||
HoverCardTrigger,
|
||||
} from "@radix-ui/react-hover-card"
|
||||
import clsx from "clsx"
|
||||
import { AvatarGroup } from "@follow/components/ui/avatar-group/index.js"
|
||||
import { FeedViewType } from "@follow/constants"
|
||||
|
||||
import { useWhoami } from "~/atoms/user"
|
||||
import { getRouteParams } from "~/hooks/biz/useRouteParams"
|
||||
import { useAuthQuery } from "~/hooks/common"
|
||||
import { useAppLayoutGridContainerWidth } from "~/providers/app-grid-layout-container-provider"
|
||||
import { Queries } from "~/queries"
|
||||
import { useEntryReadHistory } from "~/store/entry"
|
||||
|
||||
import { EntryUser, EntryUserRow, getLimit } from "./EntryReadHistory.shared"
|
||||
import { EntryUser } from "./EntryReadHistory.shared"
|
||||
|
||||
const getLimit = (width: number): number => {
|
||||
const routeParams = getRouteParams()
|
||||
// social media view has four extra buttons
|
||||
if (
|
||||
[FeedViewType.SocialMedia, FeedViewType.Pictures, FeedViewType.Videos].includes(
|
||||
routeParams.view,
|
||||
)
|
||||
) {
|
||||
if (width > 1100) return 15
|
||||
if (width > 950) return 10
|
||||
if (width > 800) return 5
|
||||
return 3
|
||||
}
|
||||
if (width > 900) return 15
|
||||
if (width > 600) return 10
|
||||
return 5
|
||||
}
|
||||
|
||||
export const EntryReadHistory: Component<{ entryId: string }> = ({ entryId }) => {
|
||||
const me = useWhoami()
|
||||
|
|
@ -37,64 +50,27 @@ export const EntryReadHistory: Component<{ entryId: string }> = ({ entryId }) =>
|
|||
className="animate-in fade-in @md:flex hidden items-center duration-200"
|
||||
data-hide-in-print
|
||||
>
|
||||
{entryHistory.userIds
|
||||
.filter((id) => id !== me?.id)
|
||||
.slice(0, LIMIT)
|
||||
<AvatarGroup>
|
||||
{entryHistory.userIds
|
||||
.filter((id) => id !== me?.id)
|
||||
.slice(0, LIMIT)
|
||||
|
||||
.map((userId, i) => (
|
||||
<EntryUser userId={userId} i={i} key={userId} />
|
||||
))}
|
||||
.map((userId) => (
|
||||
<EntryUser userId={userId} key={userId} />
|
||||
))}
|
||||
</AvatarGroup>
|
||||
|
||||
{!!totalCount && totalCount > LIMIT && (
|
||||
<HoverCard>
|
||||
<HoverCardTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
style={{
|
||||
marginLeft: `-${LIMIT * 8}px`,
|
||||
zIndex: LIMIT + 1,
|
||||
}}
|
||||
className="no-drag-region border-border bg-material-opaque ring-background relative flex size-7 items-center justify-center rounded-full border ring-2"
|
||||
>
|
||||
<span className="text-text-secondary text-[10px] font-medium tabular-nums">
|
||||
+{Math.min(totalCount - LIMIT, 99)}
|
||||
</span>
|
||||
</button>
|
||||
</HoverCardTrigger>
|
||||
|
||||
{totalCount > LIMIT && (
|
||||
<HoverCardPortal>
|
||||
<HoverCardContent
|
||||
sideOffset={12}
|
||||
align="start"
|
||||
side="right"
|
||||
className={clsx(
|
||||
"shadow-menu bg-material-medium shadow-context-menu backdrop-blur-background rounded-md border",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out",
|
||||
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
"data-[state=closed]:slide-out-to-left-5 data-[state=open]:slide-in-from-left-5",
|
||||
"data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
|
||||
"transition-all duration-200 ease-in-out",
|
||||
)}
|
||||
>
|
||||
<ScrollArea.ScrollArea
|
||||
rootClassName="max-h-[300px] max-w-[20ch] flex flex-col"
|
||||
flex
|
||||
>
|
||||
<ul>
|
||||
{entryHistory.userIds
|
||||
.filter((id) => id !== me?.id)
|
||||
.slice(LIMIT)
|
||||
.map((userId) => (
|
||||
<EntryUserRow userId={userId} key={userId} />
|
||||
))}
|
||||
</ul>
|
||||
</ScrollArea.ScrollArea>
|
||||
</HoverCardContent>
|
||||
</HoverCardPortal>
|
||||
)}
|
||||
</HoverCard>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
margin: "-8px",
|
||||
zIndex: LIMIT + 1,
|
||||
}}
|
||||
className="no-drag-region border-border bg-material-opaque ring-background relative flex size-7 items-center justify-center rounded-full border ring-2"
|
||||
>
|
||||
<span className="text-text-secondary text-[10px] font-medium tabular-nums">
|
||||
+{Math.min(totalCount - LIMIT, 99)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,109 +1,46 @@
|
|||
import { Avatar, AvatarFallback, AvatarImage } from "@follow/components/ui/avatar/index.jsx"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipPortal,
|
||||
TooltipTrigger,
|
||||
} from "@follow/components/ui/tooltip/index.jsx"
|
||||
import { EllipsisHorizontalTextWithTooltip } from "@follow/components/ui/typography/index.js"
|
||||
import { FeedViewType } from "@follow/constants"
|
||||
import { TooltipContent, TooltipPortal } from "@follow/components/ui/tooltip/index.jsx"
|
||||
import { getNameInitials } from "@follow/utils/cjk"
|
||||
import { m } from "motion/react"
|
||||
import { memo } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import { getRouteParams } from "~/hooks/biz/useRouteParams"
|
||||
import { replaceImgUrlIfNeed } from "~/lib/img-proxy"
|
||||
import { useUserById } from "~/store/user"
|
||||
|
||||
import { usePresentUserProfileModal } from "../../profile/hooks"
|
||||
|
||||
export const getLimit = (width: number): number => {
|
||||
const routeParams = getRouteParams()
|
||||
// social media view has four extra buttons
|
||||
if (
|
||||
[FeedViewType.SocialMedia, FeedViewType.Pictures, FeedViewType.Videos].includes(
|
||||
routeParams.view,
|
||||
)
|
||||
) {
|
||||
if (width > 1100) return 15
|
||||
if (width > 950) return 10
|
||||
if (width > 800) return 5
|
||||
return 3
|
||||
}
|
||||
if (width > 900) return 15
|
||||
if (width > 600) return 10
|
||||
return 5
|
||||
}
|
||||
|
||||
export const EntryUserRow: Component<{ userId: string }> = memo(({ userId }) => {
|
||||
const user = useUserById(userId)
|
||||
const presentUserProfile = usePresentUserProfileModal("drawer")
|
||||
if (!user) return null
|
||||
|
||||
return (
|
||||
<li
|
||||
onClick={() => {
|
||||
presentUserProfile(userId)
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="cursor-button hover:bg-theme-selection-hover hover:text-theme-selection-foreground relative flex min-w-0 max-w-[50ch] shrink-0 items-center gap-2 truncate rounded-md p-1 px-2"
|
||||
>
|
||||
<Avatar className="border-border ring-background block aspect-square size-7 overflow-hidden rounded-full border ring-1">
|
||||
<AvatarImage src={replaceImgUrlIfNeed(user?.image || undefined)} />
|
||||
<AvatarFallback>{getNameInitials(user.name || "")}</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
{user.name && (
|
||||
<EllipsisHorizontalTextWithTooltip className="pr-8 text-xs font-medium">
|
||||
<span>{user.name}</span>
|
||||
</EllipsisHorizontalTextWithTooltip>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})
|
||||
|
||||
export const EntryUser: Component<{
|
||||
userId: string
|
||||
i: number
|
||||
}> = memo(({ userId, i }) => {
|
||||
ref?: React.Ref<HTMLDivElement>
|
||||
}> = memo(({ userId, ref }) => {
|
||||
const user = useUserById(userId)
|
||||
const { t } = useTranslation()
|
||||
const presentUserProfile = usePresentUserProfileModal("drawer")
|
||||
if (!user) return null
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
className="no-drag-region relative cursor-pointer"
|
||||
style={{
|
||||
right: `${i * 8}px`,
|
||||
zIndex: i,
|
||||
<div className="no-drag-region relative cursor-pointer hover:!z-[99999]" ref={ref}>
|
||||
<m.button
|
||||
layout="position"
|
||||
layoutId={userId}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
presentUserProfile(userId)
|
||||
}}
|
||||
asChild
|
||||
>
|
||||
<m.button
|
||||
layout="position"
|
||||
layoutId={userId}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
presentUserProfile(userId)
|
||||
}}
|
||||
>
|
||||
<Avatar className="border-border ring-background aspect-square size-7 border ring-1">
|
||||
<AvatarImage
|
||||
src={replaceImgUrlIfNeed(user?.image || undefined)}
|
||||
className="bg-material-ultra-thick"
|
||||
/>
|
||||
<AvatarFallback>{getNameInitials(user.name || "")}</AvatarFallback>
|
||||
</Avatar>
|
||||
</m.button>
|
||||
</TooltipTrigger>
|
||||
<Avatar className="border-border ring-background aspect-square size-7 border ring-1">
|
||||
<AvatarImage
|
||||
src={replaceImgUrlIfNeed(user?.image || undefined)}
|
||||
className="bg-material-ultra-thick"
|
||||
/>
|
||||
<AvatarFallback>{getNameInitials(user.name || "")}</AvatarFallback>
|
||||
</Avatar>
|
||||
</m.button>
|
||||
<TooltipPortal>
|
||||
<TooltipContent side="top">
|
||||
{t("entry_actions.recent_reader")} {user.name}
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { views } from "@follow/constants"
|
||||
import { FeedViewType, views } from "@follow/constants"
|
||||
import { cn } from "@follow/utils/utils"
|
||||
import { AnimatePresence, m } from "motion/react"
|
||||
import { memo } from "react"
|
||||
|
|
@ -60,8 +60,8 @@ function EntryHeaderImpl({ view, entryId, className, compact }: EntryHeaderProps
|
|||
<span className="min-w-[50%] shrink truncate font-bold">
|
||||
{entryTitleMeta.title}
|
||||
</span>
|
||||
<i className="i-mgc-line-cute-re size-[10px] shrink-0 translate-y-[-3px] rotate-[-25deg]" />
|
||||
<span className="text-text-secondary text-headline shrink truncate">
|
||||
<i className="i-mgc-line-cute-re text-text-secondary size-[10px] shrink-0 translate-y-[-3px] rotate-[-25deg]" />
|
||||
<span className="text-text-secondary text-headline shrink -translate-y-px truncate">
|
||||
{entryTitleMeta.description}
|
||||
</span>
|
||||
</m.div>
|
||||
|
|
@ -69,10 +69,12 @@ function EntryHeaderImpl({ view, entryId, className, compact }: EntryHeaderProps
|
|||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<div className="relative flex shrink-0 items-center justify-end gap-2">
|
||||
<EntryHeaderActions entryId={entry.entries.id} view={view} compact={compact} />
|
||||
<MoreActions entryId={entry.entries.id} />
|
||||
</div>
|
||||
{view !== FeedViewType.SocialMedia && (
|
||||
<div className="relative flex shrink-0 items-center justify-end gap-2">
|
||||
<EntryHeaderActions entryId={entry.entries.id} view={view} compact={compact} />
|
||||
<MoreActions entryId={entry.entries.id} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,29 +1,22 @@
|
|||
import { tracker } from "@follow/tracker"
|
||||
import { EventBus } from "@follow/utils/event-bus"
|
||||
import { createElement, useCallback, useEffect } from "react"
|
||||
import { createElement, useCallback, useMemo } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import {
|
||||
useEntryIsInReadability,
|
||||
useEntryIsInReadabilitySuccess,
|
||||
useEntryReadabilityContent,
|
||||
} from "~/atoms/readability"
|
||||
import { useModalStack } from "~/components/ui/modal/stacked/hooks"
|
||||
import { useAuthQuery } from "~/hooks/common/useBizQuery"
|
||||
import { Queries } from "~/queries"
|
||||
import { useEntryTranslation } from "~/store/ai/hook"
|
||||
import { useEntry } from "~/store/entry/hooks"
|
||||
import { useInboxById } from "~/store/inbox/hooks"
|
||||
|
||||
import { ImageGalleryContent } from "./components/ImageGalleryContent"
|
||||
|
||||
declare module "@follow/utils/event-bus" {
|
||||
export interface CustomEvent {
|
||||
FOCUS_ENTRY_CONTAINER: never
|
||||
}
|
||||
}
|
||||
|
||||
export const useFocusEntryContainerSubscriptions = (
|
||||
ref: React.RefObject<HTMLDivElement | null>,
|
||||
) => {
|
||||
useEffect(() => {
|
||||
return EventBus.subscribe("FOCUS_ENTRY_CONTAINER", () => {
|
||||
ref.current?.focus()
|
||||
})
|
||||
}, [ref])
|
||||
}
|
||||
|
||||
export const useGalleryModal = () => {
|
||||
const { present } = useModalStack()
|
||||
const { t } = useTranslation()
|
||||
|
|
@ -47,3 +40,62 @@ export const useGalleryModal = () => {
|
|||
[present, t],
|
||||
)
|
||||
}
|
||||
|
||||
export const useEntryContent = (entryId: string) => {
|
||||
const entry = useEntry(entryId)
|
||||
const isInbox = useInboxById(entry?.inboxId, (inbox) => inbox !== null)
|
||||
const { error, data, isPending } = useAuthQuery(
|
||||
isInbox ? Queries.entries.byInboxId(entryId) : Queries.entries.byId(entryId),
|
||||
{
|
||||
staleTime: 300_000,
|
||||
},
|
||||
)
|
||||
|
||||
const isInReadabilityMode = useEntryIsInReadability(entryId)
|
||||
const isReadabilitySuccess = useEntryIsInReadabilitySuccess(entryId)
|
||||
const readabilityContent = useEntryReadabilityContent(entryId)
|
||||
const contentTranslated = useEntryTranslation({
|
||||
entry,
|
||||
extraFields: isReadabilitySuccess ? ["readabilityContent"] : ["content"],
|
||||
})
|
||||
|
||||
return useMemo(() => {
|
||||
const entryContent = isInReadabilityMode
|
||||
? readabilityContent?.content
|
||||
: (entry?.entries.content ?? data?.entries.content)
|
||||
const translatedContent = isInReadabilityMode
|
||||
? contentTranslated.data?.readabilityContent
|
||||
: contentTranslated.data?.content
|
||||
const content = translatedContent || entryContent
|
||||
return {
|
||||
content,
|
||||
error,
|
||||
isPending,
|
||||
}
|
||||
}, [
|
||||
contentTranslated.data?.content,
|
||||
contentTranslated.data?.readabilityContent,
|
||||
data?.entries.content,
|
||||
entry?.entries.content,
|
||||
error,
|
||||
isInReadabilityMode,
|
||||
isPending,
|
||||
readabilityContent?.content,
|
||||
])
|
||||
}
|
||||
|
||||
export const useEntryMediaInfo = (entryId: string) => {
|
||||
return useEntry(entryId, (entry) =>
|
||||
Object.fromEntries(
|
||||
entry?.entries.media
|
||||
?.filter((m) => m.type === "photo")
|
||||
.map((cur) => [
|
||||
cur.url,
|
||||
{
|
||||
width: cur.width,
|
||||
height: cur.height,
|
||||
},
|
||||
]) ?? [],
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,34 +1,40 @@
|
|||
import {
|
||||
Focusable,
|
||||
useFocusable,
|
||||
useFocusActions,
|
||||
} from "@follow/components/common/Focusable/index.js"
|
||||
import { MemoedDangerousHTMLStyle } from "@follow/components/common/MemoedDangerousHTMLStyle.js"
|
||||
import { MotionButtonBase } from "@follow/components/ui/button/index.js"
|
||||
import { ScrollArea } from "@follow/components/ui/scroll-area/index.js"
|
||||
import type { FeedViewType } from "@follow/constants"
|
||||
import { useTitle } from "@follow/hooks"
|
||||
import type { FeedModel, InboxModel } from "@follow/models/types"
|
||||
import { stopPropagation } from "@follow/utils/dom"
|
||||
import { cn } from "@follow/utils/utils"
|
||||
import { nextFrame, stopPropagation } from "@follow/utils/dom"
|
||||
import { EventBus } from "@follow/utils/event-bus"
|
||||
import { springScrollTo } from "@follow/utils/scroller"
|
||||
import { cn, combineCleanupFunctions } from "@follow/utils/utils"
|
||||
import { ErrorBoundary } from "@sentry/react"
|
||||
import * as React from "react"
|
||||
import { useEffect, useMemo, useRef } from "react"
|
||||
import { useEffect, useMemo, useRef, useState } from "react"
|
||||
|
||||
import {
|
||||
useEntryIsInReadability,
|
||||
useEntryIsInReadabilitySuccess,
|
||||
useEntryReadabilityContent,
|
||||
} from "~/atoms/readability"
|
||||
import { useUISettingKey } from "~/atoms/settings/ui"
|
||||
import { useEntryIsInReadability } from "~/atoms/readability"
|
||||
import { useIsZenMode, useUISettingKey } from "~/atoms/settings/ui"
|
||||
import { ShadowDOM } from "~/components/common/ShadowDOM"
|
||||
import type { TocRef } from "~/components/ui/markdown/components/Toc"
|
||||
import { useInPeekModal } from "~/components/ui/modal/inspire/InPeekModal"
|
||||
import { HotkeyScope } from "~/constants"
|
||||
import { useRenderStyle } from "~/hooks/biz/useRenderStyle"
|
||||
import { useRouteParamsSelector } from "~/hooks/biz/useRouteParams"
|
||||
import { useAuthQuery } from "~/hooks/common"
|
||||
import { useConditionalHotkeyScope } from "~/hooks/common"
|
||||
import { useFeedSafeUrl } from "~/hooks/common/useFeedSafeUrl"
|
||||
import { useHotkeyScope } from "~/providers/hotkey-provider"
|
||||
import { WrappedElementProvider } from "~/providers/wrapped-element-provider"
|
||||
import { Queries } from "~/queries"
|
||||
import { useEntryTranslation } from "~/store/ai/hook"
|
||||
import { useEntry } from "~/store/entry"
|
||||
import { useFeedById } from "~/store/feed"
|
||||
import { useInboxById } from "~/store/inbox"
|
||||
|
||||
import { COMMAND_ID } from "../command/commands/id"
|
||||
import { useCommandBinding, useCommandHotkey } from "../command/hooks/use-register-hotkey"
|
||||
import { EntryContentHTMLRenderer } from "../renderer/html"
|
||||
import { AISummary } from "./AISummary"
|
||||
import { EntryTimelineSidebar } from "./components/EntryTimelineSidebar"
|
||||
|
|
@ -36,7 +42,7 @@ import { EntryTitle } from "./components/EntryTitle"
|
|||
import { SourceContentPanel } from "./components/SourceContentView"
|
||||
import { SupportCreator } from "./components/SupportCreator"
|
||||
import { EntryHeader } from "./header"
|
||||
import { useFocusEntryContainerSubscriptions } from "./hooks"
|
||||
import { useEntryContent, useEntryMediaInfo } from "./hooks"
|
||||
import type { EntryContentProps } from "./index.shared"
|
||||
import {
|
||||
ContainerToc,
|
||||
|
|
@ -62,50 +68,38 @@ export const EntryContent: Component<EntryContentProps> = ({
|
|||
const feed = useFeedById(entry?.feedId) as FeedModel | InboxModel
|
||||
|
||||
const inbox = useInboxById(entry?.inboxId, (inbox) => inbox !== null)
|
||||
const isInbox = !!inbox
|
||||
const isInReadabilityMode = useEntryIsInReadability(entryId)
|
||||
|
||||
const { error, data, isPending } = useAuthQuery(
|
||||
inbox ? Queries.entries.byInboxId(entryId) : Queries.entries.byId(entryId),
|
||||
{
|
||||
staleTime: 300_000,
|
||||
},
|
||||
)
|
||||
const readabilityContent = useEntryReadabilityContent(entryId)
|
||||
const { error, content, isPending } = useEntryContent(entryId)
|
||||
|
||||
const view = useRouteParamsSelector((route) => route.view)
|
||||
|
||||
const isInReadabilityMode = useEntryIsInReadability(entryId)
|
||||
const isReadabilitySuccess = useEntryIsInReadabilitySuccess(entryId)
|
||||
const scrollerRef = useRef<HTMLDivElement | null>(null)
|
||||
useEffect(() => {
|
||||
scrollerRef.current?.scrollTo(0, 0)
|
||||
scrollerRef.current?.focus()
|
||||
}, [entryId])
|
||||
|
||||
useFocusEntryContainerSubscriptions(scrollerRef)
|
||||
useEffect(() => {
|
||||
const scrollAndFocus = () => {
|
||||
scrollerRef.current?.scrollTo(0, 0)
|
||||
}
|
||||
|
||||
scrollAndFocus()
|
||||
return combineCleanupFunctions(
|
||||
EventBus.subscribe(COMMAND_ID.timeline.switchToNext, scrollAndFocus),
|
||||
EventBus.subscribe(COMMAND_ID.timeline.switchToPrevious, scrollAndFocus),
|
||||
)
|
||||
}, [entryId])
|
||||
|
||||
const safeUrl = useFeedSafeUrl(entryId)
|
||||
|
||||
const customCSS = useUISettingKey("customCSS")
|
||||
|
||||
const contentTranslated = useEntryTranslation({
|
||||
entry,
|
||||
extraFields: isReadabilitySuccess ? ["readabilityContent"] : ["content"],
|
||||
})
|
||||
|
||||
const isInPeekModal = useInPeekModal()
|
||||
|
||||
const [isUserInteraction, setIsUserInteraction] = useState(false)
|
||||
const isZenMode = useIsZenMode()
|
||||
|
||||
if (!entry) return null
|
||||
|
||||
const entryContent = isInReadabilityMode
|
||||
? readabilityContent?.content
|
||||
: (entry?.entries.content ?? data?.entries.content)
|
||||
const translatedContent = isInReadabilityMode
|
||||
? contentTranslated.data?.readabilityContent
|
||||
: contentTranslated.data?.content
|
||||
const content = translatedContent || entryContent
|
||||
|
||||
const isInbox = !!inbox
|
||||
|
||||
return (
|
||||
<>
|
||||
{!isInPeekModal && (
|
||||
|
|
@ -117,17 +111,55 @@ export const EntryContent: Component<EntryContentProps> = ({
|
|||
/>
|
||||
)}
|
||||
|
||||
<div className="@container relative flex size-full flex-col overflow-hidden print:size-auto print:overflow-visible">
|
||||
<Focusable
|
||||
className="@container relative flex size-full flex-col overflow-hidden print:size-auto print:overflow-visible"
|
||||
onFocus={() => setIsUserInteraction(true)}
|
||||
>
|
||||
<RegisterCommands
|
||||
scrollerRef={scrollerRef}
|
||||
isUserInteraction={isUserInteraction}
|
||||
setIsUserInteraction={setIsUserInteraction}
|
||||
/>
|
||||
<EntryTimelineSidebar entryId={entry.entries.id} />
|
||||
<EntryScrollArea className={className} scrollerRef={scrollerRef}>
|
||||
{/* Indicator for the entry */}
|
||||
<div
|
||||
className="animate-in fade-in slide-in-from-bottom-24 f-motion-reduce:fade-in-0 f-motion-reduce:slide-in-from-bottom-0 select-text duration-200 ease-in-out"
|
||||
key={entry.entries.id}
|
||||
>
|
||||
{!isZenMode && (
|
||||
<>
|
||||
<div className="absolute inset-y-0 left-0 flex w-12 items-center justify-center opacity-0 duration-200 hover:opacity-100">
|
||||
<MotionButtonBase
|
||||
// -12: Visual center point
|
||||
className="absolute left-0 shrink-0 !-translate-y-12 cursor-pointer"
|
||||
onClick={() => {
|
||||
EventBus.dispatch(COMMAND_ID.timeline.switchToPrevious)
|
||||
}}
|
||||
>
|
||||
<i className="i-mgc-left-small-sharp text-text-secondary size-16" />
|
||||
</MotionButtonBase>
|
||||
</div>
|
||||
|
||||
<div className="absolute inset-y-0 right-0 flex w-12 items-center justify-center opacity-0 duration-200 hover:opacity-100">
|
||||
<MotionButtonBase
|
||||
className="absolute right-0 shrink-0 !-translate-y-12 cursor-pointer"
|
||||
onClick={() => {
|
||||
EventBus.dispatch(COMMAND_ID.timeline.switchToNext)
|
||||
}}
|
||||
>
|
||||
<i className="i-mgc-right-small-sharp text-text-secondary size-16" />
|
||||
</MotionButtonBase>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<article
|
||||
tabIndex={-1}
|
||||
onFocus={() => setIsUserInteraction(true)}
|
||||
data-testid="entry-render"
|
||||
onContextMenu={stopPropagation}
|
||||
className="@[47.5rem]:max-w-[70ch] @7xl:max-w-[80ch] relative m-auto min-w-0 max-w-[550px]"
|
||||
className="@[950px]:max-w-[70ch] @7xl:max-w-[80ch] relative m-auto min-w-0 max-w-[550px]"
|
||||
>
|
||||
<EntryTitle entryId={entryId} compact={compact} />
|
||||
|
||||
|
|
@ -188,7 +220,7 @@ export const EntryContent: Component<EntryContentProps> = ({
|
|||
</div>
|
||||
</EntryScrollArea>
|
||||
<SourceContentPanel src={safeUrl ?? "#"} />
|
||||
</div>
|
||||
</Focusable>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -224,19 +256,7 @@ const Renderer: React.FC<{
|
|||
noMedia?: boolean
|
||||
content?: Nullable<string>
|
||||
}> = React.memo(({ entryId, view, feedId, noMedia = false, content = "" }) => {
|
||||
const mediaInfo = useEntry(entryId, (entry) =>
|
||||
Object.fromEntries(
|
||||
entry?.entries.media
|
||||
?.filter((m) => m.type === "photo")
|
||||
.map((cur) => [
|
||||
cur.url,
|
||||
{
|
||||
width: cur.width,
|
||||
height: cur.height,
|
||||
},
|
||||
]) ?? [],
|
||||
),
|
||||
)
|
||||
const mediaInfo = useEntryMediaInfo(entryId)
|
||||
|
||||
const readerRenderInlineStyle = useUISettingKey("readerRenderInlineStyle")
|
||||
|
||||
|
|
@ -271,3 +291,79 @@ const Renderer: React.FC<{
|
|||
</EntryContentHTMLRenderer>
|
||||
)
|
||||
})
|
||||
|
||||
const RegisterCommands = ({
|
||||
scrollerRef,
|
||||
isUserInteraction,
|
||||
setIsUserInteraction,
|
||||
}: {
|
||||
scrollerRef: React.RefObject<HTMLDivElement | null>
|
||||
isUserInteraction: boolean
|
||||
setIsUserInteraction: (isUserInteraction: boolean) => void
|
||||
}) => {
|
||||
const containerFocused = useFocusable()
|
||||
useConditionalHotkeyScope(HotkeyScope.EntryRender, isUserInteraction && containerFocused, true)
|
||||
|
||||
const activeScope = useHotkeyScope()
|
||||
const when = activeScope.includes(HotkeyScope.EntryRender)
|
||||
|
||||
useCommandBinding({
|
||||
commandId: COMMAND_ID.entryRender.scrollUp,
|
||||
when,
|
||||
})
|
||||
|
||||
useCommandBinding({
|
||||
commandId: COMMAND_ID.entryRender.scrollDown,
|
||||
when,
|
||||
})
|
||||
|
||||
useCommandBinding({
|
||||
commandId: COMMAND_ID.entryRender.nextEntry,
|
||||
when,
|
||||
})
|
||||
|
||||
useCommandBinding({
|
||||
commandId: COMMAND_ID.entryRender.previousEntry,
|
||||
when,
|
||||
})
|
||||
|
||||
useCommandHotkey({
|
||||
commandId: COMMAND_ID.layout.focusToTimeline,
|
||||
when,
|
||||
shortcut: "Backspace, Escape",
|
||||
})
|
||||
|
||||
const { highlightBoundary } = useFocusActions()
|
||||
|
||||
useEffect(() => {
|
||||
return combineCleanupFunctions(
|
||||
EventBus.subscribe(COMMAND_ID.entryRender.scrollUp, () => {
|
||||
const currentScroll = scrollerRef.current?.scrollTop
|
||||
const delta = window.innerHeight
|
||||
|
||||
if (typeof currentScroll === "number" && delta) {
|
||||
springScrollTo(currentScroll - delta, scrollerRef.current!)
|
||||
}
|
||||
}),
|
||||
|
||||
EventBus.subscribe(COMMAND_ID.entryRender.scrollDown, () => {
|
||||
const currentScroll = scrollerRef.current?.scrollTop
|
||||
const delta = window.innerHeight
|
||||
if (typeof currentScroll === "number" && delta) {
|
||||
springScrollTo(currentScroll + delta, scrollerRef.current!)
|
||||
}
|
||||
}),
|
||||
EventBus.subscribe(COMMAND_ID.timeline.enter, () => {
|
||||
const $scroller = scrollerRef.current
|
||||
if ($scroller) {
|
||||
springScrollTo(0, $scroller)
|
||||
$scroller.focus()
|
||||
nextFrame(highlightBoundary)
|
||||
setIsUserInteraction(true)
|
||||
}
|
||||
}),
|
||||
)
|
||||
}, [highlightBoundary, scrollerRef, setIsUserInteraction])
|
||||
|
||||
return null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import type { FeedModel, InboxModel } from "@follow/models/types"
|
|||
import { nextFrame, stopPropagation } from "@follow/utils/dom"
|
||||
import { cn } from "@follow/utils/utils"
|
||||
import { ErrorBoundary } from "@sentry/react"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
import { useAudioPlayerAtomSelector } from "~/atoms/player"
|
||||
import { useUISettingKey } from "~/atoms/settings/ui"
|
||||
|
|
@ -13,10 +13,8 @@ import { ShadowDOM } from "~/components/common/ShadowDOM"
|
|||
import { useNavigateEntry } from "~/hooks/biz/useNavigateEntry"
|
||||
import { useRenderStyle } from "~/hooks/biz/useRenderStyle"
|
||||
import { useRouteParamsSelector } from "~/hooks/biz/useRouteParams"
|
||||
import { useAuthQuery, usePreventOverscrollBounce } from "~/hooks/common"
|
||||
import { usePreventOverscrollBounce } from "~/hooks/common"
|
||||
import { WrappedElementProvider } from "~/providers/wrapped-element-provider"
|
||||
import { Queries } from "~/queries"
|
||||
import { useEntryTranslation } from "~/store/ai/hook"
|
||||
import { useEntry } from "~/store/entry"
|
||||
import { useFeedById } from "~/store/feed"
|
||||
import { useInboxById } from "~/store/inbox"
|
||||
|
|
@ -28,7 +26,14 @@ import { EntryReadHistory } from "./components/EntryReadHistory"
|
|||
import { EntryTitle } from "./components/EntryTitle"
|
||||
import { SupportCreator } from "./components/SupportCreator"
|
||||
import { EntryHeader } from "./header"
|
||||
import { NoContent, RenderError, TitleMetaHandler } from "./index.shared"
|
||||
import { useEntryContent, useEntryMediaInfo } from "./hooks"
|
||||
import {
|
||||
NoContent,
|
||||
ReadabilityAutoToggleEffect,
|
||||
ReadabilityNotice,
|
||||
RenderError,
|
||||
TitleMetaHandler,
|
||||
} from "./index.shared"
|
||||
import { EntryContentLoading } from "./loading"
|
||||
|
||||
export interface EntryContentClassNames {
|
||||
|
|
@ -63,37 +68,19 @@ export const EntryContent: Component<{
|
|||
}, [navigateEntry, params])
|
||||
|
||||
const entry = useEntry(entryId)
|
||||
const mediaInfo = useEntryMediaInfo(entryId)
|
||||
|
||||
useTitle(entry?.entries.title)
|
||||
|
||||
const feed = useFeedById(entry?.feedId) as FeedModel | InboxModel
|
||||
const readerRenderInlineStyle = useUISettingKey("readerRenderInlineStyle")
|
||||
const inbox = useInboxById(entry?.inboxId, (inbox) => inbox !== null)
|
||||
const isInbox = !!inbox
|
||||
|
||||
const { error, data, isPending } = useAuthQuery(
|
||||
inbox ? Queries.entries.byInboxId(entryId) : Queries.entries.byId(entryId),
|
||||
{
|
||||
enabled: !!entryId,
|
||||
staleTime: 300_000,
|
||||
},
|
||||
)
|
||||
const { error, content, isPending } = useEntryContent(entryId)
|
||||
|
||||
const view = useRouteParamsSelector((route) => route.view)
|
||||
|
||||
const mediaInfo = useMemo(
|
||||
() =>
|
||||
Object.fromEntries(
|
||||
(entry?.entries.media ?? data?.entries.media)
|
||||
?.filter((m) => m.type === "photo")
|
||||
.map((cur) => [
|
||||
cur.url,
|
||||
{
|
||||
width: cur.width,
|
||||
height: cur.height,
|
||||
},
|
||||
]) ?? [],
|
||||
),
|
||||
[entry?.entries.media, data?.entries.media],
|
||||
)
|
||||
const hideRecentReader = useUISettingKey("hideRecentReader")
|
||||
|
||||
const { entryId: audioEntryId } = useAudioPlayerAtomSelector((state) => state)
|
||||
|
|
@ -102,19 +89,10 @@ export const EntryContent: Component<{
|
|||
const [scrollElement, setScrollElement] = useState<HTMLElement | null>(null)
|
||||
|
||||
const customCSS = useUISettingKey("customCSS")
|
||||
|
||||
const contentTranslated = useEntryTranslation({ entry, extraFields: ["content"] })
|
||||
|
||||
const stableRenderStyle = useRenderStyle()
|
||||
|
||||
if (!entry) return null
|
||||
|
||||
const entryContent = entry?.entries.content ?? data?.entries.content
|
||||
const translatedContent = contentTranslated.data?.content
|
||||
const content = translatedContent || entryContent
|
||||
|
||||
const isInbox = !!inbox
|
||||
|
||||
return (
|
||||
<WrappedElementProvider>
|
||||
<ScrollElementContext value={scrollElement}>
|
||||
|
|
@ -162,6 +140,7 @@ export const EntryContent: Component<{
|
|||
<TitleMetaHandler entryId={entry.entries.id} />
|
||||
<AISummary entryId={entry.entries.id} />
|
||||
<ErrorBoundary fallback={RenderError}>
|
||||
<ReadabilityNotice entryId={entryId} />
|
||||
<ShadowDOM injectHostStyles={!isInbox}>
|
||||
{!!customCSS && (
|
||||
<MemoedDangerousHTMLStyle>{customCSS}</MemoedDangerousHTMLStyle>
|
||||
|
|
@ -184,6 +163,13 @@ export const EntryContent: Component<{
|
|||
</div>
|
||||
</WrappedElementProvider>
|
||||
|
||||
{entry.settings?.readability && (
|
||||
<ReadabilityAutoToggleEffect
|
||||
id={entry.entries.id}
|
||||
url={entry.entries.url ?? ""}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!content && (
|
||||
<div className="center mt-16 min-w-0">
|
||||
{isPending ? (
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import { LoadingWithIcon } from "@follow/components/ui/loading/index.jsx"
|
|||
import { RootPortal } from "@follow/components/ui/portal/index.jsx"
|
||||
import { useScrollViewElement } from "@follow/components/ui/scroll-area/hooks.js"
|
||||
import { WEB_BUILD } from "@follow/shared/constants"
|
||||
import { EventBus } from "@follow/utils/event-bus"
|
||||
import { springScrollTo } from "@follow/utils/scroller"
|
||||
import { cn } from "@follow/utils/utils"
|
||||
import type { FallbackRender } from "@sentry/react"
|
||||
|
|
@ -254,13 +253,13 @@ export const ContainerToc = memo(
|
|||
|
||||
return (
|
||||
<RootPortal to={wrappedElement!}>
|
||||
<div className="group absolute right-[-130px] top-0 h-full w-[100px]" data-hide-in-print>
|
||||
<div
|
||||
className="@[770px]:block group absolute right-[-130px] top-0 hidden h-full w-[100px]"
|
||||
data-hide-in-print
|
||||
>
|
||||
<div className="sticky top-0">
|
||||
<Toc
|
||||
ref={ref}
|
||||
onItemClick={() => {
|
||||
EventBus.dispatch("FOCUS_ENTRY_CONTAINER")
|
||||
}}
|
||||
className={cn(
|
||||
"animate-in fade-in-0 slide-in-from-bottom-12 easing-spring spring-soft flex flex-col items-end",
|
||||
"scrollbar-none max-h-[calc(100vh-100px)] overflow-auto",
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ const ShortcutModalContent = () => {
|
|||
<div className="w-full space-y-6 px-4 pb-5 pt-6">
|
||||
{Object.keys(shortcuts).map((type) => (
|
||||
<section key={type}>
|
||||
<div className="text-text-secondary mb-2 pl-4 text-xs font-medium capitalize">
|
||||
<div className="text-text-secondary mb-2 pl-3 text-xs font-medium capitalize">
|
||||
{t(shortcutsType[type])}
|
||||
</div>
|
||||
<div className="text-text rounded-md border text-[13px]">
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { Logo } from "@follow/components/icons/logo.jsx"
|
||||
import { Button } from "@follow/components/ui/button/index.js"
|
||||
import { Kbd } from "@follow/components/ui/kbd/Kbd.js"
|
||||
import { ScrollArea } from "@follow/components/ui/scroll-area/index.js"
|
||||
import { tracker } from "@follow/tracker"
|
||||
import { cn } from "@follow/utils/utils"
|
||||
import { AnimatePresence, m } from "motion/react"
|
||||
|
|
@ -188,13 +189,15 @@ export function GuideModalContent({ onClose }: { onClose: () => void }) {
|
|||
)}
|
||||
</div>
|
||||
)}
|
||||
{status === "initial" ? (
|
||||
<Intro />
|
||||
) : status === "active" ? (
|
||||
guideSteps[step - 1]!.content
|
||||
) : status === "complete" ? (
|
||||
<Outtro />
|
||||
) : null}
|
||||
<ScrollArea.ScrollArea viewportClassName="px-6">
|
||||
{status === "initial" ? (
|
||||
<Intro />
|
||||
) : status === "active" ? (
|
||||
guideSteps[step - 1]!.content
|
||||
) : status === "complete" ? (
|
||||
<Outtro />
|
||||
) : null}
|
||||
</ScrollArea.ScrollArea>
|
||||
</m.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useFocusable } from "@follow/components/common/Focusable.js"
|
||||
import { useFocusable } from "@follow/components/common/Focusable/index.js"
|
||||
import { Spring } from "@follow/components/constants/spring.js"
|
||||
import { useMobile } from "@follow/components/hooks/useMobile.js"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@follow/components/ui/tooltip/index.jsx"
|
||||
|
|
@ -20,7 +20,7 @@ import {
|
|||
useAudioPlayerAtomValue,
|
||||
} from "~/atoms/player"
|
||||
import { VolumeSlider } from "~/components/ui/media/VolumeSlider"
|
||||
import { HotKeyScopeMap } from "~/constants"
|
||||
import { HotkeyScope } from "~/constants"
|
||||
import type { NavigateEntryOptions } from "~/hooks/biz/useNavigateEntry"
|
||||
import { useNavigateEntry } from "~/hooks/biz/useNavigateEntry"
|
||||
import { FeedIcon } from "~/modules/feed/feed-icon"
|
||||
|
|
@ -118,7 +118,7 @@ const CornerPlayerImpl = ({ hideControls, rounded }: ControlButtonProps) => {
|
|||
const isFocused = useFocusable()
|
||||
useHotkeys("space", handleClickPlay, {
|
||||
preventDefault: true,
|
||||
scopes: HotKeyScopeMap.Home,
|
||||
scopes: HotkeyScope.Home,
|
||||
enabled: isFocused,
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -74,7 +74,7 @@ export const UserProfileModalContent: FC<SubscriptionModalContentProps> = ({ use
|
|||
</div>
|
||||
<Button
|
||||
buttonClassName={"mt-4"}
|
||||
className="gap-1"
|
||||
textClassName="gap-1"
|
||||
onClick={() => {
|
||||
follow({
|
||||
url: `rsshub://follow/profile/${userInfo.id}`,
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ export const ConfirmDeleteModalContent = ({ id, dismiss }: { dismiss: () => void
|
|||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
className="bg-red-600"
|
||||
buttonClassName="bg-red-600"
|
||||
onClick={() => {
|
||||
deleteMutation.mutate()
|
||||
dismiss()
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ export const SettingDataControl = () => {
|
|||
<p>{t("general.rebuild_database.warning.line2")}</p>
|
||||
<div className="mt-4 flex justify-end">
|
||||
<Button
|
||||
className="bg-red-500 px-3 text-white"
|
||||
buttonClassName="bg-red-500 px-3 text-white"
|
||||
onClick={async () => {
|
||||
await clearLocalPersistStoreData()
|
||||
window.location.reload()
|
||||
|
|
|
|||
|
|
@ -97,13 +97,18 @@ export const SettingGeneral = () => {
|
|||
|
||||
{
|
||||
type: "title",
|
||||
value: t("general.sidebar"),
|
||||
value: t("general.subscription"),
|
||||
},
|
||||
defineSettingItem("autoGroup", {
|
||||
label: t("general.auto_group.label"),
|
||||
description: t("general.auto_group.description"),
|
||||
}),
|
||||
|
||||
defineSettingItem("hideAllReadSubscriptions", {
|
||||
label: t("general.hide_all_read_subscriptions.label"),
|
||||
description: t("general.hide_all_read_subscriptions.description"),
|
||||
}),
|
||||
|
||||
{
|
||||
type: "title",
|
||||
value: t("general.timeline"),
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ const ConfirmDestroyModalContent = ({ listId }: { listId: string }) => {
|
|||
{t.settings("lists.delete.warning")}
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button className="bg-red-600" onClick={() => deleteFeedList.mutate({ listId })}>
|
||||
<Button buttonClassName="bg-red-600" onClick={() => deleteFeedList.mutate({ listId })}>
|
||||
{t("words.confirm")}
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -254,7 +254,7 @@ export const ListFeedsModalContent = ({ id }: { id: string }) => {
|
|||
suggestions={autocompleteSuggestions}
|
||||
/>
|
||||
<Button
|
||||
className="whitespace-nowrap"
|
||||
textClassName="whitespace-nowrap"
|
||||
onClick={() => {
|
||||
if (isBizId(feedSearchFor)) {
|
||||
addMutation.mutate({ feedId: feedSearchFor, listId: id })
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import { useEventCallback, useOnClickOutside } from "usehooks-ts"
|
|||
|
||||
import type { MenuItemInput } from "~/atoms/context-menu"
|
||||
import { MenuItemSeparator, MenuItemText, useShowContextMenu } from "~/atoms/context-menu"
|
||||
import { useGeneralSettingSelector } from "~/atoms/settings/general"
|
||||
import { useGeneralSettingSelector, useHideAllReadSubscriptions } from "~/atoms/settings/general"
|
||||
import { ROUTE_FEED_IN_FOLDER } from "~/constants"
|
||||
import { useAddFeedToFeedList } from "~/hooks/biz/useFeedActions"
|
||||
import { useNavigateEntry } from "~/hooks/biz/useNavigateEntry"
|
||||
|
|
@ -38,7 +38,7 @@ import { useModalStack } from "../../components/ui/modal/stacked/hooks"
|
|||
import { ListCreationModalContent } from "../settings/tabs/lists/modals"
|
||||
import { useFeedListSortSelector } from "./atom"
|
||||
import { CategoryRemoveDialogContent } from "./CategoryRemoveDialogContent"
|
||||
import { FeedItem } from "./FeedItem"
|
||||
import { FeedItemAutoHideUnread } from "./FeedItem"
|
||||
import { feedColumnStyles } from "./styles"
|
||||
import { UnreadNumber } from "./UnreadNumber"
|
||||
|
||||
|
|
@ -73,15 +73,15 @@ function FeedCategoryImpl({ data: ids, view, categoryOpenStateData }: FeedCatego
|
|||
const folderName =
|
||||
subscription?.category || (autoGroup ? subscription.defaultCategory : subscription.feedId)
|
||||
|
||||
const showCollapse = sortByUnreadFeedList.length > 1 || !!subscription?.category
|
||||
const isCategory = sortByUnreadFeedList.length > 1 || !!subscription?.category
|
||||
|
||||
const open = useMemo(() => {
|
||||
if (!showCollapse) return true
|
||||
if (!isCategory) return true
|
||||
if (folderName && typeof categoryOpenStateData[folderName] === "boolean") {
|
||||
return categoryOpenStateData[folderName]
|
||||
}
|
||||
return false
|
||||
}, [categoryOpenStateData, folderName, showCollapse])
|
||||
}, [categoryOpenStateData, folderName, isCategory])
|
||||
|
||||
const setOpen = useCallback(
|
||||
(next: boolean) => {
|
||||
|
|
@ -164,7 +164,6 @@ function FeedCategoryImpl({ data: ids, view, categoryOpenStateData }: FeedCatego
|
|||
})
|
||||
|
||||
const [isCategoryEditing, setIsCategoryEditing] = useState(false)
|
||||
|
||||
const [isContextMenuOpen, setIsContextMenuOpen] = useState(false)
|
||||
const isCategoryIsWaiting = isChangePending
|
||||
|
||||
|
|
@ -270,7 +269,7 @@ function FeedCategoryImpl({ data: ids, view, categoryOpenStateData }: FeedCatego
|
|||
})
|
||||
return (
|
||||
<div tabIndex={-1} onClick={stopPropagation}>
|
||||
{!!showCollapse && (
|
||||
{!!isCategory && (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
data-active={isActive || isContextMenuOpen}
|
||||
|
|
@ -279,6 +278,7 @@ function FeedCategoryImpl({ data: ids, view, categoryOpenStateData }: FeedCatego
|
|||
"my-px px-2.5",
|
||||
feedColumnStyles.item,
|
||||
)}
|
||||
data-sub={`feed-category-${folderName}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (!isCategoryEditing) {
|
||||
|
|
@ -289,6 +289,7 @@ function FeedCategoryImpl({ data: ids, view, categoryOpenStateData }: FeedCatego
|
|||
>
|
||||
<div className="flex w-full min-w-0 items-center" onDoubleClick={toggleCategoryOpenState}>
|
||||
<button
|
||||
data-type="collapse"
|
||||
type="button"
|
||||
onClick={toggleCategoryOpenState}
|
||||
data-state={open ? "open" : "close"}
|
||||
|
|
@ -335,7 +336,7 @@ function FeedCategoryImpl({ data: ids, view, categoryOpenStateData }: FeedCatego
|
|||
ref={itemsRef}
|
||||
className="space-y-px"
|
||||
initial={
|
||||
!!showCollapse && {
|
||||
!!isCategory && {
|
||||
height: 0,
|
||||
opacity: 0.01,
|
||||
}
|
||||
|
|
@ -351,7 +352,7 @@ function FeedCategoryImpl({ data: ids, view, categoryOpenStateData }: FeedCatego
|
|||
>
|
||||
<SortedFeedItems
|
||||
ids={ids}
|
||||
showCollapse={showCollapse as boolean}
|
||||
showCollapse={isCategory as boolean}
|
||||
view={view as FeedViewType}
|
||||
/>
|
||||
</m.div>
|
||||
|
|
@ -361,7 +362,24 @@ function FeedCategoryImpl({ data: ids, view, categoryOpenStateData }: FeedCatego
|
|||
)
|
||||
}
|
||||
|
||||
export const FeedCategory = memo(FeedCategoryImpl)
|
||||
function FilterReadFeedCategory(props: FeedCategoryProps) {
|
||||
const unread = useFeedUnreadStore(
|
||||
useCallback(
|
||||
(state) => props.data.reduce((acc, feedId) => (state.data[feedId] || 0) + acc, 0),
|
||||
[props.data],
|
||||
),
|
||||
)
|
||||
if (!unread) return null
|
||||
return <FeedCategoryImpl {...props} />
|
||||
}
|
||||
|
||||
export function FeedCategoryAutoHideUnread(props: FeedCategoryProps) {
|
||||
const hideAllReadSubscriptions = useHideAllReadSubscriptions()
|
||||
if (hideAllReadSubscriptions) {
|
||||
return <FilterReadFeedCategory {...props} />
|
||||
}
|
||||
return <FeedCategoryImpl {...props} />
|
||||
}
|
||||
|
||||
const RenameCategoryForm: FC<{
|
||||
currentCategory: string
|
||||
|
|
@ -487,7 +505,7 @@ const SortByAlphabeticalList = (props: SortListProps) => {
|
|||
return (
|
||||
<Fragment>
|
||||
{sortedFeedList.map((feedId) => (
|
||||
<FeedItem
|
||||
<FeedItemAutoHideUnread
|
||||
key={feedId}
|
||||
feedId={feedId}
|
||||
view={view}
|
||||
|
|
@ -512,7 +530,7 @@ const SortByUnreadList = ({ ids, showCollapse, view }: SortListProps) => {
|
|||
return (
|
||||
<Fragment>
|
||||
{sortByUnreadFeedList.map((feedId) => (
|
||||
<FeedItem
|
||||
<FeedItemAutoHideUnread
|
||||
key={feedId}
|
||||
feedId={feedId}
|
||||
view={view}
|
||||
|
|
|
|||
|
|
@ -9,14 +9,13 @@ import {
|
|||
} from "@follow/components/ui/tooltip/index.jsx"
|
||||
import { EllipsisHorizontalTextWithTooltip } from "@follow/components/ui/typography/index.js"
|
||||
import type { FeedViewType } from "@follow/constants"
|
||||
import { nextFrame } from "@follow/utils/dom"
|
||||
import { cn, isKeyForMultiSelectPressed } from "@follow/utils/utils"
|
||||
import dayjs from "dayjs"
|
||||
import { memo, use, useCallback, useState } from "react"
|
||||
import { createElement, memo, use, useCallback, useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import { MenuItemSeparator, MenuItemText, useShowContextMenu } from "~/atoms/context-menu"
|
||||
import { getMainContainerElement } from "~/atoms/dom"
|
||||
import { useHideAllReadSubscriptions } from "~/atoms/settings/general"
|
||||
import { ErrorTooltip } from "~/components/common/ErrorTooltip"
|
||||
import { useFeedActions, useInboxActions, useListActions } from "~/hooks/biz/useFeedActions"
|
||||
import { useFollow } from "~/hooks/biz/useFollow"
|
||||
import { useNavigateEntry } from "~/hooks/biz/useNavigateEntry"
|
||||
|
|
@ -105,10 +104,6 @@ const FeedItemImpl = ({ view, feedId, className, isPreview }: FeedItemProps) =>
|
|||
entryId: null,
|
||||
view,
|
||||
})
|
||||
// focus to main container in order to let keyboard can navigate entry items by arrow keys
|
||||
nextFrame(() => {
|
||||
getMainContainerElement()?.focus()
|
||||
})
|
||||
},
|
||||
[feedId, navigate, setSelectedFeedIds, view],
|
||||
)
|
||||
|
|
@ -166,6 +161,7 @@ const FeedItemImpl = ({ view, feedId, className, isPreview }: FeedItemProps) =>
|
|||
<DraggableItemWrapper
|
||||
isInMultipleSelection={isInMultipleSelection}
|
||||
data-feed-id={feedId}
|
||||
data-sub={`feed-${feedId}`}
|
||||
data-active={
|
||||
isMultiSelectingButNotSelected
|
||||
? false
|
||||
|
|
@ -186,29 +182,10 @@ const FeedItemImpl = ({ view, feedId, className, isPreview }: FeedItemProps) =>
|
|||
<div className={cn("flex min-w-0 items-center", isFeed && feed.errorAt && "text-red")}>
|
||||
<FeedIcon fallback feed={feed} size={16} />
|
||||
<FeedTitle feed={feed} />
|
||||
{isFeed && feed.errorAt && (
|
||||
<Tooltip delayDuration={300}>
|
||||
<TooltipTrigger asChild>
|
||||
<i className="i-mgc-wifi-off-cute-re ml-1 shrink-0 text-base" />
|
||||
</TooltipTrigger>
|
||||
<TooltipPortal>
|
||||
<TooltipContent>
|
||||
<div className="flex items-center gap-1">
|
||||
<i className="i-mgc-time-cute-re" />
|
||||
{t("feed_item.error_since")}{" "}
|
||||
{dayjs
|
||||
.duration(dayjs(feed.errorAt).diff(dayjs(), "minute"), "minute")
|
||||
.humanize(true)}
|
||||
</div>
|
||||
{!!feed.errorMessage && (
|
||||
<div className="flex items-center gap-1">
|
||||
<i className="i-mgc-bug-cute-re" />
|
||||
{feed.errorMessage}
|
||||
</div>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</Tooltip>
|
||||
{isFeed && (
|
||||
<ErrorTooltip errorAt={feed.errorAt} errorMessage={feed.errorMessage}>
|
||||
<i className="i-mgc-wifi-off-cute-re ml-1 shrink-0 text-base" />
|
||||
</ErrorTooltip>
|
||||
)}
|
||||
{subscription?.isPrivate && (
|
||||
<Tooltip delayDuration={300}>
|
||||
|
|
@ -242,14 +219,35 @@ const FeedItemImpl = ({ view, feedId, className, isPreview }: FeedItemProps) =>
|
|||
)
|
||||
}
|
||||
|
||||
const FilterReadFeedItem: Component<FeedItemProps> = (props) => {
|
||||
const feedUnread = useFeedUnreadStore((state) => state.data[props.feedId] || 0)
|
||||
|
||||
if (!feedUnread) return null
|
||||
return createElement(FeedItemImpl, props)
|
||||
}
|
||||
|
||||
export const FeedItem = memo(FeedItemImpl)
|
||||
|
||||
const ListItemImpl: Component<{
|
||||
export const FeedItemAutoHideUnread: Component<FeedItemProps> = memo((props) => {
|
||||
const hideAllReadSubscriptions = useHideAllReadSubscriptions()
|
||||
if (hideAllReadSubscriptions) return createElement(FilterReadFeedItem, props)
|
||||
return createElement(FeedItemImpl, props)
|
||||
})
|
||||
|
||||
interface ListItemProps {
|
||||
listId: string
|
||||
view: FeedViewType
|
||||
iconSize?: number
|
||||
isPreview?: boolean
|
||||
}> = ({ view, listId, className, iconSize = 22, isPreview }) => {
|
||||
}
|
||||
|
||||
const ListItemImpl: Component<ListItemProps> = ({
|
||||
view,
|
||||
listId,
|
||||
className,
|
||||
iconSize = 22,
|
||||
isPreview,
|
||||
}) => {
|
||||
const list = useListById(listId)
|
||||
|
||||
const isActive = useRouteParamsSelector((routerParams) => routerParams.listId === listId)
|
||||
|
|
@ -269,10 +267,6 @@ const ListItemImpl: Component<{
|
|||
entryId: null,
|
||||
view,
|
||||
})
|
||||
// focus to main container in order to let keyboard can navigate entry items by arrow keys
|
||||
nextFrame(() => {
|
||||
getMainContainerElement()?.focus()
|
||||
})
|
||||
},
|
||||
[listId, navigate, view],
|
||||
)
|
||||
|
|
@ -292,6 +286,7 @@ const ListItemImpl: Component<{
|
|||
return (
|
||||
<div
|
||||
data-list-id={listId}
|
||||
data-sub={`list-${listId}`}
|
||||
data-active={isActive || isContextMenuOpen}
|
||||
className={cn(feedColumnStyles.item, "py-1 pl-2.5", className)}
|
||||
onClick={handleNavigate}
|
||||
|
|
@ -339,11 +334,26 @@ const ListItemImpl: Component<{
|
|||
|
||||
export const ListItem = memo(ListItemImpl)
|
||||
|
||||
const InboxItemImpl: Component<{
|
||||
const FilterReadListItem: Component<ListItemProps> = (props) => {
|
||||
const listUnread = useUnreadByListId(props.listId)
|
||||
|
||||
if (!listUnread) return null
|
||||
return createElement(ListItem, props)
|
||||
}
|
||||
|
||||
export const ListItemAutoHideUnread: Component<ListItemProps> = memo((props) => {
|
||||
const hideAllReadSubscriptions = useHideAllReadSubscriptions()
|
||||
|
||||
if (hideAllReadSubscriptions) return createElement(FilterReadListItem, props)
|
||||
return createElement(ListItemImpl, props)
|
||||
})
|
||||
|
||||
interface InboxItemProps {
|
||||
inboxId: string
|
||||
view: FeedViewType
|
||||
iconSize?: number
|
||||
}> = ({ view, inboxId, className, iconSize = 16 }) => {
|
||||
}
|
||||
const InboxItemImpl: Component<InboxItemProps> = ({ view, inboxId, className, iconSize = 16 }) => {
|
||||
const inbox = useInboxById(inboxId)
|
||||
|
||||
const isActive = useRouteParamsSelector((routerParams) => routerParams.inboxId === inboxId)
|
||||
|
|
@ -378,6 +388,7 @@ const InboxItemImpl: Component<{
|
|||
return (
|
||||
<div
|
||||
data-active={isActive || isContextMenuOpen}
|
||||
data-sub={`inbox-${inboxId}`}
|
||||
data-inbox-id={inboxId}
|
||||
className={cn(
|
||||
"cursor-menu flex w-full items-center justify-between rounded-md pr-2.5 text-base font-medium leading-loose lg:text-sm",
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue