From edd4f9e5a6dc041e9eae2cee5ddf4eb624527ef5 Mon Sep 17 00:00:00 2001 From: Innei Date: Fri, 22 Nov 2024 19:32:39 +0800 Subject: [PATCH] feat: mobile design (#1568) * init Signed-off-by: Innei * feat: feed column Signed-off-by: Innei * feat: float bar Signed-off-by: Innei * feat: profile button Signed-off-by: Innei * chore: comment Signed-off-by: Innei * feat: layout Signed-off-by: Innei * feat: split mobile entry content Signed-off-by: Innei * feat: add history stack Signed-off-by: Innei * feat: return back Signed-off-by: Innei * fix: return back position Signed-off-by: Innei * feat: entry list header Signed-off-by: Innei * feat: sync selector Signed-off-by: Innei * feat: modal stack mobile Signed-off-by: Innei * fix: modal selector Signed-off-by: Innei * cleanup Signed-off-by: Innei * fix: sheet and optimize entry readers Signed-off-by: Innei * fix: scroller Signed-off-by: Innei * feat: entry contents Signed-off-by: Innei * feat: entry content Signed-off-by: Innei * update Signed-off-by: Innei * feat: user profile sheet Signed-off-by: Innei * fix: entry content header Signed-off-by: Innei * fix: entry header Signed-off-by: Innei * chore: auto-fix linting and formatting issues * fix: return back Signed-off-by: Innei * feat: image view Signed-off-by: Innei * feat: audio Signed-off-by: Innei * feat: inline player Signed-off-by: Innei * feat: pull to refresh Signed-off-by: Innei * cleanup Signed-off-by: Innei * update Signed-off-by: Innei * chore: auto-fix linting and formatting issues * chore: fix import, remove no support display * chore: auto-fix linting and formatting issues * feat: achievement modal in mobile Signed-off-by: Innei * fix: subview mobile Signed-off-by: Innei * feat: power Signed-off-by: Innei * feat: wallet mobile design Signed-off-by: Innei * feat: pwa support (#1575) Co-authored-by: hyoban * chore: auto-fix linting and formatting issues * fix import * discover * fix: border color Signed-off-by: Innei * fix: modal async Signed-off-by: Innei * feat: mobile setting Signed-off-by: Innei * fix: entry header width Signed-off-by: Innei * fix: remove unused hide filter * feat: settings Signed-off-by: Innei * feat: setting done Signed-off-by: Innei * action card * fix discover feed form * popular filter * inbox table * fix: picture masonry pull to refresh Signed-off-by: Innei * fix: scroll element Signed-off-by: Innei * fix: radio in mobile Signed-off-by: Innei * feat: to scientific notation Signed-off-by: Innei * fix: login Signed-off-by: Innei * ResponsiveSelect for action card * chore: remove theme color Signed-off-by: Innei * type check * type check * fix: setting import circular Signed-off-by: Innei * refactor: remove hijack pushState Signed-off-by: Innei * fix: float bar Signed-off-by: Innei * fix: scroll end trigger Signed-off-by: Innei * fix: dark theme color Signed-off-by: Innei * fix: overscroll-behavior only in destop Signed-off-by: Innei * fix: ux Signed-off-by: Innei * fix: color Signed-off-by: Innei * fix: list scroll area Signed-off-by: Innei * update Signed-off-by: Innei * merge * update * update favicon link * fix: reload prompt on mobile * test Signed-off-by: Innei * test Signed-off-by: Innei * fix: ios Signed-off-by: Innei * try patch precaching * try * try * build * try * try * feat: ctx menu on safari Signed-off-by: Innei * try * clear timeout Signed-off-by: Innei * try * fix * deny og * fix patch * pre cache exclude * feat: add all Signed-off-by: Innei * i18n Signed-off-by: Innei * feat: slide Signed-off-by: Innei * feat: ico Signed-off-by: Innei * fix: build Signed-off-by: Innei * test network first Signed-off-by: Innei * test Signed-off-by: Innei * fix: icon padding * test Signed-off-by: Innei * use react-ios-pwa-prompt * fix: icon path * update * fix: prompt Signed-off-by: Innei * feat: redesign toc Signed-off-by: Innei * ctx Signed-off-by: Innei * ready to prod Signed-off-by: Innei * feat: startup screen Signed-off-by: Innei * feat: floatbar Signed-off-by: Innei * fix: switch view Signed-off-by: Innei * fix: keep scroll map Signed-off-by: Innei * enable hoverOnlyWhenSupported * fix: count Signed-off-by: Innei * fix: hoverOnlyWhenSupported only in web Signed-off-by: Innei * fix Signed-off-by: Innei * chore: auto-fix linting and formatting issues --------- Signed-off-by: Innei Signed-off-by: Innei Co-authored-by: Innei Co-authored-by: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Co-authored-by: hyoban Co-authored-by: lawvs <18554747+lawvs@users.noreply.github.com> Co-authored-by: lawvs --- .gitignore | 2 + apps/renderer/global.d.ts | 13 + apps/renderer/index.html | 17 +- apps/renderer/package.json | 4 + .../public/apple-touch-icon-180x180.png | Bin 0 -> 539 bytes apps/renderer/public/favicon.ico | Bin 15406 -> 564 bytes apps/renderer/public/manifest.json | 16 - .../renderer/public/maskable-icon-512x512.png | Bin 0 -> 1303 bytes apps/renderer/public/pwa-192x192.png | Bin 0 -> 1048 bytes apps/renderer/public/pwa-512x512.png | Bin 0 -> 2440 bytes apps/renderer/public/pwa-64x64.png | Bin 0 -> 460 bytes apps/renderer/pwa-assets.config.ts | 54 + apps/renderer/src/App.tsx | 14 + apps/renderer/src/atoms/player.ts | 4 +- apps/renderer/src/atoms/settings/general.ts | 2 + .../src/components/common/ReloadPrompt.tsx | 83 + .../renderer/src/components/mobile/button.tsx | 13 + .../ui/context-menu/context-menu.tsx | 2 +- .../src/components/ui/markdown/HTML.tsx | 11 +- .../components/ui/markdown/components/Toc.tsx | 177 +- .../ui/markdown/components/hooks.tsx | 198 ++ apps/renderer/src/components/ui/media.tsx | 1 + .../src/components/ui/media/hooks.tsx | 7 + .../src/components/ui/media/preview-media.tsx | 6 +- .../src/components/ui/modal/stacked/hooks.tsx | 85 +- .../ui/modal/stacked/internal/use-animate.ts | 1 + .../ui/modal/stacked/modal-stack.desktop.tsx | 31 + .../ui/modal/stacked/modal-stack.electron.tsx | 1 + .../ui/modal/stacked/modal-stack.mobile.tsx | 110 + .../ui/modal/stacked/modal-stack.shared.tsx | 30 + .../ui/modal/stacked/modal-stack.tsx | 6 + .../components/ui/modal/stacked/provider.tsx | 44 +- .../components/ux/pull-to-refresh/index.tsx | 163 ++ apps/renderer/src/constants/dom.ts | 5 + .../src/hooks/biz/useEntryActions.tsx | 11 + .../src/hooks/biz/useNavigateEntry.ts | 17 +- .../src/hooks/common/useContextMenu.tsx | 26 + apps/renderer/src/initialize/history.ts | 94 + apps/renderer/src/initialize/index.ts | 4 +- .../achievement/AchievementModalContent.tsx | 7 +- .../src/modules/achievement/hooks.tsx | 5 +- .../src/modules/ai/ai-daily/daily.tsx | 4 +- .../app-layout/entry-column/desktop.tsx | 61 + .../entry-column/index.electron.tsx | 3 + .../modules/app-layout/entry-column/index.tsx | 23 + .../app-layout/entry-column/mobile.tsx | 45 + .../app-layout/entry-content/desktop.tsx | 134 + .../entry-content/index.electron.tsx | 3 + .../app-layout/entry-content/index.tsx | 12 + .../app-layout/entry-content/mobile.tsx | 16 + .../feed-column/components/FooterInfo.tsx | 35 + .../app-layout/feed-column/desktop.tsx | 360 +++ .../feed-column/float-bar.mobile.tsx | 157 ++ .../app-layout/feed-column/index.electron.tsx | 3 + .../app-layout/feed-column/index.mobile.tsx | 8 + .../modules/app-layout/feed-column/index.tsx | 22 + .../app-layout/feed-column/mobile.module.css | 9 + .../modules/app-layout/feed-column/mobile.tsx | 137 + .../app-layout/subview}/hooks.ts | 0 .../app-layout/subview/index.desktop.tsx | 99 + .../app-layout/subview/index.electron.tsx | 1 + .../app-layout/subview/index.mobile.tsx | 43 + .../src/modules/app-layout/subview/index.tsx | 6 + apps/renderer/src/modules/boost/modal.tsx | 2 +- .../src/modules/command/commands/entry.tsx | 13 +- .../src/modules/discover/DiscoverFeedForm.tsx | 4 +- apps/renderer/src/modules/discover/form.tsx | 36 +- apps/renderer/src/modules/discover/import.tsx | 4 +- .../src/modules/discover/inbox-list-form.tsx | 149 +- .../modules/discover/inbox-table.desktop.tsx | 67 + .../modules/discover/inbox-table.electron.tsx | 1 + .../modules/discover/inbox-table.mobile.tsx | 38 + .../modules/discover/inbox-table.shared.tsx | 106 + .../src/modules/discover/inbox-table.tsx | 9 + .../discover/recommendation-content.tsx | 2 +- .../src/modules/discover/recommendations.tsx | 41 +- .../src/modules/discover/rss3-form.tsx | 2 +- .../src/modules/discover/transform-form.tsx | 2 +- .../src/modules/discover/user-form.tsx | 2 +- .../modules/entry-column/Items/audio-item.tsx | 4 +- .../entry-column/Items/picture-item.tsx | 2 + .../entry-column/Items/picture-masonry.tsx | 2 +- .../entry-column/Items/social-media-item.tsx | 3 +- .../entry-column/components/DateItem.tsx | 2 +- .../components/mark-all-button.tsx | 2 +- .../src/modules/entry-column/hooks.ts | 3 +- .../src/modules/entry-column/index.tsx | 111 +- .../entry-column/layouts/EntryItemWrapper.tsx | 25 +- .../layouts/EntryListHeader.desktop.tsx | 166 ++ .../layouts/EntryListHeader.electron.tsx | 1 + .../layouts/EntryListHeader.mobile.tsx | 190 ++ .../layouts/EntryListHeader.shared.tsx | 156 ++ .../entry-column/layouts/EntryListHeader.tsx | 301 +- .../entry-column/layouts/TimelineTabs.tsx | 9 +- .../src/modules/entry-column/lists.tsx | 9 +- .../templates/list-item-template.tsx | 16 +- .../modules/entry-column/wrapper.desktop.tsx | 30 + .../modules/entry-column/wrapper.electron.tsx | 1 + .../modules/entry-column/wrapper.mobile.tsx | 36 + .../modules/entry-column/wrapper.shared.tsx | 6 + .../src/modules/entry-column/wrapper.tsx | 10 + .../components/EntryReadHistory.desktop.tsx | 100 + .../components/EntryReadHistory.electron.tsx | 1 + .../components/EntryReadHistory.mobile.tsx | 157 ++ .../components/EntryReadHistory.shared.tsx | 108 + .../components/EntryReadHistory.tsx | 207 +- .../entry-content/components/EntryTitle.tsx | 2 +- .../components/SourceContentView.tsx | 13 - .../modules/entry-content/header.desktop.tsx | 231 ++ .../modules/entry-content/header.mobile.tsx | 266 ++ .../modules/entry-content/header.shared.tsx | 38 + .../src/modules/entry-content/header.tsx | 270 +- .../modules/entry-content/index.desktop.tsx | 273 ++ .../modules/entry-content/index.mobile.tsx | 228 ++ .../modules/entry-content/index.shared.tsx | 229 ++ .../src/modules/entry-content/index.tsx | 502 +--- .../src/modules/feed-column/category.tsx | 210 +- .../src/modules/feed-column/header.tsx | 15 +- .../src/modules/feed-column/index.tsx | 28 +- .../renderer/src/modules/feed-column/item.tsx | 102 +- .../src/modules/feed-column/list.desktop.tsx | 260 ++ .../src/modules/feed-column/list.electron.tsx | 1 + .../src/modules/feed-column/list.mobile.tsx | 117 + .../src/modules/feed-column/list.shared.tsx | 317 +++ .../renderer/src/modules/feed-column/list.tsx | 536 +--- .../src/modules/feed-column/styles.ts | 1 + apps/renderer/src/modules/panel/cmdk.tsx | 2 - .../{feed-column => player}/corner-player.tsx | 4 +- .../power/my-wallet-section/withdraw.tsx | 2 +- .../src/modules/power/ranking/index.tsx | 17 +- .../power/transaction-section/index.tsx | 164 +- .../transaction-section/tx-table.desktop.tsx | 95 + .../transaction-section/tx-table.electron.tsx | 1 + .../transaction-section/tx-table.mobile.tsx | 80 + .../transaction-section/tx-table.shared.tsx | 103 + .../power/transaction-section/tx-table.tsx | 10 + apps/renderer/src/modules/profile/hooks.ts | 27 +- .../profile/user-profile-modal.desktop.tsx | 309 +++ .../profile/user-profile-modal.electron.tsx | 1 + .../profile/user-profile-modal.mobile.tsx | 125 + .../profile/user-profile-modal.shared.tsx | 187 ++ .../modules/profile/user-profile-modal.tsx | 459 +--- .../src/modules/settings/action-card.tsx | 219 +- .../modules/settings/hooks/use-setting-ctx.ts | 4 +- .../modules/settings/modal/content.mobile.tsx | 58 + .../src/modules/settings/modal/content.tsx | 23 +- .../src/modules/settings/modal/hooks.ts | 25 - .../src/modules/settings/modal/layout.tsx | 25 +- ...ooks-hack.ts => use-setting-modal-hack.ts} | 0 .../modal/use-setting-modal.electron.ts | 25 + .../settings/modal/use-setting-modal.ts | 40 + .../src/modules/settings/settings-glob.ts | 18 +- .../modules/settings/tabs/actions/index.tsx | 4 +- .../src/modules/settings/tabs/apperance.tsx | 72 +- .../src/modules/settings/tabs/general.tsx | 88 +- .../src/modules/settings/tabs/invitations.tsx | 40 +- .../modules/settings/tabs/lists/modals.tsx | 2 +- apps/renderer/src/modules/settings/title.tsx | 4 +- .../modules/shared/ViewSelectorRadioGroup.tsx | 8 +- .../modules/user/ProfileButton.desktop.tsx | 236 ++ .../modules/user/ProfileButton.electron.tsx | 1 + .../src/modules/user/ProfileButton.mobile.tsx | 141 + .../src/modules/user/ProfileButton.shared.tsx | 22 + .../src/modules/user/ProfileButton.tsx | 249 +- apps/renderer/src/modules/wallet/balance.tsx | 6 +- .../renderer/src/modules/wallet/tip-modal.tsx | 2 +- .../(layer)/(subview)/discover/index.tsx | 43 +- .../pages/(main)/(layer)/(subview)/layout.tsx | 100 +- .../(main)/(layer)/(subview)/power/index.tsx | 10 +- .../feeds/[feedId]/[entryId]/index.tsx | 135 +- .../(main)/(layer)/feeds/[feedId]/index.tsx | 11 +- .../(main)/(layer)/feeds/[feedId]/layout.tsx | 62 +- apps/renderer/src/pages/(main)/index.tsx | 18 +- apps/renderer/src/pages/(main)/layout.tsx | 431 +-- .../src/providers/context-menu-provider.tsx | 5 +- .../providers/extension-expose-provider.tsx | 2 +- .../src/providers/lazy/index.electron.ts | 2 + apps/renderer/src/providers/lazy/index.ts | 30 +- .../renderer/src/providers/root-providers.tsx | 6 +- apps/renderer/src/queries/users.ts | 12 + apps/renderer/src/store/unread/hooks.ts | 30 + apps/renderer/src/styles/base.css | 13 +- apps/renderer/src/styles/scrollbar.css | 23 +- apps/renderer/src/sw.ts | 49 + apps/renderer/tsconfig.json | 7 +- apps/server/index.html | 3 +- changelog/0.2.4.md | 7 + configs/tailwind.base.config.ts | 1 + electron.vite.config.ts | 5 + locales/app/en.json | 4 - locales/app/ja.json | 4 - locales/app/zh-CN.json | 4 - locales/app/zh-HK.json | 4 - locales/app/zh-TW.json | 4 - locales/common/en.json | 1 + locales/common/zh-CN.json | 1 + locales/settings/en.json | 3 + package.json | 13 +- packages/components/package.json | 3 +- packages/components/src/atoms/viewport.ts | 21 +- packages/components/src/hooks/useMobile.ts | 10 + packages/components/src/hooks/useViewport.ts | 9 - .../src/icons/MynauiInboxArchive.tsx | 12 + .../components/src/ui/divider/Divider.tsx | 2 +- packages/components/src/ui/loading/index.tsx | 6 +- packages/components/src/ui/masonry/hooks.ts | 3 +- packages/components/src/ui/masonry/utils.ts | 2 +- packages/components/src/ui/scroll-area/ctx.ts | 2 +- .../components/src/ui/select/responsive.tsx | 107 + packages/components/src/ui/sheet/Sheet.tsx | 161 ++ packages/components/src/ui/sheet/context.tsx | 10 + packages/components/src/ui/sheet/index.ts | 1 + packages/components/src/ui/table/index.tsx | 15 +- packages/components/src/utils/selector.tsx | 37 + packages/hooks/src/index.ts | 2 + packages/hooks/src/useControlled.ts | 25 + packages/hooks/src/useLongPress.ts | 107 + packages/shared/src/env.ts | 3 +- packages/shared/src/interface/settings.ts | 1 + packages/utils/src/dom.ts | 22 + packages/utils/src/scroller.ts | 2 +- packages/utils/src/utils.ts | 19 + patches/jsonpointer.patch | 15 + patches/workbox-precaching.patch | 32 + plugins/vite/hmr.ts | 2 +- pnpm-lock.yaml | 2428 ++++++++++------- postcss.config.cjs | 3 + tailwind.config.ts | 9 + vite.config.ts | 76 + 229 files changed, 9853 insertions(+), 5455 deletions(-) create mode 100644 apps/renderer/public/apple-touch-icon-180x180.png delete mode 100644 apps/renderer/public/manifest.json create mode 100644 apps/renderer/public/maskable-icon-512x512.png create mode 100644 apps/renderer/public/pwa-192x192.png create mode 100644 apps/renderer/public/pwa-512x512.png create mode 100644 apps/renderer/public/pwa-64x64.png create mode 100644 apps/renderer/pwa-assets.config.ts create mode 100644 apps/renderer/src/components/common/ReloadPrompt.tsx create mode 100644 apps/renderer/src/components/mobile/button.tsx create mode 100644 apps/renderer/src/components/ui/markdown/components/hooks.tsx create mode 100644 apps/renderer/src/components/ui/modal/stacked/modal-stack.desktop.tsx create mode 100644 apps/renderer/src/components/ui/modal/stacked/modal-stack.electron.tsx create mode 100644 apps/renderer/src/components/ui/modal/stacked/modal-stack.mobile.tsx create mode 100644 apps/renderer/src/components/ui/modal/stacked/modal-stack.shared.tsx create mode 100644 apps/renderer/src/components/ui/modal/stacked/modal-stack.tsx create mode 100644 apps/renderer/src/components/ux/pull-to-refresh/index.tsx create mode 100644 apps/renderer/src/constants/dom.ts create mode 100644 apps/renderer/src/hooks/common/useContextMenu.tsx create mode 100644 apps/renderer/src/initialize/history.ts create mode 100644 apps/renderer/src/modules/app-layout/entry-column/desktop.tsx create mode 100644 apps/renderer/src/modules/app-layout/entry-column/index.electron.tsx create mode 100644 apps/renderer/src/modules/app-layout/entry-column/index.tsx create mode 100644 apps/renderer/src/modules/app-layout/entry-column/mobile.tsx create mode 100644 apps/renderer/src/modules/app-layout/entry-content/desktop.tsx create mode 100644 apps/renderer/src/modules/app-layout/entry-content/index.electron.tsx create mode 100644 apps/renderer/src/modules/app-layout/entry-content/index.tsx create mode 100644 apps/renderer/src/modules/app-layout/entry-content/mobile.tsx create mode 100644 apps/renderer/src/modules/app-layout/feed-column/components/FooterInfo.tsx create mode 100644 apps/renderer/src/modules/app-layout/feed-column/desktop.tsx create mode 100644 apps/renderer/src/modules/app-layout/feed-column/float-bar.mobile.tsx create mode 100644 apps/renderer/src/modules/app-layout/feed-column/index.electron.tsx create mode 100644 apps/renderer/src/modules/app-layout/feed-column/index.mobile.tsx create mode 100644 apps/renderer/src/modules/app-layout/feed-column/index.tsx create mode 100644 apps/renderer/src/modules/app-layout/feed-column/mobile.module.css create mode 100644 apps/renderer/src/modules/app-layout/feed-column/mobile.tsx rename apps/renderer/src/{pages/(main)/(layer)/(subview) => modules/app-layout/subview}/hooks.ts (100%) create mode 100644 apps/renderer/src/modules/app-layout/subview/index.desktop.tsx create mode 100644 apps/renderer/src/modules/app-layout/subview/index.electron.tsx create mode 100644 apps/renderer/src/modules/app-layout/subview/index.mobile.tsx create mode 100644 apps/renderer/src/modules/app-layout/subview/index.tsx create mode 100644 apps/renderer/src/modules/discover/inbox-table.desktop.tsx create mode 100644 apps/renderer/src/modules/discover/inbox-table.electron.tsx create mode 100644 apps/renderer/src/modules/discover/inbox-table.mobile.tsx create mode 100644 apps/renderer/src/modules/discover/inbox-table.shared.tsx create mode 100644 apps/renderer/src/modules/discover/inbox-table.tsx create mode 100644 apps/renderer/src/modules/entry-column/layouts/EntryListHeader.desktop.tsx create mode 100644 apps/renderer/src/modules/entry-column/layouts/EntryListHeader.electron.tsx create mode 100644 apps/renderer/src/modules/entry-column/layouts/EntryListHeader.mobile.tsx create mode 100644 apps/renderer/src/modules/entry-column/layouts/EntryListHeader.shared.tsx create mode 100644 apps/renderer/src/modules/entry-column/wrapper.desktop.tsx create mode 100644 apps/renderer/src/modules/entry-column/wrapper.electron.tsx create mode 100644 apps/renderer/src/modules/entry-column/wrapper.mobile.tsx create mode 100644 apps/renderer/src/modules/entry-column/wrapper.shared.tsx create mode 100644 apps/renderer/src/modules/entry-column/wrapper.tsx create mode 100644 apps/renderer/src/modules/entry-content/components/EntryReadHistory.desktop.tsx create mode 100644 apps/renderer/src/modules/entry-content/components/EntryReadHistory.electron.tsx create mode 100644 apps/renderer/src/modules/entry-content/components/EntryReadHistory.mobile.tsx create mode 100644 apps/renderer/src/modules/entry-content/components/EntryReadHistory.shared.tsx create mode 100644 apps/renderer/src/modules/entry-content/header.desktop.tsx create mode 100644 apps/renderer/src/modules/entry-content/header.mobile.tsx create mode 100644 apps/renderer/src/modules/entry-content/header.shared.tsx create mode 100644 apps/renderer/src/modules/entry-content/index.desktop.tsx create mode 100644 apps/renderer/src/modules/entry-content/index.mobile.tsx create mode 100644 apps/renderer/src/modules/entry-content/index.shared.tsx create mode 100644 apps/renderer/src/modules/feed-column/list.desktop.tsx create mode 100644 apps/renderer/src/modules/feed-column/list.electron.tsx create mode 100644 apps/renderer/src/modules/feed-column/list.mobile.tsx create mode 100644 apps/renderer/src/modules/feed-column/list.shared.tsx rename apps/renderer/src/modules/{feed-column => player}/corner-player.tsx (98%) create mode 100644 apps/renderer/src/modules/power/transaction-section/tx-table.desktop.tsx create mode 100644 apps/renderer/src/modules/power/transaction-section/tx-table.electron.tsx create mode 100644 apps/renderer/src/modules/power/transaction-section/tx-table.mobile.tsx create mode 100644 apps/renderer/src/modules/power/transaction-section/tx-table.shared.tsx create mode 100644 apps/renderer/src/modules/power/transaction-section/tx-table.tsx create mode 100644 apps/renderer/src/modules/profile/user-profile-modal.desktop.tsx create mode 100644 apps/renderer/src/modules/profile/user-profile-modal.electron.tsx create mode 100644 apps/renderer/src/modules/profile/user-profile-modal.mobile.tsx create mode 100644 apps/renderer/src/modules/profile/user-profile-modal.shared.tsx create mode 100644 apps/renderer/src/modules/settings/modal/content.mobile.tsx rename apps/renderer/src/modules/settings/modal/{hooks-hack.ts => use-setting-modal-hack.ts} (100%) create mode 100644 apps/renderer/src/modules/settings/modal/use-setting-modal.electron.ts create mode 100644 apps/renderer/src/modules/settings/modal/use-setting-modal.ts create mode 100644 apps/renderer/src/modules/user/ProfileButton.desktop.tsx create mode 100644 apps/renderer/src/modules/user/ProfileButton.electron.tsx create mode 100644 apps/renderer/src/modules/user/ProfileButton.mobile.tsx create mode 100644 apps/renderer/src/modules/user/ProfileButton.shared.tsx create mode 100644 apps/renderer/src/queries/users.ts create mode 100644 apps/renderer/src/store/unread/hooks.ts create mode 100644 apps/renderer/src/sw.ts create mode 100644 changelog/0.2.4.md create mode 100644 packages/components/src/hooks/useMobile.ts create mode 100644 packages/components/src/icons/MynauiInboxArchive.tsx create mode 100644 packages/components/src/ui/select/responsive.tsx create mode 100644 packages/components/src/ui/sheet/Sheet.tsx create mode 100644 packages/components/src/ui/sheet/context.tsx create mode 100644 packages/components/src/ui/sheet/index.ts create mode 100644 packages/components/src/utils/selector.tsx create mode 100644 packages/hooks/src/useControlled.ts create mode 100644 packages/hooks/src/useLongPress.ts create mode 100644 patches/jsonpointer.patch create mode 100644 patches/workbox-precaching.patch diff --git a/.gitignore b/.gitignore index bb82b10174..6bdb869f67 100644 --- a/.gitignore +++ b/.gitignore @@ -20,4 +20,6 @@ vite.config.*.mjs .generated .turbo + +apps/renderer/dev-dist tsconfig.tsbuildinfo diff --git a/apps/renderer/global.d.ts b/apps/renderer/global.d.ts index c60d1f76d8..97bf385221 100644 --- a/apps/renderer/global.d.ts +++ b/apps/renderer/global.d.ts @@ -10,4 +10,17 @@ declare global { } } +declare module "virtual:pwa-register/react" { + import type { Dispatch, SetStateAction } from "react" + import type { RegisterSWOptions } from "vite-plugin-pwa/types" + + export function useRegisterSW(options?: RegisterSWOptions): { + needRefresh: [boolean, Dispatch>] + offlineReady: [boolean, Dispatch>] + updateServiceWorker: (reloadPage?: boolean) => Promise + } +} + export {} + +export { type RegisterSWOptions } from "vite-plugin-pwa/types" diff --git a/apps/renderer/index.html b/apps/renderer/index.html index 9480df5909..8e8fc1b1ce 100644 --- a/apps/renderer/index.html +++ b/apps/renderer/index.html @@ -5,12 +5,15 @@ - + + - + + + Follow @@ -58,6 +61,10 @@ const isElectron = navigator.userAgent.includes("Electron") document.documentElement.dataset.buildType = isElectron ? "electron" : "web" +
@@ -77,6 +84,9 @@ inset: 0; z-index: 1000; } + [data-viewport="mobile"] #app-skeleton { + display: none; + } [data-theme="light"] { --background: 0 0% 100%; } @@ -89,6 +99,7 @@ height: 100%; width: 100%; } + .sidebar { width: 16rem; flex-shrink: 0; diff --git a/apps/renderer/package.json b/apps/renderer/package.json index 2fb9331b67..9c947d5aa6 100644 --- a/apps/renderer/package.json +++ b/apps/renderer/package.json @@ -6,6 +6,7 @@ "scripts": { "build:web": "cd ../.. && pnpm build:web", "dev": "cd ../.. && pnpm dev:web", + "generate-pwa-assets": "pwa-assets-generator public/icon.svg", "test": "vitest --typecheck", "typecheck": "tsc --noEmit" }, @@ -82,6 +83,7 @@ "react-hotkeys-hook": "4.6.1", "react-i18next": "^15.1.0", "react-intersection-observer": "9.13.1", + "react-ios-pwa-prompt": "^2.0.6", "react-resizable-layout": "npm:@innei/react-resizable-layout@0.7.3-fork.1", "react-router-dom": "6.27.0", "react-selecto": "^1.26.3", @@ -103,6 +105,7 @@ "unified": "11.0.5", "unist-util-visit-parents": "^6.0.1", "use-context-selector": "2.0.0", + "use-pull-to-refresh": "2.4.1", "use-sync-external-store": "1.2.2", "usehooks-ts": "3.1.0", "vfile": "6.0.3", @@ -125,6 +128,7 @@ "@types/node": "^22.8.7", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", + "@vite-pwa/assets-generator": "^0.2.6", "fake-indexeddb": "6.0.0", "happy-dom": "15.8.0", "react": "^18.3.1", diff --git a/apps/renderer/public/apple-touch-icon-180x180.png b/apps/renderer/public/apple-touch-icon-180x180.png new file mode 100644 index 0000000000000000000000000000000000000000..a5da8888e4265fdcd1dc1337c33e199dbdd17947 GIT binary patch literal 539 zcmeAS@N?(olHy`uVBq!ia0vp^TR@nD8Ax&oe*=;XLIFM@uK!~g{sY1C(*GB?|1a12 z|K{Q4`R8W>WjPBxB8wRqm|uV}B`4q*1%!h&pk6mu8>9}{|S#s-^eRrlMOJ<*Xlf3jz#G4a_ zPlDE;yEF0D@v6vJnZ->jMcw}fUDql37!)0taeZ~)qhBkyY_*)jQ{^0AEW2=}v^?fugUWrDiAg;i3^VL&_r>pNmD?lV_cO$j^X<#;+FUdLPI2$v zsdn(`wMd1nvK4>#TQ_+fY_+Ri`FTtF`JIeQ6H0St>6~pdTkAXP!j$$8?Gn#z_-lVW z{8V0hWa;)R>Gyq;@Auyb7eDdQcW38ThW*RW2}oDZ`H=Oo>dl+~m-}4*uM_&i!vYWZ ZocIYhMNe*6>@NTk@^tlcS?83{1ORUk|4aY? literal 0 HcmV?d00001 diff --git a/apps/renderer/public/favicon.ico b/apps/renderer/public/favicon.ico index 89db74809d3321e3b97ff5c103e1678f37c84926..9a85269e655483ff3ed80e8d48d37307973e03e6 100644 GIT binary patch literal 564 zcmV-40?YjX0096205C8B0096903HGW02TlM0EtjeM-2)Z3IG5A4M|8uQUCw|FaQ7m zFbD)tHmarnhRpr$&gW0Y z-`-iwVt;*rf^8Al9tF!#up9*|9V>4||J%+6XpOEA7<$!Vb8BE;*^3qceb{)yx8cwe zIa3i4mGS<)4x`cNK=kuHJkNt75>h&KbKbXcpbe4z1_(KIK#@vq6y#Egk!k+~h$(%b zn?^AMNG|$dgkh zD%}|tmH-Gr!ZiSu+^M@P2mzqKOPjLG0^a~byENS;GWZ4O?((vh{2;}puQ zb*x%1*;>HC(7~4NqeY4YfskC4QXwBLMt`^~B0pZ|v0rfTHC!?A*f06`8m?J+$Phx~ zqI(rj4Y=yD$Md0w{Me&%pi+A9(LDATGF%%Vblm~Zw2j_)7C4gt0000lO(Gx&G&JL5+KeyI>I^Nk0-{YzBjpiL;7a}A`DTIfL(8w4m z)0PpCwjwePK5?um2r=c?76ckc6l)qpF@Z^J=un1nK3iyG>=%r&{QU6qs9}t~hI@_x_HPj(b-1bDw1;Dck4Fh~zxnv*u&Js70o`gunB1y}pdbn6$~?R)g}<3KJ5Zr?E;{elNz z(aQiY;rF2MFLCx0*>_TJoUFvTh9LXD;L$(-g}(2Q&ku0xqj6x60~Z+0YobDr{tG<% zH~*_oKc(oq%EL!>1jT>RME_Sja(?y%Kx;=B!|7Upt4mX)U-5J4CyX@rC|CzDU>?72 zOZ&+;C_9?+mjOKW7Qn{$^=Z!Y=vVBM(R}*PCjQsaPxf7GjBIbcrKFP`*_0>|BTgd+W%$a@#p480K8rN2xyHr;VFIndi@V23s&EsJ5~X#eiL8~+GR!+ zRRFYJQ`;^g#_td5ithjPEGXU85p{LO!lcXPzzI^Nr(aY8Zze6@0<$0ocj@V-JW27p z(ybrYZiuWN`y{wU%Swfx_lQi9oGDsH54DCt9&cTy4{JJsQ2IkzoeWX;lZ>lu? zSgAPU^a}C*Gpof}XZ|2QP*V{iHLb?le9F_3T1N3qEF)PcQ<`zSOq#rZT6V!pqw?;1 zdQh^s8LN_V#%>-dPCve$Ia-c0x9?jfcefyUne1=JxFdDsN0}(w?Q6ijt`p*n6C1O~ zZMjW*ZW5P0_Nr`Y%Ev{_(a5!TgWqNSd1*5Fj+W1)DWBXQ>Z5UiXM*jwpH61Zwo`~J zm=JHZOS zV zjQ8+tc=$piT%7rGuw8U`7a)G!J%%2;ge=)xmMH!azXLc|-wVAraTNC{uV2E6)3B`( zzsvh@I`NbL$=A_g_aT6x3%*DE*j9=3AL6-fWw_6)Hq>1Xp9S&n!yDPj>3;?Ln36bt zvVnX}cR9+o16-GMzftW;J$@1Q;0NO#INg&lqnr{ILozWh{!UB(Q(u^kcS{bO12|KM zbjh5m&jUQU3qV496YZCwKEd<;D)^3@^dA*_lUMEG{PEVk$$ZOr{cnzc;C$ozI#!mP zZvYtiSfXu^8vNAH|8c+yseXAap4ZVmuf+OLYVgxMVEGP!PM*>W-|-#$0dB`L%fzq~UuyiX>ZdXBhH`-X<<|70rQvZ_dE-esKNi0-fW@Jv3LK>cs~ zKCAmaT=mLjmaHzd)B#zk>u(uLi`EupQH~zp6R;VXBh(TDadkq$0CN>G~zIM zN6p$JcqvP6A7_Psn7#2D#NPk*4;83pZs5ABD=OWO^*8^EGE|`N&Gvr!n#TUs0Okp% z|IQ1JUz~nAiT4%aJ5Q2*tj!FT!{+-l192OCBi4I^~$Z6ys$P6wV8mYk6ek56db=ISw3S?qv@F a{v%EQE?PqHGfWu|WIT}ZK*j?x9{4}tV#~Jx diff --git a/apps/renderer/public/manifest.json b/apps/renderer/public/manifest.json deleted file mode 100644 index 949134a101..0000000000 --- a/apps/renderer/public/manifest.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "theme_color": "#ff5c00", - "name": "Follow", - "icons": [ - { - "src": "/icon-192x192.png", - "sizes": "192x192", - "type": "image/png" - }, - { - "src": "/icon-512x512.png", - "sizes": "512x512", - "type": "image/png" - } - ] -} diff --git a/apps/renderer/public/maskable-icon-512x512.png b/apps/renderer/public/maskable-icon-512x512.png new file mode 100644 index 0000000000000000000000000000000000000000..0d23bd1fa1f6c3f3cf9baef89513a44ddfb7f3e2 GIT binary patch literal 1303 zcmbu8ZBSHI7{{M;?`3!Ix(myT+X~5UDum6T6ijAH+yoL(U=^|&O}3^<3sYLuT|v1A zO9u&cCoMI?y-tcWC2Ug|j^f@7Q^a(&l?dczSJU;46<<(cxwp&og-?CynK|=+=Df`F zoZtDMEykR=YGtGn0MwZoX$t`eDJ4KbO7d&%l+%)g80Y7wi%US1;>sUl?P{@VUdcCI zg0w^zWxQ1kK>9`pYAjt?2td_mroEbP?Yz^y*j&wMm<#*n4i^-Eoqumn<%-u;-n~B( zyN=$FSG*H1o&R5P+&5so{pkL{8{@j$_Vp&7npOSR#lB5Gy~Q9uel_-jxPv5yTJP#D ziD#IZfs}8F^Mk^b`u%+3wWfy99(Ji<*(S<&Fh2|zlHLZjyUVH4uoF0;4F+b=?JLC& znce)8!nzugWICuqkvP^i9ll~Lq=SxwET)Vw68D1mNw#LWDKvq&iIn*HgjXbe63of> zTcDIFqY|hL@Z_Gywftk%a^+44<7%KCKhVaZDVVZB7(?iY7)&{2SwQzKaxDHunz2Uu zKH!pFVJ2k~8A(*ZW|)kh;T4?R2PHVz6yhM`q*7rfDxZje7@p#&IOq>*l5_NK=+?AL zoqDH}D)_eBbh?fmN~KTNgBAtx%}_vGB0DGYI$xA+ zj!q2Nqcx(gDcO)rWZ=LuF1!4pACG%C5boNj%Y{OwgdPPP5d&dB<9K+4Y+%*$m9y15 zAzGf-L&l?lsYXRt9v5opA*-NAHR&?B8e1j4FX?d+oj@Eu#RM6a5)pVXf`gW7Jf7>O zlJMGa7kIN7S7$a4u5&;#v&a@_4f6J$gMn3k;+pfp2n>tt?|6aGi0oTm9Je$nFikX% z*9iXkHAC`C&`yU8_@0YQJ54%nAB;60lm35$T2%O)&@d#S=eU%Chs!O_ughkY+DgdAOlh!t6ehWR22ykXLL3Wp#M!K_ zWQVrda_9csh1b>hK literal 0 HcmV?d00001 diff --git a/apps/renderer/public/pwa-192x192.png b/apps/renderer/public/pwa-192x192.png new file mode 100644 index 0000000000000000000000000000000000000000..e0352e0aeb893dbb4c84b43ade3431c10d89768b GIT binary patch literal 1048 zcmV+z1n2vSP)VOkmO3(x`%_;|O|9b>8+y4&1 z-1fgFaA|gVy#4cGFZvZA%ny(v@B(<|0zXVah0N4#85C`nA{AB!Nu0eIXr{B#xL0YvOg z=n=r<0fcJ-;O(Lh`=|koP6Y4((Etpf?^&V`pb?c`5w$!9Ak)ev$^!uE$|+I@pp{o8 z>jH4we}sSofZzU&;1s~Q^GsVMM0x;41Ypft05T4E+XejqBm}T(0Z7@uHXVR022gbX zniv2XfB`_P0r*8Mm5AR>#r*(P$o`_0$nww#;&_;&kq+Ph$YtDv1Pg#j5Y?mzAdm|v zsRFP^QBBGKoMoXyH3>F=8v>ITsHHRl;KtBe?bOl2A}_%B;;sQ0fB_f)AV5B#*#L4q z`UFvGo(JIM^G|7$05p{=Fgbv#bcrVgP<1Yl!~mM&MXCru(K@pW0aU$PNEradvWW;l zQ+}O*9e^}%vLyhD-X-M30C^z5iVjc=VDK7%--Y1w0DLCKe-?s&8pS^kz@Nzaw$fr% z39Ad;D{}?8GIzW*nyor*00v+H2JmSB+xru(WmJGhVhLCSpq2Aqhs^*`#SURQ0Mzv& z*^qB3U!Qjxjt81(IZ?p_Md2(a2~fqWDZ&6nG@Yd1k~Xv>jaQsi>jYKC>g6s z*y9b9MEpDgFgMEkUKo-p3ki4n+g@)k>(;kPuDBSk)hrSp0KQ==e9Kz+ror%So8cR0 z$AOsb!1(tBLaXc~m*my?JiTVR633Po~ S&ezZY0000FL8 literal 0 HcmV?d00001 diff --git a/apps/renderer/public/pwa-512x512.png b/apps/renderer/public/pwa-512x512.png new file mode 100644 index 0000000000000000000000000000000000000000..737497b8590f12d34f95dbb2aa807caca9116698 GIT binary patch literal 2440 zcmZ`)3s6&68vf6{Nyrm|Jmq16j|dJbZV?^jaaEoIAq7N~w8nA8Rg6Xrq86JQr3-aM zDeEfLRnoC46$QE@k^;g-Q+4qH3KfeY5i3Q75)kAeaQ8-coNZ_JpL@UW|G#t2%)RIT z&-uSp$Rk92H$DJ}q9Q}%0WikJfQvJ9?dj(5a|Q*DLe#BdXrOJ7@r{V2h<=+dd4 zxxB!aZ!$)jqNtuiId-1b=aU0#TEa$Ga(?r95fV%Gw+CSLx+;kyec9>HbSo=IcLMsh zI!Gtfn5j0F9IoIs(&aoc9e_GQTF+gblXe0cbm5~v8#o5*Xs#4@tw2J;y66#b3~Jn! z+-0wT)-VF)xS*Baf5LJkciNjFZVr#&n_+~o&9gfK--4}zpT`g*{ts<{c#%pklIf3PUy-0}oA}H@vl@0Dq^H$WtB_HBo=2vR(6b<`m$ngWmc!*k^vPmsGAdZW zI)WYsO$_yL8f>V_HnD9>K%X1qGfc6vkP$H(0Azp_ zKUgK>QBhI=^D6S3o*9-=?n}TTm)dn*R5?=_XcjqleK5NtADAvf4euX*1eb#M6YG?XE-en|!jo1Sqz9#2`LZ%YI5neJ5Yq8G0fb1#G zq-O)(3B*F)?fD>{t(;&1oM&yZW$%o;cjXshjLma9A`Uhw6_t zb38katJz>p-Md_IfyLY*e+)nT9C&z=gZ-UD)3kXN8BGv4c812QYunjo@AmP{_0&C7 zP-FJRHO7W3GI)tg8F(Gy3>}5zabUU;90Lc31H0Jd@NlUWDi{a`XdYSu(%?60jDf8T z&>2WtBj!Wj52XU=%hf=R-RGwur!TX1_IU`lI|jm>JlKh;qTsKgQE=T91)Q)&xZkWK-ae<{U_V$J> zh= zq6fKgda@h2(Ob!YugZ<2gHktP(iZwmWgQoiu3bU}XYq3=`TG6}R3^C~qAqzcCF`VD z!`T^LYS_$E<7M5vO}L+$zX?|*Xx+*6E0yld84G|1Y>eb_@iJ!fWl|x<0x%Nq6Y&9C z8NqsVf`MYgDYh^UuL31Bel9V0hm?{U6TJhJ;TnBr>h7^BJ3ND>x{x6*NCVXdm~hwM zZKLPGsetic>AS>>PKjiXW7%xjD$ZP1cY#fxhCn;Fw*3VBf{c|h&)0#~F~+=@BZhQ1 zr97*3DH7!@KyVW`h$0str4*D!S_gwwQ>q^0+x6Q76svhQIEX6%a#ENr+ky(nO11*; zaHgV&$Cw}bFrySU5!F}`8sr=7a#q58>8Gffxwe?8P6VG3BG9zA9;E09Gy{(mZB8M4 z3~l&w#PpWDIEW_#Q6w5bva4m?H!bWA+sI?dv!UeGx=CYUJmHU?vHA9|uivi*Br0 zkbF1@Q`AY0;(bnzzwR2bWtrxN&?2 zdYVmk+gZ`F%upjGCSf| zxHa%cFsv7dGCpvh@!RL`GE58#YgpJBszfg6Z8!L?dUTD@Ylf9KL+|)~7CYeU=*hTa z3PU>U0?n>^hK68|3_s- zya`8t)QU4KZx9#EQ?~P{FA%?WQ-x)NO>Vjwv))rhAx0guKn9gep8b3mmRu0~dr(Du zBTvAVz#{#|ZGoL< pziza?zwL2Z{OM17wp-sWVRd&@+bh@Z#|{ih22WQ%mvv4FO#r1n!NULm literal 0 HcmV?d00001 diff --git a/apps/renderer/pwa-assets.config.ts b/apps/renderer/pwa-assets.config.ts new file mode 100644 index 0000000000..590eb84734 --- /dev/null +++ b/apps/renderer/pwa-assets.config.ts @@ -0,0 +1,54 @@ +import type { Preset } from "@vite-pwa/assets-generator/config" +import { defineConfig } from "@vite-pwa/assets-generator/config" + +const minimal2023Preset: Preset = { + transparent: { + sizes: [64, 192, 512], + favicons: [[48, "favicon.ico"]], + padding: 0.05, + // rgba(255, 92, 0, 1) + resizeOptions: { + fit: "contain", + background: { + r: 255, + g: 92, + b: 0, + alpha: 1, + }, + }, + }, + maskable: { + sizes: [512], + padding: 0, + resizeOptions: { + fit: "contain", + background: { + r: 255, + g: 92, + b: 0, + alpha: 1, + }, + }, + }, + apple: { + sizes: [180], + padding: 0, + resizeOptions: { + fit: "contain", + background: { + r: 255, + g: 92, + b: 0, + alpha: 1, + }, + }, + }, +} + +export default defineConfig({ + headLinkOptions: { + preset: "2023", + }, + preset: minimal2023Preset, + images: ["public/logo.svg"], +}) diff --git a/apps/renderer/src/App.tsx b/apps/renderer/src/App.tsx index f0943c91f1..abfc9e6d57 100644 --- a/apps/renderer/src/App.tsx +++ b/apps/renderer/src/App.tsx @@ -1,3 +1,4 @@ +import { isMobile } from "@follow/components/hooks/useMobile.js" import { IN_ELECTRON } from "@follow/shared/constants" import { cn, getOS } from "@follow/utils/utils" import { useEffect } from "react" @@ -12,6 +13,7 @@ import { applyAfterReadyCallbacks } from "./initialize/queue" import { removeAppSkeleton } from "./lib/app" import { appLog } from "./lib/log" import { Titlebar } from "./modules/app/Titlebar" +import { useRegisterFollowCommands } from "./modules/command/use-register-follow-commands" import { RootProviders } from "./providers/root-providers" import { handlers } from "./tipc" @@ -53,6 +55,7 @@ function App() { const AppLayer = () => { const appIsReady = useAppIsReady() + useRegisterFollowCommands() useEffect(() => { removeAppSkeleton() @@ -63,6 +66,17 @@ const AppLayer = () => { appLog("App is ready", `${doneTime}ms`) applyAfterReadyCallbacks() + + if (isMobile()) { + const handler = (e: MouseEvent) => { + e.preventDefault() + } + document.addEventListener("contextmenu", handler) + + return () => { + document.removeEventListener("contextmenu", handler) + } + } }, [appIsReady]) return appIsReady ? : diff --git a/apps/renderer/src/atoms/player.ts b/apps/renderer/src/atoms/player.ts index fd6bf6f7e0..d75c57f1a7 100644 --- a/apps/renderer/src/atoms/player.ts +++ b/apps/renderer/src/atoms/player.ts @@ -29,13 +29,15 @@ const playerInitialValue: PlayerAtomValue = { } const jsonStorage = createJSONStorage() +let hydrationDone = false const patchedLocalStorage: SyncStorage = { setItem: jsonStorage.setItem, getItem: (key, initialValue) => { const value = jsonStorage.getItem(key, initialValue) - if (value) { + if (value && !hydrationDone) { // patch status to `paused` when hydration value.status = "paused" + hydrationDone = true } return value }, diff --git a/apps/renderer/src/atoms/settings/general.ts b/apps/renderer/src/atoms/settings/general.ts index 96322c7823..c6021f948e 100644 --- a/apps/renderer/src/atoms/settings/general.ts +++ b/apps/renderer/src/atoms/settings/general.ts @@ -7,6 +7,8 @@ const createDefaultSettings = (): GeneralSettings => ({ // App appLaunchOnStartup: false, language: "en", + // mobile app + startupScreen: "timeline", // Data control dataPersist: true, sendAnonymousData: true, diff --git a/apps/renderer/src/components/common/ReloadPrompt.tsx b/apps/renderer/src/components/common/ReloadPrompt.tsx new file mode 100644 index 0000000000..dc6ff7030f --- /dev/null +++ b/apps/renderer/src/components/common/ReloadPrompt.tsx @@ -0,0 +1,83 @@ +import { useEffect } from "react" +import { toast } from "sonner" +import { useRegisterSW } from "virtual:pwa-register/react" + +// check for updates every hour +const period = 60 * 60 * 1000 + +export function ReloadPrompt() { + const { + // offlineReady: [offlineReady, setOfflineReady], + needRefresh: [needRefresh], + updateServiceWorker, + } = useRegisterSW({ + onRegisteredSW(swUrl, r) { + if (period <= 0) return + if (r?.active?.state === "activated") { + registerPeriodicSync(period, swUrl, r) + } else if (r?.installing) { + r.installing.addEventListener("statechange", (e) => { + const sw = e.target as ServiceWorker + if (sw.state === "activated") registerPeriodicSync(period, swUrl, r) + }) + } + }, + }) + + // const close = useCallback(() => { + // setOfflineReady(false) + // setNeedRefresh(false) + // }, [setNeedRefresh, setOfflineReady]) + + // useEffect(() => { + // if (offlineReady) { + // toast.info("App is ready to work offline", { + // action: { + // label: "Close", + // onClick: close, + // }, + // duration: Infinity, + // }) + // } + // }, [offlineReady, close]) + + useEffect(() => { + const isPwa = window.matchMedia("(display-mode: standalone)").matches + if (!isPwa) return + + if (needRefresh) { + toast.info("New version available", { + action: { + label: "Refresh", + onClick: () => { + updateServiceWorker(true) + }, + }, + duration: Infinity, + }) + } + }, [needRefresh, updateServiceWorker]) + + return null +} + +/** + * This function will register a periodic sync check every hour, you can modify the interval as needed. + */ +function registerPeriodicSync(period: number, swUrl: string, r: ServiceWorkerRegistration) { + if (period <= 0) return + + setInterval(async () => { + if ("onLine" in navigator && !navigator.onLine) return + + const resp = await fetch(swUrl, { + cache: "no-store", + headers: { + cache: "no-store", + "cache-control": "no-cache", + }, + }) + + if (resp?.status === 200) await r.update() + }, period) +} diff --git a/apps/renderer/src/components/mobile/button.tsx b/apps/renderer/src/components/mobile/button.tsx new file mode 100644 index 0000000000..ff2ebd7754 --- /dev/null +++ b/apps/renderer/src/components/mobile/button.tsx @@ -0,0 +1,13 @@ +import { MotionButtonBase } from "@follow/components/ui/button/index.js" +import { cn } from "@follow/utils/utils" + +export const HeaderTopReturnBackButton: Component<{ to?: string }> = ({ className, to }) => ( + window.history.returnBack(to)} + className={cn("center size-8", className)} + > + + + Back + +) diff --git a/apps/renderer/src/components/ui/context-menu/context-menu.tsx b/apps/renderer/src/components/ui/context-menu/context-menu.tsx index cbea7e8bd2..88951f355f 100644 --- a/apps/renderer/src/components/ui/context-menu/context-menu.tsx +++ b/apps/renderer/src/components/ui/context-menu/context-menu.tsx @@ -63,7 +63,7 @@ const ContextMenuContent = React.forwardRef< ref={ref} className={cn( "z-[60] min-w-32 overflow-hidden rounded-md border border-border bg-theme-modal-background-opaque p-1 text-theme-foreground/90 shadow-lg dark:shadow-zinc-800/60", - "text-xs", + "text-xs motion-duration-150 motion-scale-in-75 lg:animate-none", className, )} {...props} diff --git a/apps/renderer/src/components/ui/markdown/HTML.tsx b/apps/renderer/src/components/ui/markdown/HTML.tsx index cd96dea961..c6f45f6da9 100644 --- a/apps/renderer/src/components/ui/markdown/HTML.tsx +++ b/apps/renderer/src/components/ui/markdown/HTML.tsx @@ -2,6 +2,7 @@ import { MemoedDangerousHTMLStyle } from "@follow/components/common/MemoedDanger import katexStyle from "katex/dist/katex.min.css?raw" import { createElement, Fragment, memo, useEffect, useMemo, useRef, useState } from "react" +import { ENTRY_CONTENT_RENDER_CONTAINER_ID } from "~/constants/dom" import { parseHtml } from "~/lib/parse-html" import { useWrappedElementSize } from "~/providers/wrapped-element-provider" @@ -79,7 +80,15 @@ const HTMLImpl = (props: HTMLProp {katexStyle} - {createElement(as, { ...rest, ref: setRefElement }, markdownElement)} + {createElement( + as, + { + ...rest, + id: ENTRY_CONTENT_RENDER_CONTAINER_ID, + ref: setRefElement, + }, + markdownElement, + )} {!!accessory && {accessory}} diff --git a/apps/renderer/src/components/ui/markdown/components/Toc.tsx b/apps/renderer/src/components/ui/markdown/components/Toc.tsx index a1d9321bfd..f1620a1ab2 100644 --- a/apps/renderer/src/components/ui/markdown/components/Toc.tsx +++ b/apps/renderer/src/components/ui/markdown/components/Toc.tsx @@ -1,38 +1,20 @@ -import { getViewport, useViewport } from "@follow/components/hooks/useViewport.js" -import { useScrollViewElement } from "@follow/components/ui/scroll-area/hooks.js" -import { getElementTop } from "@follow/utils/dom" -import { springScrollToElement } from "@follow/utils/scroller" +import { useViewport } from "@follow/components/hooks/useViewport.js" import { cn } from "@follow/utils/utils" import * as HoverCard from "@radix-ui/react-hover-card" -import { throttle } from "es-toolkit/compat" import { AnimatePresence, m } from "framer-motion" -import { - memo, - startTransition, - useContext, - useEffect, - useLayoutEffect, - useMemo, - useRef, - useState, -} from "react" -import { useEventCallback } from "usehooks-ts" +import { memo, useContext, useEffect, useRef, useState } from "react" import { useRealInWideMode } from "~/atoms/settings/ui" import { - useGetWrappedElementPosition, useWrappedElementPosition, useWrappedElementSize, } from "~/providers/wrapped-element-provider" import { MarkdownRenderContainerRefContext } from "../context" +import { useScrollTracking, useTocItems } from "./hooks" import type { TocItemProps } from "./TocItem" import { TocItem } from "./TocItem" -type DebouncedFuncLeading any> = T & { - cancel: () => void - flush: () => void -} export interface ITocItem { depth: number title: string @@ -42,7 +24,7 @@ export interface ITocItem { $heading: HTMLHeadingElement } -interface TocProps { +export interface TocProps { onItemClick?: (index: number, $el: HTMLElement | null, anchorId: string) => void } @@ -234,157 +216,6 @@ const MemoedItem = memo((props) => { }) MemoedItem.displayName = "MemoedItem" -// Hooks -const useTocItems = (markdownElement: HTMLElement | null) => { - const $headings = useMemo( - () => - (markdownElement?.querySelectorAll("h1, h2, h3, h4, h5, h6") || []) as HTMLHeadingElement[], - [markdownElement], - ) - - const toc: ITocItem[] = useMemo( - () => - Array.from($headings).map((el, idx) => { - const depth = +el.tagName.slice(1) - const elClone = el.cloneNode(true) as HTMLElement - const title = elClone.textContent || "" - const index = idx - - return { - depth, - index: Number.isNaN(index) ? -1 : index, - title, - anchorId: el.dataset.rid || "", - $heading: el, - } - }), - [$headings], - ) - - const rootDepth = useMemo( - () => - toc?.length - ? (toc.reduce( - (d: number, cur) => Math.min(d, cur.depth), - toc[0]?.depth || 0, - ) as any as number) - : 0, - [toc], - ) - - return { toc, rootDepth } -} - -const useScrollTracking = (toc: ITocItem[], options: Pick) => { - const scrollContainerElement = useScrollViewElement() - const [currentScrollRange, setCurrentScrollRange] = useState([-1, 0] as [number, number]) - const { h } = useWrappedElementSize() - const getWrappedElPos = useGetWrappedElementPosition() - - const headingRangeParser = () => { - // calculate the range of data-container-top between each two headings - const titleBetweenPositionTopRangeMap = [] as [number, number][] - for (let i = 0; i < toc.length - 1; i++) { - const { $heading } = toc[i] - const $nextHeading = toc[i + 1].$heading - - const headingTop = - Number.parseInt($heading.dataset["containerTop"] || "0") || getElementTop($heading) - if (!$heading.dataset) { - // @ts-expect-error - $heading.dataset["containerTop"] = headingTop.toString() - } - - const nextTop = getElementTop($nextHeading) - if (!$nextHeading.dataset) { - // @ts-expect-error - $nextHeading.dataset["containerTop"] = nextTop.toString() - } - - titleBetweenPositionTopRangeMap.push([headingTop, nextTop]) - } - return titleBetweenPositionTopRangeMap - } - - const [titleBetweenPositionTopRangeMap, setTitleBetweenPositionTopRangeMap] = - useState(headingRangeParser) - - useLayoutEffect(() => { - startTransition(() => { - setTitleBetweenPositionTopRangeMap(headingRangeParser) - }) - }, [toc, h]) - - const throttleCallerRef = useRef void>>() - - useEffect(() => { - if (!scrollContainerElement) return - - const handler = throttle(() => { - const { y } = getWrappedElPos() - const top = scrollContainerElement.scrollTop + y - const winHeight = getViewport().h - const deltaHeight = top >= winHeight ? winHeight : (top / winHeight) * winHeight - - const actualTop = Math.floor(Math.max(0, top - y + deltaHeight)) || 0 - - // current top is in which range? - const currentRangeIndex = titleBetweenPositionTopRangeMap.findIndex( - ([start, end]) => actualTop >= start && actualTop <= end, - ) - const currentRange = titleBetweenPositionTopRangeMap[currentRangeIndex] - - if (currentRange) { - const [start, end] = currentRange - - // current top is this range, the precent is ? - const precent = (actualTop - start) / (end - start) - - // position , precent - setCurrentScrollRange([currentRangeIndex, precent]) - } else { - const last = titleBetweenPositionTopRangeMap.at(-1) || [0, 0] - - if (top + winHeight > last[1]) { - setCurrentScrollRange([ - titleBetweenPositionTopRangeMap.length, - 1 - (last[1] - top) / winHeight, - ]) - } else { - setCurrentScrollRange([-1, 1]) - } - } - }, 100) - - throttleCallerRef.current = handler - scrollContainerElement.addEventListener("scroll", handler) - - return () => { - scrollContainerElement.removeEventListener("scroll", handler) - handler.cancel() - } - }, [getWrappedElPos, scrollContainerElement, titleBetweenPositionTopRangeMap]) - - const handleScrollTo = useEventCallback( - (i: number, $el: HTMLElement | null, _anchorId: string) => { - options.onItemClick?.(i, $el, _anchorId) - if ($el) { - const handle = () => { - springScrollToElement($el, -100, scrollContainerElement!).then(() => { - throttleCallerRef.current?.cancel() - setTimeout(() => { - setCurrentScrollRange([i, 1]) - }, 36) - }) - } - handle() - } - }, - ) - - return { currentScrollRange, handleScrollTo } -} - // Types interface TocContainerProps { className?: string diff --git a/apps/renderer/src/components/ui/markdown/components/hooks.tsx b/apps/renderer/src/components/ui/markdown/components/hooks.tsx new file mode 100644 index 0000000000..62a51ab885 --- /dev/null +++ b/apps/renderer/src/components/ui/markdown/components/hooks.tsx @@ -0,0 +1,198 @@ +import { getViewport } from "@follow/components/hooks/useViewport.js" +import { useScrollViewElement } from "@follow/components/ui/scroll-area/hooks.js" +import { getElementTop } from "@follow/utils/dom" +import { springScrollToElement } from "@follow/utils/scroller" +import { throttle } from "es-toolkit/compat" +import { + startTransition, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react" +import { useEventCallback } from "usehooks-ts" + +import { + useGetWrappedElementPosition, + useWrappedElementSize, +} from "~/providers/wrapped-element-provider" + +import type { ITocItem, TocProps } from "./Toc" + +// Hooks +export const useTocItems = (markdownElement: HTMLElement | null) => { + const $headings = useMemo( + () => + (markdownElement?.querySelectorAll("h1, h2, h3, h4, h5, h6") || []) as HTMLHeadingElement[], + [markdownElement], + ) + + const toc: ITocItem[] = useMemo( + () => + Array.from($headings).map((el, idx) => { + const depth = +el.tagName.slice(1) + const elClone = el.cloneNode(true) as HTMLElement + const title = elClone.textContent || "" + const index = idx + + return { + depth, + index: Number.isNaN(index) ? -1 : index, + title, + anchorId: el.dataset.rid || "", + $heading: el, + } + }), + [$headings], + ) + + const rootDepth = useMemo( + () => + toc?.length + ? (toc.reduce( + (d: number, cur) => Math.min(d, cur.depth), + toc[0]?.depth || 0, + ) as any as number) + : 0, + [toc], + ) + + return { toc, rootDepth } +} + +type DebouncedFuncLeading any> = T & { + cancel: () => void + flush: () => void +} + +export const useScrollTracking = ( + toc: ITocItem[], + options: Pick & { + useWindowScroll?: boolean + }, +) => { + const _scrollContainerElement = useScrollViewElement() + const scrollContainerElement = options.useWindowScroll ? document : _scrollContainerElement + const [currentScrollRange, setCurrentScrollRange] = useState([-1, 0] as [number, number]) + const { h } = useWrappedElementSize() + + const getWrappedElPos = useGetWrappedElementPosition() + + const headingRangeParser = useCallback(() => { + // calculate the range of data-container-top between each two headings + const titleBetweenPositionTopRangeMap = [] as [number, number][] + for (let i = 0; i < toc.length - 1; i++) { + const { $heading } = toc[i] + const $nextHeading = toc[i + 1].$heading + + const headingTop = + Number.parseInt($heading.dataset["containerTop"] || "0") || getElementTop($heading) + if (!$heading.dataset) { + // @ts-expect-error + $heading.dataset["containerTop"] = headingTop.toString() + } + + const nextTop = getElementTop($nextHeading) + if (!$nextHeading.dataset) { + // @ts-expect-error + $nextHeading.dataset["containerTop"] = nextTop.toString() + } + + titleBetweenPositionTopRangeMap.push([headingTop, nextTop]) + } + return titleBetweenPositionTopRangeMap + }, [toc]) + + const headingRangeParserEvent = useEventCallback(headingRangeParser) + + const [titleBetweenPositionTopRangeMap, setTitleBetweenPositionTopRangeMap] = + useState(headingRangeParser) + + useLayoutEffect(() => { + startTransition(() => { + setTitleBetweenPositionTopRangeMap(headingRangeParserEvent) + }) + }, [toc, h, headingRangeParserEvent]) + + const throttleCallerRef = useRef void>>() + + useEffect(() => { + if (!scrollContainerElement) return + + const handler = throttle(() => { + const { y } = getWrappedElPos() + + const top = + scrollContainerElement === document + ? document.documentElement.scrollTop + y + : (scrollContainerElement as HTMLElement).scrollTop + y + + const winHeight = getViewport().h + const deltaHeight = top >= winHeight ? winHeight : (top / winHeight) * winHeight + + const actualTop = Math.floor(Math.max(0, top - y + deltaHeight)) || 0 + + // current top is in which range? + const currentRangeIndex = titleBetweenPositionTopRangeMap.findIndex( + ([start, end]) => actualTop >= start && actualTop <= end, + ) + const currentRange = titleBetweenPositionTopRangeMap[currentRangeIndex] + + if (currentRange) { + const [start, end] = currentRange + + // current top is this range, the precent is ? + const precent = (actualTop - start) / (end - start) + + // position , precent + setCurrentScrollRange([currentRangeIndex, precent]) + } else { + const last = titleBetweenPositionTopRangeMap.at(-1) || [0, 0] + + if (top + winHeight > last[1]) { + setCurrentScrollRange([ + titleBetweenPositionTopRangeMap.length, + 1 - (last[1] - top) / winHeight, + ]) + } else { + setCurrentScrollRange([-1, 1]) + } + } + }, 100) + + throttleCallerRef.current = handler + scrollContainerElement.addEventListener("scroll", handler) + + return () => { + scrollContainerElement.removeEventListener("scroll", handler) + handler.cancel() + } + }, [getWrappedElPos, scrollContainerElement, titleBetweenPositionTopRangeMap]) + + const handleScrollTo = useEventCallback( + (i: number, $el: HTMLElement | null, _anchorId: string) => { + options.onItemClick?.(i, $el, _anchorId) + if ($el) { + const handle = () => { + springScrollToElement( + $el, + -100, + scrollContainerElement === document + ? undefined + : (scrollContainerElement as HTMLElement), + ).then(() => { + throttleCallerRef.current?.cancel() + setTimeout(() => { + setCurrentScrollRange([i, 1]) + }, 36) + }) + } + handle() + } + }, + ) + + return { currentScrollRange, handleScrollTo } +} diff --git a/apps/renderer/src/components/ui/media.tsx b/apps/renderer/src/components/ui/media.tsx index 2258025b9e..10f747b260 100644 --- a/apps/renderer/src/components/ui/media.tsx +++ b/apps/renderer/src/components/ui/media.tsx @@ -46,6 +46,7 @@ export type MediaProps = BaseProps & previewImageUrl?: string }) ) + const MediaImpl: FC = ({ className, proxy, diff --git a/apps/renderer/src/components/ui/media/hooks.tsx b/apps/renderer/src/components/ui/media/hooks.tsx index 5692a25c54..6a7471e8aa 100644 --- a/apps/renderer/src/components/ui/media/hooks.tsx +++ b/apps/renderer/src/components/ui/media/hooks.tsx @@ -1,5 +1,8 @@ +import { isMobile } from "@follow/components/hooks/useMobile.js" import { useCallback } from "react" +import { replaceImgUrlIfNeed } from "~/lib/img-proxy" + import { PlainModal } from "../modal/stacked/custom-modal" import { useModalStack } from "../modal/stacked/hooks" import type { PreviewMediaProps } from "./preview-media" @@ -9,6 +12,10 @@ export const usePreviewMedia = (children?: React.ReactNode) => { const { present } = useModalStack() return useCallback( (media: PreviewMediaProps[], initialIndex = 0) => { + if (isMobile()) { + window.open(replaceImgUrlIfNeed(media[initialIndex].url)) + return + } present({ content: () => (
diff --git a/apps/renderer/src/components/ui/media/preview-media.tsx b/apps/renderer/src/components/ui/media/preview-media.tsx index 7d596cdc39..918966b410 100644 --- a/apps/renderer/src/components/ui/media/preview-media.tsx +++ b/apps/renderer/src/components/ui/media/preview-media.tsx @@ -33,7 +33,7 @@ const Wrapper: Component<{ const { t } = useTranslation(["shortcuts", "common"]) return ( -
+
- ) : ( + ) : isLoading ? ( - )} + ) : null}
diff --git a/apps/renderer/src/components/ui/modal/stacked/hooks.tsx b/apps/renderer/src/components/ui/modal/stacked/hooks.tsx index 4ce40744e1..12d898d8cb 100644 --- a/apps/renderer/src/components/ui/modal/stacked/hooks.tsx +++ b/apps/renderer/src/components/ui/modal/stacked/hooks.tsx @@ -1,8 +1,9 @@ import { Button } from "@follow/components/ui/button/index.js" +import { nextFrame } from "@follow/utils/dom" import type { DragControls } from "framer-motion" import { atom, useAtomValue } from "jotai" import type { ResizeCallback, ResizeStartCallback } from "re-resizable" -import { useCallback, useContext, useId, useRef, useState } from "react" +import { useContext, useId, useRef, useState } from "react" import { flushSync } from "react-dom" import { useTranslation } from "react-i18next" import { useContextSelector } from "use-context-selector" @@ -22,49 +23,49 @@ export const useModalStack = (options?: ModalStackOptions) => { const currentCount = useRef(0) const { wrapper } = options || {} - return { - present: useCallback( - (props: ModalProps & { id?: string }) => { - const fallbackModelId = `${id}-${++currentCount.current}` - const modalId = props.id ?? fallbackModelId - - const currentStack = jotaiStore.get(modalStackAtom) - - const existingModal = currentStack.find((item) => item.id === modalId) - if (existingModal) { - // Move to top - jotaiStore.set(modalStackAtom, (p) => { - const index = p.indexOf(existingModal) - return [...p.slice(0, index), ...p.slice(index + 1), existingModal] - }) - } else { - // NOTE: The props of the Command Modal are immutable, so we'll just take the store value and inject it. - // There is no need to inject `overlay` props, this is rendered responsively based on ui changes. - const uiSettings = getUISettings() - const modalConfig: Partial = { - draggable: uiSettings.modalDraggable, - modal: true, - } - jotaiStore.set(modalStackAtom, (p) => { - const modalProps: ModalProps = { - ...modalConfig, - ...props, - - wrapper, - } - modalIdToPropsMap[modalId] = modalProps - return p.concat({ - id: modalId, - ...modalProps, - }) - }) - } + const presentSync = (props: ModalProps & { id?: string }) => { + const fallbackModelId = `${id}-${++currentCount.current}` + const modalId = props.id ?? fallbackModelId - return () => { - jotaiStore.set(modalStackAtom, (p) => p.filter((item) => item.id !== modalId)) + const currentStack = jotaiStore.get(modalStackAtom) + + const existingModal = currentStack.find((item) => item.id === modalId) + if (existingModal) { + // Move to top + jotaiStore.set(modalStackAtom, (p) => { + const index = p.indexOf(existingModal) + return [...p.slice(0, index), ...p.slice(index + 1), existingModal] + }) + } else { + // NOTE: The props of the Command Modal are immutable, so we'll just take the store value and inject it. + // There is no need to inject `overlay` props, this is rendered responsively based on ui changes. + const uiSettings = getUISettings() + const modalConfig: Partial = { + draggable: uiSettings.modalDraggable, + modal: true, + } + jotaiStore.set(modalStackAtom, (p) => { + const modalProps: ModalProps = { + ...modalConfig, + ...props, + + wrapper, } - }, - [id, wrapper], + modalIdToPropsMap[modalId] = modalProps + return p.concat({ + id: modalId, + ...modalProps, + }) + }) + } + + return () => { + jotaiStore.set(modalStackAtom, (p) => p.filter((item) => item.id !== modalId)) + } + } + return { + present: useEventCallback((props: ModalProps & { id?: string }) => + nextFrame(() => presentSync(props)), ), ...actions, diff --git a/apps/renderer/src/components/ui/modal/stacked/internal/use-animate.ts b/apps/renderer/src/components/ui/modal/stacked/internal/use-animate.ts index a964bea144..212696892a 100644 --- a/apps/renderer/src/components/ui/modal/stacked/internal/use-animate.ts +++ b/apps/renderer/src/components/ui/modal/stacked/internal/use-animate.ts @@ -9,6 +9,7 @@ import { modalMontionConfig } from "../constants" */ export const useModalAnimate = (isTop: boolean) => { const animateController = useAnimationControls() + useEffect(() => { nextFrame(() => { animateController.start(modalMontionConfig.animate) diff --git a/apps/renderer/src/components/ui/modal/stacked/modal-stack.desktop.tsx b/apps/renderer/src/components/ui/modal/stacked/modal-stack.desktop.tsx new file mode 100644 index 0000000000..3ccc950c73 --- /dev/null +++ b/apps/renderer/src/components/ui/modal/stacked/modal-stack.desktop.tsx @@ -0,0 +1,31 @@ +import { AnimatePresence } from "framer-motion" +import { useAtomValue } from "jotai" + +import { modalStackAtom } from "./atom" +import { useModalStack } from "./hooks" +import { ModalInternal } from "./modal" +import { useModalStackCalculationAndEffect } from "./modal-stack.shared" + +export const ModalStack = () => { + const { present } = useModalStack() + window.presentModal = present + + const stack = useAtomValue(modalStackAtom) + + const { topModalIndex, overlayOptions } = useModalStackCalculationAndEffect() + + return ( + + {stack.map((item, index) => ( + + ))} + + ) +} diff --git a/apps/renderer/src/components/ui/modal/stacked/modal-stack.electron.tsx b/apps/renderer/src/components/ui/modal/stacked/modal-stack.electron.tsx new file mode 100644 index 0000000000..8ddef9c661 --- /dev/null +++ b/apps/renderer/src/components/ui/modal/stacked/modal-stack.electron.tsx @@ -0,0 +1 @@ +export { ModalStack } from "./modal-stack.desktop" diff --git a/apps/renderer/src/components/ui/modal/stacked/modal-stack.mobile.tsx b/apps/renderer/src/components/ui/modal/stacked/modal-stack.mobile.tsx new file mode 100644 index 0000000000..7842df8643 --- /dev/null +++ b/apps/renderer/src/components/ui/modal/stacked/modal-stack.mobile.tsx @@ -0,0 +1,110 @@ +import { sheetStackAtom } from "@follow/components/ui/sheet/context.js" +import type { SheetRef } from "@follow/components/ui/sheet/Sheet.js" +import { PresentSheet } from "@follow/components/ui/sheet/Sheet.js" +import { jotaiStore } from "@follow/utils/jotai" +import { AnimatePresence } from "framer-motion" +import { produce } from "immer" +import { useAtomValue, useSetAtom } from "jotai" +import { createElement, useMemo, useRef } from "react" +import { useEventCallback } from "usehooks-ts" + +import { modalStackAtom } from "./atom" +import { MODAL_STACK_Z_INDEX } from "./constants" +import type { CurrentModalContentProps, ModalActionsInternal } from "./context" +import { CurrentModalContext } from "./context" +import { useModalStack } from "./hooks" +import { ModalInternal } from "./modal" +import { useModalStackCalculationAndEffect } from "./modal-stack.shared" +import type { ModalProps } from "./types" + +export const ModalStack = () => { + const { present } = useModalStack() + window.presentModal = present + + const stack = useAtomValue(modalStackAtom) + + const { topModalIndex, overlayOptions } = useModalStackCalculationAndEffect() + + return ( + + {stack.map((item, index) => { + const shouldUseModal = !!item.CustomModalComponent + if (!shouldUseModal) return + return ( + + ) + })} + + ) +} + +const ModalToSheet = (props: ModalProps & { index: number; id: string }) => { + const sheetRef = useRef(null) + const drawerLength = useRef(jotaiStore.get(sheetStackAtom).length).current + const { title, content, id } = props + + const close = useEventCallback(() => { + setStack((p) => p.filter((modal) => modal.id !== id)) + }) + + const setStack = useSetAtom(modalStackAtom) + // TODO Compatible with other modal stack methods + const ModalProps: ModalActionsInternal = useMemo( + () => ({ + dismiss: () => { + sheetRef.current?.dismiss() + }, + getIndex: () => props.index, + setClickOutSideToDismiss: (v) => { + setStack((state) => + produce(state, (draft) => { + const model = draft.find((modal) => modal.id === id) + if (!model) return + if (model.clickOutsideToDismiss === v) return + model.clickOutsideToDismiss = v + }), + ) + }, + }), + [id, props.index, setStack], + ) + const modalContentRef = useRef(null) + const ModalContextProps = useMemo( + () => ({ + ...ModalProps, + ref: modalContentRef, + }), + [ModalProps], + ) + + const finalChildren = ( + + {createElement(content, ModalProps)} + + ) + + return ( + { + if (!open) { + setTimeout(() => { + close() + }, 1000) + } + }} + content={finalChildren} + /> + ) +} diff --git a/apps/renderer/src/components/ui/modal/stacked/modal-stack.shared.tsx b/apps/renderer/src/components/ui/modal/stacked/modal-stack.shared.tsx new file mode 100644 index 0000000000..009773c904 --- /dev/null +++ b/apps/renderer/src/components/ui/modal/stacked/modal-stack.shared.tsx @@ -0,0 +1,30 @@ +import { useAtomValue } from "jotai" +import { useEffect } from "react" + +import { modalStackAtom } from "./atom" + +export const useModalStackCalculationAndEffect = () => { + const stack = useAtomValue(modalStackAtom) + const topModalIndex = stack.findLastIndex((item) => item.modal) + const overlayIndex = stack.findLastIndex((item) => item.overlay || item.modal) + const overlayOptions = stack[overlayIndex]?.overlayOptions + + const hasModalStack = stack.length > 0 + const topModalIsNotSetAsAModal = topModalIndex !== stack.length - 1 + + useEffect(() => { + // NOTE: document.body is being used by radix's dismissable, + // and using that will cause radix to get the value of `none` as the store value, + // and then revert to `none` instead of `auto` after a modal dismiss. + document.documentElement.style.pointerEvents = + hasModalStack && !topModalIsNotSetAsAModal ? "none" : "auto" + document.documentElement.dataset.hasModal = hasModalStack.toString() + }, [hasModalStack, topModalIsNotSetAsAModal]) + + return { + overlayOptions, + topModalIndex, + hasModalStack, + topModalIsNotSetAsAModal, + } +} diff --git a/apps/renderer/src/components/ui/modal/stacked/modal-stack.tsx b/apps/renderer/src/components/ui/modal/stacked/modal-stack.tsx new file mode 100644 index 0000000000..b386d54511 --- /dev/null +++ b/apps/renderer/src/components/ui/modal/stacked/modal-stack.tsx @@ -0,0 +1,6 @@ +import { withResponsiveSyncComponent } from "@follow/components/utils/selector.js" + +import { ModalStack as Desktop } from "./modal-stack.desktop" +import { ModalStack as Mobile } from "./modal-stack.mobile" + +export const ModalStack = withResponsiveSyncComponent(Desktop, Mobile) diff --git a/apps/renderer/src/components/ui/modal/stacked/provider.tsx b/apps/renderer/src/components/ui/modal/stacked/provider.tsx index cbe957c83c..fcffdb1cc7 100644 --- a/apps/renderer/src/components/ui/modal/stacked/provider.tsx +++ b/apps/renderer/src/components/ui/modal/stacked/provider.tsx @@ -1,11 +1,6 @@ -import { AnimatePresence } from "framer-motion" -import { useAtomValue } from "jotai" import type { FC, PropsWithChildren } from "react" -import { useEffect } from "react" -import { modalStackAtom } from "./atom" -import { useModalStack } from "./hooks" -import { ModalInternal } from "./modal" +import { ModalStack } from "./modal-stack" import type { ModalProps } from "./types" declare global { @@ -20,40 +15,3 @@ export const ModalStackProvider: FC = ({ children }) => ( ) - -const ModalStack = () => { - const { present } = useModalStack() - window.presentModal = present - - const stack = useAtomValue(modalStackAtom) - - const topModalIndex = stack.findLastIndex((item) => item.modal) - const overlayIndex = stack.findLastIndex((item) => item.overlay || item.modal) - const overlayOptions = stack[overlayIndex]?.overlayOptions - - const hasModalStack = stack.length > 0 - const topModalIsNotSetAsAModal = topModalIndex !== stack.length - 1 - - useEffect(() => { - // NOTE: document.body is being used by radix's dismissable, - // and using that will cause radix to get the value of `none` as the store value, - // and then revert to `none` instead of `auto` after a modal dismiss. - document.documentElement.style.pointerEvents = - hasModalStack && !topModalIsNotSetAsAModal ? "none" : "auto" - document.documentElement.dataset.hasModal = hasModalStack.toString() - }, [hasModalStack, topModalIsNotSetAsAModal]) - return ( - - {stack.map((item, index) => ( - - ))} - - ) -} diff --git a/apps/renderer/src/components/ux/pull-to-refresh/index.tsx b/apps/renderer/src/components/ux/pull-to-refresh/index.tsx new file mode 100644 index 0000000000..5325b58ab1 --- /dev/null +++ b/apps/renderer/src/components/ux/pull-to-refresh/index.tsx @@ -0,0 +1,163 @@ +import { clsx } from "clsx" +import type { ReactNode } from "react" +import { useEffect, useRef, useState } from "react" + +import { ENTRY_COLUMN_LIST_SCROLLER_ID } from "~/constants/dom" + +interface PullToRefreshProps { + children: ReactNode + onRefresh: () => Promise + className?: string + scrollContainerSelector?: string +} +const THRESHOLD = 80 +const MAX_PULL_DISTANCE = 120 + +export function PullToRefresh({ + children, + onRefresh, + className, + scrollContainerSelector = `#${ENTRY_COLUMN_LIST_SCROLLER_ID}`, +}: PullToRefreshProps) { + const [startY, setStartY] = useState(0) + const [pulling, setPulling] = useState(false) + const [pullDistance, setPullDistance] = useState(0) + const [shouldAllowPull, setShouldAllowPull] = useState(false) + + const [isRefreshing, setIsRefreshing] = useState(false) + + useEffect(() => { + if (!pulling) { + setPullDistance(0) + } + }, [pulling]) + const containerRef = useRef(null) + const contentRef = useRef(null) + useEffect(() => { + const element = containerRef.current + if (!element) return + + const touchStartHandler = (e: TouchEvent) => { + const scrollContainer = contentRef.current?.querySelector( + scrollContainerSelector, + ) as HTMLElement + if (!scrollContainer) return + + const touchY = e.touches[0].clientY + setStartY(touchY) + + if (scrollContainer.scrollTop <= 0) { + setShouldAllowPull(true) + } else { + setShouldAllowPull(false) + } + } + + const touchMoveHandler = (e: TouchEvent) => { + if (!shouldAllowPull || isRefreshing) return + + const y = e.touches[0].clientY + const delta = y - startY + + if (delta > 0) { + e.preventDefault() + setPulling(true) + setPullDistance(Math.min(delta, MAX_PULL_DISTANCE)) + } + } + + const touchEndHandler = async () => { + if (!pulling || isRefreshing) return + + setPulling(false) + + if (pullDistance >= THRESHOLD) { + const promise = onRefresh() + setIsRefreshing(true) + + try { + await promise + } finally { + setIsRefreshing(false) + setPullDistance(0) + } + } + } + + element.addEventListener("touchstart", touchStartHandler, { passive: true }) + element.addEventListener("touchmove", touchMoveHandler, { passive: false }) + element.addEventListener("touchend", touchEndHandler, { passive: true }) + + return () => { + element.removeEventListener("touchstart", touchStartHandler) + element.removeEventListener("touchmove", touchMoveHandler) + element.removeEventListener("touchend", touchEndHandler) + } + }, [ + startY, + shouldAllowPull, + pulling, + pullDistance, + onRefresh, + scrollContainerSelector, + isRefreshing, + ]) + + // 计算实际的下拉距离 + const actualPullDistance = isRefreshing + ? THRESHOLD // 刷新时保持在阈值位置 + : pullDistance + + // 计算下拉进度 (0-1) + const pullProgress = Math.max(Math.min(pullDistance / THRESHOLD - 0.2, 1), 0) + const SIZE = 24 + const STROKE_WIDTH = 2 + const RADIUS = (SIZE - STROKE_WIDTH) / 2 + const CIRCUMFERENCE = 2 * Math.PI * RADIUS + const strokeDashoffset = CIRCUMFERENCE * (1 - pullProgress) + + return ( +
+
0 ? "opacity-100" : "opacity-0", + !pulling && "duration-200", + )} + style={{ + transform: `translateY(${actualPullDistance - 60}px)`, + }} + > + + + +
+ + {/* Content area */} +
+ {children} +
+
+ ) +} diff --git a/apps/renderer/src/constants/dom.ts b/apps/renderer/src/constants/dom.ts new file mode 100644 index 0000000000..4c65cbd84a --- /dev/null +++ b/apps/renderer/src/constants/dom.ts @@ -0,0 +1,5 @@ +export const ENTRY_CONTENT_RENDER_CONTAINER_ID = "follow-entry-render" + +export const LOGO_MOBILE_ID = "follow-logo-mobile" + +export const ENTRY_COLUMN_LIST_SCROLLER_ID = "entry-column-scroller" diff --git a/apps/renderer/src/hooks/biz/useEntryActions.tsx b/apps/renderer/src/hooks/biz/useEntryActions.tsx index 6de5d8b3cd..fcc908df32 100644 --- a/apps/renderer/src/hooks/biz/useEntryActions.tsx +++ b/apps/renderer/src/hooks/biz/useEntryActions.tsx @@ -1,4 +1,5 @@ import type { FeedViewType } from "@follow/constants" +import type { ReactNode } from "react" import { useCallback, useMemo } from "react" import { @@ -13,6 +14,7 @@ import { shortcuts } from "~/constants/shortcuts" import { tipcClient } from "~/lib/client" import { COMMAND_ID } from "~/modules/command/commands/id" import { useGetCommand, useRunCommandFn } from "~/modules/command/hooks/use-command" +import type { FollowCommandId } from "~/modules/command/types" import { useEntry } from "~/store/entry" import { useFeedById } from "~/store/feed" import { useInboxById } from "~/store/inbox" @@ -55,6 +57,15 @@ export const useEntryReadabilityToggle = ({ id, url }: { id: string; url: string } }, [id, url]) +export type EntryActionItem = { + id: FollowCommandId + name: string + icon?: ReactNode + active?: boolean + shortcut?: string + onClick: () => void +} + export const useEntryActions = ({ entryId, view }: { entryId: string; view?: FeedViewType }) => { const entry = useEntry(entryId) const feed = useFeedById(entry?.feedId, (feed) => { diff --git a/apps/renderer/src/hooks/biz/useNavigateEntry.ts b/apps/renderer/src/hooks/biz/useNavigateEntry.ts index 8a0e0c7a90..50ebcdcfe3 100644 --- a/apps/renderer/src/hooks/biz/useNavigateEntry.ts +++ b/apps/renderer/src/hooks/biz/useNavigateEntry.ts @@ -1,4 +1,5 @@ import { getReadonlyRoute, getStableRouterNavigate } from "@follow/components/atoms/route.js" +import { isMobile } from "@follow/components/hooks/useMobile.js" import { FeedViewType } from "@follow/constants" import { isUndefined } from "es-toolkit/compat" @@ -23,7 +24,8 @@ export type NavigateEntryOptions = Partial<{ /** * @description a hook to navigate to `feedId`, `entryId`, add search for `view`, `level` */ -// eslint-disable-next-line @eslint-react/hooks-extra/ensure-custom-hooks-using-other-hooks + +// eslint-disable-next-line @eslint-react/hooks-extra/no-redundant-custom-hook, @eslint-react/hooks-extra/ensure-custom-hooks-using-other-hooks export const useNavigateEntry = () => navigateEntry export const navigateEntry = (options: NavigateEntryOptions) => { @@ -66,7 +68,14 @@ export const navigateEntry = (options: NavigateEntryOptions) => { }) } - return getStableRouterNavigate()?.( - `/feeds/${finalFeedId}/${entryId || ROUTE_ENTRY_PENDING}?${nextSearchParams.toString()}`, - ) + let path = `/feeds` + if (finalFeedId) { + path += `/${finalFeedId}` + } + if (entryId) { + path += `/${entryId}` + } else { + if (!isMobile()) path += `/${ROUTE_ENTRY_PENDING}` + } + return getStableRouterNavigate()?.(`${path}?${nextSearchParams.toString()}`) } diff --git a/apps/renderer/src/hooks/common/useContextMenu.tsx b/apps/renderer/src/hooks/common/useContextMenu.tsx new file mode 100644 index 0000000000..2fe03a8933 --- /dev/null +++ b/apps/renderer/src/hooks/common/useContextMenu.tsx @@ -0,0 +1,26 @@ +import { useLongPress } from "@follow/hooks" + +interface UseContextMenuOptions { + onContextMenu: (e: React.MouseEvent) => void + onTouchStart?: (e: React.TouchEvent) => void + onTouchMove?: (e: React.TouchEvent) => void + onTouchEnd?: (e: React.TouchEvent) => void +} + +export const useContextMenu = ({ + onContextMenu, + onTouchStart, + onTouchMove, + onTouchEnd, +}: UseContextMenuOptions) => { + const props = useLongPress({ + onLongPress: onContextMenu as any, + onTouchStart, + onTouchMove, + onTouchEnd, + }) + return { + ...props, + onContextMenu, + } +} diff --git a/apps/renderer/src/initialize/history.ts b/apps/renderer/src/initialize/history.ts new file mode 100644 index 0000000000..df4ce15ae3 --- /dev/null +++ b/apps/renderer/src/initialize/history.ts @@ -0,0 +1,94 @@ +import { nextFrame } from "@follow/utils/dom" +import { jotaiStore } from "@follow/utils/jotai" +import { atom } from "jotai" + +import { router } from "~/router" + +const historyAtom = atom([]) + +declare global { + interface History { + stack: string[] + + returnBack: (to?: string) => void + + get isPop(): boolean + } +} + +let __isPop = false +const F = {} as { isPop: boolean } +let resetTimer: any = null +Object.defineProperty(F, "isPop", { + get() { + return __isPop + }, + set(value) { + if (!value) return + + resetTimer && clearTimeout(resetTimer) + resetTimer = setTimeout(() => { + __isPop = false + }, 1200) + + __isPop = true + }, +}) + +export const registerHistoryStack = () => { + const onPopState = (e: PopStateEvent) => { + F.isPop = true + + const url = e.state?.url + if (url) { + jotaiStore.set(historyAtom, jotaiStore.get(historyAtom).slice(0, -1)) + } + } + window.addEventListener("popstate", onPopState) + + const unsub = router.subscribe((e) => { + const url = e.location.pathname + e.location.search + jotaiStore.set(historyAtom, [...jotaiStore.get(historyAtom), url]) + }) + + Object.defineProperty(window.history, "stack", { + get() { + return jotaiStore.get(historyAtom) + }, + enumerable: false, + }) + + Object.defineProperty(window.history, "returnBack", { + value: (to?: string) => { + const stack = jotaiStore.get(historyAtom) + F.isPop = true + const last = stack.at(-1) + + to = typeof to === "string" ? to : last + + if (!last || last !== to) { + window.router.navigate(to ?? "/") + + nextFrame(() => { + jotaiStore.set(historyAtom, []) + }) + } else { + window.history.back() + } + }, + enumerable: false, + }) + + Object.defineProperty(window.history, "isPop", { + get() { + return F.isPop + }, + enumerable: false, + }) + + return () => { + window.removeEventListener("popstate", onPopState) + + unsub() + } +} diff --git a/apps/renderer/src/initialize/index.ts b/apps/renderer/src/initialize/index.ts index 59d7b0d438..83c4d31720 100644 --- a/apps/renderer/src/initialize/index.ts +++ b/apps/renderer/src/initialize/index.ts @@ -17,6 +17,7 @@ import { subscribeNetworkStatus } from "../atoms/network" import { getGeneralSettings, subscribeShouldUseIndexedDB } from "../atoms/settings/general" import { appLog } from "../lib/log" import { initAnalytics } from "./analytics" +import { registerHistoryStack } from "./history" import { hydrateDatabaseToStore, hydrateSettings, setHydrated } from "./hydrate" import { doMigration } from "./migrates" import { initSentry } from "./sentry" @@ -42,7 +43,7 @@ export const initializeApp = async () => { appLog(`${APP_NAME}: Follow your favorites in one inbox`, repository.url) if (isDev) { - const favicon = await import("~/../public/favicon-dev.ico?url") + const favicon = await import("/favicon-dev.ico?url") const url = new URL(favicon.default, import.meta.url).href @@ -69,6 +70,7 @@ export const initializeApp = async () => { credentials: "include", }) initializeDayjs() + registerHistoryStack() // Set Environment document.documentElement.dataset.buildType = isElectronBuild ? "electron" : "web" diff --git a/apps/renderer/src/modules/achievement/AchievementModalContent.tsx b/apps/renderer/src/modules/achievement/AchievementModalContent.tsx index 1d14318614..d159008e57 100644 --- a/apps/renderer/src/modules/achievement/AchievementModalContent.tsx +++ b/apps/renderer/src/modules/achievement/AchievementModalContent.tsx @@ -197,7 +197,7 @@ export const AchievementModalContent: FC = () => { return (
{ * - +