From 2b6e68391b5f63dcee7e8d292bb9293e19c6f3fd Mon Sep 17 00:00:00 2001
From: khannurien
Date: Sun, 27 Sep 2026 19:44:14 +0000
Subject: [PATCH] =?UTF-8?q?v3:=20src/=20gets=20one=20layout=20rule=20and?=
=?UTF-8?q?=20keeps=20to=20it=20=E2=80=94=20each=20layer=20holds=20one=20k?=
=?UTF-8?q?ind=20of=20module,=20and=20every=20layer=20is=20split=20into=20?=
=?UTF-8?q?the=20same=20domains=20=E2=80=94=20the=20previous=20pass=20sort?=
=?UTF-8?q?ed=20components=20into=20folders=20but=20left=20hooks,=20helper?=
=?UTF-8?q?s=20and=20a=20barrel=20inside=20them,=20kept=20hooks/,=20utils/?=
=?UTF-8?q?=20and=20contexts/=20flat,=20and=20still=20grouped=20most=20tes?=
=?UTF-8?q?ts=20by=20theme.=20The=20rule:=20components/=20holds=20only=20c?=
=?UTF-8?q?omponents=20(.tsx),=20contexts/=20holds=20FooContext.ts=20+=20F?=
=?UTF-8?q?ooProvider.tsx=20pairs,=20hooks/=20every=20hook,=20utils/=20eve?=
=?UTF-8?q?ry=20non-React=20module,=20pages/=20one=20component=20per=20rou?=
=?UTF-8?q?te=20and=20nothing=20else.=20Each=20of=20components/,=20context?=
=?UTF-8?q?s/,=20hooks/=20and=20utils/=20is=20split=20into=20the=20same=20?=
=?UTF-8?q?domains=20=E2=80=94=20auth,=20category,=20chat,=20comment,=20du?=
=?UTF-8?q?mp,=20editor,=20feed,=20form,=20layout,=20player,=20playlist,?=
=?UTF-8?q?=20profile,=20social,=20theme,=20ws=20=E2=80=94=20plus=20shared?=
=?UTF-8?q?/=20for=20what=20belongs=20to=20no=20one=20domain,=20so=20the?=
=?UTF-8?q?=20chat=20is=20components/chat,=20contexts/chat,=20hooks/chat?=
=?UTF-8?q?=20and=20utils/chat.=20components/ui=20becomes=20components/sha?=
=?UTF-8?q?red=20to=20match.=20Out=20of=20components/:=20useApiForm=20goes?=
=?UTF-8?q?=20to=20hooks/form,=20chat's=20message.ts=20and=20time.ts=20to?=
=?UTF-8?q?=20utils/chat=20(with=20chatDraft=20and=20chatText,=20now=20dra?=
=?UTF-8?q?ft.ts=20and=20text.ts),=20the=20emoji=20skin=20tone=20to=20util?=
=?UTF-8?q?s/editor,=20the=20remembered=20volume=20to=20utils/player;=20pl?=
=?UTF-8?q?ayer/icons.tsx=20is=20PlayerIcons.tsx.=20components/form/index.?=
=?UTF-8?q?ts=20is=20gone:=20everything=20is=20imported=20from=20its=20own?=
=?UTF-8?q?=20file,=20FormProvider=20straight=20from=20react-hook-form,=20?=
=?UTF-8?q?and=20expectOk=20sits=20beside=20friendlyFetchError=20in=20util?=
=?UTF-8?q?s/shared/api.ts=20(apiError.ts),=20since=20pages=20that=20have?=
=?UTF-8?q?=20no=20form=20used=20it=20too.=20Out=20of=20pages/:=20the=20in?=
=?UTF-8?q?dex's=20feed=20views,=20tab=20bar=20and=20presence=20row=20move?=
=?UTF-8?q?=20to=20components/feed;=20HotFeed=20and=20NewFeed,=20identical?=
=?UTF-8?q?=20but=20for=20the=20sort,=20are=20one=20DumpFeed=20with=20an?=
=?UTF-8?q?=20order=20prop,=20which=20also=20takes=20over=20MainFeedProps?=
=?UTF-8?q?=20from=20pages/index/types.ts.=20App=20is=20a=20named=20export?=
=?UTF-8?q?=20like=20everything=20else.=20Tests=20mirror=20the=20tree=20on?=
=?UTF-8?q?e=20file=20per=20module=20=E2=80=94=20test///.test.tsx=20=E2=80=94=20so=20the=20themed=20files=20(hooks?=
=?UTF-8?q?/misc,=20data,=20editor,=20sync;=20utils/misc,=20text,=20storag?=
=?UTF-8?q?e,=20remote,=20journal;=20contexts/providers;=20pages/auth=20an?=
=?UTF-8?q?d=20UserSubpages;=20layout/Fabs)=20are=20split=20by=20subject,?=
=?UTF-8?q?=20the=20setup=20each=20piece=20needs=20kept=20and=20the=20rest?=
=?UTF-8?q?=20dropped;=20the=20floating-button=20test=20becomes=20one=20te?=
=?UTF-8?q?st=20per=20button,=20627=20tests=20now.=20The=20README=20states?=
=?UTF-8?q?=20the=20rule.=20No=20behaviour=20changes;=20the=20catalogues?=
=?UTF-8?q?=20only=20move=20their=20source=20references.?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-Authored-By: Claude Opus 5.5
---
README.md | 34 +-
api/services/dump-service.ts | 2 +-
src/App.tsx | 22 +-
src/components/category/CategoryManager.tsx | Bin 7027 -> 7209 bytes
src/components/category/CategorySelect.tsx | 2 +-
src/components/category/CategorySwitcher.tsx | 2 +-
src/components/chat/ChatEditBox.tsx | 6 +-
src/components/chat/ChatMessageItem.tsx | 14 +-
src/components/chat/ChatModal.tsx | 25 +-
src/components/chat/PendingItem.tsx | 6 +-
src/components/chat/ReplyRef.tsx | 2 +-
src/components/comment/CommentForm.tsx | 11 +-
src/components/comment/CommentNode.tsx | 18 +-
src/components/comment/CommentThread.tsx | 6 +-
src/components/dump/DumpCard.tsx | 16 +-
src/components/dump/FilePreview.tsx | 16 +-
src/components/dump/JournalCard.tsx | 18 +-
src/components/dump/RichContentCard.tsx | 6 +-
.../dump/create/DumpCreateModal.tsx | 30 +-
.../dump/create/DuplicateNotice.tsx | 4 +-
src/components/editor/EmojiPickerPopup.tsx | 2 +-
src/components/editor/MentionDropdown.tsx | 4 +-
src/components/editor/TextEditor.tsx | 10 +-
.../feed/DumpFeed.tsx} | 38 +-
.../index => components/feed}/FeedTabBar.tsx | 8 +-
.../feed}/FollowedFeed.tsx | 18 +-
.../index => components/feed}/JournalFeed.tsx | 8 +-
.../index => components/feed}/PresenceRow.tsx | 6 +-
src/components/form/FileDropZone.tsx | 2 +-
src/components/form/FormError.tsx | 2 +-
src/components/form/TextField.tsx | 2 +-
src/components/form/index.ts | 9 -
src/components/layout/AppHeader.tsx | 6 +-
src/components/layout/ChatButton.tsx | 4 +-
src/components/layout/ChatFab.tsx | 6 +-
src/components/layout/DumpFab.tsx | 2 +-
src/components/layout/NotificationBell.tsx | 2 +-
src/components/layout/PageError.tsx | 2 +-
src/components/layout/UserMenu.tsx | 2 +-
src/components/player/GlobalPlayer.tsx | 10 +-
src/components/player/MediaPlayer.tsx | 11 +-
.../player/{icons.tsx => PlayerIcons.tsx} | 0
src/components/player/Waveform.tsx | 2 +-
.../playlist/AddToPlaylistModal.tsx | 4 +-
src/components/playlist/NewPlaylistButton.tsx | 2 +-
src/components/playlist/PlaylistCard.tsx | 8 +-
.../playlist/PlaylistCreateForm.tsx | 22 +-
.../profile/ChangePasswordModal.tsx | 20 +-
.../profile/ProfileSubpageHeader.tsx | 2 +-
src/components/{ui => shared}/Avatar.tsx | 2 +-
.../{ui => shared}/ConfirmModal.tsx | 2 +-
.../{ui => shared}/CountedInput.tsx | 2 +-
src/components/{ui => shared}/ErrorCard.tsx | 0
src/components/{ui => shared}/ImagePicker.tsx | 0
src/components/{ui => shared}/Modal.tsx | 2 +-
src/components/{ui => shared}/TabBar.tsx | 0
src/components/{ui => shared}/Thumbnail.tsx | 2 +-
.../{ui => shared}/ThumbnailPlaceholder.tsx | 4 +-
src/components/{ui => shared}/Tooltip.tsx | 0
src/components/social/FollowButton.tsx | 4 +-
src/components/social/UserListPopover.tsx | 4 +-
src/contexts/{ => auth}/AuthContext.ts | 2 +-
src/contexts/{ => auth}/AuthProvider.tsx | 6 +-
.../{ => category}/CategoriesContext.ts | 2 +-
.../{ => category}/CategoriesProvider.tsx | 4 +-
src/contexts/{ => chat}/ChatContext.ts | 0
src/contexts/{ => chat}/ChatProvider.tsx | 4 +-
src/contexts/{ => player}/PlayerContext.ts | 0
src/contexts/{ => player}/PlayerProvider.tsx | 7 +-
src/contexts/{ => social}/FollowContext.ts | 0
src/contexts/{ => social}/FollowProvider.tsx | 6 +-
src/contexts/{ => theme}/ThemeContext.ts | 0
src/contexts/{ => theme}/ThemeProvider.tsx | 0
.../{ => ws}/AvatarOverrideContext.ts | 0
src/contexts/{ => ws}/WSContext.ts | 2 +-
src/contexts/{ => ws}/WSProvider.tsx | 14 +-
src/hooks/{ => auth}/useAuth.ts | 6 +-
src/hooks/{ => category}/useCategories.ts | 2 +-
src/hooks/{ => chat}/useChat.ts | 2 +-
src/hooks/{ => dump}/useDumpCardLink.ts | 6 +-
src/hooks/{ => dump}/useDumpListSync.ts | 4 +-
src/hooks/{ => dump}/useUserDumpFeed.ts | 16 +-
src/hooks/{ => editor}/useEmojiTrigger.ts | 2 +-
.../{ => editor}/useMentionAutocomplete.ts | 4 +-
src/hooks/{ => feed}/useDefaultFeedTab.ts | 2 +-
src/hooks/{ => feed}/useFading.ts | 0
src/hooks/{ => feed}/useFeedCache.ts | 0
src/hooks/{ => feed}/useInfiniteScroll.ts | 0
src/hooks/{ => feed}/usePositionAwareSync.ts | 0
src/hooks/{ => feed}/useScrollSave.ts | 0
src/{components => hooks}/form/useApiForm.ts | 17 +-
src/hooks/{ => player}/usePlayRichContent.ts | 9 +-
src/hooks/{ => player}/useYouTubePlaylist.ts | 2 +-
.../{ => playlist}/usePlaylistListSync.ts | 4 +-
src/hooks/{ => shared}/useAnchoredFloat.ts | 0
src/hooks/{ => shared}/useDocumentTitle.ts | 0
src/hooks/{ => shared}/useHeightTransition.ts | 0
src/hooks/{ => shared}/useRoomBeside.ts | 0
src/hooks/{ => shared}/useScrollLock.ts | 0
src/hooks/{ => shared}/useSoundsEnabled.ts | 5 +-
src/hooks/{ => shared}/useTabParam.ts | 0
src/hooks/{ => social}/useFollows.ts | 2 +-
src/hooks/{ => theme}/useTheme.ts | 2 +-
src/hooks/{ => ws}/useWS.ts | 2 +-
src/locales/en.po | 661 +++++++++---------
src/locales/fr.po | 661 +++++++++---------
src/main.tsx | 2 +-
src/pages/Dump.tsx | 22 +-
src/pages/DumpEdit.tsx | 42 +-
src/pages/Index.tsx | 37 +-
src/pages/NotFound.tsx | 2 +-
src/pages/Notifications.tsx | 16 +-
src/pages/PlaylistDetail.tsx | 39 +-
src/pages/ResetPassword.tsx | 16 +-
src/pages/RestrictedGuest.tsx | 2 +-
src/pages/RestrictedLoggedIn.tsx | 2 +-
src/pages/Search.tsx | 16 +-
src/pages/UserDumps.tsx | 12 +-
src/pages/UserLogin.tsx | 20 +-
src/pages/UserPlaylists.tsx | 24 +-
src/pages/UserPublicProfile.tsx | 51 +-
src/pages/UserRegister.tsx | 20 +-
src/pages/UserUpvoted.tsx | 12 +-
src/pages/index/HotFeed.tsx | 74 --
src/pages/index/types.ts | 16 -
src/test/components/dump/DumpCard.test.tsx | 2 +-
src/test/components/dump/FilePreview.test.tsx | 2 +-
src/test/components/dump/JournalCard.test.tsx | 2 +-
.../feed}/FeedTabBar.test.tsx | 2 +-
.../feed}/PresenceRow.test.tsx | 2 +-
src/test/components/layout/AppHeader.test.tsx | 4 +-
.../components/layout/ChatButton.test.tsx | 2 +-
.../{Fabs.test.tsx => ChatFab.test.tsx} | 44 +-
src/test/components/layout/DumpFab.test.tsx | 41 ++
.../layout/NotificationBell.test.tsx | 2 +-
.../components/player/GlobalPlayer.test.tsx | 4 +-
.../components/playlist/PlaylistCard.test.tsx | 2 +-
.../components/{ui => shared}/Avatar.test.tsx | 4 +-
.../{ui => shared}/ConfirmModal.test.tsx | 4 +-
.../{ui => shared}/CountedInput.test.tsx | 2 +-
.../components/{ui => shared}/Modal.test.tsx | 2 +-
.../components/{ui => shared}/TabBar.test.tsx | 2 +-
.../{ui => shared}/Thumbnail.test.tsx | 2 +-
.../ThumbnailPlaceholder.test.tsx | 4 +-
.../{ui => shared}/Tooltip.test.tsx | 2 +-
src/test/contexts/auth/AuthProvider.test.tsx | 123 ++++
.../category/CategoriesProvider.test.tsx | 39 ++
src/test/contexts/chat/ChatProvider.test.tsx | 56 ++
.../{ => player}/PlayerProvider.test.tsx | 14 +-
src/test/contexts/providers.test.tsx | 323 ---------
.../contexts/social/FollowProvider.test.tsx | 71 ++
.../contexts/theme/ThemeProvider.test.tsx | 46 ++
.../contexts/{ => ws}/WSProvider.test.tsx | 16 +-
src/test/fixtures.ts | 2 +-
src/test/hooks/{ => auth}/useAuth.test.tsx | 12 +-
src/test/hooks/data.test.tsx | 377 ----------
src/test/hooks/dump/useDumpCardLink.test.tsx | 85 +++
src/test/hooks/dump/useDumpListSync.test.tsx | 110 +++
src/test/hooks/dump/useUserDumpFeed.test.tsx | 134 ++++
.../hooks/editor/useEmojiTrigger.test.tsx | 89 +++
.../useMentionAutocomplete.test.tsx} | 91 +--
.../hooks/feed/useDefaultFeedTab.test.tsx | 15 +
src/test/hooks/feed/useFading.test.tsx | 55 ++
src/test/hooks/feed/useFeedCache.test.tsx | 97 +++
.../hooks/feed/useInfiniteScroll.test.tsx | 115 +++
.../hooks/feed/usePositionAwareSync.test.tsx | 91 +++
src/test/hooks/feed/useScrollSave.test.tsx | 52 ++
src/test/hooks/misc.test.tsx | 615 ----------------
.../hooks/player/usePlayRichContent.test.tsx | 112 +++
.../hooks/player/useYouTubePlaylist.test.tsx | 139 ++++
.../playlist/usePlaylistListSync.test.tsx | 165 +++++
.../hooks/shared/useAnchoredFloat.test.tsx | 65 ++
.../hooks/shared/useDocumentTitle.test.tsx | 33 +
.../hooks/shared/useHeightTransition.test.tsx | 29 +
src/test/hooks/shared/useScrollLock.test.tsx | 20 +
.../hooks/shared/useSoundsEnabled.test.tsx | 13 +
src/test/hooks/shared/useTabParam.test.tsx | 61 ++
src/test/hooks/sync.test.tsx | 301 --------
src/test/{utils => }/model.test.ts | 2 +-
src/test/pages/NotFound.test.tsx | 16 +
src/test/pages/ResetPassword.test.tsx | 75 ++
src/test/pages/RestrictedGuest.test.tsx | 25 +
src/test/pages/RestrictedLoggedIn.test.tsx | 34 +
...erSubpages.test.tsx => UserDumps.test.tsx} | 83 +--
src/test/pages/UserLogin.test.tsx | 123 ++++
src/test/pages/UserPublicProfile.test.tsx | 2 +-
src/test/pages/UserRegister.test.tsx | 92 +++
src/test/pages/UserUpvoted.test.tsx | 89 +++
src/test/pages/auth.test.tsx | 331 ---------
src/test/render.tsx | 24 +-
src/test/utils/auth/permissions.test.ts | 17 +
src/test/utils/auth/storage.test.ts | 74 ++
src/test/utils/chat/text.test.ts | 47 ++
src/test/utils/dump/draft.test.ts | 78 +++
src/test/utils/editor/text.test.ts | 49 ++
src/test/utils/feed/hotScore.test.ts | 15 +
.../journalLayout.test.ts} | 17 +-
src/test/utils/misc.test.ts | 178 -----
src/test/utils/player/bandcamp.test.ts | 102 +++
src/test/utils/player/duration.test.ts | 14 +
src/test/utils/player/streamSources.test.ts | 103 +++
src/test/utils/player/waveform.test.ts | 33 +
src/test/utils/player/youtube.test.ts | 43 ++
src/test/utils/remote.test.ts | 238 -------
src/test/utils/shared/api.test.ts | 14 +
src/test/utils/shared/charCount.test.ts | 14 +
src/test/utils/shared/format.test.ts | 27 +
src/test/utils/shared/proxyImage.test.ts | 46 ++
src/test/utils/shared/relativeTime.test.ts | 34 +
src/test/utils/{ => shared}/sound.test.ts | 2 +-
src/test/utils/shared/thumbnailTint.test.ts | 43 ++
src/test/utils/shared/urls.test.ts | 46 ++
src/test/utils/shared/visited.test.ts | 32 +
src/test/utils/storage.test.ts | 184 -----
src/test/utils/text.test.ts | 157 -----
src/utils/apiError.ts | 10 -
src/utils/{ => auth}/permissions.ts | 2 +-
src/utils/{authStorage.ts => auth/storage.ts} | 2 +-
src/utils/{chatDraft.ts => chat/draft.ts} | 2 +-
src/{components => utils}/chat/message.ts | 4 +-
src/utils/{chatText.ts => chat/text.ts} | 0
src/{components => utils}/chat/time.ts | 0
src/utils/{dumpDraft.ts => dump/draft.ts} | 0
src/{components => utils}/editor/skinTone.ts | 0
src/utils/{editorText.ts => editor/text.ts} | 0
src/utils/{ => feed}/hotScore.ts | 2 +-
src/utils/{ => feed}/journalLayout.ts | 2 +-
src/utils/{ => player}/bandcamp.ts | 4 +-
src/utils/{ => player}/duration.ts | 0
src/utils/{ => player}/streamSources.ts | 2 +-
src/{components => utils}/player/volume.ts | 0
src/utils/{ => player}/waveform.ts | 0
src/utils/{ => player}/youtube.ts | 2 +-
src/utils/shared/api.ts | 24 +
src/utils/{ => shared}/charCount.ts | 0
src/utils/{ => shared}/format.ts | 0
src/utils/{ => shared}/proxyImage.ts | 2 +-
src/utils/{ => shared}/relativeTime.ts | 2 +-
src/utils/{ => shared}/sound.ts | 0
src/utils/{ => shared}/thumbnailTint.ts | 0
src/utils/{ => shared}/urls.ts | 2 +-
src/utils/{ => shared}/visited.ts | 0
242 files changed, 4390 insertions(+), 4229 deletions(-)
rename src/{pages/index/NewFeed.tsx => components/feed/DumpFeed.tsx} (57%)
rename src/{pages/index => components/feed}/FeedTabBar.tsx (84%)
rename src/{pages/index => components/feed}/FollowedFeed.tsx (95%)
rename src/{pages/index => components/feed}/JournalFeed.tsx (90%)
rename src/{pages/index => components/feed}/PresenceRow.tsx (93%)
delete mode 100644 src/components/form/index.ts
rename src/components/player/{icons.tsx => PlayerIcons.tsx} (100%)
rename src/components/{ui => shared}/Avatar.tsx (95%)
rename src/components/{ui => shared}/ConfirmModal.tsx (96%)
rename src/components/{ui => shared}/CountedInput.tsx (89%)
rename src/components/{ui => shared}/ErrorCard.tsx (100%)
rename src/components/{ui => shared}/ImagePicker.tsx (100%)
rename src/components/{ui => shared}/Modal.tsx (96%)
rename src/components/{ui => shared}/TabBar.tsx (100%)
rename src/components/{ui => shared}/Thumbnail.tsx (96%)
rename src/components/{ui => shared}/ThumbnailPlaceholder.tsx (94%)
rename src/components/{ui => shared}/Tooltip.tsx (100%)
rename src/contexts/{ => auth}/AuthContext.ts (86%)
rename src/contexts/{ => auth}/AuthProvider.tsx (96%)
rename src/contexts/{ => category}/CategoriesContext.ts (90%)
rename src/contexts/{ => category}/CategoriesProvider.tsx (95%)
rename src/contexts/{ => chat}/ChatContext.ts (100%)
rename src/contexts/{ => chat}/ChatProvider.tsx (93%)
rename src/contexts/{ => player}/PlayerContext.ts (100%)
rename src/contexts/{ => player}/PlayerProvider.tsx (99%)
rename src/contexts/{ => social}/FollowContext.ts (100%)
rename src/contexts/{ => social}/FollowProvider.tsx (96%)
rename src/contexts/{ => theme}/ThemeContext.ts (100%)
rename src/contexts/{ => theme}/ThemeProvider.tsx (100%)
rename src/contexts/{ => ws}/AvatarOverrideContext.ts (100%)
rename src/contexts/{ => ws}/WSContext.ts (99%)
rename src/contexts/{ => ws}/WSProvider.tsx (99%)
rename src/hooks/{ => auth}/useAuth.ts (97%)
rename src/hooks/{ => category}/useCategories.ts (55%)
rename src/hooks/{ => chat}/useChat.ts (60%)
rename src/hooks/{ => dump}/useDumpCardLink.ts (91%)
rename src/hooks/{ => dump}/useDumpListSync.ts (97%)
rename src/hooks/{ => dump}/useUserDumpFeed.ts (93%)
rename src/hooks/{ => editor}/useEmojiTrigger.ts (98%)
rename src/hooks/{ => editor}/useMentionAutocomplete.ts (98%)
rename src/hooks/{ => feed}/useDefaultFeedTab.ts (96%)
rename src/hooks/{ => feed}/useFading.ts (100%)
rename src/hooks/{ => feed}/useFeedCache.ts (100%)
rename src/hooks/{ => feed}/useInfiniteScroll.ts (100%)
rename src/hooks/{ => feed}/usePositionAwareSync.ts (100%)
rename src/hooks/{ => feed}/useScrollSave.ts (100%)
rename src/{components => hooks}/form/useApiForm.ts (65%)
rename src/hooks/{ => player}/usePlayRichContent.ts (92%)
rename src/hooks/{ => player}/useYouTubePlaylist.ts (99%)
rename src/hooks/{ => playlist}/usePlaylistListSync.ts (97%)
rename src/hooks/{ => shared}/useAnchoredFloat.ts (100%)
rename src/hooks/{ => shared}/useDocumentTitle.ts (100%)
rename src/hooks/{ => shared}/useHeightTransition.ts (100%)
rename src/hooks/{ => shared}/useRoomBeside.ts (100%)
rename src/hooks/{ => shared}/useScrollLock.ts (100%)
rename src/hooks/{ => shared}/useSoundsEnabled.ts (86%)
rename src/hooks/{ => shared}/useTabParam.ts (100%)
rename src/hooks/{ => social}/useFollows.ts (57%)
rename src/hooks/{ => theme}/useTheme.ts (59%)
rename src/hooks/{ => ws}/useWS.ts (91%)
delete mode 100644 src/pages/index/HotFeed.tsx
delete mode 100644 src/pages/index/types.ts
rename src/test/{pages/index => components/feed}/FeedTabBar.test.tsx (95%)
rename src/test/{pages/index => components/feed}/PresenceRow.test.tsx (96%)
rename src/test/components/layout/{Fabs.test.tsx => ChatFab.test.tsx} (63%)
create mode 100644 src/test/components/layout/DumpFab.test.tsx
rename src/test/components/{ui => shared}/Avatar.test.tsx (91%)
rename src/test/components/{ui => shared}/ConfirmModal.test.tsx (94%)
rename src/test/components/{ui => shared}/CountedInput.test.tsx (88%)
rename src/test/components/{ui => shared}/Modal.test.tsx (97%)
rename src/test/components/{ui => shared}/TabBar.test.tsx (92%)
rename src/test/components/{ui => shared}/Thumbnail.test.tsx (96%)
rename src/test/components/{ui => shared}/ThumbnailPlaceholder.test.tsx (89%)
rename src/test/components/{ui => shared}/Tooltip.test.tsx (91%)
create mode 100644 src/test/contexts/auth/AuthProvider.test.tsx
create mode 100644 src/test/contexts/category/CategoriesProvider.test.tsx
create mode 100644 src/test/contexts/chat/ChatProvider.test.tsx
rename src/test/contexts/{ => player}/PlayerProvider.test.tsx (96%)
delete mode 100644 src/test/contexts/providers.test.tsx
create mode 100644 src/test/contexts/social/FollowProvider.test.tsx
create mode 100644 src/test/contexts/theme/ThemeProvider.test.tsx
rename src/test/contexts/{ => ws}/WSProvider.test.tsx (98%)
rename src/test/hooks/{ => auth}/useAuth.test.tsx (96%)
delete mode 100644 src/test/hooks/data.test.tsx
create mode 100644 src/test/hooks/dump/useDumpCardLink.test.tsx
create mode 100644 src/test/hooks/dump/useDumpListSync.test.tsx
create mode 100644 src/test/hooks/dump/useUserDumpFeed.test.tsx
create mode 100644 src/test/hooks/editor/useEmojiTrigger.test.tsx
rename src/test/hooks/{editor.test.tsx => editor/useMentionAutocomplete.test.tsx} (67%)
create mode 100644 src/test/hooks/feed/useDefaultFeedTab.test.tsx
create mode 100644 src/test/hooks/feed/useFading.test.tsx
create mode 100644 src/test/hooks/feed/useFeedCache.test.tsx
create mode 100644 src/test/hooks/feed/useInfiniteScroll.test.tsx
create mode 100644 src/test/hooks/feed/usePositionAwareSync.test.tsx
create mode 100644 src/test/hooks/feed/useScrollSave.test.tsx
delete mode 100644 src/test/hooks/misc.test.tsx
create mode 100644 src/test/hooks/player/usePlayRichContent.test.tsx
create mode 100644 src/test/hooks/player/useYouTubePlaylist.test.tsx
create mode 100644 src/test/hooks/playlist/usePlaylistListSync.test.tsx
create mode 100644 src/test/hooks/shared/useAnchoredFloat.test.tsx
create mode 100644 src/test/hooks/shared/useDocumentTitle.test.tsx
create mode 100644 src/test/hooks/shared/useHeightTransition.test.tsx
create mode 100644 src/test/hooks/shared/useScrollLock.test.tsx
create mode 100644 src/test/hooks/shared/useSoundsEnabled.test.tsx
create mode 100644 src/test/hooks/shared/useTabParam.test.tsx
delete mode 100644 src/test/hooks/sync.test.tsx
rename src/test/{utils => }/model.test.ts (99%)
create mode 100644 src/test/pages/NotFound.test.tsx
create mode 100644 src/test/pages/ResetPassword.test.tsx
create mode 100644 src/test/pages/RestrictedGuest.test.tsx
create mode 100644 src/test/pages/RestrictedLoggedIn.test.tsx
rename src/test/pages/{UserSubpages.test.tsx => UserDumps.test.tsx} (56%)
create mode 100644 src/test/pages/UserLogin.test.tsx
create mode 100644 src/test/pages/UserRegister.test.tsx
create mode 100644 src/test/pages/UserUpvoted.test.tsx
delete mode 100644 src/test/pages/auth.test.tsx
create mode 100644 src/test/utils/auth/permissions.test.ts
create mode 100644 src/test/utils/auth/storage.test.ts
create mode 100644 src/test/utils/chat/text.test.ts
create mode 100644 src/test/utils/dump/draft.test.ts
create mode 100644 src/test/utils/editor/text.test.ts
create mode 100644 src/test/utils/feed/hotScore.test.ts
rename src/test/utils/{journal.test.ts => feed/journalLayout.test.ts} (90%)
delete mode 100644 src/test/utils/misc.test.ts
create mode 100644 src/test/utils/player/bandcamp.test.ts
create mode 100644 src/test/utils/player/duration.test.ts
create mode 100644 src/test/utils/player/streamSources.test.ts
create mode 100644 src/test/utils/player/waveform.test.ts
create mode 100644 src/test/utils/player/youtube.test.ts
delete mode 100644 src/test/utils/remote.test.ts
create mode 100644 src/test/utils/shared/api.test.ts
create mode 100644 src/test/utils/shared/charCount.test.ts
create mode 100644 src/test/utils/shared/format.test.ts
create mode 100644 src/test/utils/shared/proxyImage.test.ts
create mode 100644 src/test/utils/shared/relativeTime.test.ts
rename src/test/utils/{ => shared}/sound.test.ts (98%)
create mode 100644 src/test/utils/shared/thumbnailTint.test.ts
create mode 100644 src/test/utils/shared/urls.test.ts
create mode 100644 src/test/utils/shared/visited.test.ts
delete mode 100644 src/test/utils/storage.test.ts
delete mode 100644 src/test/utils/text.test.ts
delete mode 100644 src/utils/apiError.ts
rename src/utils/{ => auth}/permissions.ts (93%)
rename src/utils/{authStorage.ts => auth/storage.ts} (95%)
rename src/utils/{chatDraft.ts => chat/draft.ts} (94%)
rename src/{components => utils}/chat/message.ts (87%)
rename src/utils/{chatText.ts => chat/text.ts} (100%)
rename src/{components => utils}/chat/time.ts (100%)
rename src/utils/{dumpDraft.ts => dump/draft.ts} (100%)
rename src/{components => utils}/editor/skinTone.ts (100%)
rename src/utils/{editorText.ts => editor/text.ts} (100%)
rename src/utils/{ => feed}/hotScore.ts (80%)
rename src/utils/{ => feed}/journalLayout.ts (99%)
rename src/utils/{ => player}/bandcamp.ts (94%)
rename src/utils/{ => player}/duration.ts (100%)
rename src/utils/{ => player}/streamSources.ts (93%)
rename src/{components => utils}/player/volume.ts (100%)
rename src/utils/{ => player}/waveform.ts (100%)
rename src/utils/{ => player}/youtube.ts (97%)
create mode 100644 src/utils/shared/api.ts
rename src/utils/{ => shared}/charCount.ts (100%)
rename src/utils/{ => shared}/format.ts (100%)
rename src/utils/{ => shared}/proxyImage.ts (97%)
rename src/utils/{ => shared}/relativeTime.ts (95%)
rename src/utils/{ => shared}/sound.ts (100%)
rename src/utils/{ => shared}/thumbnailTint.ts (100%)
rename src/utils/{ => shared}/urls.ts (98%)
rename src/utils/{ => shared}/visited.ts (100%)
diff --git a/README.md b/README.md
index e4d2ae9d..0977fc54 100644
--- a/README.md
+++ b/README.md
@@ -124,29 +124,23 @@ api/
uploads/ # User-uploaded files — avatars, dumps, thumbnails, … (not committed)
src/ # React frontend (Vite)
config/ # API base URL, validation constants, feed tabs, upload limits
- pages/ # Route-level components
- index/ # Feed views (hot, new, followed, journal), feed tab bar, presence row
- components/ # Shared UI components, by feature
- category/ # Category picker, switcher and manager
- chat/ # The chat modal and its message rows
- comment/ # Comment threads, nodes and forms
- dump/ # Dump cards, rich-content and file previews
- create/ # The three-step new-dump modal
- editor/ # Text editor, emoji picker, mentions, Markdown
- form/ # Reusable form controls
- layout/ # Header, page shell, floating buttons
- player/ # Media player, global player, seek bar, waveform
- playlist/ # Playlist cards, creation and membership
- profile/ # Profile subpage header, password change
- social/ # Vote, like and follow buttons, who-voted popover
- ui/ # Generic pieces: modal, tooltip, tabs, avatar, thumbnails
- test/ # Frontend tests (Vitest), mirroring src/
- contexts/ # Auth, WebSocket, player, follows, theme
- hooks/ # Data fetching and UI hooks
- utils/ # Formatting, URL, hot-score, waveform, … helpers
+ pages/ # One component per route, nothing else
+ components/ # React components only (.tsx)
+ contexts/ # FooContext.ts + FooProvider.tsx pairs
+ hooks/ # Every hook (useFoo.ts)
+ utils/ # Every non-React module: pure helpers, storage, API glue
+ test/ # Frontend tests (Vitest): test///.test.tsx
locales/ # Lingui message catalogues (en, fr)
themes/ # Per-theme CSS files
model.ts # Shared frontend types
i18n.ts # Lingui runtime setup
public/ # Static assets (favicon, manifest, service worker)
```
+
+Each of `components/`, `contexts/`, `hooks/` and `utils/` is split into the
+same domain folders — `auth`, `category`, `chat`, `comment`, `dump`, `editor`,
+`feed`, `form`, `layout`, `player`, `playlist`, `profile`, `social`, `theme`,
+`ws` — plus `shared/` for what belongs to no single domain. So the chat lives in
+`components/chat/`, `contexts/chat/`, `hooks/chat/` and `utils/chat/`, and its
+tests in `test/`. Files are imported directly (no `index.ts`
+barrels), and everything uses named exports.
diff --git a/api/services/dump-service.ts b/api/services/dump-service.ts
index 502bd19e..f8af384d 100644
--- a/api/services/dump-service.ts
+++ b/api/services/dump-service.ts
@@ -240,7 +240,7 @@ export async function createFileDump(
/**
* A file dump's version as the app puts it in file and thumbnail URLs
* (`?v=`): it changes whenever the file can have. Mirrored by
- * `dumpFileVersion` in src/utils/urls.ts.
+ * `dumpFileVersion` in src/utils/shared/urls.ts.
*/
export function dumpFileVersion(
dump: Pick,
diff --git a/src/App.tsx b/src/App.tsx
index 205d2447..c7df7e08 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -10,17 +10,17 @@ import {
import { RestrictedGuest } from "./pages/RestrictedGuest.tsx";
import { RestrictedLoggedIn } from "./pages/RestrictedLoggedIn.tsx";
-import { useAuth } from "./hooks/useAuth.ts";
-import { useDefaultFeedTab } from "./hooks/useDefaultFeedTab.ts";
+import { useAuth } from "./hooks/auth/useAuth.ts";
+import { useDefaultFeedTab } from "./hooks/feed/useDefaultFeedTab.ts";
import { DEFAULT_FEED_TAB } from "./config/feedTabs.ts";
-import { AuthProvider } from "./contexts/AuthProvider.tsx";
-import { PlayerProvider } from "./contexts/PlayerProvider.tsx";
-import { WSProvider } from "./contexts/WSProvider.tsx";
-import { FollowProvider } from "./contexts/FollowProvider.tsx";
-import { CategoriesProvider } from "./contexts/CategoriesProvider.tsx";
-import { ThemeProvider } from "./contexts/ThemeProvider.tsx";
-import { ChatProvider } from "./contexts/ChatProvider.tsx";
+import { AuthProvider } from "./contexts/auth/AuthProvider.tsx";
+import { PlayerProvider } from "./contexts/player/PlayerProvider.tsx";
+import { WSProvider } from "./contexts/ws/WSProvider.tsx";
+import { FollowProvider } from "./contexts/social/FollowProvider.tsx";
+import { CategoriesProvider } from "./contexts/category/CategoriesProvider.tsx";
+import { ThemeProvider } from "./contexts/theme/ThemeProvider.tsx";
+import { ChatProvider } from "./contexts/chat/ChatProvider.tsx";
import { GlobalPlayer } from "./components/player/GlobalPlayer.tsx";
import { DumpFab } from "./components/layout/DumpFab.tsx";
import { ChatFab } from "./components/layout/ChatFab.tsx";
@@ -181,7 +181,7 @@ function AppRoutes() {
);
}
-function App() {
+export function App() {
return (
@@ -203,5 +203,3 @@ function App() {
);
}
-
-export default App;
diff --git a/src/components/category/CategoryManager.tsx b/src/components/category/CategoryManager.tsx
index c240d55311ceaba15df445366ee09b39bc9eeaf9..3d52ac309c31b17ea25afe9422c3e2598d1091c3 100644
GIT binary patch
delta 305
zcmextw$frlG$Tu5X-UT9C`L<;7wuKJ?X#N-m)jQsp;U66KU4Z)>JxtS$Sr6nc#c^K9p
lOQPFUTAb=wkO^`KvNoWnK-LuNgPjRg!wgik*@?+e5cCYA*l)
delta 121
zcmZ2!@!4!cG~?t1MvKV{8BLfl!@h1P)MyPNKG#B&(;C5-SUfaU5kqHi(s69
zqWrSVlvFT3xHKs@v&5;iq$EENBwt#b>R6Bo(xSsvtB_WdpR1sxr>CC=6wuGiOG&NR
J{Da9*5&*4PD2D(5
diff --git a/src/components/category/CategorySelect.tsx b/src/components/category/CategorySelect.tsx
index fca16bad..e7e01d17 100644
--- a/src/components/category/CategorySelect.tsx
+++ b/src/components/category/CategorySelect.tsx
@@ -1,5 +1,5 @@
import { Trans } from "@lingui/react/macro";
-import { useCategories } from "../../hooks/useCategories.ts";
+import { useCategories } from "../../hooks/category/useCategories.ts";
/**
* Multi-select of categories rendered as toggle chips. Renders nothing when no
diff --git a/src/components/category/CategorySwitcher.tsx b/src/components/category/CategorySwitcher.tsx
index 9f8cf100..4f053dc1 100644
--- a/src/components/category/CategorySwitcher.tsx
+++ b/src/components/category/CategorySwitcher.tsx
@@ -8,7 +8,7 @@ import {
import { createPortal } from "react-dom";
import { useNavigate, useParams } from "react-router";
import { t } from "@lingui/core/macro";
-import { useCategories } from "../../hooks/useCategories.ts";
+import { useCategories } from "../../hooks/category/useCategories.ts";
import { DEFAULT_FEED_TAB } from "../../config/feedTabs.ts";
/**
diff --git a/src/components/chat/ChatEditBox.tsx b/src/components/chat/ChatEditBox.tsx
index a2e3104a..ec3fda5b 100644
--- a/src/components/chat/ChatEditBox.tsx
+++ b/src/components/chat/ChatEditBox.tsx
@@ -1,7 +1,11 @@
import { useEffect, useRef, useState } from "react";
import { Trans } from "@lingui/react/macro";
import { TextEditor, type TextEditorHandle } from "../editor/TextEditor.tsx";
-import { COUNT_FROM, MAX_CHAT_LENGTH, sendable } from "./message.ts";
+import {
+ COUNT_FROM,
+ MAX_CHAT_LENGTH,
+ sendable,
+} from "../../utils/chat/message.ts";
export interface ChatEditBoxProps {
initial: string;
diff --git a/src/components/chat/ChatMessageItem.tsx b/src/components/chat/ChatMessageItem.tsx
index 66572978..743d47e3 100644
--- a/src/components/chat/ChatMessageItem.tsx
+++ b/src/components/chat/ChatMessageItem.tsx
@@ -3,16 +3,16 @@ import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { Link } from "react-router";
import { Markdown } from "../editor/Markdown.tsx";
-import { Avatar } from "../ui/Avatar.tsx";
-import { ConfirmModal } from "../ui/ConfirmModal.tsx";
-import { Tooltip } from "../ui/Tooltip.tsx";
-import { useHeightTransition } from "../../hooks/useHeightTransition.ts";
-import { relativeTime } from "../../utils/relativeTime.ts";
+import { Avatar } from "../shared/Avatar.tsx";
+import { ConfirmModal } from "../shared/ConfirmModal.tsx";
+import { Tooltip } from "../shared/Tooltip.tsx";
+import { useHeightTransition } from "../../hooks/shared/useHeightTransition.ts";
+import { relativeTime } from "../../utils/shared/relativeTime.ts";
import type { ChatMessage } from "../../model.ts";
import { ChatEditBox } from "./ChatEditBox.tsx";
import { ReplyRef } from "./ReplyRef.tsx";
-import { messageTextClass } from "./message.ts";
-import { clockTime } from "./time.ts";
+import { messageTextClass } from "../../utils/chat/message.ts";
+import { clockTime } from "../../utils/chat/time.ts";
export interface ChatMessageItemProps {
message: ChatMessage;
diff --git a/src/components/chat/ChatModal.tsx b/src/components/chat/ChatModal.tsx
index 77f71724..3d4ae462 100644
--- a/src/components/chat/ChatModal.tsx
+++ b/src/components/chat/ChatModal.tsx
@@ -9,19 +9,19 @@ import {
} from "react";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
-import { Avatar } from "../ui/Avatar.tsx";
-import { Modal } from "../ui/Modal.tsx";
+import { Avatar } from "../shared/Avatar.tsx";
+import { Modal } from "../shared/Modal.tsx";
import { TextEditor, type TextEditorHandle } from "../editor/TextEditor.tsx";
-import { useWS, useWSChat } from "../../hooks/useWS.ts";
-import { useAuth } from "../../hooks/useAuth.ts";
-import { can } from "../../utils/permissions.ts";
-import { readDraft, writeDraft } from "../../utils/chatDraft.ts";
-import { mentionsUser } from "../../utils/chatText.ts";
+import { useWS, useWSChat } from "../../hooks/ws/useWS.ts";
+import { useAuth } from "../../hooks/auth/useAuth.ts";
+import { can } from "../../utils/auth/permissions.ts";
+import { readDraft, writeDraft } from "../../utils/chat/draft.ts";
+import { mentionsUser } from "../../utils/chat/text.ts";
import { API_URL } from "../../config/api.ts";
import type {
ChatReplyTarget,
PendingChatMessage,
-} from "../../contexts/WSContext.ts";
+} from "../../contexts/ws/WSContext.ts";
import {
type ChatMessage,
deserializeChatMessage,
@@ -29,8 +29,13 @@ import {
} from "../../model.ts";
import { ChatMessageItem } from "./ChatMessageItem.tsx";
import { PendingItem } from "./PendingItem.tsx";
-import { COUNT_FROM, MAX_CHAT_LENGTH, sendable, snippet } from "./message.ts";
-import { dayLabel, isSameDay } from "./time.ts";
+import {
+ COUNT_FROM,
+ MAX_CHAT_LENGTH,
+ sendable,
+ snippet,
+} from "../../utils/chat/message.ts";
+import { dayLabel, isSameDay } from "../../utils/chat/time.ts";
// Matches the server's default page size; a full page means there may be more.
const PAGE_SIZE = 50;
diff --git a/src/components/chat/PendingItem.tsx b/src/components/chat/PendingItem.tsx
index 240cc78e..8074a877 100644
--- a/src/components/chat/PendingItem.tsx
+++ b/src/components/chat/PendingItem.tsx
@@ -1,10 +1,10 @@
import { Trans } from "@lingui/react/macro";
import { Markdown } from "../editor/Markdown.tsx";
-import { Avatar } from "../ui/Avatar.tsx";
-import type { PendingChatMessage } from "../../contexts/WSContext.ts";
+import { Avatar } from "../shared/Avatar.tsx";
+import type { PendingChatMessage } from "../../contexts/ws/WSContext.ts";
import type { PublicUser } from "../../model.ts";
import { ReplyRef } from "./ReplyRef.tsx";
-import { messageTextClass } from "./message.ts";
+import { messageTextClass } from "../../utils/chat/message.ts";
export interface PendingItemProps {
pending: PendingChatMessage;
diff --git a/src/components/chat/ReplyRef.tsx b/src/components/chat/ReplyRef.tsx
index d29fd90a..bdd73c4f 100644
--- a/src/components/chat/ReplyRef.tsx
+++ b/src/components/chat/ReplyRef.tsx
@@ -1,5 +1,5 @@
import { Trans } from "@lingui/react/macro";
-import { snippet } from "./message.ts";
+import { snippet } from "../../utils/chat/message.ts";
export interface ReplyRefProps {
replyToId: string;
diff --git a/src/components/comment/CommentForm.tsx b/src/components/comment/CommentForm.tsx
index 196c4ebe..6baffee8 100644
--- a/src/components/comment/CommentForm.tsx
+++ b/src/components/comment/CommentForm.tsx
@@ -1,14 +1,11 @@
import React from "react";
-import { Controller } from "react-hook-form";
+import { Controller, FormProvider } from "react-hook-form";
import { Trans } from "@lingui/react/macro";
import { VALIDATION } from "../../config/api.ts";
import { TextEditor, type TextEditorHandle } from "../editor/TextEditor.tsx";
-import {
- FormError,
- FormProvider,
- SubmitButton,
- useApiForm,
-} from "../form/index.ts";
+import { FormError } from "../form/FormError.tsx";
+import { SubmitButton } from "../form/SubmitButton.tsx";
+import { useApiForm } from "../../hooks/form/useApiForm.ts";
export interface CommentFormProps {
/** Async submit; throw to surface a root error, resolve to clear/close. */
diff --git a/src/components/comment/CommentNode.tsx b/src/components/comment/CommentNode.tsx
index ffa41971..4167b607 100644
--- a/src/components/comment/CommentNode.tsx
+++ b/src/components/comment/CommentNode.tsx
@@ -3,7 +3,7 @@ import { Link } from "react-router";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { API_URL } from "../../config/api.ts";
-import { can } from "../../utils/permissions.ts";
+import { can } from "../../utils/auth/permissions.ts";
import type {
Comment,
CreateCommentRequest,
@@ -12,17 +12,17 @@ import type {
User,
} from "../../model.ts";
import { deserializeComment } from "../../model.ts";
-import { Avatar } from "../ui/Avatar.tsx";
+import { Avatar } from "../shared/Avatar.tsx";
import { Markdown } from "../editor/Markdown.tsx";
import type { TextEditorHandle } from "../editor/TextEditor.tsx";
import { LikeButton } from "../social/LikeButton.tsx";
-import { relativeTime } from "../../utils/relativeTime.ts";
-import { Tooltip } from "../ui/Tooltip.tsx";
-import { ConfirmModal } from "../ui/ConfirmModal.tsx";
-import { useHeightTransition } from "../../hooks/useHeightTransition.ts";
-import { useWS } from "../../hooks/useWS.ts";
-import { useAuth } from "../../hooks/useAuth.ts";
-import { expectOk } from "../form/index.ts";
+import { relativeTime } from "../../utils/shared/relativeTime.ts";
+import { Tooltip } from "../shared/Tooltip.tsx";
+import { ConfirmModal } from "../shared/ConfirmModal.tsx";
+import { useHeightTransition } from "../../hooks/shared/useHeightTransition.ts";
+import { useWS } from "../../hooks/ws/useWS.ts";
+import { useAuth } from "../../hooks/auth/useAuth.ts";
+import { expectOk } from "../../utils/shared/api.ts";
import { CommentForm } from "./CommentForm.tsx";
interface CommentNodeProps {
diff --git a/src/components/comment/CommentThread.tsx b/src/components/comment/CommentThread.tsx
index 509dc647..f545eb32 100644
--- a/src/components/comment/CommentThread.tsx
+++ b/src/components/comment/CommentThread.tsx
@@ -9,9 +9,9 @@ import type {
User,
} from "../../model.ts";
import { deserializeComment } from "../../model.ts";
-import { Avatar } from "../ui/Avatar.tsx";
-import { useAuth } from "../../hooks/useAuth.ts";
-import { expectOk } from "../form/index.ts";
+import { Avatar } from "../shared/Avatar.tsx";
+import { useAuth } from "../../hooks/auth/useAuth.ts";
+import { expectOk } from "../../utils/shared/api.ts";
import { CommentForm } from "./CommentForm.tsx";
import { CommentNode } from "./CommentNode.tsx";
diff --git a/src/components/dump/DumpCard.tsx b/src/components/dump/DumpCard.tsx
index e5e784ab..01f6123a 100644
--- a/src/components/dump/DumpCard.tsx
+++ b/src/components/dump/DumpCard.tsx
@@ -2,22 +2,22 @@ import { memo } from "react";
import { Link } from "react-router";
import { Plural, Trans } from "@lingui/react/macro";
import type { Dump } from "../../model.ts";
-import { relativeTime } from "../../utils/relativeTime.ts";
-import { dumpThumbnailUrl, dumpUrl } from "../../utils/urls.ts";
-import { useAuth } from "../../hooks/useAuth.ts";
-import { useCategories } from "../../hooks/useCategories.ts";
-import { useDumpCardLink } from "../../hooks/useDumpCardLink.ts";
+import { relativeTime } from "../../utils/shared/relativeTime.ts";
+import { dumpThumbnailUrl, dumpUrl } from "../../utils/shared/urls.ts";
+import { useAuth } from "../../hooks/auth/useAuth.ts";
+import { useCategories } from "../../hooks/category/useCategories.ts";
+import { useDumpCardLink } from "../../hooks/dump/useDumpCardLink.ts";
import {
isDumpVisited,
isRecent,
markDumpVisited,
-} from "../../utils/visited.ts";
+} from "../../utils/shared/visited.ts";
import { FilePreview } from "./FilePreview.tsx";
import { RichContentCard } from "./RichContentCard.tsx";
-import { ThumbnailPlaceholder } from "../ui/ThumbnailPlaceholder.tsx";
+import { ThumbnailPlaceholder } from "../shared/ThumbnailPlaceholder.tsx";
import { VoteButton } from "../social/VoteButton.tsx";
import { Markdown } from "../editor/Markdown.tsx";
-import { Tooltip } from "../ui/Tooltip.tsx";
+import { Tooltip } from "../shared/Tooltip.tsx";
interface DumpCardProps {
dump: Dump;
diff --git a/src/components/dump/FilePreview.tsx b/src/components/dump/FilePreview.tsx
index 669298d4..f5c7db77 100644
--- a/src/components/dump/FilePreview.tsx
+++ b/src/components/dump/FilePreview.tsx
@@ -1,15 +1,19 @@
import { useContext, useEffect, useState } from "react";
import type { Dump } from "../../model.ts";
-import { formatBytes } from "../../utils/format.ts";
-import { dumpFileUrl, dumpThumbnailUrl, dumpUrl } from "../../utils/urls.ts";
-import { useAuth } from "../../hooks/useAuth.ts";
-import { IconPause, IconPlay } from "../player/icons.tsx";
+import { formatBytes } from "../../utils/shared/format.ts";
+import {
+ dumpFileUrl,
+ dumpThumbnailUrl,
+ dumpUrl,
+} from "../../utils/shared/urls.ts";
+import { useAuth } from "../../hooks/auth/useAuth.ts";
+import { IconPause, IconPlay } from "../player/PlayerIcons.tsx";
import { MediaPlayer } from "../player/MediaPlayer.tsx";
import {
PlayerContext,
type PlayerItem,
PlayerTimeContext,
-} from "../../contexts/PlayerContext.ts";
+} from "../../contexts/player/PlayerContext.ts";
import {
BAR_GAP,
BAR_W,
@@ -17,7 +21,7 @@ import {
NUM_BARS,
VIEWBOX_W,
WAVEFORM_H,
-} from "../../utils/waveform.ts";
+} from "../../utils/player/waveform.ts";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
diff --git a/src/components/dump/JournalCard.tsx b/src/components/dump/JournalCard.tsx
index 1a6304d9..720a6ef3 100644
--- a/src/components/dump/JournalCard.tsx
+++ b/src/components/dump/JournalCard.tsx
@@ -2,28 +2,28 @@ import { memo } from "react";
import { Link } from "react-router";
import { Plural, Trans } from "@lingui/react/macro";
import type { Dump } from "../../model.ts";
-import { relativeTime } from "../../utils/relativeTime.ts";
-import { dumpThumbnailUrl, dumpUrl } from "../../utils/urls.ts";
-import { useAuth } from "../../hooks/useAuth.ts";
-import { useDumpCardLink } from "../../hooks/useDumpCardLink.ts";
+import { relativeTime } from "../../utils/shared/relativeTime.ts";
+import { dumpThumbnailUrl, dumpUrl } from "../../utils/shared/urls.ts";
+import { useAuth } from "../../hooks/auth/useAuth.ts";
+import { useDumpCardLink } from "../../hooks/dump/useDumpCardLink.ts";
import {
isDumpVisited,
isRecent,
markDumpVisited,
-} from "../../utils/visited.ts";
+} from "../../utils/shared/visited.ts";
import {
hasQuote,
hasThumbnail,
type JournalShape,
-} from "../../utils/journalLayout.ts";
+} from "../../utils/feed/journalLayout.ts";
import { VoteButton } from "../social/VoteButton.tsx";
import { Markdown } from "../editor/Markdown.tsx";
-import { Tooltip } from "../ui/Tooltip.tsx";
-import { Thumbnail } from "../ui/Thumbnail.tsx";
+import { Tooltip } from "../shared/Tooltip.tsx";
+import { Thumbnail } from "../shared/Thumbnail.tsx";
import {
canPlayRichContent,
usePlayRichContent,
-} from "../../hooks/usePlayRichContent.ts";
+} from "../../hooks/player/usePlayRichContent.ts";
export type { JournalShape };
diff --git a/src/components/dump/RichContentCard.tsx b/src/components/dump/RichContentCard.tsx
index 9c9f71fa..84148587 100644
--- a/src/components/dump/RichContentCard.tsx
+++ b/src/components/dump/RichContentCard.tsx
@@ -1,12 +1,12 @@
import { useContext } from "react";
import { t } from "@lingui/core/macro";
import type { RichContent } from "../../model.ts";
-import { PlayerContext } from "../../contexts/PlayerContext.ts";
+import { PlayerContext } from "../../contexts/player/PlayerContext.ts";
import {
canPlayRichContent,
usePlayRichContent,
-} from "../../hooks/usePlayRichContent.ts";
-import { Thumbnail } from "../ui/Thumbnail.tsx";
+} from "../../hooks/player/usePlayRichContent.ts";
+import { Thumbnail } from "../shared/Thumbnail.tsx";
interface RichContentCardProps {
richContent: RichContent;
diff --git a/src/components/dump/create/DumpCreateModal.tsx b/src/components/dump/create/DumpCreateModal.tsx
index ce1428a5..b5dcc425 100644
--- a/src/components/dump/create/DumpCreateModal.tsx
+++ b/src/components/dump/create/DumpCreateModal.tsx
@@ -19,34 +19,32 @@ import {
deserializeDumpUrlMatch,
deserializePlaylistMembership,
} from "../../../model.ts";
-import { useAuth } from "../../../hooks/useAuth.ts";
-import { useWS } from "../../../hooks/useWS.ts";
-import { dumpUrl, normalizeUrl } from "../../../utils/urls.ts";
-import { titleFromFileName } from "../../../utils/format.ts";
+import { useAuth } from "../../../hooks/auth/useAuth.ts";
+import { useWS } from "../../../hooks/ws/useWS.ts";
+import { dumpUrl, normalizeUrl } from "../../../utils/shared/urls.ts";
+import { titleFromFileName } from "../../../utils/shared/format.ts";
import {
clearDumpDraft,
type DumpDraft,
EMPTY_DUMP_DRAFT,
loadDumpDraft,
saveDumpDraft,
-} from "../../../utils/dumpDraft.ts";
+} from "../../../utils/dump/draft.ts";
import { MAX_FILE_SIZE } from "../../../config/upload.ts";
import { RichContentCard } from "../RichContentCard.tsx";
import type { RichContent } from "../../../model.ts";
-import { Modal } from "../../ui/Modal.tsx";
+import { Modal } from "../../shared/Modal.tsx";
import type { TextEditorHandle } from "../../editor/TextEditor.tsx";
import { CategorySelect } from "../../category/CategorySelect.tsx";
import { PlaylistMembershipPanel } from "../../playlist/PlaylistMembershipPanel.tsx";
-import {
- expectOk,
- FileField,
- FormError,
- FormProvider,
- RichTextField,
- SubmitButton,
- useApiForm,
- VisibilityToggle,
-} from "../../form/index.ts";
+import { expectOk } from "../../../utils/shared/api.ts";
+import { FileField } from "../../form/FileField.tsx";
+import { FormError } from "../../form/FormError.tsx";
+import { FormProvider } from "react-hook-form";
+import { RichTextField } from "../../form/RichTextField.tsx";
+import { SubmitButton } from "../../form/SubmitButton.tsx";
+import { useApiForm } from "../../../hooks/form/useApiForm.ts";
+import { VisibilityToggle } from "../../form/SegmentedField.tsx";
import { DuplicateNotice } from "./DuplicateNotice.tsx";
import { LocalFilePreview } from "./LocalFilePreview.tsx";
import { type Step, StepIndicator } from "./StepIndicator.tsx";
diff --git a/src/components/dump/create/DuplicateNotice.tsx b/src/components/dump/create/DuplicateNotice.tsx
index 2fdc296f..6a18bb6d 100644
--- a/src/components/dump/create/DuplicateNotice.tsx
+++ b/src/components/dump/create/DuplicateNotice.tsx
@@ -1,8 +1,8 @@
import { Link } from "react-router";
import { Plural, Trans } from "@lingui/react/macro";
import type { DumpUrlMatch } from "../../../model.ts";
-import { dumpUrl } from "../../../utils/urls.ts";
-import { relativeTime } from "../../../utils/relativeTime.ts";
+import { dumpUrl } from "../../../utils/shared/urls.ts";
+import { relativeTime } from "../../../utils/shared/relativeTime.ts";
/** "Already dumped" hint — never blocking, since reposts are sometimes on purpose. */
export function DuplicateNotice(
diff --git a/src/components/editor/EmojiPickerPopup.tsx b/src/components/editor/EmojiPickerPopup.tsx
index 079d9a7b..caa98c64 100644
--- a/src/components/editor/EmojiPickerPopup.tsx
+++ b/src/components/editor/EmojiPickerPopup.tsx
@@ -12,7 +12,7 @@ import {
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { useLingui } from "@lingui/react";
-import { readSkinTone, SKIN_TONE_KEY } from "./skinTone.ts";
+import { readSkinTone, SKIN_TONE_KEY } from "../../utils/editor/skinTone.ts";
/** Remembers the skin tone picked in the selector for the next picker. */
function SkinTonePersist() {
diff --git a/src/components/editor/MentionDropdown.tsx b/src/components/editor/MentionDropdown.tsx
index cd228e12..adc1af00 100644
--- a/src/components/editor/MentionDropdown.tsx
+++ b/src/components/editor/MentionDropdown.tsx
@@ -1,6 +1,6 @@
import { type CSSProperties, useLayoutEffect, useRef } from "react";
-import { Avatar } from "../ui/Avatar.tsx";
-import type { UserResult } from "../../hooks/useMentionAutocomplete.ts";
+import { Avatar } from "../shared/Avatar.tsx";
+import type { UserResult } from "../../hooks/editor/useMentionAutocomplete.ts";
interface MentionDropdownProps {
/** For the textarea's `aria-controls` / `aria-activedescendant`. */
diff --git a/src/components/editor/TextEditor.tsx b/src/components/editor/TextEditor.tsx
index ea45f0d2..b7778fd7 100644
--- a/src/components/editor/TextEditor.tsx
+++ b/src/components/editor/TextEditor.tsx
@@ -11,13 +11,13 @@ import {
import { createPortal } from "react-dom";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
-import { charCountClass } from "../../utils/charCount.ts";
+import { charCountClass } from "../../utils/shared/charCount.ts";
import { EmojiPickerPopup } from "./EmojiPickerPopup.tsx";
import { MentionDropdown } from "./MentionDropdown.tsx";
-import { useMentionAutocomplete } from "../../hooks/useMentionAutocomplete.ts";
-import { useEmojiTrigger } from "../../hooks/useEmojiTrigger.ts";
-import { useAnchoredFloat } from "../../hooks/useAnchoredFloat.ts";
-import { useAuth } from "../../hooks/useAuth.ts";
+import { useMentionAutocomplete } from "../../hooks/editor/useMentionAutocomplete.ts";
+import { useEmojiTrigger } from "../../hooks/editor/useEmojiTrigger.ts";
+import { useAnchoredFloat } from "../../hooks/shared/useAnchoredFloat.ts";
+import { useAuth } from "../../hooks/auth/useAuth.ts";
import { API_URL } from "../../config/api.ts";
export interface TextEditorHandle {
diff --git a/src/pages/index/NewFeed.tsx b/src/components/feed/DumpFeed.tsx
similarity index 57%
rename from src/pages/index/NewFeed.tsx
rename to src/components/feed/DumpFeed.tsx
index c27618b6..85edc5a1 100644
--- a/src/pages/index/NewFeed.tsx
+++ b/src/components/feed/DumpFeed.tsx
@@ -1,12 +1,30 @@
-import { useMemo } from "react";
+import { type Ref, useMemo } from "react";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
-import { DumpCard } from "../../components/dump/DumpCard.tsx";
-import { ErrorCard } from "../../components/ui/ErrorCard.tsx";
-import type { MainFeedProps } from "./types.ts";
+import { DumpCard } from "../dump/DumpCard.tsx";
+import { ErrorCard } from "../shared/ErrorCard.tsx";
+import type { Dump, User } from "../../model.ts";
+import { hotScore } from "../../utils/feed/hotScore.ts";
-export function NewFeed(
+// What the index hands whichever main feed its tab shows.
+export interface MainFeedProps {
+ dumps: Dump[];
+ loading: boolean;
+ error: string | null;
+ hasMore: boolean;
+ loadingMore: boolean;
+ sentinelRef: Ref;
+ voteCounts: Record;
+ myVotes: Set;
+ user: User | null;
+ castVote: (id: string, shownCount?: number) => void;
+ removeVote: (id: string, shownCount?: number) => void;
+}
+
+// The hot and new tabs: the same list, ordered by hot score or by date.
+export function DumpFeed(
{
+ order,
dumps,
loading,
error,
@@ -18,12 +36,16 @@ export function NewFeed(
user,
castVote,
removeVote,
- }: MainFeedProps,
+ }: MainFeedProps & { order: "hot" | "new" },
) {
const sorted = useMemo(
() =>
- [...dumps].sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()),
- [dumps],
+ [...dumps].sort((a, b) =>
+ order === "hot"
+ ? hotScore(b) - hotScore(a)
+ : b.createdAt.getTime() - a.createdAt.getTime()
+ ),
+ [dumps, order],
);
if (loading) {
diff --git a/src/pages/index/FeedTabBar.tsx b/src/components/feed/FeedTabBar.tsx
similarity index 84%
rename from src/pages/index/FeedTabBar.tsx
rename to src/components/feed/FeedTabBar.tsx
index 9a0033df..8205fe74 100644
--- a/src/pages/index/FeedTabBar.tsx
+++ b/src/components/feed/FeedTabBar.tsx
@@ -1,14 +1,14 @@
import { useNavigate, useParams } from "react-router";
import { Trans } from "@lingui/react/macro";
-import { useAuth } from "../../hooks/useAuth.ts";
+import { useAuth } from "../../hooks/auth/useAuth.ts";
import {
DEFAULT_FEED_TAB,
FEED_TABS,
type FeedTab,
} from "../../config/feedTabs.ts";
-import { useDefaultFeedTab } from "../../hooks/useDefaultFeedTab.ts";
-import { TabBar } from "../../components/ui/TabBar.tsx";
-import { CategorySwitcher } from "../../components/category/CategorySwitcher.tsx";
+import { useDefaultFeedTab } from "../../hooks/feed/useDefaultFeedTab.ts";
+import { TabBar } from "../shared/TabBar.tsx";
+import { CategorySwitcher } from "../category/CategorySwitcher.tsx";
/**
* Feed sort tabs (hot/new/journal/followed). Navigation stays within the
diff --git a/src/pages/index/FollowedFeed.tsx b/src/components/feed/FollowedFeed.tsx
similarity index 95%
rename from src/pages/index/FollowedFeed.tsx
rename to src/components/feed/FollowedFeed.tsx
index efa6cb78..f0e065b3 100644
--- a/src/pages/index/FollowedFeed.tsx
+++ b/src/components/feed/FollowedFeed.tsx
@@ -1,15 +1,15 @@
import { useCallback, useEffect, useState } from "react";
-import { TabBar } from "../../components/ui/TabBar.tsx";
+import { TabBar } from "../shared/TabBar.tsx";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
-import { DumpCard } from "../../components/dump/DumpCard.tsx";
-import { ErrorCard } from "../../components/ui/ErrorCard.tsx";
+import { DumpCard } from "../dump/DumpCard.tsx";
+import { ErrorCard } from "../shared/ErrorCard.tsx";
import { API_URL, DEFAULT_PAGE_SIZE } from "../../config/api.ts";
-import { useAuth } from "../../hooks/useAuth.ts";
-import { useDumpListSync } from "../../hooks/useDumpListSync.ts";
-import { useFeedCache } from "../../hooks/useFeedCache.ts";
-import { useInfiniteScroll } from "../../hooks/useInfiniteScroll.ts";
-import { useScrollSave } from "../../hooks/useScrollSave.ts";
+import { useAuth } from "../../hooks/auth/useAuth.ts";
+import { useDumpListSync } from "../../hooks/dump/useDumpListSync.ts";
+import { useFeedCache } from "../../hooks/feed/useFeedCache.ts";
+import { useInfiniteScroll } from "../../hooks/feed/useInfiniteScroll.ts";
+import { useScrollSave } from "../../hooks/feed/useScrollSave.ts";
import {
deserializeDump,
type Dump,
@@ -18,7 +18,7 @@ import {
type RawDump,
type User,
} from "../../model.ts";
-import { friendlyFetchError } from "../../utils/apiError.ts";
+import { friendlyFetchError } from "../../utils/shared/api.ts";
type FeedState =
| { status: "loading" }
diff --git a/src/pages/index/JournalFeed.tsx b/src/components/feed/JournalFeed.tsx
similarity index 90%
rename from src/pages/index/JournalFeed.tsx
rename to src/components/feed/JournalFeed.tsx
index 09fcb32d..60978ec4 100644
--- a/src/pages/index/JournalFeed.tsx
+++ b/src/components/feed/JournalFeed.tsx
@@ -1,10 +1,10 @@
import { Fragment, useMemo } from "react";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
-import { ErrorCard } from "../../components/ui/ErrorCard.tsx";
-import { JournalCard } from "../../components/dump/JournalCard.tsx";
-import { composeJournal } from "../../utils/journalLayout.ts";
-import type { MainFeedProps } from "./types.ts";
+import { ErrorCard } from "../shared/ErrorCard.tsx";
+import { JournalCard } from "../dump/JournalCard.tsx";
+import { composeJournal } from "../../utils/feed/journalLayout.ts";
+import type { MainFeedProps } from "./DumpFeed.tsx";
export function JournalFeed(
{
diff --git a/src/pages/index/PresenceRow.tsx b/src/components/feed/PresenceRow.tsx
similarity index 93%
rename from src/pages/index/PresenceRow.tsx
rename to src/components/feed/PresenceRow.tsx
index 9026d08c..38b4aa48 100644
--- a/src/pages/index/PresenceRow.tsx
+++ b/src/components/feed/PresenceRow.tsx
@@ -1,9 +1,9 @@
import { useState } from "react";
import { Link } from "react-router";
import { t } from "@lingui/core/macro";
-import { Avatar } from "../../components/ui/Avatar.tsx";
-import { useWSPresence } from "../../hooks/useWS.ts";
-import { useRoomBeside } from "../../hooks/useRoomBeside.ts";
+import { Avatar } from "../shared/Avatar.tsx";
+import { useWSPresence } from "../../hooks/ws/useWS.ts";
+import { useRoomBeside } from "../../hooks/shared/useRoomBeside.ts";
// Past this many, the row ends in a "+N" counter rather than growing, so a
// busy evening doesn't push the feed tabs out of the header.
diff --git a/src/components/form/FileDropZone.tsx b/src/components/form/FileDropZone.tsx
index e6f78e6d..6a3cf689 100644
--- a/src/components/form/FileDropZone.tsx
+++ b/src/components/form/FileDropZone.tsx
@@ -1,7 +1,7 @@
import { useCallback, useRef, useState } from "react";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
-import { formatBytes } from "../../utils/format.ts";
+import { formatBytes } from "../../utils/shared/format.ts";
function fileIcon(mime: string): string {
if (mime.startsWith("image/")) return "🖼";
diff --git a/src/components/form/FormError.tsx b/src/components/form/FormError.tsx
index c4b96571..605d0484 100644
--- a/src/components/form/FormError.tsx
+++ b/src/components/form/FormError.tsx
@@ -1,7 +1,7 @@
import { t } from "@lingui/core/macro";
import { useFormState } from "react-hook-form";
-import { ErrorCard } from "../ui/ErrorCard.tsx";
+import { ErrorCard } from "../shared/ErrorCard.tsx";
/**
* Renders the form's root error (set by {@link useApiForm}'s `submit` on a
diff --git a/src/components/form/TextField.tsx b/src/components/form/TextField.tsx
index b4d55287..73756e6c 100644
--- a/src/components/form/TextField.tsx
+++ b/src/components/form/TextField.tsx
@@ -7,7 +7,7 @@ import {
useWatch,
} from "react-hook-form";
-import { charCountClass } from "../../utils/charCount.ts";
+import { charCountClass } from "../../utils/shared/charCount.ts";
interface TextFieldProps {
name: Path;
diff --git a/src/components/form/index.ts b/src/components/form/index.ts
deleted file mode 100644
index 7b349522..00000000
--- a/src/components/form/index.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-export { expectOk, useApiForm } from "./useApiForm.ts";
-export { TextField } from "./TextField.tsx";
-export { RichTextField } from "./RichTextField.tsx";
-export { SegmentedField, VisibilityToggle } from "./SegmentedField.tsx";
-export { FileField } from "./FileField.tsx";
-export { SubmitButton } from "./SubmitButton.tsx";
-export { FormActions } from "./FormActions.tsx";
-export { FormError } from "./FormError.tsx";
-export { FormProvider } from "react-hook-form";
diff --git a/src/components/layout/AppHeader.tsx b/src/components/layout/AppHeader.tsx
index acfec65c..09e48cfb 100644
--- a/src/components/layout/AppHeader.tsx
+++ b/src/components/layout/AppHeader.tsx
@@ -2,13 +2,13 @@ import { lazy, type ReactNode, Suspense, useRef, useState } from "react";
import { Link, useLocation, useNavigate, useSearchParams } from "react-router";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
-import { useAuth } from "../../hooks/useAuth.ts";
-import { useWS } from "../../hooks/useWS.ts";
+import { useAuth } from "../../hooks/auth/useAuth.ts";
+import { useWS } from "../../hooks/ws/useWS.ts";
import { NotificationBell } from "./NotificationBell.tsx";
import { ChatButton } from "./ChatButton.tsx";
import { UserMenu } from "./UserMenu.tsx";
import { SearchBar } from "./SearchBar.tsx";
-import { SITE_EMOJI, SITE_NAME } from "../../hooks/useDocumentTitle.ts";
+import { SITE_EMOJI, SITE_NAME } from "../../hooks/shared/useDocumentTitle.ts";
const DumpCreateModal = lazy(() =>
import("../dump/create/DumpCreateModal.tsx").then((m) => ({
diff --git a/src/components/layout/ChatButton.tsx b/src/components/layout/ChatButton.tsx
index 8ddc9aa1..5a626d18 100644
--- a/src/components/layout/ChatButton.tsx
+++ b/src/components/layout/ChatButton.tsx
@@ -1,6 +1,6 @@
import { t } from "@lingui/core/macro";
-import { useWSChat } from "../../hooks/useWS.ts";
-import { useChat } from "../../hooks/useChat.ts";
+import { useWSChat } from "../../hooks/ws/useWS.ts";
+import { useChat } from "../../hooks/chat/useChat.ts";
/** Header chat button with an unread-message badge. Opens the app-wide chat. */
export function ChatButton() {
diff --git a/src/components/layout/ChatFab.tsx b/src/components/layout/ChatFab.tsx
index 4be59dea..9754c7c7 100644
--- a/src/components/layout/ChatFab.tsx
+++ b/src/components/layout/ChatFab.tsx
@@ -1,9 +1,9 @@
import { useEffect, useState } from "react";
import { useLocation } from "react-router";
import { t } from "@lingui/core/macro";
-import { useAuth } from "../../hooks/useAuth.ts";
-import { useWSChat } from "../../hooks/useWS.ts";
-import { useChat } from "../../hooks/useChat.ts";
+import { useAuth } from "../../hooks/auth/useAuth.ts";
+import { useWSChat } from "../../hooks/ws/useWS.ts";
+import { useChat } from "../../hooks/chat/useChat.ts";
/**
* Floating chat button shown once the header has scrolled out of view, mirroring
diff --git a/src/components/layout/DumpFab.tsx b/src/components/layout/DumpFab.tsx
index f32860d0..5535db7b 100644
--- a/src/components/layout/DumpFab.tsx
+++ b/src/components/layout/DumpFab.tsx
@@ -1,7 +1,7 @@
import { lazy, Suspense, useEffect, useState } from "react";
import { useLocation } from "react-router";
import { t } from "@lingui/core/macro";
-import { useAuth } from "../../hooks/useAuth.ts";
+import { useAuth } from "../../hooks/auth/useAuth.ts";
const DumpCreateModal = lazy(() =>
import("../dump/create/DumpCreateModal.tsx").then((m) => ({
diff --git a/src/components/layout/NotificationBell.tsx b/src/components/layout/NotificationBell.tsx
index f63d5324..0567cfe8 100644
--- a/src/components/layout/NotificationBell.tsx
+++ b/src/components/layout/NotificationBell.tsx
@@ -1,7 +1,7 @@
import { useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router";
import { t } from "@lingui/core/macro";
-import { useWS } from "../../hooks/useWS.ts";
+import { useWS } from "../../hooks/ws/useWS.ts";
export function NotificationBell() {
const { unreadNotificationCount, lastNotification } = useWS();
diff --git a/src/components/layout/PageError.tsx b/src/components/layout/PageError.tsx
index 3e8414b7..4c0b5f44 100644
--- a/src/components/layout/PageError.tsx
+++ b/src/components/layout/PageError.tsx
@@ -1,7 +1,7 @@
import type { ReactNode } from "react";
import { t } from "@lingui/core/macro";
import { PageShell } from "./PageShell.tsx";
-import { ErrorCard } from "../ui/ErrorCard.tsx";
+import { ErrorCard } from "../shared/ErrorCard.tsx";
export function PageError(
{ title, message, actions }: {
diff --git a/src/components/layout/UserMenu.tsx b/src/components/layout/UserMenu.tsx
index 6b24eb59..3383825e 100644
--- a/src/components/layout/UserMenu.tsx
+++ b/src/components/layout/UserMenu.tsx
@@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from "react";
import { Link } from "react-router";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
-import { Avatar } from "../ui/Avatar.tsx";
+import { Avatar } from "../shared/Avatar.tsx";
import type { User } from "../../model.ts";
export function UserMenu({ user }: { user: User }) {
diff --git a/src/components/player/GlobalPlayer.tsx b/src/components/player/GlobalPlayer.tsx
index b815ee39..9810ba5b 100644
--- a/src/components/player/GlobalPlayer.tsx
+++ b/src/components/player/GlobalPlayer.tsx
@@ -6,12 +6,12 @@ import {
PlayerContext,
type PlayerItem,
playerItemKey,
-} from "../../contexts/PlayerContext.ts";
+} from "../../contexts/player/PlayerContext.ts";
import { MediaPlayer } from "./MediaPlayer.tsx";
-import { fmt } from "../../utils/duration.ts";
-import { Thumbnail } from "../ui/Thumbnail.tsx";
-import { useYouTubePlaylist } from "../../hooks/useYouTubePlaylist.ts";
-import { embedListId, withJsApi } from "../../utils/youtube.ts";
+import { fmt } from "../../utils/player/duration.ts";
+import { Thumbnail } from "../shared/Thumbnail.tsx";
+import { useYouTubePlaylist } from "../../hooks/player/useYouTubePlaylist.ts";
+import { embedListId, withJsApi } from "../../utils/player/youtube.ts";
type EmbedItem = Extract;
diff --git a/src/components/player/MediaPlayer.tsx b/src/components/player/MediaPlayer.tsx
index a957b856..f7195da4 100644
--- a/src/components/player/MediaPlayer.tsx
+++ b/src/components/player/MediaPlayer.tsx
@@ -1,10 +1,15 @@
import { useEffect, useRef, useState } from "react";
-import { fmt } from "../../utils/duration.ts";
+import { fmt } from "../../utils/player/duration.ts";
import { t } from "@lingui/core/macro";
-import { IconFullscreen, IconPause, IconPlay, IconVolume } from "./icons.tsx";
+import {
+ IconFullscreen,
+ IconPause,
+ IconPlay,
+ IconVolume,
+} from "./PlayerIcons.tsx";
import { SeekBar } from "./SeekBar.tsx";
import { Waveform } from "./Waveform.tsx";
-import { rememberVolume, storedVolume } from "./volume.ts";
+import { rememberVolume, storedVolume } from "../../utils/player/volume.ts";
const HIDE_DELAY = 2500;
diff --git a/src/components/player/icons.tsx b/src/components/player/PlayerIcons.tsx
similarity index 100%
rename from src/components/player/icons.tsx
rename to src/components/player/PlayerIcons.tsx
diff --git a/src/components/player/Waveform.tsx b/src/components/player/Waveform.tsx
index 40737d90..49c8db21 100644
--- a/src/components/player/Waveform.tsx
+++ b/src/components/player/Waveform.tsx
@@ -6,7 +6,7 @@ import {
NUM_BARS,
VIEWBOX_W,
WAVEFORM_H,
-} from "../../utils/waveform.ts";
+} from "../../utils/player/waveform.ts";
export function Waveform(
{ src, current, duration, onSeek }: {
diff --git a/src/components/playlist/AddToPlaylistModal.tsx b/src/components/playlist/AddToPlaylistModal.tsx
index 42516a78..6e118e13 100644
--- a/src/components/playlist/AddToPlaylistModal.tsx
+++ b/src/components/playlist/AddToPlaylistModal.tsx
@@ -1,10 +1,10 @@
import { useEffect, useState } from "react";
import { t } from "@lingui/core/macro";
import { API_URL } from "../../config/api.ts";
-import { useAuth } from "../../hooks/useAuth.ts";
+import { useAuth } from "../../hooks/auth/useAuth.ts";
import type { PlaylistMembership, RawPlaylistMembership } from "../../model.ts";
import { deserializePlaylistMembership } from "../../model.ts";
-import { Modal } from "../ui/Modal.tsx";
+import { Modal } from "../shared/Modal.tsx";
import { PlaylistMembershipPanel } from "./PlaylistMembershipPanel.tsx";
interface AddToPlaylistModalProps {
diff --git a/src/components/playlist/NewPlaylistButton.tsx b/src/components/playlist/NewPlaylistButton.tsx
index 37efc97e..fc576b60 100644
--- a/src/components/playlist/NewPlaylistButton.tsx
+++ b/src/components/playlist/NewPlaylistButton.tsx
@@ -2,7 +2,7 @@ import { useState } from "react";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import type { Playlist } from "../../model.ts";
-import { Modal } from "../ui/Modal.tsx";
+import { Modal } from "../shared/Modal.tsx";
import { PlaylistCreateForm } from "./PlaylistCreateForm.tsx";
interface NewPlaylistButtonProps {
diff --git a/src/components/playlist/PlaylistCard.tsx b/src/components/playlist/PlaylistCard.tsx
index 92aeb580..77d2267e 100644
--- a/src/components/playlist/PlaylistCard.tsx
+++ b/src/components/playlist/PlaylistCard.tsx
@@ -2,14 +2,14 @@ import { Link, useNavigate } from "react-router";
import { t } from "@lingui/core/macro";
import { Plural, Trans } from "@lingui/react/macro";
import type { Playlist } from "../../model.ts";
-import { relativeTime } from "../../utils/relativeTime.ts";
-import { playlistImageUrl, playlistUrl } from "../../utils/urls.ts";
+import { relativeTime } from "../../utils/shared/relativeTime.ts";
+import { playlistImageUrl, playlistUrl } from "../../utils/shared/urls.ts";
import {
isPlaylistVisited,
isRecent,
markPlaylistVisited,
-} from "../../utils/visited.ts";
-import { Tooltip } from "../ui/Tooltip.tsx";
+} from "../../utils/shared/visited.ts";
+import { Tooltip } from "../shared/Tooltip.tsx";
interface PlaylistCardProps {
playlist: Playlist;
diff --git a/src/components/playlist/PlaylistCreateForm.tsx b/src/components/playlist/PlaylistCreateForm.tsx
index 664c9a6c..64729036 100644
--- a/src/components/playlist/PlaylistCreateForm.tsx
+++ b/src/components/playlist/PlaylistCreateForm.tsx
@@ -7,18 +7,16 @@ import type {
RawPlaylist,
} from "../../model.ts";
import { deserializePlaylist } from "../../model.ts";
-import { useAuth } from "../../hooks/useAuth.ts";
-import {
- expectOk,
- FormActions,
- FormError,
- FormProvider,
- RichTextField,
- SubmitButton,
- TextField,
- useApiForm,
- VisibilityToggle,
-} from "../form/index.ts";
+import { useAuth } from "../../hooks/auth/useAuth.ts";
+import { expectOk } from "../../utils/shared/api.ts";
+import { FormActions } from "../form/FormActions.tsx";
+import { FormError } from "../form/FormError.tsx";
+import { FormProvider } from "react-hook-form";
+import { RichTextField } from "../form/RichTextField.tsx";
+import { SubmitButton } from "../form/SubmitButton.tsx";
+import { TextField } from "../form/TextField.tsx";
+import { useApiForm } from "../../hooks/form/useApiForm.ts";
+import { VisibilityToggle } from "../form/SegmentedField.tsx";
interface PlaylistCreateFormProps {
/** If provided, the new playlist will have this dump added to it. */
diff --git a/src/components/profile/ChangePasswordModal.tsx b/src/components/profile/ChangePasswordModal.tsx
index 1bcd64bf..616804a8 100644
--- a/src/components/profile/ChangePasswordModal.tsx
+++ b/src/components/profile/ChangePasswordModal.tsx
@@ -2,17 +2,15 @@ import { useState } from "react";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { API_URL, VALIDATION } from "../../config/api.ts";
-import { useAuth } from "../../hooks/useAuth.ts";
-import { Modal } from "../ui/Modal.tsx";
-import {
- expectOk,
- FormActions,
- FormError,
- FormProvider,
- SubmitButton,
- TextField,
- useApiForm,
-} from "../form/index.ts";
+import { useAuth } from "../../hooks/auth/useAuth.ts";
+import { Modal } from "../shared/Modal.tsx";
+import { expectOk } from "../../utils/shared/api.ts";
+import { FormActions } from "../form/FormActions.tsx";
+import { FormError } from "../form/FormError.tsx";
+import { FormProvider } from "react-hook-form";
+import { SubmitButton } from "../form/SubmitButton.tsx";
+import { TextField } from "../form/TextField.tsx";
+import { useApiForm } from "../../hooks/form/useApiForm.ts";
interface ChangePasswordModalProps {
onClose: () => void;
diff --git a/src/components/profile/ProfileSubpageHeader.tsx b/src/components/profile/ProfileSubpageHeader.tsx
index b6b56277..5daade0c 100644
--- a/src/components/profile/ProfileSubpageHeader.tsx
+++ b/src/components/profile/ProfileSubpageHeader.tsx
@@ -1,7 +1,7 @@
import type { ReactNode } from "react";
import { Link } from "react-router";
import type { PublicUser } from "../../model.ts";
-import { Avatar } from "../ui/Avatar.tsx";
+import { Avatar } from "../shared/Avatar.tsx";
interface ProfileSubpageHeaderProps {
username: string;
diff --git a/src/components/ui/Avatar.tsx b/src/components/shared/Avatar.tsx
similarity index 95%
rename from src/components/ui/Avatar.tsx
rename to src/components/shared/Avatar.tsx
index 6aefb544..6526fa08 100644
--- a/src/components/ui/Avatar.tsx
+++ b/src/components/shared/Avatar.tsx
@@ -1,6 +1,6 @@
import { useContext, useState } from "react";
import { API_URL } from "../../config/api.ts";
-import { AvatarOverrideContext } from "../../contexts/AvatarOverrideContext.ts";
+import { AvatarOverrideContext } from "../../contexts/ws/AvatarOverrideContext.ts";
interface AvatarProps {
userId: string;
diff --git a/src/components/ui/ConfirmModal.tsx b/src/components/shared/ConfirmModal.tsx
similarity index 96%
rename from src/components/ui/ConfirmModal.tsx
rename to src/components/shared/ConfirmModal.tsx
index 60b19389..20925825 100644
--- a/src/components/ui/ConfirmModal.tsx
+++ b/src/components/shared/ConfirmModal.tsx
@@ -2,7 +2,7 @@ import { useEffect, useId } from "react";
import { createPortal } from "react-dom";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
-import { useScrollLock } from "../../hooks/useScrollLock.ts";
+import { useScrollLock } from "../../hooks/shared/useScrollLock.ts";
interface ConfirmModalProps {
message: string;
diff --git a/src/components/ui/CountedInput.tsx b/src/components/shared/CountedInput.tsx
similarity index 89%
rename from src/components/ui/CountedInput.tsx
rename to src/components/shared/CountedInput.tsx
index 5e488e9b..b7b3664b 100644
--- a/src/components/ui/CountedInput.tsx
+++ b/src/components/shared/CountedInput.tsx
@@ -1,5 +1,5 @@
import type React from "react";
-import { charCountClass } from "../../utils/charCount.ts";
+import { charCountClass } from "../../utils/shared/charCount.ts";
interface CountedInputProps
extends
diff --git a/src/components/ui/ErrorCard.tsx b/src/components/shared/ErrorCard.tsx
similarity index 100%
rename from src/components/ui/ErrorCard.tsx
rename to src/components/shared/ErrorCard.tsx
diff --git a/src/components/ui/ImagePicker.tsx b/src/components/shared/ImagePicker.tsx
similarity index 100%
rename from src/components/ui/ImagePicker.tsx
rename to src/components/shared/ImagePicker.tsx
diff --git a/src/components/ui/Modal.tsx b/src/components/shared/Modal.tsx
similarity index 96%
rename from src/components/ui/Modal.tsx
rename to src/components/shared/Modal.tsx
index 9e359dbb..88bfa402 100644
--- a/src/components/ui/Modal.tsx
+++ b/src/components/shared/Modal.tsx
@@ -1,7 +1,7 @@
import { type ReactNode, useCallback, useEffect, useId, useRef } from "react";
import { createPortal } from "react-dom";
import { t } from "@lingui/core/macro";
-import { useScrollLock } from "../../hooks/useScrollLock.ts";
+import { useScrollLock } from "../../hooks/shared/useScrollLock.ts";
interface ModalProps {
title: string;
diff --git a/src/components/ui/TabBar.tsx b/src/components/shared/TabBar.tsx
similarity index 100%
rename from src/components/ui/TabBar.tsx
rename to src/components/shared/TabBar.tsx
diff --git a/src/components/ui/Thumbnail.tsx b/src/components/shared/Thumbnail.tsx
similarity index 96%
rename from src/components/ui/Thumbnail.tsx
rename to src/components/shared/Thumbnail.tsx
index 9cfc289e..53d9c51b 100644
--- a/src/components/ui/Thumbnail.tsx
+++ b/src/components/shared/Thumbnail.tsx
@@ -1,5 +1,5 @@
import { type ComponentProps, useState } from "react";
-import { canProxyImage, proxyImageUrl } from "../../utils/proxyImage.ts";
+import { canProxyImage, proxyImageUrl } from "../../utils/shared/proxyImage.ts";
import { ThumbnailPlaceholder } from "./ThumbnailPlaceholder.tsx";
interface ThumbnailProps {
diff --git a/src/components/ui/ThumbnailPlaceholder.tsx b/src/components/shared/ThumbnailPlaceholder.tsx
similarity index 94%
rename from src/components/ui/ThumbnailPlaceholder.tsx
rename to src/components/shared/ThumbnailPlaceholder.tsx
index c6907ddd..1996627f 100644
--- a/src/components/ui/ThumbnailPlaceholder.tsx
+++ b/src/components/shared/ThumbnailPlaceholder.tsx
@@ -1,6 +1,6 @@
import { useState } from "react";
-import { proxyImageUrl } from "../../utils/proxyImage.ts";
-import { initialsFor, tintFor } from "../../utils/thumbnailTint.ts";
+import { proxyImageUrl } from "../../utils/shared/proxyImage.ts";
+import { initialsFor, tintFor } from "../../utils/shared/thumbnailTint.ts";
interface ThumbnailPlaceholderProps {
/** The dumped URL — seeds the fallback hue and the initial. */
diff --git a/src/components/ui/Tooltip.tsx b/src/components/shared/Tooltip.tsx
similarity index 100%
rename from src/components/ui/Tooltip.tsx
rename to src/components/shared/Tooltip.tsx
diff --git a/src/components/social/FollowButton.tsx b/src/components/social/FollowButton.tsx
index ea7c639e..78a49e62 100644
--- a/src/components/social/FollowButton.tsx
+++ b/src/components/social/FollowButton.tsx
@@ -1,7 +1,7 @@
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
-import { useAuth } from "../../hooks/useAuth.ts";
-import { useFollows } from "../../hooks/useFollows.ts";
+import { useAuth } from "../../hooks/auth/useAuth.ts";
+import { useFollows } from "../../hooks/social/useFollows.ts";
interface FollowUserButtonProps {
targetUserId: string;
diff --git a/src/components/social/UserListPopover.tsx b/src/components/social/UserListPopover.tsx
index a661b704..dedfced4 100644
--- a/src/components/social/UserListPopover.tsx
+++ b/src/components/social/UserListPopover.tsx
@@ -3,8 +3,8 @@ import { createPortal } from "react-dom";
import { Link } from "react-router";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
-import { Avatar } from "../ui/Avatar.tsx";
-import { useAuth } from "../../hooks/useAuth.ts";
+import { Avatar } from "../shared/Avatar.tsx";
+import { useAuth } from "../../hooks/auth/useAuth.ts";
import { DEFAULT_PAGE_SIZE } from "../../config/api.ts";
import {
deserializePublicUser,
diff --git a/src/contexts/AuthContext.ts b/src/contexts/auth/AuthContext.ts
similarity index 86%
rename from src/contexts/AuthContext.ts
rename to src/contexts/auth/AuthContext.ts
index 28a1f51e..7d69efef 100644
--- a/src/contexts/AuthContext.ts
+++ b/src/contexts/auth/AuthContext.ts
@@ -1,6 +1,6 @@
import { createContext, type Dispatch, type SetStateAction } from "react";
-import { type AuthResponse } from "../model.ts";
+import { type AuthResponse } from "../../model.ts";
export interface AuthContextValue {
authResponse: AuthResponse | null;
diff --git a/src/contexts/AuthProvider.tsx b/src/contexts/auth/AuthProvider.tsx
similarity index 96%
rename from src/contexts/AuthProvider.tsx
rename to src/contexts/auth/AuthProvider.tsx
index 164390c5..818ad635 100644
--- a/src/contexts/AuthProvider.tsx
+++ b/src/contexts/auth/AuthProvider.tsx
@@ -2,14 +2,14 @@ import { type ReactNode, useEffect, useState } from "react";
import { AuthContext, type AuthContextValue } from "./AuthContext.ts";
-import { type AuthResponse, deserializePublicUser } from "../model.ts";
-import { API_URL } from "../config/api.ts";
+import { type AuthResponse, deserializePublicUser } from "../../model.ts";
+import { API_URL } from "../../config/api.ts";
import {
AUTH_STORAGE_KEY,
forgetStoredSession,
readStoredSession,
storeSession,
-} from "../utils/authStorage.ts";
+} from "../../utils/auth/storage.ts";
export function AuthProvider({ children }: { children: ReactNode }) {
const [authResponse, setAuthResponse] = useState(
diff --git a/src/contexts/CategoriesContext.ts b/src/contexts/category/CategoriesContext.ts
similarity index 90%
rename from src/contexts/CategoriesContext.ts
rename to src/contexts/category/CategoriesContext.ts
index 7d5e8c5c..f897454d 100644
--- a/src/contexts/CategoriesContext.ts
+++ b/src/contexts/category/CategoriesContext.ts
@@ -1,5 +1,5 @@
import { createContext } from "react";
-import type { Category } from "../model.ts";
+import type { Category } from "../../model.ts";
export interface CategoriesContextValue {
categories: Category[];
diff --git a/src/contexts/CategoriesProvider.tsx b/src/contexts/category/CategoriesProvider.tsx
similarity index 95%
rename from src/contexts/CategoriesProvider.tsx
rename to src/contexts/category/CategoriesProvider.tsx
index 5b0e9a53..797f1d5e 100644
--- a/src/contexts/CategoriesProvider.tsx
+++ b/src/contexts/category/CategoriesProvider.tsx
@@ -9,12 +9,12 @@ import {
CategoriesContext,
type CategoriesContextValue,
} from "./CategoriesContext.ts";
-import { API_URL } from "../config/api.ts";
+import { API_URL } from "../../config/api.ts";
import {
type Category,
deserializeCategory,
type RawCategory,
-} from "../model.ts";
+} from "../../model.ts";
async function fetchCategories(
signal?: AbortSignal,
diff --git a/src/contexts/ChatContext.ts b/src/contexts/chat/ChatContext.ts
similarity index 100%
rename from src/contexts/ChatContext.ts
rename to src/contexts/chat/ChatContext.ts
diff --git a/src/contexts/ChatProvider.tsx b/src/contexts/chat/ChatProvider.tsx
similarity index 93%
rename from src/contexts/ChatProvider.tsx
rename to src/contexts/chat/ChatProvider.tsx
index 8664ada2..2fbe0da9 100644
--- a/src/contexts/ChatProvider.tsx
+++ b/src/contexts/chat/ChatProvider.tsx
@@ -7,11 +7,11 @@ import {
useState,
} from "react";
import { useSearchParams } from "react-router";
-import { useAuth } from "../hooks/useAuth.ts";
+import { useAuth } from "../../hooks/auth/useAuth.ts";
import { ChatContext, type ChatContextValue } from "./ChatContext.ts";
const ChatModal = lazy(() =>
- import("../components/chat/ChatModal.tsx").then((m) => ({
+ import("../../components/chat/ChatModal.tsx").then((m) => ({
default: m.ChatModal,
}))
);
diff --git a/src/contexts/PlayerContext.ts b/src/contexts/player/PlayerContext.ts
similarity index 100%
rename from src/contexts/PlayerContext.ts
rename to src/contexts/player/PlayerContext.ts
diff --git a/src/contexts/PlayerProvider.tsx b/src/contexts/player/PlayerProvider.tsx
similarity index 99%
rename from src/contexts/PlayerProvider.tsx
rename to src/contexts/player/PlayerProvider.tsx
index 67ba8c22..5a2c6297 100644
--- a/src/contexts/PlayerProvider.tsx
+++ b/src/contexts/player/PlayerProvider.tsx
@@ -5,8 +5,11 @@ import {
playerItemKey,
PlayerTimeContext,
} from "./PlayerContext.ts";
-import { isStreamStale, resolveStreamQueue } from "../utils/streamSources.ts";
-import { useAuth } from "../hooks/useAuth.ts";
+import {
+ isStreamStale,
+ resolveStreamQueue,
+} from "../../utils/player/streamSources.ts";
+import { useAuth } from "../../hooks/auth/useAuth.ts";
const STORAGE_KEY = "player";
diff --git a/src/contexts/FollowContext.ts b/src/contexts/social/FollowContext.ts
similarity index 100%
rename from src/contexts/FollowContext.ts
rename to src/contexts/social/FollowContext.ts
diff --git a/src/contexts/FollowProvider.tsx b/src/contexts/social/FollowProvider.tsx
similarity index 96%
rename from src/contexts/FollowProvider.tsx
rename to src/contexts/social/FollowProvider.tsx
index d8fa4b92..82d8d6fc 100644
--- a/src/contexts/FollowProvider.tsx
+++ b/src/contexts/social/FollowProvider.tsx
@@ -6,9 +6,9 @@ import {
useState,
} from "react";
import { FollowContext, type FollowContextValue } from "./FollowContext.ts";
-import { API_URL } from "../config/api.ts";
-import { useAuth } from "../hooks/useAuth.ts";
-import type { FollowStatus } from "../model.ts";
+import { API_URL } from "../../config/api.ts";
+import { useAuth } from "../../hooks/auth/useAuth.ts";
+import type { FollowStatus } from "../../model.ts";
export function FollowProvider({ children }: { children: ReactNode }) {
const { token, authFetch } = useAuth();
diff --git a/src/contexts/ThemeContext.ts b/src/contexts/theme/ThemeContext.ts
similarity index 100%
rename from src/contexts/ThemeContext.ts
rename to src/contexts/theme/ThemeContext.ts
diff --git a/src/contexts/ThemeProvider.tsx b/src/contexts/theme/ThemeProvider.tsx
similarity index 100%
rename from src/contexts/ThemeProvider.tsx
rename to src/contexts/theme/ThemeProvider.tsx
diff --git a/src/contexts/AvatarOverrideContext.ts b/src/contexts/ws/AvatarOverrideContext.ts
similarity index 100%
rename from src/contexts/AvatarOverrideContext.ts
rename to src/contexts/ws/AvatarOverrideContext.ts
diff --git a/src/contexts/WSContext.ts b/src/contexts/ws/WSContext.ts
similarity index 99%
rename from src/contexts/WSContext.ts
rename to src/contexts/ws/WSContext.ts
index 969c0ec8..aebbb050 100644
--- a/src/contexts/WSContext.ts
+++ b/src/contexts/ws/WSContext.ts
@@ -7,7 +7,7 @@ import type {
OnlineUser,
Playlist,
PublicUser,
-} from "../model.ts";
+} from "../../model.ts";
export interface VoteEvent {
dumpId: string;
diff --git a/src/contexts/WSProvider.tsx b/src/contexts/ws/WSProvider.tsx
similarity index 99%
rename from src/contexts/WSProvider.tsx
rename to src/contexts/ws/WSProvider.tsx
index 55262d62..b5b0edd6 100644
--- a/src/contexts/WSProvider.tsx
+++ b/src/contexts/ws/WSProvider.tsx
@@ -27,7 +27,7 @@ import {
type AvatarOverride,
AvatarOverrideContext,
} from "./AvatarOverrideContext.ts";
-import { WS_URL } from "../config/api.ts";
+import { WS_URL } from "../../config/api.ts";
import type {
ChatMessage,
Dump,
@@ -35,7 +35,7 @@ import type {
Notification,
OnlineUser,
OutgoingWSMessage,
-} from "../model.ts";
+} from "../../model.ts";
import {
deserializeChatMessage,
deserializeComment,
@@ -43,12 +43,12 @@ import {
deserializeNotification,
deserializePlaylist,
deserializePublicUser,
-} from "../model.ts";
+} from "../../model.ts";
import { t } from "@lingui/core/macro";
-import { useAuth } from "../hooks/useAuth.ts";
-import { setTitleBadge } from "../hooks/useDocumentTitle.ts";
-import { playChime } from "../utils/sound.ts";
-import { mentionsUser } from "../utils/chatText.ts";
+import { useAuth } from "../../hooks/auth/useAuth.ts";
+import { setTitleBadge } from "../../hooks/shared/useDocumentTitle.ts";
+import { playChime } from "../../utils/shared/sound.ts";
+import { mentionsUser } from "../../utils/chat/text.ts";
interface WSProviderProps {
children: ReactNode;
diff --git a/src/hooks/useAuth.ts b/src/hooks/auth/useAuth.ts
similarity index 97%
rename from src/hooks/useAuth.ts
rename to src/hooks/auth/useAuth.ts
index 14688894..b15382d5 100644
--- a/src/hooks/useAuth.ts
+++ b/src/hooks/auth/useAuth.ts
@@ -1,13 +1,13 @@
import { useCallback, useContext } from "react";
-import { AuthContext } from "../contexts/AuthContext.ts";
+import { AuthContext } from "../../contexts/auth/AuthContext.ts";
-import { type AuthResponse } from "../model.ts";
+import { type AuthResponse } from "../../model.ts";
import {
forgetStoredSession,
isTokenExpired,
storeSession,
-} from "../utils/authStorage.ts";
+} from "../../utils/auth/storage.ts";
export const useAuth = () => {
const { authResponse, setAuthResponse } = useContext(AuthContext);
diff --git a/src/hooks/useCategories.ts b/src/hooks/category/useCategories.ts
similarity index 55%
rename from src/hooks/useCategories.ts
rename to src/hooks/category/useCategories.ts
index 96f0325b..0505933e 100644
--- a/src/hooks/useCategories.ts
+++ b/src/hooks/category/useCategories.ts
@@ -1,4 +1,4 @@
import { useContext } from "react";
-import { CategoriesContext } from "../contexts/CategoriesContext.ts";
+import { CategoriesContext } from "../../contexts/category/CategoriesContext.ts";
export const useCategories = () => useContext(CategoriesContext);
diff --git a/src/hooks/useChat.ts b/src/hooks/chat/useChat.ts
similarity index 60%
rename from src/hooks/useChat.ts
rename to src/hooks/chat/useChat.ts
index fb08f79d..d5b522b9 100644
--- a/src/hooks/useChat.ts
+++ b/src/hooks/chat/useChat.ts
@@ -1,5 +1,5 @@
import { useContext } from "react";
-import { ChatContext } from "../contexts/ChatContext.ts";
+import { ChatContext } from "../../contexts/chat/ChatContext.ts";
export function useChat() {
return useContext(ChatContext);
diff --git a/src/hooks/useDumpCardLink.ts b/src/hooks/dump/useDumpCardLink.ts
similarity index 91%
rename from src/hooks/useDumpCardLink.ts
rename to src/hooks/dump/useDumpCardLink.ts
index f0ff1890..ded386fe 100644
--- a/src/hooks/useDumpCardLink.ts
+++ b/src/hooks/dump/useDumpCardLink.ts
@@ -1,8 +1,8 @@
import type { MouseEvent } from "react";
import { useNavigate } from "react-router";
-import type { Dump } from "../model.ts";
-import { dumpUrl } from "../utils/urls.ts";
-import { markDumpVisited } from "../utils/visited.ts";
+import type { Dump } from "../../model.ts";
+import { dumpUrl } from "../../utils/shared/urls.ts";
+import { markDumpVisited } from "../../utils/shared/visited.ts";
function isOverLink(e: MouseEvent) {
return (e.target as Element).closest("a") !== null;
diff --git a/src/hooks/useDumpListSync.ts b/src/hooks/dump/useDumpListSync.ts
similarity index 97%
rename from src/hooks/useDumpListSync.ts
rename to src/hooks/dump/useDumpListSync.ts
index 1e5f240a..8fbc4179 100644
--- a/src/hooks/useDumpListSync.ts
+++ b/src/hooks/dump/useDumpListSync.ts
@@ -1,6 +1,6 @@
import { useEffect, useLayoutEffect, useRef } from "react";
-import type { Dump } from "../model.ts";
-import { useWS } from "./useWS.ts";
+import type { Dump } from "../../model.ts";
+import { useWS } from "../ws/useWS.ts";
interface DumpListSyncOptions {
/** Keep private dumps visible (caller is the owner). */
diff --git a/src/hooks/useUserDumpFeed.ts b/src/hooks/dump/useUserDumpFeed.ts
similarity index 93%
rename from src/hooks/useUserDumpFeed.ts
rename to src/hooks/dump/useUserDumpFeed.ts
index 8ffcb8a6..7ebee931 100644
--- a/src/hooks/useUserDumpFeed.ts
+++ b/src/hooks/dump/useUserDumpFeed.ts
@@ -7,18 +7,18 @@ import {
useState,
} from "react";
-import { API_URL, DEFAULT_PAGE_SIZE } from "../config/api.ts";
-import { friendlyFetchError } from "../utils/apiError.ts";
-import type { Dump, PaginatedData, PublicUser, RawDump } from "../model.ts";
+import { API_URL, DEFAULT_PAGE_SIZE } from "../../config/api.ts";
+import { friendlyFetchError } from "../../utils/shared/api.ts";
+import type { Dump, PaginatedData, PublicUser, RawDump } from "../../model.ts";
import {
deserializeDump,
deserializePublicUser,
hydrateDump,
-} from "../model.ts";
-import { useAuth } from "./useAuth.ts";
-import { useFeedCache } from "./useFeedCache.ts";
-import { useInfiniteScroll } from "./useInfiniteScroll.ts";
-import { useScrollSave } from "./useScrollSave.ts";
+} from "../../model.ts";
+import { useAuth } from "../auth/useAuth.ts";
+import { useFeedCache } from "../feed/useFeedCache.ts";
+import { useInfiniteScroll } from "../feed/useInfiniteScroll.ts";
+import { useScrollSave } from "../feed/useScrollSave.ts";
type State =
| { status: "loading" }
diff --git a/src/hooks/useEmojiTrigger.ts b/src/hooks/editor/useEmojiTrigger.ts
similarity index 98%
rename from src/hooks/useEmojiTrigger.ts
rename to src/hooks/editor/useEmojiTrigger.ts
index c4236448..600e07d3 100644
--- a/src/hooks/useEmojiTrigger.ts
+++ b/src/hooks/editor/useEmojiTrigger.ts
@@ -1,5 +1,5 @@
import { useCallback, useRef, useState } from "react";
-import { insideCode } from "../utils/editorText.ts";
+import { insideCode } from "../../utils/editor/text.ts";
// Trigger: ':' not preceded by a letter, digit or another ':', followed by a
// letter then at least one more word char / '+' / '-'. Two characters, not
diff --git a/src/hooks/useMentionAutocomplete.ts b/src/hooks/editor/useMentionAutocomplete.ts
similarity index 98%
rename from src/hooks/useMentionAutocomplete.ts
rename to src/hooks/editor/useMentionAutocomplete.ts
index b8dedc9a..ab5a3ac0 100644
--- a/src/hooks/useMentionAutocomplete.ts
+++ b/src/hooks/editor/useMentionAutocomplete.ts
@@ -5,8 +5,8 @@ import {
useRef,
useState,
} from "react";
-import { API_URL } from "../config/api.ts";
-import { insideCode } from "../utils/editorText.ts";
+import { API_URL } from "../../config/api.ts";
+import { insideCode } from "../../utils/editor/text.ts";
export interface UserResult {
id: string;
diff --git a/src/hooks/useDefaultFeedTab.ts b/src/hooks/feed/useDefaultFeedTab.ts
similarity index 96%
rename from src/hooks/useDefaultFeedTab.ts
rename to src/hooks/feed/useDefaultFeedTab.ts
index 9ea2f425..c674d0a3 100644
--- a/src/hooks/useDefaultFeedTab.ts
+++ b/src/hooks/feed/useDefaultFeedTab.ts
@@ -3,7 +3,7 @@ import {
DEFAULT_FEED_TAB,
FEED_TABS,
type FeedTab,
-} from "../config/feedTabs.ts";
+} from "../../config/feedTabs.ts";
const STORAGE_KEY = "default-feed-tab";
diff --git a/src/hooks/useFading.ts b/src/hooks/feed/useFading.ts
similarity index 100%
rename from src/hooks/useFading.ts
rename to src/hooks/feed/useFading.ts
diff --git a/src/hooks/useFeedCache.ts b/src/hooks/feed/useFeedCache.ts
similarity index 100%
rename from src/hooks/useFeedCache.ts
rename to src/hooks/feed/useFeedCache.ts
diff --git a/src/hooks/useInfiniteScroll.ts b/src/hooks/feed/useInfiniteScroll.ts
similarity index 100%
rename from src/hooks/useInfiniteScroll.ts
rename to src/hooks/feed/useInfiniteScroll.ts
diff --git a/src/hooks/usePositionAwareSync.ts b/src/hooks/feed/usePositionAwareSync.ts
similarity index 100%
rename from src/hooks/usePositionAwareSync.ts
rename to src/hooks/feed/usePositionAwareSync.ts
diff --git a/src/hooks/useScrollSave.ts b/src/hooks/feed/useScrollSave.ts
similarity index 100%
rename from src/hooks/useScrollSave.ts
rename to src/hooks/feed/useScrollSave.ts
diff --git a/src/components/form/useApiForm.ts b/src/hooks/form/useApiForm.ts
similarity index 65%
rename from src/components/form/useApiForm.ts
rename to src/hooks/form/useApiForm.ts
index e26ca195..f88b8105 100644
--- a/src/components/form/useApiForm.ts
+++ b/src/hooks/form/useApiForm.ts
@@ -7,8 +7,7 @@ import {
type UseFormReturn,
} from "react-hook-form";
-import { friendlyFetchError } from "../../utils/apiError.ts";
-import { parseAPIResponse } from "../../model.ts";
+import { friendlyFetchError } from "../../utils/shared/api.ts";
export interface UseApiFormReturn
extends UseFormReturn {
@@ -16,7 +15,7 @@ export interface UseApiFormReturn
* Wraps an async submit handler with the shared API-form lifecycle: clears the
* previous root error, runs the handler while RHF keeps `formState.isSubmitting`
* true, and on a thrown error surfaces a single root error via
- * `friendlyFetchError`. Pair it with {@link expectOk} so API-level failures and
+ * `friendlyFetchError`. Pair it with `expectOk` so API-level failures and
* network failures both become root errors through one code path. Returns the
* `onSubmit` handler to spread onto `
} />
+
+ ,
+ );
+ return { open };
+ }
+
+ test("a click navigates and marks visited", () => {
+ setup();
+ fireEvent.click(screen.getByTestId("card"));
+ expect(screen.getByText("dump page")).toBeInTheDocument();
+ expect(isDumpVisited("d1")).toBe(true);
+ });
+
+ test("ctrl/cmd/shift-click opens a new tab", () => {
+ const { open } = setup();
+ fireEvent.click(screen.getByTestId("card"), { ctrlKey: true });
+ expect(open).toHaveBeenCalledWith("/dumps/hello", "_blank", "noopener");
+ });
+
+ test("a middle click opens a new tab, except over a link", () => {
+ const { open } = setup();
+ const mouseDown = new MouseEvent("mousedown", {
+ button: 1,
+ bubbles: true,
+ cancelable: true,
+ });
+ screen.getByTestId("card").dispatchEvent(mouseDown);
+ expect(mouseDown.defaultPrevented).toBe(true);
+ fireEvent(
+ screen.getByTestId("card"),
+ new MouseEvent("auxclick", { button: 1, bubbles: true }),
+ );
+ expect(open).toHaveBeenCalledTimes(1);
+ fireEvent(
+ screen.getByText("inner link"),
+ new MouseEvent("auxclick", { button: 1, bubbles: true }),
+ );
+ expect(open).toHaveBeenCalledTimes(1);
+ fireEvent(
+ screen.getByTestId("card"),
+ new MouseEvent("auxclick", { button: 2, bubbles: true }),
+ );
+ expect(open).toHaveBeenCalledTimes(1);
+ });
+
+ test("a modified click on the play button opens the dump instead of playing", () => {
+ const { open } = setup();
+ const play = screen.getByText("play");
+ fireEvent.click(play, { metaKey: true });
+ expect(open).toHaveBeenCalledTimes(1);
+ expect(play.dataset.played).toBeUndefined();
+ fireEvent.click(play);
+ expect(play.dataset.played).toBe("1");
+ });
+});
diff --git a/src/test/hooks/dump/useDumpListSync.test.tsx b/src/test/hooks/dump/useDumpListSync.test.tsx
new file mode 100644
index 00000000..2f15700b
--- /dev/null
+++ b/src/test/hooks/dump/useDumpListSync.test.tsx
@@ -0,0 +1,110 @@
+import { describe, expect, test } from "vitest";
+import { act, renderHook } from "@testing-library/react";
+import { type ReactNode, useState } from "react";
+import {
+ defaultWSValue,
+ WSContext,
+ type WSContextValue,
+} from "../../../contexts/ws/WSContext.ts";
+import type { Dump } from "../../../model.ts";
+import { useDumpListSync } from "../../../hooks/dump/useDumpListSync.ts";
+import { makeDump } from "../../fixtures.ts";
+
+/** A WS context whose value a test can push new events into. */
+function liveWS() {
+ let push: (patch: Partial) => void = () => {};
+ const base = defaultWSValue;
+ function Wrapper({ children }: { children: ReactNode }) {
+ const [value, setValue] = useState(base);
+ push = (patch) => setValue((v) => ({ ...v, ...patch }));
+ return {children};
+ }
+ return {
+ Wrapper,
+ emit: (patch: Partial) => act(() => push(patch)),
+ };
+}
+
+function useList(initial: T[]) {
+ const [items, setItems] = useState(initial);
+ return { items, setItems };
+}
+
+const ids = (xs: { id: string }[]) => xs.map((x) => x.id);
+
+describe("useDumpListSync", () => {
+ function setup(
+ initial: Dump[],
+ opts?: Parameters[1],
+ ) {
+ const ws = liveWS();
+ const hook = renderHook(() => {
+ const list = useList(initial);
+ useDumpListSync(list.setItems, opts);
+ return list;
+ }, { wrapper: ws.Wrapper });
+ return { ...ws, hook };
+ }
+
+ test("drops deleted dumps", () => {
+ const { hook, emit } = setup([
+ makeDump({ id: "a" }),
+ makeDump({ id: "b" }),
+ ]);
+ emit({ deletedDumpIds: new Set(["a"]) });
+ expect(ids(hook.result.current.items)).toEqual(["b"]);
+ });
+
+ test("updates a dump in place", () => {
+ const { hook, emit } = setup([makeDump({ id: "a", title: "old" })]);
+ emit({ lastDumpEvent: makeDump({ id: "a", title: "new" }) });
+ expect(hook.result.current.items[0].title).toBe("new");
+ });
+
+ test("a dump gone private leaves others' lists but not the owner's", () => {
+ const a = makeDump({ id: "a" });
+ const viewer = setup([a]);
+ viewer.emit({ lastDumpEvent: { ...a, isPrivate: true } });
+ expect(viewer.hook.result.current.items).toEqual([]);
+
+ const owner = setup([a], { isOwner: true });
+ owner.emit({ lastDumpEvent: { ...a, isPrivate: true } });
+ expect(owner.hook.result.current.items[0].isPrivate).toBe(true);
+ });
+
+ test("re-inserts only the owner's dumps, and only when asked", () => {
+ const mine = makeDump({ id: "m", userId: "me" });
+ const theirs = makeDump({ id: "t", userId: "them" });
+
+ const none = setup([]);
+ none.emit({ lastDumpEvent: mine });
+ expect(none.hook.result.current.items).toEqual([]);
+
+ const owned = setup([], { ownerId: "me" });
+ owned.emit({ lastDumpEvent: theirs });
+ expect(owned.hook.result.current.items).toEqual([]);
+ owned.emit({ lastDumpEvent: mine });
+ expect(ids(owned.hook.result.current.items)).toEqual(["m"]);
+
+ const skip = setup([], { ownerId: "me", skipReinsert: true });
+ skip.emit({ lastDumpEvent: mine });
+ expect(skip.hook.result.current.items).toEqual([]);
+
+ const privateOne = setup([], { ownerId: "me" });
+ privateOne.emit({ lastDumpEvent: { ...mine, isPrivate: true } });
+ expect(privateOne.hook.result.current.items).toEqual([]);
+ });
+
+ test("comment events move the count, never below zero; edits don't", () => {
+ const { hook, emit } = setup([makeDump({ id: "a", commentCount: 0 })]);
+ emit({ lastCommentEvent: { type: "created", dumpId: "a" } });
+ expect(hook.result.current.items[0].commentCount).toBe(1);
+ emit({ lastCommentEvent: { type: "updated", dumpId: "a" } });
+ expect(hook.result.current.items[0].commentCount).toBe(1);
+ emit({ lastCommentEvent: { type: "deleted", dumpId: "a" } });
+ emit({ lastCommentEvent: { type: "deleted", dumpId: "a" } });
+ expect(hook.result.current.items[0].commentCount).toBe(0);
+ emit({ lastCommentEvent: { type: "created", dumpId: "zzz" } });
+ expect(hook.result.current.items).toHaveLength(1);
+ });
+});
diff --git a/src/test/hooks/dump/useUserDumpFeed.test.tsx b/src/test/hooks/dump/useUserDumpFeed.test.tsx
new file mode 100644
index 00000000..217d2794
--- /dev/null
+++ b/src/test/hooks/dump/useUserDumpFeed.test.tsx
@@ -0,0 +1,134 @@
+import { describe, expect, test } from "vitest";
+import { act, renderHook, waitFor } from "@testing-library/react";
+import { type ReactNode, useState } from "react";
+import { MemoryRouter } from "react-router";
+import { AuthContext } from "../../../contexts/auth/AuthContext.ts";
+import { useUserDumpFeed } from "../../../hooks/dump/useUserDumpFeed.ts";
+import { calls, fail, route } from "../../fetch.ts";
+import { makeDump, makeSession, makeUser, page, wire } from "../../fixtures.ts";
+import { FakeIntersectionObserver } from "../../setup.ts";
+
+describe("useUserDumpFeed", () => {
+ const ann = makeUser({ username: "ann" });
+ function setup(
+ opts: { session?: ReturnType | null } = {},
+ ) {
+ const session = opts.session ?? null;
+ function Wrapper({ children }: { children: ReactNode }) {
+ const [authResponse, setAuthResponse] = useState(session);
+ return (
+
+
+ {children}
+
+
+ );
+ }
+ return renderHook(
+ ({ username }: { username?: string }) =>
+ useUserDumpFeed(username, "dumps", "cache-key"),
+ {
+ initialProps: { username: "ann" as string | undefined },
+ wrapper: Wrapper,
+ },
+ );
+ }
+
+ test("loads the profile and the first page", async () => {
+ route("GET /api/users/:u", () => wire(ann));
+ route("GET /api/users/:u/dumps", () => page([makeDump({ id: "a" })], true));
+ const session = makeSession();
+ const { result } = setup({ session });
+ await waitFor(() => expect(result.current.state.status).toBe("loaded"));
+ const s = result.current.state as Extract<
+ typeof result.current.state,
+ { status: "loaded" }
+ >;
+ expect(s.profileUser.username).toBe("ann");
+ expect(s.items[0].createdAt).toBeInstanceOf(Date);
+ expect(s.hasMore).toBe(true);
+ const [req] = calls("GET /api/users/:u/dumps");
+ expect(req.url.searchParams.get("page")).toBe("1");
+ expect(req.headers.get("Authorization")).toBe(`Bearer ${session.token}`);
+ });
+
+ test("an unknown user is an error", async () => {
+ route("GET /api/users/:u", () => fail("User not found", 404));
+ route("GET /api/users/:u/dumps", () => page([]));
+ const { result } = setup();
+ await waitFor(() => expect(result.current.state.status).toBe("error"));
+ });
+
+ test("an offline load says so", async () => {
+ route("GET /api/users/:u", () => new TypeError("Failed to fetch"));
+ route("GET /api/users/:u/dumps", () => page([]));
+ const { result } = setup();
+ await waitFor(() =>
+ expect(result.current.state).toMatchObject({
+ status: "error",
+ error: expect.stringMatching(/Could not connect/),
+ })
+ );
+ });
+
+ test("scrolling to the sentinel loads the next page", async () => {
+ route("GET /api/users/:u", () => wire(ann));
+ route(
+ "GET /api/users/:u/dumps",
+ (req) =>
+ req.url.searchParams.get("page") === "1"
+ ? page([makeDump({ id: "a" })], true)
+ : page([makeDump({ id: "b" })], false),
+ );
+ const { result } = setup();
+ await waitFor(() => expect(result.current.state.status).toBe("loaded"));
+ const sentinel = document.createElement("div");
+ act(() => {
+ result.current.sentinelRef(sentinel);
+ });
+ const obs = FakeIntersectionObserver.instances.at(-1)!;
+ act(() => obs.report(true));
+ await waitFor(() => {
+ const s = result.current.state;
+ expect(s.status === "loaded" && s.items.map((d) => d.id)).toEqual([
+ "a",
+ "b",
+ ]);
+ });
+ const s = result.current.state as Extract<
+ typeof result.current.state,
+ { status: "loaded" }
+ >;
+ expect(s.hasMore).toBe(false);
+ expect(s.page).toBe(2);
+ });
+
+ test("changing user resets to loading", async () => {
+ route(
+ "GET /api/users/:u",
+ (req) => wire(makeUser({ username: req.params.u })),
+ );
+ route("GET /api/users/:u/dumps", () => page([]));
+ const { result, rerender } = setup();
+ await waitFor(() => expect(result.current.state.status).toBe("loaded"));
+ rerender({ username: "bob" });
+ expect(result.current.state.status).toBe("loading");
+ await waitFor(() => expect(result.current.state.status).toBe("loaded"));
+ });
+
+ test("setItems edits the loaded list", async () => {
+ route("GET /api/users/:u", () => wire(ann));
+ route(
+ "GET /api/users/:u/dumps",
+ () => page([makeDump({ id: "a" }), makeDump({ id: "b" })]),
+ );
+ const { result } = setup();
+ await waitFor(() => expect(result.current.state.status).toBe("loaded"));
+ act(() => result.current.setItems((prev) => prev.slice(1)));
+ const s = result.current.state as Extract<
+ typeof result.current.state,
+ { status: "loaded" }
+ >;
+ expect(s.items.map((d) => d.id)).toEqual(["b"]);
+ });
+});
diff --git a/src/test/hooks/editor/useEmojiTrigger.test.tsx b/src/test/hooks/editor/useEmojiTrigger.test.tsx
new file mode 100644
index 00000000..5618e2dd
--- /dev/null
+++ b/src/test/hooks/editor/useEmojiTrigger.test.tsx
@@ -0,0 +1,89 @@
+import { describe, expect, test, vi } from "vitest";
+import { act, renderHook } from "@testing-library/react";
+import { useEmojiTrigger } from "../../../hooks/editor/useEmojiTrigger.ts";
+
+describe("useEmojiTrigger", () => {
+ function setup() {
+ const replace = vi.fn();
+ const hook = renderHook(() => useEmojiTrigger(replace));
+ const type = (value: string, cursor = value.length) =>
+ act(() => hook.result.current.syncEmojiTrigger(value, cursor, true));
+ return { replace, hook, type };
+ }
+
+ test("opens inline on ':' and two letters", () => {
+ const { hook, type } = setup();
+ type("hello :s");
+ expect(hook.result.current.emojiMode).toBe("closed");
+ type("hello :sm");
+ expect(hook.result.current.emojiMode).toBe("inline");
+ expect(hook.result.current.emojiQuery).toBe("sm");
+ });
+
+ test("emoticons, times and labels don't trigger", () => {
+ const { hook, type } = setup();
+ for (const text of [":D", ":P", "12:30", "note:ab", "a::bc", "http://ab"]) {
+ type(text);
+ expect(hook.result.current.emojiMode, text).toBe("closed");
+ }
+ });
+
+ test("Unicode names work", () => {
+ const { hook, type } = setup();
+ type(":cœur");
+ expect(hook.result.current.emojiQuery).toBe("cœur");
+ });
+
+ test("not inside code", () => {
+ const { hook, type } = setup();
+ type("`:smile");
+ expect(hook.result.current.emojiMode).toBe("closed");
+ });
+
+ test("closes when the caret leaves the query", () => {
+ const { hook, type } = setup();
+ type(":smile");
+ act(() => hook.result.current.syncEmojiTrigger(":smile more", 11, false));
+ expect(hook.result.current.emojiMode).toBe("closed");
+ });
+
+ test("a caret move alone never opens it", () => {
+ const { hook } = setup();
+ act(() => hook.result.current.syncEmojiTrigger(":smile", 6, false));
+ expect(hook.result.current.emojiMode).toBe("closed");
+ });
+
+ test("an inline pick replaces the query and adds a space", () => {
+ const { hook, type, replace } = setup();
+ type("hi :smi");
+ act(() => hook.result.current.selectEmoji("😄"));
+ expect(replace).toHaveBeenCalledWith(3, 7, "😄", true);
+ expect(hook.result.current.emojiMode).toBe("closed");
+ });
+
+ test("browse mode inserts at the caret and ignores typing", () => {
+ const { hook, type, replace } = setup();
+ act(() => hook.result.current.openEmojiBrowser(2, 4));
+ type("ab");
+ expect(hook.result.current.emojiMode).toBe("browse");
+ act(() => hook.result.current.setEmojiQuery("cat"));
+ expect(hook.result.current.emojiQuery).toBe("cat");
+ act(() => hook.result.current.selectEmoji("🐱"));
+ expect(replace).toHaveBeenCalledWith(2, 4, "🐱", false);
+ });
+
+ test("selecting or querying while closed does nothing", () => {
+ const { hook, replace } = setup();
+ act(() => hook.result.current.setEmojiQuery("x"));
+ act(() => hook.result.current.selectEmoji("😀"));
+ expect(replace).not.toHaveBeenCalled();
+ expect(hook.result.current.emojiQuery).toBe("");
+ });
+
+ test("closeEmoji", () => {
+ const { hook, type } = setup();
+ type(":smile");
+ act(() => hook.result.current.closeEmoji());
+ expect(hook.result.current.emojiMode).toBe("closed");
+ });
+});
diff --git a/src/test/hooks/editor.test.tsx b/src/test/hooks/editor/useMentionAutocomplete.test.tsx
similarity index 67%
rename from src/test/hooks/editor.test.tsx
rename to src/test/hooks/editor/useMentionAutocomplete.test.tsx
index 9a1c744a..ad98f176 100644
--- a/src/test/hooks/editor.test.tsx
+++ b/src/test/hooks/editor/useMentionAutocomplete.test.tsx
@@ -1,11 +1,10 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { act, renderHook } from "@testing-library/react";
-import { useEmojiTrigger } from "../../hooks/useEmojiTrigger.ts";
import {
type MentionKey,
useMentionAutocomplete,
-} from "../../hooks/useMentionAutocomplete.ts";
-import { calls, deferred, route } from "../fetch.ts";
+} from "../../../hooks/editor/useMentionAutocomplete.ts";
+import { calls, deferred, route } from "../../fetch.ts";
function key(k: string, mods: Partial = {}): MentionKey {
return {
@@ -20,92 +19,6 @@ function key(k: string, mods: Partial = {}): MentionKey {
};
}
-describe("useEmojiTrigger", () => {
- function setup() {
- const replace = vi.fn();
- const hook = renderHook(() => useEmojiTrigger(replace));
- const type = (value: string, cursor = value.length) =>
- act(() => hook.result.current.syncEmojiTrigger(value, cursor, true));
- return { replace, hook, type };
- }
-
- test("opens inline on ':' and two letters", () => {
- const { hook, type } = setup();
- type("hello :s");
- expect(hook.result.current.emojiMode).toBe("closed");
- type("hello :sm");
- expect(hook.result.current.emojiMode).toBe("inline");
- expect(hook.result.current.emojiQuery).toBe("sm");
- });
-
- test("emoticons, times and labels don't trigger", () => {
- const { hook, type } = setup();
- for (const text of [":D", ":P", "12:30", "note:ab", "a::bc", "http://ab"]) {
- type(text);
- expect(hook.result.current.emojiMode, text).toBe("closed");
- }
- });
-
- test("Unicode names work", () => {
- const { hook, type } = setup();
- type(":cœur");
- expect(hook.result.current.emojiQuery).toBe("cœur");
- });
-
- test("not inside code", () => {
- const { hook, type } = setup();
- type("`:smile");
- expect(hook.result.current.emojiMode).toBe("closed");
- });
-
- test("closes when the caret leaves the query", () => {
- const { hook, type } = setup();
- type(":smile");
- act(() => hook.result.current.syncEmojiTrigger(":smile more", 11, false));
- expect(hook.result.current.emojiMode).toBe("closed");
- });
-
- test("a caret move alone never opens it", () => {
- const { hook } = setup();
- act(() => hook.result.current.syncEmojiTrigger(":smile", 6, false));
- expect(hook.result.current.emojiMode).toBe("closed");
- });
-
- test("an inline pick replaces the query and adds a space", () => {
- const { hook, type, replace } = setup();
- type("hi :smi");
- act(() => hook.result.current.selectEmoji("😄"));
- expect(replace).toHaveBeenCalledWith(3, 7, "😄", true);
- expect(hook.result.current.emojiMode).toBe("closed");
- });
-
- test("browse mode inserts at the caret and ignores typing", () => {
- const { hook, type, replace } = setup();
- act(() => hook.result.current.openEmojiBrowser(2, 4));
- type("ab");
- expect(hook.result.current.emojiMode).toBe("browse");
- act(() => hook.result.current.setEmojiQuery("cat"));
- expect(hook.result.current.emojiQuery).toBe("cat");
- act(() => hook.result.current.selectEmoji("🐱"));
- expect(replace).toHaveBeenCalledWith(2, 4, "🐱", false);
- });
-
- test("selecting or querying while closed does nothing", () => {
- const { hook, replace } = setup();
- act(() => hook.result.current.setEmojiQuery("x"));
- act(() => hook.result.current.selectEmoji("😀"));
- expect(replace).not.toHaveBeenCalled();
- expect(hook.result.current.emojiQuery).toBe("");
- });
-
- test("closeEmoji", () => {
- const { hook, type } = setup();
- type(":smile");
- act(() => hook.result.current.closeEmoji());
- expect(hook.result.current.emojiMode).toBe("closed");
- });
-});
-
describe("useMentionAutocomplete", () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
diff --git a/src/test/hooks/feed/useDefaultFeedTab.test.tsx b/src/test/hooks/feed/useDefaultFeedTab.test.tsx
new file mode 100644
index 00000000..f22d8f59
--- /dev/null
+++ b/src/test/hooks/feed/useDefaultFeedTab.test.tsx
@@ -0,0 +1,15 @@
+import { describe, expect, test } from "vitest";
+import { act, renderHook } from "@testing-library/react";
+import { useDefaultFeedTab } from "../../../hooks/feed/useDefaultFeedTab.ts";
+
+describe("useDefaultFeedTab", () => {
+ test("validates and persists", () => {
+ localStorage.setItem("default-feed-tab", "bogus");
+ const { result } = renderHook(useDefaultFeedTab);
+ expect(result.current[0]).toBe("journal");
+ act(() => result.current[1]("new"));
+ expect(result.current[0]).toBe("new");
+ expect(localStorage.getItem("default-feed-tab")).toBe("new");
+ expect(renderHook(useDefaultFeedTab).result.current[0]).toBe("new");
+ });
+});
diff --git a/src/test/hooks/feed/useFading.test.tsx b/src/test/hooks/feed/useFading.test.tsx
new file mode 100644
index 00000000..6a1989a4
--- /dev/null
+++ b/src/test/hooks/feed/useFading.test.tsx
@@ -0,0 +1,55 @@
+import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
+import { act, renderHook } from "@testing-library/react";
+import { useFading } from "../../../hooks/feed/useFading.ts";
+
+afterEach(() => vi.useRealTimers());
+
+describe("useFading", () => {
+ beforeEach(() => vi.useFakeTimers());
+
+ test("cooldown for 2s, then dismissing for 350ms, then gone", () => {
+ const { result } = renderHook(useFading);
+ act(() => result.current.startFading("a"));
+ expect(result.current.fading).toEqual({ a: "cooldown" });
+ act(() => vi.advanceTimersByTime(2000));
+ expect(result.current.fading).toEqual({ a: "dismissing" });
+ act(() => vi.advanceTimersByTime(350));
+ expect(result.current.fading).toEqual({});
+ });
+
+ test("starting twice is one animation", () => {
+ const { result } = renderHook(useFading);
+ act(() => result.current.startFading("a"));
+ act(() => vi.advanceTimersByTime(1000));
+ act(() => result.current.startFading("a"));
+ act(() => vi.advanceTimersByTime(1000));
+ expect(result.current.fading).toEqual({ a: "dismissing" });
+ });
+
+ test("cancel during cooldown and during dismissal", () => {
+ const { result } = renderHook(useFading);
+ act(() => result.current.startFading("a"));
+ act(() => result.current.cancelFading("a"));
+ expect(result.current.fading).toEqual({});
+ act(() => vi.advanceTimersByTime(5000));
+ expect(result.current.fading).toEqual({});
+
+ act(() => result.current.startFading("b"));
+ act(() => vi.advanceTimersByTime(2100));
+ act(() => result.current.cancelFading("b"));
+ expect(result.current.fading).toEqual({});
+ // Cancelled means it can start over.
+ act(() => result.current.startFading("b"));
+ expect(result.current.fading).toEqual({ b: "cooldown" });
+ });
+
+ test("cancelAll", () => {
+ const { result } = renderHook(useFading);
+ act(() => {
+ result.current.startFading("a");
+ result.current.startFading("b");
+ });
+ act(() => result.current.cancelAll());
+ expect(result.current.fading).toEqual({});
+ });
+});
diff --git a/src/test/hooks/feed/useFeedCache.test.tsx b/src/test/hooks/feed/useFeedCache.test.tsx
new file mode 100644
index 00000000..ada7f3f2
--- /dev/null
+++ b/src/test/hooks/feed/useFeedCache.test.tsx
@@ -0,0 +1,97 @@
+import { afterEach, describe, expect, test, vi } from "vitest";
+import {
+ act,
+ fireEvent,
+ render,
+ renderHook,
+ screen,
+} from "@testing-library/react";
+import { type ReactNode } from "react";
+import { MemoryRouter, Route, Routes, useNavigate } from "react-router";
+import { useFeedCache } from "../../../hooks/feed/useFeedCache.ts";
+
+afterEach(() => vi.useRealTimers());
+
+describe("useFeedCache", () => {
+ // A MemoryRouter's first entry is a POP, as after Back.
+ const wrapper = () => ({ children }: { children: ReactNode }) => (
+ {children}
+ );
+
+ test("restores a saved feed on a back/forward (POP) visit", () => {
+ const first = renderHook(() => useFeedCache<{ n: number }>("k", (x) => x), {
+ wrapper: wrapper(),
+ });
+ act(() => first.result.current.saveState([{ n: 1 }], 2, true, 300));
+ const second = renderHook(
+ () => useFeedCache<{ n: number }>("k", (x) => ({ n: x.n * 10 })),
+ { wrapper: wrapper() },
+ );
+ expect(second.result.current.cached).toMatchObject({
+ items: [{ n: 10 }],
+ page: 2,
+ hasMore: true,
+ scrollY: 300,
+ });
+ });
+
+ test("an expired entry is ignored", () => {
+ vi.useFakeTimers();
+ const first = renderHook(() => useFeedCache("k", (x) => x), {
+ wrapper: wrapper(),
+ });
+ act(() => first.result.current.saveState([], 1, false, 0));
+ vi.advanceTimersByTime(11 * 60 * 1000);
+ const second = renderHook(() => useFeedCache("k", (x) => x), {
+ wrapper: wrapper(),
+ });
+ expect(second.result.current.cached).toBeNull();
+ });
+
+ test("a fresh (PUSH) visit ignores the cache", () => {
+ sessionStorage.setItem(
+ "k",
+ JSON.stringify({
+ items: [1],
+ page: 1,
+ hasMore: false,
+ scrollY: 0,
+ savedAt: Date.now(),
+ }),
+ );
+ function Probe() {
+ const { cached } = useFeedCache("k", (x) => x);
+ return {cached ? "cached" : "fresh"}
;
+ }
+ function Home() {
+ const navigate = useNavigate();
+ return (
+
+ );
+ }
+ render(
+
+
+ } />
+ } />
+
+ ,
+ );
+ fireEvent.click(screen.getByText("go"));
+ expect(screen.getByText("fresh")).toBeInTheDocument();
+ });
+
+ test("corrupt storage reads as no cache; a full quota doesn't throw", () => {
+ sessionStorage.setItem("k", "{");
+ const { result } = renderHook(() => useFeedCache("k", (x) => x), {
+ wrapper: wrapper(),
+ });
+ expect(result.current.cached).toBeNull();
+ vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
+ throw new Error("QuotaExceededError");
+ });
+ expect(() => result.current.saveState([], 1, false, 0)).not.toThrow();
+ });
+});
diff --git a/src/test/hooks/feed/useInfiniteScroll.test.tsx b/src/test/hooks/feed/useInfiniteScroll.test.tsx
new file mode 100644
index 00000000..7e677a2f
--- /dev/null
+++ b/src/test/hooks/feed/useInfiniteScroll.test.tsx
@@ -0,0 +1,115 @@
+import { afterEach, describe, expect, test, vi } from "vitest";
+import { act, render } from "@testing-library/react";
+import { useInfiniteScroll } from "../../../hooks/feed/useInfiniteScroll.ts";
+import { FakeIntersectionObserver } from "../../setup.ts";
+
+afterEach(() => vi.useRealTimers());
+
+describe("useInfiniteScroll", () => {
+ function Feed({ onMore, enabled }: { onMore: () => void; enabled: boolean }) {
+ const ref = useInfiniteScroll(onMore, enabled);
+ return ;
+ }
+ const intersect = (isIntersecting: boolean) => {
+ const obs = FakeIntersectionObserver.instances.at(-1)!;
+ act(() => obs.report(isIntersecting));
+ };
+
+ test("loads more when the sentinel shows, only while enabled", () => {
+ const onMore = vi.fn();
+ const { rerender } = render();
+ expect(FakeIntersectionObserver.instances.at(-1)!.elements.size).toBe(1);
+ intersect(false);
+ expect(onMore).not.toHaveBeenCalled();
+ intersect(true);
+ expect(onMore).toHaveBeenCalledTimes(1);
+ rerender();
+ intersect(true);
+ expect(onMore).toHaveBeenCalledTimes(1);
+ });
+
+ // A load that fails re-enables the feed with the sentinel still in view: a
+ // browser's new observer reports it at once, and without a pause that's a
+ // request loop as fast as the request can fail.
+ test("a sentinel that stays in view waits longer each time", () => {
+ vi.useFakeTimers();
+ const onMore = vi.fn();
+ render();
+ intersect(true);
+ expect(onMore).toHaveBeenCalledTimes(1);
+ // Reported again right after each load, as a new observer does: how long
+ // until the next one?
+ const delays: number[] = [];
+ for (let i = 0; i < 8; i++) {
+ const before = onMore.mock.calls.length;
+ intersect(true);
+ let waited = 0;
+ while (onMore.mock.calls.length === before) {
+ act(() => vi.advanceTimersByTime(100));
+ waited += 100;
+ }
+ delays.push(waited);
+ }
+ expect(delays).toEqual([
+ 1000,
+ 2000,
+ 4000,
+ 8000,
+ 16000,
+ 30000,
+ 30000,
+ 30000,
+ ]);
+ });
+
+ test("re-rendering doesn't restart the wait", () => {
+ vi.useFakeTimers();
+ const onMore = vi.fn();
+ const { rerender } = render();
+ intersect(true);
+ intersect(true);
+ act(() => vi.advanceTimersByTime(600));
+ // A new onLoadMore (the feed's state changed) remakes the observer.
+ rerender( onMore()} enabled />);
+ intersect(true);
+ act(() => vi.advanceTimersByTime(400));
+ expect(onMore).toHaveBeenCalledTimes(2);
+ });
+
+ test("leaving the view, or new items pushing it down, asks at once again", () => {
+ vi.useFakeTimers();
+ const onMore = vi.fn();
+ render();
+ const at = (top: number) => {
+ const obs = FakeIntersectionObserver.instances.at(-1)!;
+ act(() => obs.report(true, top));
+ };
+ at(500);
+ at(500);
+ expect(onMore).toHaveBeenCalledTimes(1); // waiting
+ act(() => FakeIntersectionObserver.instances.at(-1)!.report(false, 500));
+ at(500);
+ expect(onMore).toHaveBeenCalledTimes(2);
+ at(500);
+ at(900); // the page grew: that load worked
+ expect(onMore).toHaveBeenCalledTimes(3);
+ });
+
+ test("a pending retry is dropped when the feed is disabled or unmounted", () => {
+ vi.useFakeTimers();
+ const onMore = vi.fn();
+ const { rerender, unmount } = render();
+ intersect(true);
+ intersect(true);
+ rerender();
+ act(() => vi.advanceTimersByTime(60_000));
+ expect(onMore).toHaveBeenCalledTimes(1);
+ rerender();
+ intersect(true); // long overdue: at once
+ expect(onMore).toHaveBeenCalledTimes(2);
+ intersect(true);
+ unmount();
+ act(() => vi.advanceTimersByTime(60_000));
+ expect(onMore).toHaveBeenCalledTimes(2);
+ });
+});
diff --git a/src/test/hooks/feed/usePositionAwareSync.test.tsx b/src/test/hooks/feed/usePositionAwareSync.test.tsx
new file mode 100644
index 00000000..c91221b3
--- /dev/null
+++ b/src/test/hooks/feed/usePositionAwareSync.test.tsx
@@ -0,0 +1,91 @@
+import { describe, expect, test } from "vitest";
+import { act, renderHook } from "@testing-library/react";
+import { type ReactNode, useState } from "react";
+import {
+ defaultWSValue,
+ WSContext,
+ type WSContextValue,
+} from "../../../contexts/ws/WSContext.ts";
+import type { Dump, Playlist } from "../../../model.ts";
+import { useDumpListSync } from "../../../hooks/dump/useDumpListSync.ts";
+import { usePositionAwareSync } from "../../../hooks/feed/usePositionAwareSync.ts";
+import { makeDump, makePlaylist } from "../../fixtures.ts";
+
+/** A WS context whose value a test can push new events into. */
+function liveWS() {
+ let push: (patch: Partial) => void = () => {};
+ const base = defaultWSValue;
+ function Wrapper({ children }: { children: ReactNode }) {
+ const [value, setValue] = useState(base);
+ push = (patch) => setValue((v) => ({ ...v, ...patch }));
+ return {children};
+ }
+ return {
+ Wrapper,
+ emit: (patch: Partial) => act(() => push(patch)),
+ };
+}
+
+function useList(initial: T[]) {
+ const [items, setItems] = useState(initial);
+ return { items, setItems };
+}
+
+const ids = (xs: { id: string }[]) => xs.map((x) => x.id);
+
+describe("usePositionAwareSync", () => {
+ test("a dump that goes private and back returns to its old place", () => {
+ const ws = liveWS();
+ const initial = ["a", "b", "c"].map((id) => makeDump({ id, userId: "me" }));
+ // Wire the pair exactly as UserPublicProfile does.
+ const both = renderHook(() => {
+ const { items, setItems } = useList(initial);
+ const [event, setEvent] = useState(null);
+ usePositionAwareSync(
+ items,
+ setItems,
+ event,
+ (d) => d.isPrivate,
+ (d) => !d.isPrivate,
+ );
+ useDumpListSync(setItems, { ownerId: "me", skipReinsert: true });
+ return { items, setEvent };
+ }, { wrapper: ws.Wrapper });
+
+ const b = initial[1];
+ act(() => both.result.current.setEvent({ ...b, isPrivate: true }));
+ ws.emit({ lastDumpEvent: { ...b, isPrivate: true } });
+ expect(ids(both.result.current.items)).toEqual(["a", "c"]);
+
+ act(() => both.result.current.setEvent({ ...b, isPrivate: false }));
+ ws.emit({ lastDumpEvent: { ...b, isPrivate: false } });
+ expect(ids(both.result.current.items)).toEqual(["a", "b", "c"]);
+ });
+
+ test("a position is only used once, and clamps to the list end", () => {
+ const initial = ["a", "b", "c"].map((id) => makePlaylist({ id }));
+ const hook = renderHook(() => {
+ const { items, setItems } = useList(initial);
+ const [event, setEvent] = useState(null);
+ usePositionAwareSync(
+ items,
+ setItems,
+ event,
+ (p) => !p.isPublic,
+ (p) => p.isPublic,
+ );
+ return { items, setItems, setEvent };
+ });
+ const c = initial[2];
+ act(() => hook.result.current.setEvent({ ...c, isPublic: false }));
+ act(() => hook.result.current.setItems((prev) => prev.slice(0, 1)));
+ act(() => hook.result.current.setEvent({ ...c, isPublic: true }));
+ expect(ids(hook.result.current.items)).toEqual(["a", "c"]);
+ // Nothing saved any more: a second public event is left alone.
+ act(() => hook.result.current.setItems((prev) => prev.slice(0, 1)));
+ act(() =>
+ hook.result.current.setEvent({ ...c, isPublic: true, title: "x" })
+ );
+ expect(ids(hook.result.current.items)).toEqual(["a"]);
+ });
+});
diff --git a/src/test/hooks/feed/useScrollSave.test.tsx b/src/test/hooks/feed/useScrollSave.test.tsx
new file mode 100644
index 00000000..02b633b9
--- /dev/null
+++ b/src/test/hooks/feed/useScrollSave.test.tsx
@@ -0,0 +1,52 @@
+import { describe, expect, test, vi } from "vitest";
+import { renderHook } from "@testing-library/react";
+import { useScrollSave } from "../../../hooks/feed/useScrollSave.ts";
+
+describe("useScrollSave", () => {
+ const scrollTo = (y: number) => {
+ Object.defineProperty(globalThis, "scrollY", {
+ value: y,
+ configurable: true,
+ });
+ globalThis.dispatchEvent(new Event("scroll"));
+ };
+
+ test("saves the last position when the page is left, not while scrolling", () => {
+ const onSave = vi.fn();
+ const { unmount } = renderHook(() => useScrollSave(true, onSave));
+ scrollTo(100);
+ scrollTo(250);
+ expect(onSave).not.toHaveBeenCalled();
+ // The next page has already scrolled back up by the time this unmounts.
+ Object.defineProperty(globalThis, "scrollY", {
+ value: 0,
+ configurable: true,
+ });
+ unmount();
+ expect(onSave).toHaveBeenCalledExactlyOnceWith(250);
+ });
+
+ test("saves on pagehide, with the latest callback", () => {
+ const first = vi.fn();
+ const second = vi.fn();
+ const { rerender, unmount } = renderHook(
+ ({ cb }) => useScrollSave(true, cb),
+ { initialProps: { cb: first } },
+ );
+ scrollTo(40);
+ rerender({ cb: second });
+ globalThis.dispatchEvent(new Event("pagehide"));
+ expect(first).not.toHaveBeenCalled();
+ expect(second).toHaveBeenCalledExactlyOnceWith(40);
+ unmount();
+ });
+
+ test("nothing is tracked or saved while disabled", () => {
+ const onSave = vi.fn();
+ const { unmount } = renderHook(() => useScrollSave(false, onSave));
+ scrollTo(10);
+ globalThis.dispatchEvent(new Event("pagehide"));
+ unmount();
+ expect(onSave).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/test/hooks/misc.test.tsx b/src/test/hooks/misc.test.tsx
deleted file mode 100644
index 82945847..00000000
--- a/src/test/hooks/misc.test.tsx
+++ /dev/null
@@ -1,615 +0,0 @@
-import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
-import {
- act,
- fireEvent,
- render,
- renderHook,
- screen,
-} from "@testing-library/react";
-import { type ReactNode, useRef } from "react";
-import {
- MemoryRouter,
- Route,
- Routes,
- useLocation,
- useNavigate,
-} from "react-router";
-import { useFading } from "../../hooks/useFading.ts";
-import { useFeedCache } from "../../hooks/useFeedCache.ts";
-import { useTabParam } from "../../hooks/useTabParam.ts";
-import {
- setTitleBadge,
- SITE_NAME,
- useDocumentTitle,
-} from "../../hooks/useDocumentTitle.ts";
-import { useInfiniteScroll } from "../../hooks/useInfiniteScroll.ts";
-import { useScrollLock } from "../../hooks/useScrollLock.ts";
-import { useScrollSave } from "../../hooks/useScrollSave.ts";
-import { useDefaultFeedTab } from "../../hooks/useDefaultFeedTab.ts";
-import { useSoundsEnabled } from "../../hooks/useSoundsEnabled.ts";
-import { useDumpCardLink } from "../../hooks/useDumpCardLink.ts";
-import { useAnchoredFloat } from "../../hooks/useAnchoredFloat.ts";
-import { useHeightTransition } from "../../hooks/useHeightTransition.ts";
-import { isDumpVisited } from "../../utils/visited.ts";
-import { FakeIntersectionObserver } from "../setup.ts";
-
-afterEach(() => vi.useRealTimers());
-
-describe("useFading", () => {
- beforeEach(() => vi.useFakeTimers());
-
- test("cooldown for 2s, then dismissing for 350ms, then gone", () => {
- const { result } = renderHook(useFading);
- act(() => result.current.startFading("a"));
- expect(result.current.fading).toEqual({ a: "cooldown" });
- act(() => vi.advanceTimersByTime(2000));
- expect(result.current.fading).toEqual({ a: "dismissing" });
- act(() => vi.advanceTimersByTime(350));
- expect(result.current.fading).toEqual({});
- });
-
- test("starting twice is one animation", () => {
- const { result } = renderHook(useFading);
- act(() => result.current.startFading("a"));
- act(() => vi.advanceTimersByTime(1000));
- act(() => result.current.startFading("a"));
- act(() => vi.advanceTimersByTime(1000));
- expect(result.current.fading).toEqual({ a: "dismissing" });
- });
-
- test("cancel during cooldown and during dismissal", () => {
- const { result } = renderHook(useFading);
- act(() => result.current.startFading("a"));
- act(() => result.current.cancelFading("a"));
- expect(result.current.fading).toEqual({});
- act(() => vi.advanceTimersByTime(5000));
- expect(result.current.fading).toEqual({});
-
- act(() => result.current.startFading("b"));
- act(() => vi.advanceTimersByTime(2100));
- act(() => result.current.cancelFading("b"));
- expect(result.current.fading).toEqual({});
- // Cancelled means it can start over.
- act(() => result.current.startFading("b"));
- expect(result.current.fading).toEqual({ b: "cooldown" });
- });
-
- test("cancelAll", () => {
- const { result } = renderHook(useFading);
- act(() => {
- result.current.startFading("a");
- result.current.startFading("b");
- });
- act(() => result.current.cancelAll());
- expect(result.current.fading).toEqual({});
- });
-});
-
-describe("useFeedCache", () => {
- // A MemoryRouter's first entry is a POP, as after Back.
- const wrapper = () => ({ children }: { children: ReactNode }) => (
- {children}
- );
-
- test("restores a saved feed on a back/forward (POP) visit", () => {
- const first = renderHook(() => useFeedCache<{ n: number }>("k", (x) => x), {
- wrapper: wrapper(),
- });
- act(() => first.result.current.saveState([{ n: 1 }], 2, true, 300));
- const second = renderHook(
- () => useFeedCache<{ n: number }>("k", (x) => ({ n: x.n * 10 })),
- { wrapper: wrapper() },
- );
- expect(second.result.current.cached).toMatchObject({
- items: [{ n: 10 }],
- page: 2,
- hasMore: true,
- scrollY: 300,
- });
- });
-
- test("an expired entry is ignored", () => {
- vi.useFakeTimers();
- const first = renderHook(() => useFeedCache("k", (x) => x), {
- wrapper: wrapper(),
- });
- act(() => first.result.current.saveState([], 1, false, 0));
- vi.advanceTimersByTime(11 * 60 * 1000);
- const second = renderHook(() => useFeedCache("k", (x) => x), {
- wrapper: wrapper(),
- });
- expect(second.result.current.cached).toBeNull();
- });
-
- test("a fresh (PUSH) visit ignores the cache", () => {
- sessionStorage.setItem(
- "k",
- JSON.stringify({
- items: [1],
- page: 1,
- hasMore: false,
- scrollY: 0,
- savedAt: Date.now(),
- }),
- );
- function Probe() {
- const { cached } = useFeedCache("k", (x) => x);
- return {cached ? "cached" : "fresh"}
;
- }
- function Home() {
- const navigate = useNavigate();
- return (
-
- );
- }
- render(
-
-
- } />
- } />
-
- ,
- );
- fireEvent.click(screen.getByText("go"));
- expect(screen.getByText("fresh")).toBeInTheDocument();
- });
-
- test("corrupt storage reads as no cache; a full quota doesn't throw", () => {
- sessionStorage.setItem("k", "{");
- const { result } = renderHook(() => useFeedCache("k", (x) => x), {
- wrapper: wrapper(),
- });
- expect(result.current.cached).toBeNull();
- vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
- throw new Error("QuotaExceededError");
- });
- expect(() => result.current.saveState([], 1, false, 0)).not.toThrow();
- });
-});
-
-describe("useTabParam", () => {
- const TABS = ["dumps", "playlists", "votes"] as const;
- function setup(path: string, state?: unknown) {
- let loc!: ReturnType;
- const hook = renderHook(() => {
- loc = useLocation();
- return useTabParam(TABS, "dumps");
- }, {
- wrapper: ({ children }) => (
-
- {children}
-
- ),
- });
- return { hook, loc: () => loc };
- }
-
- test("reads the tab after /~/", () => {
- expect(setup("/users/ann/~/playlists").hook.result.current[0]).toBe(
- "playlists",
- );
- expect(setup("/users/ann/~/playlists/").hook.result.current[0]).toBe(
- "playlists",
- );
- expect(setup("/users/ann").hook.result.current[0]).toBe("dumps");
- expect(setup("/users/ann/~/bogus").hook.result.current[0]).toBe("dumps");
- });
-
- test("the default tab collapses to the base path", () => {
- const { hook, loc } = setup("/users/ann/~/votes");
- act(() => hook.result.current[1]("dumps"));
- expect(loc().pathname).toBe("/users/ann");
- act(() => hook.result.current[1]("playlists"));
- expect(loc().pathname).toBe("/users/ann/~/playlists");
- });
-
- test("at the root the default stays explicit", () => {
- const { hook, loc } = setup("/~/votes");
- act(() => hook.result.current[1]("dumps"));
- expect(loc().pathname).toBe("/~/dumps");
- });
-
- test("keeps the query string and the entry's state", () => {
- const { hook, loc } = setup("/search?q=cats", { back: "/x" });
- act(() => hook.result.current[1]("votes"));
- expect(loc().pathname).toBe("/search/~/votes");
- expect(loc().search).toBe("?q=cats");
- expect(loc().state).toEqual({ back: "/x" });
- });
-});
-
-describe("useDocumentTitle", () => {
- test("composes page, site name and unread badge", () => {
- const { rerender } = renderHook(
- ({ title }: { title: string | null | undefined }) =>
- useDocumentTitle(title),
- { initialProps: { title: "Cats" as string | null | undefined } },
- );
- expect(document.title).toBe(`Cats · ${SITE_NAME}`);
- act(() => setTitleBadge(3));
- expect(document.title).toBe(`(3) Cats · ${SITE_NAME}`);
- act(() => setTitleBadge(150));
- expect(document.title).toBe(`(99+) Cats · ${SITE_NAME}`);
- rerender({ title: null });
- expect(document.title).toBe(`(99+) ${SITE_NAME}`);
- act(() => setTitleBadge(0));
- expect(document.title).toBe(SITE_NAME);
- // undefined leaves it be
- rerender({ title: undefined });
- expect(document.title).toBe(SITE_NAME);
- });
-
- test("the site name defaults to gerbeur when unsubstituted", () => {
- expect(SITE_NAME).toBe("gerbeur");
- });
-});
-
-describe("useInfiniteScroll", () => {
- function Feed({ onMore, enabled }: { onMore: () => void; enabled: boolean }) {
- const ref = useInfiniteScroll(onMore, enabled);
- return ;
- }
- const intersect = (isIntersecting: boolean) => {
- const obs = FakeIntersectionObserver.instances.at(-1)!;
- act(() => obs.report(isIntersecting));
- };
-
- test("loads more when the sentinel shows, only while enabled", () => {
- const onMore = vi.fn();
- const { rerender } = render();
- expect(FakeIntersectionObserver.instances.at(-1)!.elements.size).toBe(1);
- intersect(false);
- expect(onMore).not.toHaveBeenCalled();
- intersect(true);
- expect(onMore).toHaveBeenCalledTimes(1);
- rerender();
- intersect(true);
- expect(onMore).toHaveBeenCalledTimes(1);
- });
-
- // A load that fails re-enables the feed with the sentinel still in view: a
- // browser's new observer reports it at once, and without a pause that's a
- // request loop as fast as the request can fail.
- test("a sentinel that stays in view waits longer each time", () => {
- vi.useFakeTimers();
- const onMore = vi.fn();
- render();
- intersect(true);
- expect(onMore).toHaveBeenCalledTimes(1);
- // Reported again right after each load, as a new observer does: how long
- // until the next one?
- const delays: number[] = [];
- for (let i = 0; i < 8; i++) {
- const before = onMore.mock.calls.length;
- intersect(true);
- let waited = 0;
- while (onMore.mock.calls.length === before) {
- act(() => vi.advanceTimersByTime(100));
- waited += 100;
- }
- delays.push(waited);
- }
- expect(delays).toEqual([
- 1000,
- 2000,
- 4000,
- 8000,
- 16000,
- 30000,
- 30000,
- 30000,
- ]);
- });
-
- test("re-rendering doesn't restart the wait", () => {
- vi.useFakeTimers();
- const onMore = vi.fn();
- const { rerender } = render();
- intersect(true);
- intersect(true);
- act(() => vi.advanceTimersByTime(600));
- // A new onLoadMore (the feed's state changed) remakes the observer.
- rerender( onMore()} enabled />);
- intersect(true);
- act(() => vi.advanceTimersByTime(400));
- expect(onMore).toHaveBeenCalledTimes(2);
- });
-
- test("leaving the view, or new items pushing it down, asks at once again", () => {
- vi.useFakeTimers();
- const onMore = vi.fn();
- render();
- const at = (top: number) => {
- const obs = FakeIntersectionObserver.instances.at(-1)!;
- act(() => obs.report(true, top));
- };
- at(500);
- at(500);
- expect(onMore).toHaveBeenCalledTimes(1); // waiting
- act(() => FakeIntersectionObserver.instances.at(-1)!.report(false, 500));
- at(500);
- expect(onMore).toHaveBeenCalledTimes(2);
- at(500);
- at(900); // the page grew: that load worked
- expect(onMore).toHaveBeenCalledTimes(3);
- });
-
- test("a pending retry is dropped when the feed is disabled or unmounted", () => {
- vi.useFakeTimers();
- const onMore = vi.fn();
- const { rerender, unmount } = render();
- intersect(true);
- intersect(true);
- rerender();
- act(() => vi.advanceTimersByTime(60_000));
- expect(onMore).toHaveBeenCalledTimes(1);
- rerender();
- intersect(true); // long overdue: at once
- expect(onMore).toHaveBeenCalledTimes(2);
- intersect(true);
- unmount();
- act(() => vi.advanceTimersByTime(60_000));
- expect(onMore).toHaveBeenCalledTimes(2);
- });
-});
-
-describe("useScrollLock", () => {
- function Lock() {
- useScrollLock();
- return null;
- }
- test("counts nested locks", () => {
- const root = document.documentElement;
- const a = render();
- const b = render();
- expect(root.style.overflowY).toBe("hidden");
- a.unmount();
- expect(root.style.overflowY).toBe("hidden");
- b.unmount();
- expect(root.style.overflowY).toBe("");
- });
-});
-
-describe("useScrollSave", () => {
- const scrollTo = (y: number) => {
- Object.defineProperty(globalThis, "scrollY", {
- value: y,
- configurable: true,
- });
- globalThis.dispatchEvent(new Event("scroll"));
- };
-
- test("saves the last position when the page is left, not while scrolling", () => {
- const onSave = vi.fn();
- const { unmount } = renderHook(() => useScrollSave(true, onSave));
- scrollTo(100);
- scrollTo(250);
- expect(onSave).not.toHaveBeenCalled();
- // The next page has already scrolled back up by the time this unmounts.
- Object.defineProperty(globalThis, "scrollY", {
- value: 0,
- configurable: true,
- });
- unmount();
- expect(onSave).toHaveBeenCalledExactlyOnceWith(250);
- });
-
- test("saves on pagehide, with the latest callback", () => {
- const first = vi.fn();
- const second = vi.fn();
- const { rerender, unmount } = renderHook(
- ({ cb }) => useScrollSave(true, cb),
- { initialProps: { cb: first } },
- );
- scrollTo(40);
- rerender({ cb: second });
- globalThis.dispatchEvent(new Event("pagehide"));
- expect(first).not.toHaveBeenCalled();
- expect(second).toHaveBeenCalledExactlyOnceWith(40);
- unmount();
- });
-
- test("nothing is tracked or saved while disabled", () => {
- const onSave = vi.fn();
- const { unmount } = renderHook(() => useScrollSave(false, onSave));
- scrollTo(10);
- globalThis.dispatchEvent(new Event("pagehide"));
- unmount();
- expect(onSave).not.toHaveBeenCalled();
- });
-});
-
-describe("stored preferences", () => {
- test("useDefaultFeedTab validates and persists", () => {
- localStorage.setItem("default-feed-tab", "bogus");
- const { result } = renderHook(useDefaultFeedTab);
- expect(result.current[0]).toBe("journal");
- act(() => result.current[1]("new"));
- expect(result.current[0]).toBe("new");
- expect(localStorage.getItem("default-feed-tab")).toBe("new");
- expect(renderHook(useDefaultFeedTab).result.current[0]).toBe("new");
- });
-
- test("useSoundsEnabled", () => {
- const { result } = renderHook(useSoundsEnabled);
- expect(result.current[0]).toBe(true);
- act(() => result.current[1](false));
- expect(result.current[0]).toBe(false);
- expect(localStorage.getItem("sounds")).toBe("off");
- });
-});
-
-describe("useDumpCardLink", () => {
- function Card() {
- const link = useDumpCardLink({ id: "d1", slug: "hello" });
- return (
-
-
inner link
-
-
- );
- }
- function setup() {
- const open = vi.spyOn(globalThis, "open").mockImplementation(() => null);
- render(
-
-
- } />
- dump page} />
-
- ,
- );
- return { open };
- }
-
- test("a click navigates and marks visited", () => {
- setup();
- fireEvent.click(screen.getByTestId("card"));
- expect(screen.getByText("dump page")).toBeInTheDocument();
- expect(isDumpVisited("d1")).toBe(true);
- });
-
- test("ctrl/cmd/shift-click opens a new tab", () => {
- const { open } = setup();
- fireEvent.click(screen.getByTestId("card"), { ctrlKey: true });
- expect(open).toHaveBeenCalledWith("/dumps/hello", "_blank", "noopener");
- });
-
- test("a middle click opens a new tab, except over a link", () => {
- const { open } = setup();
- const mouseDown = new MouseEvent("mousedown", {
- button: 1,
- bubbles: true,
- cancelable: true,
- });
- screen.getByTestId("card").dispatchEvent(mouseDown);
- expect(mouseDown.defaultPrevented).toBe(true);
- fireEvent(
- screen.getByTestId("card"),
- new MouseEvent("auxclick", { button: 1, bubbles: true }),
- );
- expect(open).toHaveBeenCalledTimes(1);
- fireEvent(
- screen.getByText("inner link"),
- new MouseEvent("auxclick", { button: 1, bubbles: true }),
- );
- expect(open).toHaveBeenCalledTimes(1);
- fireEvent(
- screen.getByTestId("card"),
- new MouseEvent("auxclick", { button: 2, bubbles: true }),
- );
- expect(open).toHaveBeenCalledTimes(1);
- });
-
- test("a modified click on the play button opens the dump instead of playing", () => {
- const { open } = setup();
- const play = screen.getByText("play");
- fireEvent.click(play, { metaKey: true });
- expect(open).toHaveBeenCalledTimes(1);
- expect(play.dataset.played).toBeUndefined();
- fireEvent.click(play);
- expect(play.dataset.played).toBe("1");
- });
-});
-
-describe("useAnchoredFloat", () => {
- function Float({ open, top }: { open: boolean; top: number }) {
- const ref = useRef(null);
- const style = useAnchoredFloat(ref, open, {
- width: 300,
- chromeHeight: 40,
- minContentHeight: 100,
- maxContentHeight: 400,
- });
- return (
- <>
- {
- ref.current = el;
- if (el) {
- el.getBoundingClientRect = () =>
- ({
- top,
- bottom: top + 20,
- left: 900,
- right: 1000,
- width: 100,
- height: 20,
- }) as DOMRect;
- }
- }}
- />
-
- >
- );
- }
- const styleOf = () => JSON.parse(screen.getByTestId("style").textContent!);
-
- beforeEach(() => {
- vi.stubGlobal("innerWidth", 1024);
- vi.stubGlobal("innerHeight", 768);
- });
-
- test("null while closed", () => {
- render(
);
- expect(styleOf()).toBeNull();
- });
-
- test("opens below an anchor near the top, clamped into the viewport", () => {
- render(
);
- const s = styleOf();
- expect(s.position).toBe("fixed");
- expect(s.top).toBe(124);
- expect(s.bottom).toBe("auto");
- expect(s.left).toBe(1024 - 300 - 8);
- expect(s["--float-max-h"]).toBe("400px");
- });
-
- test("flips above an anchor near the bottom", () => {
- render(
);
- const s = styleOf();
- expect(s.bottom).toBe(768 - 700 + 4);
- expect(s.top).toBe("auto");
- });
-});
-
-describe("useHeightTransition", () => {
- test("animates from the old height to the new one when watch changes", () => {
- const animate = vi.spyOn(Element.prototype, "animate");
- let height = 100;
- vi.spyOn(Element.prototype, "getBoundingClientRect").mockImplementation(
- () => ({ height }) as DOMRect,
- );
- function Box({ open }: { open: boolean }) {
- const ref = useHeightTransition
(open);
- return ;
- }
- const { rerender } = render();
- height = 250;
- rerender();
- expect(animate).toHaveBeenCalledTimes(1);
- expect(animate.mock.calls[0][0]).toEqual([
- expect.objectContaining({ height: "100px" }),
- expect.objectContaining({ height: "250px" }),
- ]);
-
- // No change in height, no animation.
- rerender();
- expect(animate).toHaveBeenCalledTimes(1);
- });
-});
diff --git a/src/test/hooks/player/usePlayRichContent.test.tsx b/src/test/hooks/player/usePlayRichContent.test.tsx
new file mode 100644
index 00000000..9346781a
--- /dev/null
+++ b/src/test/hooks/player/usePlayRichContent.test.tsx
@@ -0,0 +1,112 @@
+import { afterEach, describe, expect, test, vi } from "vitest";
+import { act, renderHook } from "@testing-library/react";
+import { type ReactNode } from "react";
+import { fail, route } from "../../fetch.ts";
+
+describe("usePlayRichContent", () => {
+ // The Bandcamp mode is read from a tag once, at module load.
+ async function load(mode: "embed" | "native") {
+ document.head.innerHTML = ``;
+ vi.resetModules();
+ const hookMod = await import("../../../hooks/player/usePlayRichContent.ts");
+ const ctxMod = await import("../../../contexts/player/PlayerContext.ts");
+ const play = vi.fn();
+ const playQueue = vi.fn();
+ const wrapper = ({ children }: { children: ReactNode }) => (
+
+ {children}
+
+ );
+ const hook = renderHook(hookMod.usePlayRichContent, { wrapper });
+ return { hook, play, playQueue, canPlay: hookMod.canPlayRichContent };
+ }
+ afterEach(() => {
+ document.head.innerHTML = "";
+ });
+
+ const yt = {
+ type: "youtube",
+ url: "https://youtu.be/x",
+ title: "Vid",
+ embedUrl: "https://www.youtube.com/embed/x",
+ thumbnailUrl: "https://i.ytimg.com/x.jpg",
+ siteName: "YouTube",
+ };
+ const bc = {
+ type: "bandcamp",
+ url: "https://a.bandcamp.com/album/x",
+ title: "Album",
+ embedUrl: "https://bandcamp.com/EmbeddedPlayer/album=1",
+ };
+
+ test("embeds play in the iframe", async () => {
+ const { hook, play } = await load("embed");
+ let started!: boolean;
+ await act(async () => {
+ started = await hook.result.current.playRichContent(yt, "/dumps/1");
+ });
+ expect(started).toBe(true);
+ expect(play).toHaveBeenCalledWith({
+ kind: "embed",
+ embedUrl: yt.embedUrl,
+ title: "Vid",
+ type: "youtube",
+ dumpHref: "/dumps/1",
+ artworkUrl: yt.thumbnailUrl,
+ subtitle: "YouTube",
+ });
+ });
+
+ test("nothing to embed: not started", async () => {
+ const { hook, play, canPlay } = await load("embed");
+ const bare = { type: "link", url: "https://x" };
+ expect(canPlay(bare)).toBe(false);
+ let started = true;
+ await act(async () => {
+ started = await hook.result.current.playRichContent(bare);
+ });
+ expect(started).toBe(false);
+ expect(play).not.toHaveBeenCalled();
+ });
+
+ test("native Bandcamp plays a resolved queue", async () => {
+ route("GET /api/bandcamp/tracks", () => ({
+ sourceUrl: bc.url,
+ itemType: "album",
+ resolvedAt: 1,
+ tracks: [{
+ index: 0,
+ title: "T",
+ streamUrl: "https://s/0",
+ streamable: true,
+ capped: false,
+ }],
+ }));
+ const { hook, playQueue, canPlay } = await load("native");
+ expect(canPlay({ type: "bandcamp", url: bc.url })).toBe(true);
+ await act(() => hook.result.current.playRichContent(bc));
+ expect(playQueue).toHaveBeenCalledWith(
+ [expect.objectContaining({ kind: "stream", streamUrl: "https://s/0" })],
+ 0,
+ );
+ expect(hook.result.current.pending).toBe(false);
+ });
+
+ test("native Bandcamp that can't resolve falls back to the embed", async () => {
+ vi.spyOn(console, "warn").mockImplementation(() => {});
+ route("GET /api/bandcamp/tracks", () => fail("x", 500));
+ const { hook, play, playQueue } = await load("native");
+ await act(() => hook.result.current.playRichContent(bc));
+ expect(playQueue).not.toHaveBeenCalled();
+ expect(play).toHaveBeenCalledWith(
+ expect.objectContaining({ kind: "embed" }),
+ );
+ });
+
+ test("an unknown mode reads as embed", async () => {
+ const { canPlay } = await load("bogus" as "embed");
+ expect(canPlay({ type: "bandcamp", url: bc.url })).toBe(false);
+ });
+});
diff --git a/src/test/hooks/player/useYouTubePlaylist.test.tsx b/src/test/hooks/player/useYouTubePlaylist.test.tsx
new file mode 100644
index 00000000..36c57121
--- /dev/null
+++ b/src/test/hooks/player/useYouTubePlaylist.test.tsx
@@ -0,0 +1,139 @@
+import { afterEach, describe, expect, test, vi } from "vitest";
+import { act, renderHook, waitFor } from "@testing-library/react";
+import { useRef } from "react";
+import { calls, fail, route } from "../../fetch.ts";
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+ document.head.querySelectorAll("script").forEach((s) => s.remove());
+});
+
+describe("useYouTubePlaylist", () => {
+ type Events = {
+ onReady?: () => void;
+ onStateChange?: (e: { data: number }) => void;
+ };
+ function installYT(ids = ["a", "b", "c"]) {
+ const player = {
+ events: {} as Events,
+ index: 0,
+ playVideo: vi.fn(),
+ playVideoAt: vi.fn(),
+ nextVideo: vi.fn(),
+ previousVideo: vi.fn(),
+ getPlaylist: () => ids,
+ getPlaylistIndex() {
+ return this.index;
+ },
+ destroy: vi.fn(),
+ };
+ // Forwards to `player`, which the tests inspect and steer.
+ class Player {
+ constructor(_el: HTMLIFrameElement, opts: { events: Events }) {
+ player.events = opts.events;
+ }
+ playVideo = player.playVideo;
+ playVideoAt = player.playVideoAt;
+ nextVideo = player.nextVideo;
+ previousVideo = player.previousVideo;
+ getPlaylist = player.getPlaylist;
+ getPlaylistIndex = () => player.index;
+ destroy = player.destroy;
+ }
+ window.YT = { Player };
+ return player;
+ }
+
+ // The IFrame API loads once per document and is cached in the module.
+ async function setup(embedUrl: string | null) {
+ vi.resetModules();
+ const { useYouTubePlaylist } = await import(
+ "../../../hooks/player/useYouTubePlaylist.ts"
+ );
+ return renderHook(({ url }) => {
+ const ref = useRef(
+ document.createElement("iframe"),
+ );
+ return useYouTubePlaylist(url, ref);
+ }, { initialProps: { url: embedUrl } });
+ }
+
+ test("titles from the server, order and position from the player", async () => {
+ // The API script loads once per document; later tests find YT ready.
+ const player = installYT();
+ route("GET /api/youtube/playlist", () => ({
+ tracks: [
+ { videoId: "a", title: "A", duration: 10 },
+ { videoId: "c", title: "C", duration: 30 },
+ ],
+ }));
+ const { result } = await setup(
+ "https://www.youtube.com/embed/videoseries?list=PL1",
+ );
+ await waitFor(() => expect(result.current.rows).toHaveLength(2));
+ expect(result.current.ready).toBe(false);
+ await waitFor(() => expect(player.events.onReady).toBeDefined());
+ player.index = 1;
+ act(() => player.events.onReady!());
+ expect(result.current.ready).toBe(true);
+ expect(result.current.index).toBe(1);
+ // The player's list wins, matched to the titles by id.
+ expect(result.current.rows).toEqual([
+ { videoId: "a", title: "A", duration: 10 },
+ { videoId: "b", title: undefined, duration: undefined },
+ { videoId: "c", title: "C", duration: 30 },
+ ]);
+ act(() => result.current.playAt(2));
+ act(() => result.current.next());
+ act(() => result.current.previous());
+ expect(player.playVideoAt).toHaveBeenCalledWith(2);
+ expect(player.nextVideo).toHaveBeenCalled();
+ expect(player.previousVideo).toHaveBeenCalled();
+ });
+
+ test("a pause as the tab goes to the background is undone, a few times at most", async () => {
+ const player = installYT();
+ route(
+ "GET /api/youtube/playlist",
+ () => ({ tracks: [{ videoId: "a", title: "A" }] }),
+ );
+ await setup("https://www.youtube.com/embed/x?list=PL2");
+ await waitFor(() => expect(player.events.onStateChange).toBeDefined());
+ const hidden = vi.spyOn(document, "hidden", "get").mockReturnValue(true);
+ document.dispatchEvent(new Event("visibilitychange"));
+ for (let i = 0; i < 5; i++) {
+ act(() => player.events.onStateChange!({ data: 2 }));
+ }
+ expect(player.playVideo).toHaveBeenCalledTimes(3);
+ hidden.mockReturnValue(false);
+ document.dispatchEvent(new Event("visibilitychange"));
+ act(() => player.events.onStateChange!({ data: 2 }));
+ expect(player.playVideo).toHaveBeenCalledTimes(3);
+ });
+
+ test("no list in the URL: no rows, no requests", async () => {
+ const { result } = await setup("https://www.youtube.com/embed/x");
+ expect(result.current.rows).toEqual([]);
+ expect(calls("GET /api/youtube/playlist")).toHaveLength(0);
+ });
+
+ test("a new embed drops the old list; unmount destroys the player", async () => {
+ vi.spyOn(console, "warn").mockImplementation(() => {});
+ const player = installYT();
+ route(
+ "GET /api/youtube/playlist",
+ () => ({ tracks: [{ videoId: "a", title: "A" }] }),
+ );
+ const { result, rerender, unmount } = await setup(
+ "https://www.youtube.com/embed/x?list=PL3",
+ );
+ await waitFor(() => expect(result.current.rows).toHaveLength(1));
+ await waitFor(() => expect(player.events.onReady).toBeDefined());
+ route("GET /api/youtube/playlist", () => fail("x", 500));
+ rerender({ url: "https://www.youtube.com/embed/y?list=PL4" });
+ expect(result.current.rows).toEqual([]);
+ expect(result.current.index).toBe(-1);
+ unmount();
+ expect(player.destroy).toHaveBeenCalled();
+ });
+});
diff --git a/src/test/hooks/playlist/usePlaylistListSync.test.tsx b/src/test/hooks/playlist/usePlaylistListSync.test.tsx
new file mode 100644
index 00000000..a216dac4
--- /dev/null
+++ b/src/test/hooks/playlist/usePlaylistListSync.test.tsx
@@ -0,0 +1,165 @@
+import { describe, expect, test } from "vitest";
+import { act, renderHook } from "@testing-library/react";
+import { type ReactNode, useState } from "react";
+import {
+ defaultWSValue,
+ WSContext,
+ type WSContextValue,
+} from "../../../contexts/ws/WSContext.ts";
+import type { Playlist } from "../../../model.ts";
+import { usePlaylistListSync } from "../../../hooks/playlist/usePlaylistListSync.ts";
+import { makePlaylist } from "../../fixtures.ts";
+
+/** A WS context whose value a test can push new events into. */
+function liveWS() {
+ let push: (patch: Partial) => void = () => {};
+ const base = defaultWSValue;
+ function Wrapper({ children }: { children: ReactNode }) {
+ const [value, setValue] = useState(base);
+ push = (patch) => setValue((v) => ({ ...v, ...patch }));
+ return {children};
+ }
+ return {
+ Wrapper,
+ emit: (patch: Partial) => act(() => push(patch)),
+ };
+}
+
+function useList(initial: T[]) {
+ const [items, setItems] = useState(initial);
+ return { items, setItems };
+}
+
+const ids = (xs: { id: string }[]) => xs.map((x) => x.id);
+
+describe("usePlaylistListSync", () => {
+ function setup(
+ initial: Playlist[],
+ opts?: Parameters[1],
+ ) {
+ const ws = liveWS();
+ const hook = renderHook(() => {
+ const list = useList(initial);
+ usePlaylistListSync(list.setItems, opts);
+ return list;
+ }, { wrapper: ws.Wrapper });
+ return { ...ws, hook };
+ }
+
+ test("prepends a created playlist once", () => {
+ const { hook, emit } = setup([makePlaylist({ id: "a" })]);
+ const p = makePlaylist({ id: "b" });
+ emit({
+ lastPlaylistEvent: { type: "created", playlistId: "b", playlist: p },
+ });
+ emit({
+ lastPlaylistEvent: {
+ type: "created",
+ playlistId: "b",
+ playlist: { ...p },
+ },
+ });
+ expect(ids(hook.result.current.items)).toEqual(["b", "a"]);
+ });
+
+ test("created: filtered by owner, privacy and noNewEntries", () => {
+ const p = makePlaylist({ id: "p", userId: "them" });
+ const byOwner = setup([], { ownerId: "me" });
+ byOwner.emit({
+ lastPlaylistEvent: { type: "created", playlistId: "p", playlist: p },
+ });
+ expect(byOwner.hook.result.current.items).toEqual([]);
+
+ const priv = setup([]);
+ priv.emit({
+ lastPlaylistEvent: {
+ type: "created",
+ playlistId: "p",
+ playlist: { ...p, isPublic: false },
+ },
+ });
+ expect(priv.hook.result.current.items).toEqual([]);
+
+ const followed = setup([], { noNewEntries: true });
+ followed.emit({
+ lastPlaylistEvent: { type: "created", playlistId: "p", playlist: p },
+ });
+ expect(followed.hook.result.current.items).toEqual([]);
+ });
+
+ test("updated: in place, removed when private, re-added when public", () => {
+ const p = makePlaylist({ id: "p", title: "one" });
+ const { hook, emit } = setup([p]);
+ emit({
+ lastPlaylistEvent: {
+ type: "updated",
+ playlistId: "p",
+ playlist: { ...p, title: "two" },
+ },
+ });
+ expect(hook.result.current.items[0].title).toBe("two");
+ emit({
+ lastPlaylistEvent: {
+ type: "updated",
+ playlistId: "p",
+ playlist: { ...p, isPublic: false },
+ },
+ });
+ expect(hook.result.current.items).toEqual([]);
+ emit({
+ lastPlaylistEvent: {
+ type: "updated",
+ playlistId: "p",
+ playlist: { ...p, title: "back" },
+ },
+ });
+ expect(hook.result.current.items[0].title).toBe("back");
+ });
+
+ test("updated and not in the list: skipReinsert and noNewEntries hold it back", () => {
+ const p = makePlaylist({ id: "p" });
+ for (const opts of [{ skipReinsert: true }, { noNewEntries: true }]) {
+ const { hook, emit } = setup([], opts);
+ emit({
+ lastPlaylistEvent: { type: "updated", playlistId: "p", playlist: p },
+ });
+ expect(hook.result.current.items).toEqual([]);
+ }
+ });
+
+ test("deleted and deletedPlaylistIds both remove", () => {
+ const { hook, emit } = setup([
+ makePlaylist({ id: "a" }),
+ makePlaylist({ id: "b" }),
+ makePlaylist({ id: "c" }),
+ ]);
+ emit({ lastPlaylistEvent: { type: "deleted", playlistId: "a" } });
+ emit({ deletedPlaylistIds: new Set(["b"]) });
+ expect(ids(hook.result.current.items)).toEqual(["c"]);
+ });
+
+ test("dumps_updated sets the count", () => {
+ const { hook, emit } = setup([makePlaylist({ id: "a", dumpCount: 1 })]);
+ emit({
+ lastPlaylistEvent: {
+ type: "dumps_updated",
+ playlistId: "a",
+ dumpIds: ["x", "y", "z"],
+ },
+ });
+ expect(hook.result.current.items[0].dumpCount).toBe(3);
+ });
+
+ test("dumps_updated counts the members this viewer can't see too", () => {
+ const { hook, emit } = setup([makePlaylist({ id: "a", dumpCount: 3 })]);
+ emit({
+ lastPlaylistEvent: {
+ type: "dumps_updated",
+ playlistId: "a",
+ dumpIds: ["x", "y"],
+ dumpCount: 4,
+ },
+ });
+ expect(hook.result.current.items[0].dumpCount).toBe(4);
+ });
+});
diff --git a/src/test/hooks/shared/useAnchoredFloat.test.tsx b/src/test/hooks/shared/useAnchoredFloat.test.tsx
new file mode 100644
index 00000000..6746e3ec
--- /dev/null
+++ b/src/test/hooks/shared/useAnchoredFloat.test.tsx
@@ -0,0 +1,65 @@
+import { beforeEach, describe, expect, test, vi } from "vitest";
+import { render, screen } from "@testing-library/react";
+import { useRef } from "react";
+import { useAnchoredFloat } from "../../../hooks/shared/useAnchoredFloat.ts";
+
+describe("useAnchoredFloat", () => {
+ function Float({ open, top }: { open: boolean; top: number }) {
+ const ref = useRef(null);
+ const style = useAnchoredFloat(ref, open, {
+ width: 300,
+ chromeHeight: 40,
+ minContentHeight: 100,
+ maxContentHeight: 400,
+ });
+ return (
+ <>
+ {
+ ref.current = el;
+ if (el) {
+ el.getBoundingClientRect = () =>
+ ({
+ top,
+ bottom: top + 20,
+ left: 900,
+ right: 1000,
+ width: 100,
+ height: 20,
+ }) as DOMRect;
+ }
+ }}
+ />
+
+ >
+ );
+ }
+ const styleOf = () => JSON.parse(screen.getByTestId("style").textContent!);
+
+ beforeEach(() => {
+ vi.stubGlobal("innerWidth", 1024);
+ vi.stubGlobal("innerHeight", 768);
+ });
+
+ test("null while closed", () => {
+ render(
);
+ expect(styleOf()).toBeNull();
+ });
+
+ test("opens below an anchor near the top, clamped into the viewport", () => {
+ render(
);
+ const s = styleOf();
+ expect(s.position).toBe("fixed");
+ expect(s.top).toBe(124);
+ expect(s.bottom).toBe("auto");
+ expect(s.left).toBe(1024 - 300 - 8);
+ expect(s["--float-max-h"]).toBe("400px");
+ });
+
+ test("flips above an anchor near the bottom", () => {
+ render(
);
+ const s = styleOf();
+ expect(s.bottom).toBe(768 - 700 + 4);
+ expect(s.top).toBe("auto");
+ });
+});
diff --git a/src/test/hooks/shared/useDocumentTitle.test.tsx b/src/test/hooks/shared/useDocumentTitle.test.tsx
new file mode 100644
index 00000000..286fad8c
--- /dev/null
+++ b/src/test/hooks/shared/useDocumentTitle.test.tsx
@@ -0,0 +1,33 @@
+import { describe, expect, test } from "vitest";
+import { act, renderHook } from "@testing-library/react";
+import {
+ setTitleBadge,
+ SITE_NAME,
+ useDocumentTitle,
+} from "../../../hooks/shared/useDocumentTitle.ts";
+
+describe("useDocumentTitle", () => {
+ test("composes page, site name and unread badge", () => {
+ const { rerender } = renderHook(
+ ({ title }: { title: string | null | undefined }) =>
+ useDocumentTitle(title),
+ { initialProps: { title: "Cats" as string | null | undefined } },
+ );
+ expect(document.title).toBe(`Cats · ${SITE_NAME}`);
+ act(() => setTitleBadge(3));
+ expect(document.title).toBe(`(3) Cats · ${SITE_NAME}`);
+ act(() => setTitleBadge(150));
+ expect(document.title).toBe(`(99+) Cats · ${SITE_NAME}`);
+ rerender({ title: null });
+ expect(document.title).toBe(`(99+) ${SITE_NAME}`);
+ act(() => setTitleBadge(0));
+ expect(document.title).toBe(SITE_NAME);
+ // undefined leaves it be
+ rerender({ title: undefined });
+ expect(document.title).toBe(SITE_NAME);
+ });
+
+ test("the site name defaults to gerbeur when unsubstituted", () => {
+ expect(SITE_NAME).toBe("gerbeur");
+ });
+});
diff --git a/src/test/hooks/shared/useHeightTransition.test.tsx b/src/test/hooks/shared/useHeightTransition.test.tsx
new file mode 100644
index 00000000..1dc610e2
--- /dev/null
+++ b/src/test/hooks/shared/useHeightTransition.test.tsx
@@ -0,0 +1,29 @@
+import { describe, expect, test, vi } from "vitest";
+import { render } from "@testing-library/react";
+import { useHeightTransition } from "../../../hooks/shared/useHeightTransition.ts";
+
+describe("useHeightTransition", () => {
+ test("animates from the old height to the new one when watch changes", () => {
+ const animate = vi.spyOn(Element.prototype, "animate");
+ let height = 100;
+ vi.spyOn(Element.prototype, "getBoundingClientRect").mockImplementation(
+ () => ({ height }) as DOMRect,
+ );
+ function Box({ open }: { open: boolean }) {
+ const ref = useHeightTransition
(open);
+ return ;
+ }
+ const { rerender } = render();
+ height = 250;
+ rerender();
+ expect(animate).toHaveBeenCalledTimes(1);
+ expect(animate.mock.calls[0][0]).toEqual([
+ expect.objectContaining({ height: "100px" }),
+ expect.objectContaining({ height: "250px" }),
+ ]);
+
+ // No change in height, no animation.
+ rerender();
+ expect(animate).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/test/hooks/shared/useScrollLock.test.tsx b/src/test/hooks/shared/useScrollLock.test.tsx
new file mode 100644
index 00000000..b3b90e89
--- /dev/null
+++ b/src/test/hooks/shared/useScrollLock.test.tsx
@@ -0,0 +1,20 @@
+import { describe, expect, test } from "vitest";
+import { render } from "@testing-library/react";
+import { useScrollLock } from "../../../hooks/shared/useScrollLock.ts";
+
+describe("useScrollLock", () => {
+ function Lock() {
+ useScrollLock();
+ return null;
+ }
+ test("counts nested locks", () => {
+ const root = document.documentElement;
+ const a = render();
+ const b = render();
+ expect(root.style.overflowY).toBe("hidden");
+ a.unmount();
+ expect(root.style.overflowY).toBe("hidden");
+ b.unmount();
+ expect(root.style.overflowY).toBe("");
+ });
+});
diff --git a/src/test/hooks/shared/useSoundsEnabled.test.tsx b/src/test/hooks/shared/useSoundsEnabled.test.tsx
new file mode 100644
index 00000000..4ac8ada1
--- /dev/null
+++ b/src/test/hooks/shared/useSoundsEnabled.test.tsx
@@ -0,0 +1,13 @@
+import { describe, expect, test } from "vitest";
+import { act, renderHook } from "@testing-library/react";
+import { useSoundsEnabled } from "../../../hooks/shared/useSoundsEnabled.ts";
+
+describe("useSoundsEnabled", () => {
+ test("defaults on and persists", () => {
+ const { result } = renderHook(useSoundsEnabled);
+ expect(result.current[0]).toBe(true);
+ act(() => result.current[1](false));
+ expect(result.current[0]).toBe(false);
+ expect(localStorage.getItem("sounds")).toBe("off");
+ });
+});
diff --git a/src/test/hooks/shared/useTabParam.test.tsx b/src/test/hooks/shared/useTabParam.test.tsx
new file mode 100644
index 00000000..0064364d
--- /dev/null
+++ b/src/test/hooks/shared/useTabParam.test.tsx
@@ -0,0 +1,61 @@
+import { describe, expect, test } from "vitest";
+import { act, renderHook } from "@testing-library/react";
+import { MemoryRouter, useLocation } from "react-router";
+import { useTabParam } from "../../../hooks/shared/useTabParam.ts";
+
+describe("useTabParam", () => {
+ const TABS = ["dumps", "playlists", "votes"] as const;
+ function setup(path: string, state?: unknown) {
+ let loc!: ReturnType;
+ const hook = renderHook(() => {
+ loc = useLocation();
+ return useTabParam(TABS, "dumps");
+ }, {
+ wrapper: ({ children }) => (
+
+ {children}
+
+ ),
+ });
+ return { hook, loc: () => loc };
+ }
+
+ test("reads the tab after /~/", () => {
+ expect(setup("/users/ann/~/playlists").hook.result.current[0]).toBe(
+ "playlists",
+ );
+ expect(setup("/users/ann/~/playlists/").hook.result.current[0]).toBe(
+ "playlists",
+ );
+ expect(setup("/users/ann").hook.result.current[0]).toBe("dumps");
+ expect(setup("/users/ann/~/bogus").hook.result.current[0]).toBe("dumps");
+ });
+
+ test("the default tab collapses to the base path", () => {
+ const { hook, loc } = setup("/users/ann/~/votes");
+ act(() => hook.result.current[1]("dumps"));
+ expect(loc().pathname).toBe("/users/ann");
+ act(() => hook.result.current[1]("playlists"));
+ expect(loc().pathname).toBe("/users/ann/~/playlists");
+ });
+
+ test("at the root the default stays explicit", () => {
+ const { hook, loc } = setup("/~/votes");
+ act(() => hook.result.current[1]("dumps"));
+ expect(loc().pathname).toBe("/~/dumps");
+ });
+
+ test("keeps the query string and the entry's state", () => {
+ const { hook, loc } = setup("/search?q=cats", { back: "/x" });
+ act(() => hook.result.current[1]("votes"));
+ expect(loc().pathname).toBe("/search/~/votes");
+ expect(loc().search).toBe("?q=cats");
+ expect(loc().state).toEqual({ back: "/x" });
+ });
+});
diff --git a/src/test/hooks/sync.test.tsx b/src/test/hooks/sync.test.tsx
deleted file mode 100644
index 00babc24..00000000
--- a/src/test/hooks/sync.test.tsx
+++ /dev/null
@@ -1,301 +0,0 @@
-import { describe, expect, test } from "vitest";
-import { act, renderHook } from "@testing-library/react";
-import { type ReactNode, useState } from "react";
-import {
- defaultWSValue,
- WSContext,
- type WSContextValue,
-} from "../../contexts/WSContext.ts";
-import type { Dump, Playlist } from "../../model.ts";
-import { useDumpListSync } from "../../hooks/useDumpListSync.ts";
-import { usePlaylistListSync } from "../../hooks/usePlaylistListSync.ts";
-import { usePositionAwareSync } from "../../hooks/usePositionAwareSync.ts";
-import { makeDump, makePlaylist } from "../fixtures.ts";
-
-/** A WS context whose value a test can push new events into. */
-function liveWS() {
- let push: (patch: Partial) => void = () => {};
- const base = defaultWSValue;
- function Wrapper({ children }: { children: ReactNode }) {
- const [value, setValue] = useState(base);
- push = (patch) => setValue((v) => ({ ...v, ...patch }));
- return {children};
- }
- return {
- Wrapper,
- emit: (patch: Partial) => act(() => push(patch)),
- };
-}
-
-function useList(initial: T[]) {
- const [items, setItems] = useState(initial);
- return { items, setItems };
-}
-
-const ids = (xs: { id: string }[]) => xs.map((x) => x.id);
-
-describe("useDumpListSync", () => {
- function setup(
- initial: Dump[],
- opts?: Parameters[1],
- ) {
- const ws = liveWS();
- const hook = renderHook(() => {
- const list = useList(initial);
- useDumpListSync(list.setItems, opts);
- return list;
- }, { wrapper: ws.Wrapper });
- return { ...ws, hook };
- }
-
- test("drops deleted dumps", () => {
- const { hook, emit } = setup([
- makeDump({ id: "a" }),
- makeDump({ id: "b" }),
- ]);
- emit({ deletedDumpIds: new Set(["a"]) });
- expect(ids(hook.result.current.items)).toEqual(["b"]);
- });
-
- test("updates a dump in place", () => {
- const { hook, emit } = setup([makeDump({ id: "a", title: "old" })]);
- emit({ lastDumpEvent: makeDump({ id: "a", title: "new" }) });
- expect(hook.result.current.items[0].title).toBe("new");
- });
-
- test("a dump gone private leaves others' lists but not the owner's", () => {
- const a = makeDump({ id: "a" });
- const viewer = setup([a]);
- viewer.emit({ lastDumpEvent: { ...a, isPrivate: true } });
- expect(viewer.hook.result.current.items).toEqual([]);
-
- const owner = setup([a], { isOwner: true });
- owner.emit({ lastDumpEvent: { ...a, isPrivate: true } });
- expect(owner.hook.result.current.items[0].isPrivate).toBe(true);
- });
-
- test("re-inserts only the owner's dumps, and only when asked", () => {
- const mine = makeDump({ id: "m", userId: "me" });
- const theirs = makeDump({ id: "t", userId: "them" });
-
- const none = setup([]);
- none.emit({ lastDumpEvent: mine });
- expect(none.hook.result.current.items).toEqual([]);
-
- const owned = setup([], { ownerId: "me" });
- owned.emit({ lastDumpEvent: theirs });
- expect(owned.hook.result.current.items).toEqual([]);
- owned.emit({ lastDumpEvent: mine });
- expect(ids(owned.hook.result.current.items)).toEqual(["m"]);
-
- const skip = setup([], { ownerId: "me", skipReinsert: true });
- skip.emit({ lastDumpEvent: mine });
- expect(skip.hook.result.current.items).toEqual([]);
-
- const privateOne = setup([], { ownerId: "me" });
- privateOne.emit({ lastDumpEvent: { ...mine, isPrivate: true } });
- expect(privateOne.hook.result.current.items).toEqual([]);
- });
-
- test("comment events move the count, never below zero; edits don't", () => {
- const { hook, emit } = setup([makeDump({ id: "a", commentCount: 0 })]);
- emit({ lastCommentEvent: { type: "created", dumpId: "a" } });
- expect(hook.result.current.items[0].commentCount).toBe(1);
- emit({ lastCommentEvent: { type: "updated", dumpId: "a" } });
- expect(hook.result.current.items[0].commentCount).toBe(1);
- emit({ lastCommentEvent: { type: "deleted", dumpId: "a" } });
- emit({ lastCommentEvent: { type: "deleted", dumpId: "a" } });
- expect(hook.result.current.items[0].commentCount).toBe(0);
- emit({ lastCommentEvent: { type: "created", dumpId: "zzz" } });
- expect(hook.result.current.items).toHaveLength(1);
- });
-});
-
-describe("usePlaylistListSync", () => {
- function setup(
- initial: Playlist[],
- opts?: Parameters[1],
- ) {
- const ws = liveWS();
- const hook = renderHook(() => {
- const list = useList(initial);
- usePlaylistListSync(list.setItems, opts);
- return list;
- }, { wrapper: ws.Wrapper });
- return { ...ws, hook };
- }
-
- test("prepends a created playlist once", () => {
- const { hook, emit } = setup([makePlaylist({ id: "a" })]);
- const p = makePlaylist({ id: "b" });
- emit({
- lastPlaylistEvent: { type: "created", playlistId: "b", playlist: p },
- });
- emit({
- lastPlaylistEvent: {
- type: "created",
- playlistId: "b",
- playlist: { ...p },
- },
- });
- expect(ids(hook.result.current.items)).toEqual(["b", "a"]);
- });
-
- test("created: filtered by owner, privacy and noNewEntries", () => {
- const p = makePlaylist({ id: "p", userId: "them" });
- const byOwner = setup([], { ownerId: "me" });
- byOwner.emit({
- lastPlaylistEvent: { type: "created", playlistId: "p", playlist: p },
- });
- expect(byOwner.hook.result.current.items).toEqual([]);
-
- const priv = setup([]);
- priv.emit({
- lastPlaylistEvent: {
- type: "created",
- playlistId: "p",
- playlist: { ...p, isPublic: false },
- },
- });
- expect(priv.hook.result.current.items).toEqual([]);
-
- const followed = setup([], { noNewEntries: true });
- followed.emit({
- lastPlaylistEvent: { type: "created", playlistId: "p", playlist: p },
- });
- expect(followed.hook.result.current.items).toEqual([]);
- });
-
- test("updated: in place, removed when private, re-added when public", () => {
- const p = makePlaylist({ id: "p", title: "one" });
- const { hook, emit } = setup([p]);
- emit({
- lastPlaylistEvent: {
- type: "updated",
- playlistId: "p",
- playlist: { ...p, title: "two" },
- },
- });
- expect(hook.result.current.items[0].title).toBe("two");
- emit({
- lastPlaylistEvent: {
- type: "updated",
- playlistId: "p",
- playlist: { ...p, isPublic: false },
- },
- });
- expect(hook.result.current.items).toEqual([]);
- emit({
- lastPlaylistEvent: {
- type: "updated",
- playlistId: "p",
- playlist: { ...p, title: "back" },
- },
- });
- expect(hook.result.current.items[0].title).toBe("back");
- });
-
- test("updated and not in the list: skipReinsert and noNewEntries hold it back", () => {
- const p = makePlaylist({ id: "p" });
- for (const opts of [{ skipReinsert: true }, { noNewEntries: true }]) {
- const { hook, emit } = setup([], opts);
- emit({
- lastPlaylistEvent: { type: "updated", playlistId: "p", playlist: p },
- });
- expect(hook.result.current.items).toEqual([]);
- }
- });
-
- test("deleted and deletedPlaylistIds both remove", () => {
- const { hook, emit } = setup([
- makePlaylist({ id: "a" }),
- makePlaylist({ id: "b" }),
- makePlaylist({ id: "c" }),
- ]);
- emit({ lastPlaylistEvent: { type: "deleted", playlistId: "a" } });
- emit({ deletedPlaylistIds: new Set(["b"]) });
- expect(ids(hook.result.current.items)).toEqual(["c"]);
- });
-
- test("dumps_updated sets the count", () => {
- const { hook, emit } = setup([makePlaylist({ id: "a", dumpCount: 1 })]);
- emit({
- lastPlaylistEvent: {
- type: "dumps_updated",
- playlistId: "a",
- dumpIds: ["x", "y", "z"],
- },
- });
- expect(hook.result.current.items[0].dumpCount).toBe(3);
- });
-
- test("dumps_updated counts the members this viewer can't see too", () => {
- const { hook, emit } = setup([makePlaylist({ id: "a", dumpCount: 3 })]);
- emit({
- lastPlaylistEvent: {
- type: "dumps_updated",
- playlistId: "a",
- dumpIds: ["x", "y"],
- dumpCount: 4,
- },
- });
- expect(hook.result.current.items[0].dumpCount).toBe(4);
- });
-});
-
-describe("usePositionAwareSync", () => {
- test("a dump that goes private and back returns to its old place", () => {
- const ws = liveWS();
- const initial = ["a", "b", "c"].map((id) => makeDump({ id, userId: "me" }));
- // Wire the pair exactly as UserPublicProfile does.
- const both = renderHook(() => {
- const { items, setItems } = useList(initial);
- const [event, setEvent] = useState(null);
- usePositionAwareSync(
- items,
- setItems,
- event,
- (d) => d.isPrivate,
- (d) => !d.isPrivate,
- );
- useDumpListSync(setItems, { ownerId: "me", skipReinsert: true });
- return { items, setEvent };
- }, { wrapper: ws.Wrapper });
-
- const b = initial[1];
- act(() => both.result.current.setEvent({ ...b, isPrivate: true }));
- ws.emit({ lastDumpEvent: { ...b, isPrivate: true } });
- expect(ids(both.result.current.items)).toEqual(["a", "c"]);
-
- act(() => both.result.current.setEvent({ ...b, isPrivate: false }));
- ws.emit({ lastDumpEvent: { ...b, isPrivate: false } });
- expect(ids(both.result.current.items)).toEqual(["a", "b", "c"]);
- });
-
- test("a position is only used once, and clamps to the list end", () => {
- const initial = ["a", "b", "c"].map((id) => makePlaylist({ id }));
- const hook = renderHook(() => {
- const { items, setItems } = useList(initial);
- const [event, setEvent] = useState(null);
- usePositionAwareSync(
- items,
- setItems,
- event,
- (p) => !p.isPublic,
- (p) => p.isPublic,
- );
- return { items, setItems, setEvent };
- });
- const c = initial[2];
- act(() => hook.result.current.setEvent({ ...c, isPublic: false }));
- act(() => hook.result.current.setItems((prev) => prev.slice(0, 1)));
- act(() => hook.result.current.setEvent({ ...c, isPublic: true }));
- expect(ids(hook.result.current.items)).toEqual(["a", "c"]);
- // Nothing saved any more: a second public event is left alone.
- act(() => hook.result.current.setItems((prev) => prev.slice(0, 1)));
- act(() =>
- hook.result.current.setEvent({ ...c, isPublic: true, title: "x" })
- );
- expect(ids(hook.result.current.items)).toEqual(["a"]);
- });
-});
diff --git a/src/test/utils/model.test.ts b/src/test/model.test.ts
similarity index 99%
rename from src/test/utils/model.test.ts
rename to src/test/model.test.ts
index cb7ac776..1f30bd3e 100644
--- a/src/test/utils/model.test.ts
+++ b/src/test/model.test.ts
@@ -8,7 +8,7 @@ import {
deserializePlaylistMembership,
deserializePlaylistWithDumps,
parseAPIResponse,
-} from "../../model.ts";
+} from "../model.ts";
describe("parseAPIResponse", () => {
test("accepts either envelope", () => {
diff --git a/src/test/pages/NotFound.test.tsx b/src/test/pages/NotFound.test.tsx
new file mode 100644
index 00000000..3609a33d
--- /dev/null
+++ b/src/test/pages/NotFound.test.tsx
@@ -0,0 +1,16 @@
+import { describe, expect, test } from "vitest";
+import { screen, waitFor } from "@testing-library/react";
+import { NotFound } from "../../pages/NotFound.tsx";
+import { renderWithContexts } from "../render.tsx";
+
+describe("NotFound", () => {
+ test("NotFound", async () => {
+ renderWithContexts(, { route: "/nope" });
+ expect(screen.getByRole("heading", { name: "404" })).toBeInTheDocument();
+ await waitFor(() => expect(document.title).toMatch(/^Page not found/));
+ expect(screen.getByRole("link", { name: "Go home" })).toHaveAttribute(
+ "href",
+ "/",
+ );
+ });
+});
diff --git a/src/test/pages/ResetPassword.test.tsx b/src/test/pages/ResetPassword.test.tsx
new file mode 100644
index 00000000..20fc3fb4
--- /dev/null
+++ b/src/test/pages/ResetPassword.test.tsx
@@ -0,0 +1,75 @@
+import { describe, expect, test } from "vitest";
+import { screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { Route } from "react-router";
+import { ResetPassword } from "../../pages/ResetPassword.tsx";
+import { calls, fail, route } from "../fetch.ts";
+import { renderWithContexts } from "../render.tsx";
+
+describe("ResetPassword", () => {
+ function setup(search = "?token=rst") {
+ const user = userEvent.setup();
+ renderWithContexts(, {
+ route: `/reset-password${search}`,
+ path: "/reset-password",
+ extraRoutes: login page} />,
+ });
+ return user;
+ }
+
+ test("no token: an invalid link", () => {
+ setup("");
+ expect(screen.getByRole("heading", { name: "Invalid link" }))
+ .toBeInTheDocument();
+ expect(screen.getByRole("link", { name: "Back to login" })).toHaveAttribute(
+ "href",
+ "/login",
+ );
+ });
+
+ test("the two passwords must match", async () => {
+ const user = setup();
+ await user.type(screen.getByPlaceholderText("New password"), "longenough");
+ await user.type(
+ screen.getByPlaceholderText("Confirm new password"),
+ "different",
+ );
+ expect(await screen.findByText("Passwords do not match"))
+ .toBeInTheDocument();
+ await user.click(screen.getByRole("button", { name: "Set new password" }));
+ expect(calls("POST /api/users/reset-password")).toHaveLength(0);
+ });
+
+ test("sets the password, then offers login", async () => {
+ route("POST /api/users/reset-password", () => ({}));
+ const user = setup();
+ await user.type(screen.getByPlaceholderText("New password"), "longenough");
+ await user.type(
+ screen.getByPlaceholderText("Confirm new password"),
+ "longenough{Enter}",
+ );
+ await screen.findByRole("heading", { name: "Password updated" });
+ expect(calls("POST /api/users/reset-password")[0].body).toEqual({
+ token: "rst",
+ newPassword: "longenough",
+ });
+ await user.click(screen.getByRole("button", { name: "Go to login" }));
+ expect(screen.getByText("login page")).toBeInTheDocument();
+ });
+
+ test("an expired token is reported", async () => {
+ route(
+ "POST /api/users/reset-password",
+ () => fail("Reset link expired", 400),
+ );
+ const user = setup();
+ await user.type(screen.getByPlaceholderText("New password"), "longenough");
+ await user.type(
+ screen.getByPlaceholderText("Confirm new password"),
+ "longenough{Enter}",
+ );
+ expect(await screen.findByRole("alert")).toHaveTextContent(
+ "Reset link expired",
+ );
+ });
+});
diff --git a/src/test/pages/RestrictedGuest.test.tsx b/src/test/pages/RestrictedGuest.test.tsx
new file mode 100644
index 00000000..dd2571ec
--- /dev/null
+++ b/src/test/pages/RestrictedGuest.test.tsx
@@ -0,0 +1,25 @@
+import { describe, expect, test } from "vitest";
+import { screen } from "@testing-library/react";
+import { Route } from "react-router";
+import { RestrictedGuest } from "../../pages/RestrictedGuest.tsx";
+import { makeSession } from "../fixtures.ts";
+import { renderWithContexts } from "../render.tsx";
+
+const home = home page} />;
+
+describe("RestrictedGuest", () => {
+ test("sends members home", () => {
+ renderWithContexts(
+
+ form
+ ,
+ {
+ session: makeSession(),
+ route: "/login",
+ path: "/login",
+ extraRoutes: home,
+ },
+ );
+ expect(screen.getByText("home page")).toBeInTheDocument();
+ });
+});
diff --git a/src/test/pages/RestrictedLoggedIn.test.tsx b/src/test/pages/RestrictedLoggedIn.test.tsx
new file mode 100644
index 00000000..61bc7e70
--- /dev/null
+++ b/src/test/pages/RestrictedLoggedIn.test.tsx
@@ -0,0 +1,34 @@
+import { describe, expect, test } from "vitest";
+import { screen } from "@testing-library/react";
+import { Route } from "react-router";
+import { RestrictedLoggedIn } from "../../pages/RestrictedLoggedIn.tsx";
+import { makeSession } from "../fixtures.ts";
+import { renderWithContexts } from "../render.tsx";
+
+describe("RestrictedLoggedIn", () => {
+ test("sends guests to login", () => {
+ renderWithContexts(
+
+ secret
+ ,
+ {
+ route: "/notifications",
+ path: "/notifications",
+ extraRoutes: login page} />,
+ },
+ );
+ expect(screen.getByText("login page")).toBeInTheDocument();
+ });
+
+ test("lets members through", () => {
+ renderWithContexts(
+
+ secret
+ ,
+ {
+ session: makeSession(),
+ },
+ );
+ expect(screen.getByText("secret")).toBeInTheDocument();
+ });
+});
diff --git a/src/test/pages/UserSubpages.test.tsx b/src/test/pages/UserDumps.test.tsx
similarity index 56%
rename from src/test/pages/UserSubpages.test.tsx
rename to src/test/pages/UserDumps.test.tsx
index bf562118..4108c044 100644
--- a/src/test/pages/UserSubpages.test.tsx
+++ b/src/test/pages/UserDumps.test.tsx
@@ -1,8 +1,7 @@
-import { describe, expect, test, vi } from "vitest";
-import { act, screen, waitFor } from "@testing-library/react";
+import { describe, expect, test } from "vitest";
+import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { UserDumps } from "../../pages/UserDumps.tsx";
-import { UserUpvoted } from "../../pages/UserUpvoted.tsx";
import { fail, route } from "../fetch.ts";
import { makeDump, makeSession, makeUser, page, wire } from "../fixtures.ts";
import { renderApp } from "../render.tsx";
@@ -89,81 +88,3 @@ describe("UserDumps", () => {
expect(titles).toEqual(["T-x", "T-y", "T-z"]);
});
});
-
-describe("UserUpvoted", () => {
- const voted = () => [
- makeDump({ id: "v1", title: "Liked one" }),
- makeDump({ id: "v2", title: "Liked two" }),
- ];
-
- function openVotes(session: ReturnType | null = null) {
- route("GET /api/users/:u", () => wire(ann));
- route("GET /api/users/:u/votes", () => page(voted()));
- const user = userEvent.setup();
- renderApp(, {
- route: "/users/ann/upvoted",
- path: "/users/:username/upvoted",
- session,
- });
- return { user, socket: FakeWebSocket.last };
- }
-
- test("lists what they upvoted", async () => {
- openVotes();
- expect(await screen.findByText("Liked one")).toBeInTheDocument();
- expect(screen.getByRole("heading", { name: "Upvoted" }))
- .toBeInTheDocument();
- });
-
- test("your own list shows even before the live connection says what you voted", async () => {
- openVotes(annSession);
- // The socket is never opened: no welcome, no myVotes.
- expect(await screen.findByText("Liked one")).toBeInTheDocument();
- expect(screen.getByText("Liked two")).toBeInTheDocument();
- });
-
- test("unvoting on your own list fades the dump out", async () => {
- vi.useFakeTimers({ shouldAdvanceTime: true });
- try {
- const { socket } = openVotes(annSession);
- await screen.findByText("Liked one");
- socket.welcome({ myVotes: ["v1", "v2"] });
- socket.receive({
- type: "votes_update",
- dumpId: "v1",
- voteCount: 0,
- voterId: "ann",
- action: "remove",
- });
- expect(screen.getByText("Liked one").closest(".dump-card")).toHaveClass(
- "dump-card--fading",
- );
- act(() => vi.advanceTimersByTime(2_400));
- expect(screen.queryByText("Liked one")).not.toBeInTheDocument();
- } finally {
- vi.useRealTimers();
- }
- });
-
- test("their new upvote appears at the top", async () => {
- route(
- "GET /api/dumps/:id",
- () => wire(makeDump({ id: "v3", title: "Fresh like" })),
- );
- const { socket } = openVotes();
- await screen.findByText("Liked one");
- socket.welcome();
- socket.receive({
- type: "votes_update",
- dumpId: "v3",
- voteCount: 1,
- voterId: "ann",
- action: "cast",
- });
- await waitFor(() =>
- expect(document.querySelector(".dump-card-title")).toHaveTextContent(
- "Fresh like",
- )
- );
- });
-});
diff --git a/src/test/pages/UserLogin.test.tsx b/src/test/pages/UserLogin.test.tsx
new file mode 100644
index 00000000..e63adeb3
--- /dev/null
+++ b/src/test/pages/UserLogin.test.tsx
@@ -0,0 +1,123 @@
+import { describe, expect, test } from "vitest";
+import { screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { Route } from "react-router";
+import { UserLogin } from "../../pages/UserLogin.tsx";
+import { AUTH_STORAGE_KEY } from "../../utils/auth/storage.ts";
+import { calls, deferred, fail, route } from "../fetch.ts";
+import { makeSession, wire } from "../fixtures.ts";
+import { currentPath, renderWithContexts } from "../render.tsx";
+
+const home = home page} />;
+
+describe("UserLogin", () => {
+ function setup() {
+ const user = userEvent.setup();
+ renderWithContexts(, {
+ route: "/login",
+ path: "/login",
+ extraRoutes: home,
+ });
+ return user;
+ }
+
+ test("logs in, stores the session and goes home", async () => {
+ const session = makeSession({ username: "ann" });
+ route("POST /api/users/login", () => wire(session));
+ const user = setup();
+ expect(document.title).toMatch(/^Log in · /);
+ await user.type(screen.getByPlaceholderText("Username"), "ann");
+ await user.type(screen.getByPlaceholderText("Password"), "hunter22{Enter}");
+ await screen.findByText("home page");
+ expect(calls("POST /api/users/login")[0].body).toEqual({
+ username: "ann",
+ password: "hunter22",
+ });
+ expect(JSON.parse(localStorage.getItem(AUTH_STORAGE_KEY)!).user.username)
+ .toBe("ann");
+ });
+
+ test("both fields are required", async () => {
+ const user = setup();
+ // The header has a "Log in" button too; this is the form's.
+ await user.click(
+ document.querySelector(
+ ".auth-card button[type=submit]",
+ )!,
+ );
+ expect(calls("POST /api/users/login")).toHaveLength(0);
+ expect(screen.getByPlaceholderText("Username")).toHaveAttribute(
+ "aria-invalid",
+ "true",
+ );
+ });
+
+ test("shows the server's refusal", async () => {
+ route("POST /api/users/login", () => fail("Invalid credentials", 401));
+ const user = setup();
+ await user.type(screen.getByPlaceholderText("Username"), "ann");
+ await user.type(screen.getByPlaceholderText("Password"), "nope{Enter}");
+ const alert = await screen.findByRole("alert");
+ expect(alert).toHaveTextContent("Login failed");
+ expect(alert).toHaveTextContent("Invalid credentials");
+ expect(currentPath()).toBe("/login");
+ });
+
+ test("says when the server can't be reached", async () => {
+ route("POST /api/users/login", () => new TypeError("Failed to fetch"));
+ const user = setup();
+ await user.type(screen.getByPlaceholderText("Username"), "ann");
+ await user.type(screen.getByPlaceholderText("Password"), "pw{Enter}");
+ expect(await screen.findByRole("alert")).toHaveTextContent(
+ /Could not connect/,
+ );
+ });
+
+ test("the button says so while logging in", async () => {
+ const pending = deferred();
+ route("POST /api/users/login", () => pending.promise);
+ const user = setup();
+ await user.type(screen.getByPlaceholderText("Username"), "ann");
+ await user.type(screen.getByPlaceholderText("Password"), "pw{Enter}");
+ expect(await screen.findByRole("button", { name: "Logging in…" }))
+ .toBeDisabled();
+ pending.resolve(
+ new Response(
+ JSON.stringify({ success: true, data: wire(makeSession()) }),
+ ),
+ );
+ await screen.findByText("home page");
+ });
+
+ test("password reset request", async () => {
+ route("POST /api/users/request-password-reset", () => ({}));
+ const user = setup();
+ await user.click(screen.getByRole("button", { name: "Forgot password?" }));
+ await user.type(
+ screen.getByPlaceholderText("Your email address"),
+ " a@b.c {Enter}",
+ );
+ await screen.findByText(/you'll receive a reset link/);
+ expect(calls("POST /api/users/request-password-reset")[0].body).toEqual({
+ email: "a@b.c",
+ });
+ });
+
+ test("a failed reset request is reported, not claimed as sent", async () => {
+ route(
+ "POST /api/users/request-password-reset",
+ () => fail("Internal error", 500),
+ );
+ const user = setup();
+ await user.click(screen.getByRole("button", { name: "Forgot password?" }));
+ await user.type(
+ screen.getByPlaceholderText("Your email address"),
+ "a@b.c{Enter}",
+ );
+ expect(await screen.findByRole("alert")).toHaveTextContent(
+ "Request failed",
+ );
+ expect(screen.queryByText(/you'll receive a reset link/)).not
+ .toBeInTheDocument();
+ });
+});
diff --git a/src/test/pages/UserPublicProfile.test.tsx b/src/test/pages/UserPublicProfile.test.tsx
index 9cb1301c..40e1f35b 100644
--- a/src/test/pages/UserPublicProfile.test.tsx
+++ b/src/test/pages/UserPublicProfile.test.tsx
@@ -15,7 +15,7 @@ import {
} from "../fixtures.ts";
import { currentPath, renderApp } from "../render.tsx";
import { FakeWebSocket } from "../ws.ts";
-import { AUTH_STORAGE_KEY } from "../../utils/authStorage.ts";
+import { AUTH_STORAGE_KEY } from "../../utils/auth/storage.ts";
const ann = makeUser({
id: "ann",
diff --git a/src/test/pages/UserRegister.test.tsx b/src/test/pages/UserRegister.test.tsx
new file mode 100644
index 00000000..805027b2
--- /dev/null
+++ b/src/test/pages/UserRegister.test.tsx
@@ -0,0 +1,92 @@
+import { describe, expect, test } from "vitest";
+import { screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { Route } from "react-router";
+import { UserRegister } from "../../pages/UserRegister.tsx";
+import { calls, fail, route } from "../fetch.ts";
+import { makeSession, wire } from "../fixtures.ts";
+import { renderWithContexts } from "../render.tsx";
+
+const home = home page} />;
+
+describe("UserRegister", () => {
+ function setup(search = "?token=inv-1") {
+ const user = userEvent.setup();
+ renderWithContexts(, {
+ route: `/register${search}`,
+ path: "/register",
+ extraRoutes: home,
+ });
+ return user;
+ }
+
+ test("no token: an invalid invite, no request", () => {
+ setup("");
+ expect(screen.getByRole("alert")).toHaveTextContent("Invalid invite");
+ expect(calls("GET /api/invites/:t")).toHaveLength(0);
+ });
+
+ test("an unknown token is invalid", async () => {
+ route("GET /api/invites/:t", () => fail("nope", 404));
+ setup();
+ expect(screen.getByText("Checking invite…")).toBeInTheDocument();
+ expect(await screen.findByRole("alert")).toHaveTextContent("already used");
+ });
+
+ test("registers with a valid invite", async () => {
+ route("GET /api/invites/:t", () => ({}));
+ const session = makeSession({ username: "new_one" });
+ route("POST /api/users/register", () => wire(session));
+ const user = setup();
+ await user.type(await screen.findByPlaceholderText("Username"), "new_one");
+ await user.type(screen.getByPlaceholderText("Email address"), "n@o.pe");
+ await user.type(
+ screen.getByPlaceholderText("Password (min. 8 characters)"),
+ "longenough{Enter}",
+ );
+ await screen.findByText("home page");
+ expect(calls("POST /api/users/register")[0].body).toEqual({
+ username: "new_one",
+ password: "longenough",
+ inviteToken: "inv-1",
+ email: "n@o.pe",
+ });
+ expect(calls("GET /api/invites/:t")[0].params.t).toBe("inv-1");
+ });
+
+ test("validates the username and password length", async () => {
+ route("GET /api/invites/:t", () => ({}));
+ const user = setup();
+ await user.type(
+ await screen.findByPlaceholderText("Username"),
+ "bad name!",
+ );
+ await user.type(screen.getByPlaceholderText("Email address"), "n@o.pe");
+ await user.type(
+ screen.getByPlaceholderText("Password (min. 8 characters)"),
+ "short{Enter}",
+ );
+ expect(await screen.findByText(/letters, numbers, or underscores/))
+ .toBeInTheDocument();
+ expect(screen.getByText("At least 8 characters")).toBeInTheDocument();
+ expect(calls("POST /api/users/register")).toHaveLength(0);
+ });
+
+ test("shows a server refusal (username taken)", async () => {
+ route("GET /api/invites/:t", () => ({}));
+ route(
+ "POST /api/users/register",
+ () => fail("Username already taken", 409),
+ );
+ const user = setup();
+ await user.type(await screen.findByPlaceholderText("Username"), "taken");
+ await user.type(screen.getByPlaceholderText("Email address"), "n@o.pe");
+ await user.type(
+ screen.getByPlaceholderText("Password (min. 8 characters)"),
+ "longenough{Enter}",
+ );
+ expect(await screen.findByRole("alert")).toHaveTextContent(
+ "Username already taken",
+ );
+ });
+});
diff --git a/src/test/pages/UserUpvoted.test.tsx b/src/test/pages/UserUpvoted.test.tsx
new file mode 100644
index 00000000..42f68358
--- /dev/null
+++ b/src/test/pages/UserUpvoted.test.tsx
@@ -0,0 +1,89 @@
+import { describe, expect, test, vi } from "vitest";
+import { act, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { UserUpvoted } from "../../pages/UserUpvoted.tsx";
+import { route } from "../fetch.ts";
+import { makeDump, makeSession, makeUser, page, wire } from "../fixtures.ts";
+import { renderApp } from "../render.tsx";
+import { FakeWebSocket } from "../ws.ts";
+
+const ann = makeUser({ id: "ann", username: "ann" });
+const annSession = makeSession({ id: "ann", username: "ann" });
+
+describe("UserUpvoted", () => {
+ const voted = () => [
+ makeDump({ id: "v1", title: "Liked one" }),
+ makeDump({ id: "v2", title: "Liked two" }),
+ ];
+
+ function openVotes(session: ReturnType | null = null) {
+ route("GET /api/users/:u", () => wire(ann));
+ route("GET /api/users/:u/votes", () => page(voted()));
+ const user = userEvent.setup();
+ renderApp(, {
+ route: "/users/ann/upvoted",
+ path: "/users/:username/upvoted",
+ session,
+ });
+ return { user, socket: FakeWebSocket.last };
+ }
+
+ test("lists what they upvoted", async () => {
+ openVotes();
+ expect(await screen.findByText("Liked one")).toBeInTheDocument();
+ expect(screen.getByRole("heading", { name: "Upvoted" }))
+ .toBeInTheDocument();
+ });
+
+ test("your own list shows even before the live connection says what you voted", async () => {
+ openVotes(annSession);
+ // The socket is never opened: no welcome, no myVotes.
+ expect(await screen.findByText("Liked one")).toBeInTheDocument();
+ expect(screen.getByText("Liked two")).toBeInTheDocument();
+ });
+
+ test("unvoting on your own list fades the dump out", async () => {
+ vi.useFakeTimers({ shouldAdvanceTime: true });
+ try {
+ const { socket } = openVotes(annSession);
+ await screen.findByText("Liked one");
+ socket.welcome({ myVotes: ["v1", "v2"] });
+ socket.receive({
+ type: "votes_update",
+ dumpId: "v1",
+ voteCount: 0,
+ voterId: "ann",
+ action: "remove",
+ });
+ expect(screen.getByText("Liked one").closest(".dump-card")).toHaveClass(
+ "dump-card--fading",
+ );
+ act(() => vi.advanceTimersByTime(2_400));
+ expect(screen.queryByText("Liked one")).not.toBeInTheDocument();
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ test("their new upvote appears at the top", async () => {
+ route(
+ "GET /api/dumps/:id",
+ () => wire(makeDump({ id: "v3", title: "Fresh like" })),
+ );
+ const { socket } = openVotes();
+ await screen.findByText("Liked one");
+ socket.welcome();
+ socket.receive({
+ type: "votes_update",
+ dumpId: "v3",
+ voteCount: 1,
+ voterId: "ann",
+ action: "cast",
+ });
+ await waitFor(() =>
+ expect(document.querySelector(".dump-card-title")).toHaveTextContent(
+ "Fresh like",
+ )
+ );
+ });
+});
diff --git a/src/test/pages/auth.test.tsx b/src/test/pages/auth.test.tsx
deleted file mode 100644
index 74860827..00000000
--- a/src/test/pages/auth.test.tsx
+++ /dev/null
@@ -1,331 +0,0 @@
-import { describe, expect, test } from "vitest";
-import { screen, waitFor } from "@testing-library/react";
-import userEvent from "@testing-library/user-event";
-import { Route } from "react-router";
-import { UserLogin } from "../../pages/UserLogin.tsx";
-import { UserRegister } from "../../pages/UserRegister.tsx";
-import { ResetPassword } from "../../pages/ResetPassword.tsx";
-import { NotFound } from "../../pages/NotFound.tsx";
-import { RestrictedGuest } from "../../pages/RestrictedGuest.tsx";
-import { RestrictedLoggedIn } from "../../pages/RestrictedLoggedIn.tsx";
-import { AUTH_STORAGE_KEY } from "../../utils/authStorage.ts";
-import { calls, deferred, fail, route } from "../fetch.ts";
-import { makeSession, wire } from "../fixtures.ts";
-import { currentPath, renderWithContexts } from "../render.tsx";
-
-const home = home page} />;
-
-describe("UserLogin", () => {
- function setup() {
- const user = userEvent.setup();
- renderWithContexts(, {
- route: "/login",
- path: "/login",
- extraRoutes: home,
- });
- return user;
- }
-
- test("logs in, stores the session and goes home", async () => {
- const session = makeSession({ username: "ann" });
- route("POST /api/users/login", () => wire(session));
- const user = setup();
- expect(document.title).toMatch(/^Log in · /);
- await user.type(screen.getByPlaceholderText("Username"), "ann");
- await user.type(screen.getByPlaceholderText("Password"), "hunter22{Enter}");
- await screen.findByText("home page");
- expect(calls("POST /api/users/login")[0].body).toEqual({
- username: "ann",
- password: "hunter22",
- });
- expect(JSON.parse(localStorage.getItem(AUTH_STORAGE_KEY)!).user.username)
- .toBe("ann");
- });
-
- test("both fields are required", async () => {
- const user = setup();
- // The header has a "Log in" button too; this is the form's.
- await user.click(
- document.querySelector(
- ".auth-card button[type=submit]",
- )!,
- );
- expect(calls("POST /api/users/login")).toHaveLength(0);
- expect(screen.getByPlaceholderText("Username")).toHaveAttribute(
- "aria-invalid",
- "true",
- );
- });
-
- test("shows the server's refusal", async () => {
- route("POST /api/users/login", () => fail("Invalid credentials", 401));
- const user = setup();
- await user.type(screen.getByPlaceholderText("Username"), "ann");
- await user.type(screen.getByPlaceholderText("Password"), "nope{Enter}");
- const alert = await screen.findByRole("alert");
- expect(alert).toHaveTextContent("Login failed");
- expect(alert).toHaveTextContent("Invalid credentials");
- expect(currentPath()).toBe("/login");
- });
-
- test("says when the server can't be reached", async () => {
- route("POST /api/users/login", () => new TypeError("Failed to fetch"));
- const user = setup();
- await user.type(screen.getByPlaceholderText("Username"), "ann");
- await user.type(screen.getByPlaceholderText("Password"), "pw{Enter}");
- expect(await screen.findByRole("alert")).toHaveTextContent(
- /Could not connect/,
- );
- });
-
- test("the button says so while logging in", async () => {
- const pending = deferred();
- route("POST /api/users/login", () => pending.promise);
- const user = setup();
- await user.type(screen.getByPlaceholderText("Username"), "ann");
- await user.type(screen.getByPlaceholderText("Password"), "pw{Enter}");
- expect(await screen.findByRole("button", { name: "Logging in…" }))
- .toBeDisabled();
- pending.resolve(
- new Response(
- JSON.stringify({ success: true, data: wire(makeSession()) }),
- ),
- );
- await screen.findByText("home page");
- });
-
- test("password reset request", async () => {
- route("POST /api/users/request-password-reset", () => ({}));
- const user = setup();
- await user.click(screen.getByRole("button", { name: "Forgot password?" }));
- await user.type(
- screen.getByPlaceholderText("Your email address"),
- " a@b.c {Enter}",
- );
- await screen.findByText(/you'll receive a reset link/);
- expect(calls("POST /api/users/request-password-reset")[0].body).toEqual({
- email: "a@b.c",
- });
- });
-
- test("a failed reset request is reported, not claimed as sent", async () => {
- route(
- "POST /api/users/request-password-reset",
- () => fail("Internal error", 500),
- );
- const user = setup();
- await user.click(screen.getByRole("button", { name: "Forgot password?" }));
- await user.type(
- screen.getByPlaceholderText("Your email address"),
- "a@b.c{Enter}",
- );
- expect(await screen.findByRole("alert")).toHaveTextContent(
- "Request failed",
- );
- expect(screen.queryByText(/you'll receive a reset link/)).not
- .toBeInTheDocument();
- });
-});
-
-describe("UserRegister", () => {
- function setup(search = "?token=inv-1") {
- const user = userEvent.setup();
- renderWithContexts(, {
- route: `/register${search}`,
- path: "/register",
- extraRoutes: home,
- });
- return user;
- }
-
- test("no token: an invalid invite, no request", () => {
- setup("");
- expect(screen.getByRole("alert")).toHaveTextContent("Invalid invite");
- expect(calls("GET /api/invites/:t")).toHaveLength(0);
- });
-
- test("an unknown token is invalid", async () => {
- route("GET /api/invites/:t", () => fail("nope", 404));
- setup();
- expect(screen.getByText("Checking invite…")).toBeInTheDocument();
- expect(await screen.findByRole("alert")).toHaveTextContent("already used");
- });
-
- test("registers with a valid invite", async () => {
- route("GET /api/invites/:t", () => ({}));
- const session = makeSession({ username: "new_one" });
- route("POST /api/users/register", () => wire(session));
- const user = setup();
- await user.type(await screen.findByPlaceholderText("Username"), "new_one");
- await user.type(screen.getByPlaceholderText("Email address"), "n@o.pe");
- await user.type(
- screen.getByPlaceholderText("Password (min. 8 characters)"),
- "longenough{Enter}",
- );
- await screen.findByText("home page");
- expect(calls("POST /api/users/register")[0].body).toEqual({
- username: "new_one",
- password: "longenough",
- inviteToken: "inv-1",
- email: "n@o.pe",
- });
- expect(calls("GET /api/invites/:t")[0].params.t).toBe("inv-1");
- });
-
- test("validates the username and password length", async () => {
- route("GET /api/invites/:t", () => ({}));
- const user = setup();
- await user.type(
- await screen.findByPlaceholderText("Username"),
- "bad name!",
- );
- await user.type(screen.getByPlaceholderText("Email address"), "n@o.pe");
- await user.type(
- screen.getByPlaceholderText("Password (min. 8 characters)"),
- "short{Enter}",
- );
- expect(await screen.findByText(/letters, numbers, or underscores/))
- .toBeInTheDocument();
- expect(screen.getByText("At least 8 characters")).toBeInTheDocument();
- expect(calls("POST /api/users/register")).toHaveLength(0);
- });
-
- test("shows a server refusal (username taken)", async () => {
- route("GET /api/invites/:t", () => ({}));
- route(
- "POST /api/users/register",
- () => fail("Username already taken", 409),
- );
- const user = setup();
- await user.type(await screen.findByPlaceholderText("Username"), "taken");
- await user.type(screen.getByPlaceholderText("Email address"), "n@o.pe");
- await user.type(
- screen.getByPlaceholderText("Password (min. 8 characters)"),
- "longenough{Enter}",
- );
- expect(await screen.findByRole("alert")).toHaveTextContent(
- "Username already taken",
- );
- });
-});
-
-describe("ResetPassword", () => {
- function setup(search = "?token=rst") {
- const user = userEvent.setup();
- renderWithContexts(, {
- route: `/reset-password${search}`,
- path: "/reset-password",
- extraRoutes: login page} />,
- });
- return user;
- }
-
- test("no token: an invalid link", () => {
- setup("");
- expect(screen.getByRole("heading", { name: "Invalid link" }))
- .toBeInTheDocument();
- expect(screen.getByRole("link", { name: "Back to login" })).toHaveAttribute(
- "href",
- "/login",
- );
- });
-
- test("the two passwords must match", async () => {
- const user = setup();
- await user.type(screen.getByPlaceholderText("New password"), "longenough");
- await user.type(
- screen.getByPlaceholderText("Confirm new password"),
- "different",
- );
- expect(await screen.findByText("Passwords do not match"))
- .toBeInTheDocument();
- await user.click(screen.getByRole("button", { name: "Set new password" }));
- expect(calls("POST /api/users/reset-password")).toHaveLength(0);
- });
-
- test("sets the password, then offers login", async () => {
- route("POST /api/users/reset-password", () => ({}));
- const user = setup();
- await user.type(screen.getByPlaceholderText("New password"), "longenough");
- await user.type(
- screen.getByPlaceholderText("Confirm new password"),
- "longenough{Enter}",
- );
- await screen.findByRole("heading", { name: "Password updated" });
- expect(calls("POST /api/users/reset-password")[0].body).toEqual({
- token: "rst",
- newPassword: "longenough",
- });
- await user.click(screen.getByRole("button", { name: "Go to login" }));
- expect(screen.getByText("login page")).toBeInTheDocument();
- });
-
- test("an expired token is reported", async () => {
- route(
- "POST /api/users/reset-password",
- () => fail("Reset link expired", 400),
- );
- const user = setup();
- await user.type(screen.getByPlaceholderText("New password"), "longenough");
- await user.type(
- screen.getByPlaceholderText("Confirm new password"),
- "longenough{Enter}",
- );
- expect(await screen.findByRole("alert")).toHaveTextContent(
- "Reset link expired",
- );
- });
-});
-
-describe("route guards and 404", () => {
- test("guest-only pages send members home", () => {
- renderWithContexts(
-
- form
- ,
- {
- session: makeSession(),
- route: "/login",
- path: "/login",
- extraRoutes: home,
- },
- );
- expect(screen.getByText("home page")).toBeInTheDocument();
- });
-
- test("member-only pages send guests to login", () => {
- renderWithContexts(
-
- secret
- ,
- {
- route: "/notifications",
- path: "/notifications",
- extraRoutes: login page} />,
- },
- );
- expect(screen.getByText("login page")).toBeInTheDocument();
- });
-
- test("each guard lets the right people through", () => {
- renderWithContexts(
-
- secret
- ,
- {
- session: makeSession(),
- },
- );
- expect(screen.getByText("secret")).toBeInTheDocument();
- });
-
- test("NotFound", async () => {
- renderWithContexts(, { route: "/nope" });
- expect(screen.getByRole("heading", { name: "404" })).toBeInTheDocument();
- await waitFor(() => expect(document.title).toMatch(/^Page not found/));
- expect(screen.getByRole("link", { name: "Go home" })).toHaveAttribute(
- "href",
- "/",
- );
- });
-});
diff --git a/src/test/render.tsx b/src/test/render.tsx
index 224ad3c0..1cd476cf 100644
--- a/src/test/render.tsx
+++ b/src/test/render.tsx
@@ -5,8 +5,8 @@ import { i18n } from "@lingui/core";
import { MemoryRouter, Route, Routes, useLocation } from "react-router";
import type { AuthResponse, Category } from "../model.ts";
-import { AuthContext } from "../contexts/AuthContext.ts";
-import { AuthProvider } from "../contexts/AuthProvider.tsx";
+import { AuthContext } from "../contexts/auth/AuthContext.ts";
+import { AuthProvider } from "../contexts/auth/AuthProvider.tsx";
import {
defaultWSChatValue,
defaultWSPresenceValue,
@@ -17,19 +17,19 @@ import {
type WSContextValue,
WSPresenceContext,
type WSPresenceContextValue,
-} from "../contexts/WSContext.ts";
-import { WSProvider } from "../contexts/WSProvider.tsx";
+} from "../contexts/ws/WSContext.ts";
+import { WSProvider } from "../contexts/ws/WSProvider.tsx";
import {
defaultFollowValue,
FollowContext,
type FollowContextValue,
-} from "../contexts/FollowContext.ts";
-import { FollowProvider } from "../contexts/FollowProvider.tsx";
+} from "../contexts/social/FollowContext.ts";
+import { FollowProvider } from "../contexts/social/FollowProvider.tsx";
import {
CategoriesContext,
type CategoriesContextValue,
-} from "../contexts/CategoriesContext.ts";
-import { CategoriesProvider } from "../contexts/CategoriesProvider.tsx";
+} from "../contexts/category/CategoriesContext.ts";
+import { CategoriesProvider } from "../contexts/category/CategoriesProvider.tsx";
import {
defaultPlayerTimeValue,
defaultPlayerValue,
@@ -37,10 +37,10 @@ import {
type PlayerContextValue,
PlayerTimeContext,
type PlayerTimeValue,
-} from "../contexts/PlayerContext.ts";
-import { PlayerProvider } from "../contexts/PlayerProvider.tsx";
-import { ThemeProvider } from "../contexts/ThemeProvider.tsx";
-import { ChatProvider } from "../contexts/ChatProvider.tsx";
+} from "../contexts/player/PlayerContext.ts";
+import { PlayerProvider } from "../contexts/player/PlayerProvider.tsx";
+import { ThemeProvider } from "../contexts/theme/ThemeProvider.tsx";
+import { ChatProvider } from "../contexts/chat/ChatProvider.tsx";
import { route } from "./fetch.ts";
import { storeSession } from "./fixtures.ts";
diff --git a/src/test/utils/auth/permissions.test.ts b/src/test/utils/auth/permissions.test.ts
new file mode 100644
index 00000000..d09bf828
--- /dev/null
+++ b/src/test/utils/auth/permissions.test.ts
@@ -0,0 +1,17 @@
+import { describe, expect, test } from "vitest";
+import { can } from "../../../utils/auth/permissions.ts";
+
+describe("can", () => {
+ test("admins can do everything, moderators moderate, users nothing", () => {
+ expect(can({ role: "admin" }, "category:manage")).toBe(true);
+ expect(can({ role: "admin" }, "user:manage")).toBe(true);
+ expect(can({ role: "moderator" }, "dump:moderate")).toBe(true);
+ expect(can({ role: "moderator" }, "chat:moderate")).toBe(true);
+ expect(can({ role: "moderator" }, "category:manage")).toBe(false);
+ expect(can({ role: "user" }, "dump:moderate")).toBe(false);
+ });
+ test("no user, no permission", () => {
+ expect(can(null, "dump:moderate")).toBe(false);
+ expect(can(undefined, "dump:moderate")).toBe(false);
+ });
+});
diff --git a/src/test/utils/auth/storage.test.ts b/src/test/utils/auth/storage.test.ts
new file mode 100644
index 00000000..84110e0b
--- /dev/null
+++ b/src/test/utils/auth/storage.test.ts
@@ -0,0 +1,74 @@
+import { describe, expect, test } from "vitest";
+import {
+ AUTH_STORAGE_KEY,
+ forgetStoredSession,
+ isTokenExpired,
+ readStoredSession,
+ storeSession,
+} from "../../../utils/auth/storage.ts";
+import { makeSession, makeToken, wire } from "../../fixtures.ts";
+
+describe("isTokenExpired", () => {
+ test("reads exp from the payload", () => {
+ expect(isTokenExpired(makeToken({ expiresIn: 60 }))).toBe(false);
+ expect(isTokenExpired(makeToken({ expiresIn: -60 }))).toBe(true);
+ });
+ test("a token it can't read is treated as expired", () => {
+ expect(isTokenExpired("garbage")).toBe(true);
+ expect(isTokenExpired("a.b.c")).toBe(true);
+ });
+ test("no exp means it doesn't expire", () => {
+ const token = `x.${btoa(JSON.stringify({ sub: "u" }))}.y`;
+ expect(isTokenExpired(token)).toBe(false);
+ });
+});
+
+describe("stored session", () => {
+ test("round-trips, with dates revived", () => {
+ const session = makeSession({ username: "ann" });
+ storeSession(session);
+ const read = readStoredSession();
+ expect(read?.user.username).toBe("ann");
+ expect(read?.user.createdAt).toBeInstanceOf(Date);
+ });
+
+ test("nothing stored", () => {
+ expect(readStoredSession()).toBeNull();
+ });
+
+ test("unparseable, expired and roleless sessions are discarded", () => {
+ localStorage.setItem(AUTH_STORAGE_KEY, "{not json");
+ expect(readStoredSession()).toBeNull();
+ expect(localStorage.getItem(AUTH_STORAGE_KEY)).toBeNull();
+
+ const expired = makeSession();
+ expired.token = makeToken({ expiresIn: -1 });
+ storeSession(expired);
+ expect(readStoredSession()).toBeNull();
+ expect(localStorage.getItem(AUTH_STORAGE_KEY)).toBeNull();
+
+ const stored = wire(makeSession());
+ const { role: _role, ...roleless } = stored.user;
+ localStorage.setItem(
+ AUTH_STORAGE_KEY,
+ JSON.stringify({ ...stored, user: roleless }),
+ );
+ expect(readStoredSession()).toBeNull();
+ expect(localStorage.getItem(AUTH_STORAGE_KEY)).toBeNull();
+ });
+
+ test("forgetStoredSession only forgets the session holding that token", () => {
+ const session = makeSession();
+ storeSession(session);
+ forgetStoredSession("some-older-token");
+ expect(localStorage.getItem(AUTH_STORAGE_KEY)).not.toBeNull();
+ forgetStoredSession(session.token);
+ expect(localStorage.getItem(AUTH_STORAGE_KEY)).toBeNull();
+ });
+
+ test("forgetStoredSession clears an unreadable entry", () => {
+ localStorage.setItem(AUTH_STORAGE_KEY, "{bad");
+ forgetStoredSession("anything");
+ expect(localStorage.getItem(AUTH_STORAGE_KEY)).toBeNull();
+ });
+});
diff --git a/src/test/utils/chat/text.test.ts b/src/test/utils/chat/text.test.ts
new file mode 100644
index 00000000..ff99e60c
--- /dev/null
+++ b/src/test/utils/chat/text.test.ts
@@ -0,0 +1,47 @@
+import { describe, expect, test } from "vitest";
+import { markdownToPlainText, mentionsUser } from "../../../utils/chat/text.ts";
+
+describe("mentionsUser", () => {
+ test("case-insensitive whole-word match", () => {
+ expect(mentionsUser("hey @Alice!", "alice")).toBe(true);
+ expect(mentionsUser("hey @alicex", "alice")).toBe(false);
+ });
+ test("follows the server's rule: not in an email, a link or brackets", () => {
+ expect(mentionsUser("mail bob@alice", "alice")).toBe(false);
+ expect(mentionsUser("[@alice](x)", "alice")).toBe(false);
+ expect(mentionsUser("(@alice)", "alice")).toBe(false);
+ });
+ test("any of several mentions", () => {
+ expect(mentionsUser("@bob @carol @alice", "alice")).toBe(true);
+ });
+});
+
+describe("markdownToPlainText", () => {
+ test("links become their text, images their alt or a label", () => {
+ expect(markdownToPlainText("see [this](https://x.y) now", "img")).toBe(
+ "see this now",
+ );
+ expect(markdownToPlainText("", "Image")).toBe("cat");
+ expect(markdownToPlainText("", "Image")).toBe("Image");
+ });
+ test("markup characters and block markers go", () => {
+ expect(markdownToPlainText("# Title\n> quote\n- **bold** `code`", "")).toBe(
+ "Title quote bold code",
+ );
+ });
+ test("a body cut off mid-link doesn't show a half URL", () => {
+ expect(markdownToPlainText("look [here](https://exa", "img")).toBe(
+ "look here",
+ );
+ expect(markdownToPlainText("pic ).toBe(
+ "pic alt",
+ );
+ expect(markdownToPlainText("pic ).toBe(
+ "pic Image",
+ );
+ expect(markdownToPlainText("pic ![unfinished", "Image")).toBe("pic");
+ });
+ test("whitespace collapses to one line", () => {
+ expect(markdownToPlainText("a\n\n b\tc", "")).toBe("a b c");
+ });
+});
diff --git a/src/test/utils/dump/draft.test.ts b/src/test/utils/dump/draft.test.ts
new file mode 100644
index 00000000..df3704e8
--- /dev/null
+++ b/src/test/utils/dump/draft.test.ts
@@ -0,0 +1,78 @@
+import { describe, expect, test, vi } from "vitest";
+import {
+ clearDumpDraft,
+ EMPTY_DUMP_DRAFT,
+ isDumpDraftEmpty,
+ loadDumpDraft,
+ saveDumpDraft,
+} from "../../../utils/dump/draft.ts";
+
+describe("dump draft", () => {
+ const draft = {
+ ...EMPTY_DUMP_DRAFT,
+ url: "https://a.b",
+ comment: "why",
+ };
+
+ test("an empty draft is empty; any content isn't", () => {
+ expect(isDumpDraftEmpty(EMPTY_DUMP_DRAFT)).toBe(true);
+ expect(isDumpDraftEmpty({ ...EMPTY_DUMP_DRAFT, url: " " })).toBe(true);
+ expect(isDumpDraftEmpty({ ...EMPTY_DUMP_DRAFT, isPublic: false })).toBe(
+ false,
+ );
+ expect(isDumpDraftEmpty({ ...EMPTY_DUMP_DRAFT, categoryIds: ["c"] })).toBe(
+ false,
+ );
+ });
+
+ test("a title from the preview isn't worth keeping; a typed one is", () => {
+ expect(isDumpDraftEmpty({ ...EMPTY_DUMP_DRAFT, title: "Scraped" })).toBe(
+ true,
+ );
+ expect(
+ isDumpDraftEmpty({
+ ...EMPTY_DUMP_DRAFT,
+ title: "Mine",
+ titleFromUser: true,
+ }),
+ ).toBe(false);
+ });
+
+ test("save, load and clear", () => {
+ saveDumpDraft(draft);
+ expect(loadDumpDraft()).toEqual(draft);
+ clearDumpDraft();
+ expect(loadDumpDraft()).toBeNull();
+ });
+
+ test("saving an empty draft removes the stored one", () => {
+ saveDumpDraft(draft);
+ saveDumpDraft(EMPTY_DUMP_DRAFT);
+ expect(localStorage.getItem("gerbeur.dumpDraft")).toBeNull();
+ });
+
+ test("a malformed stored draft is sanitised or ignored", () => {
+ localStorage.setItem(
+ "gerbeur.dumpDraft",
+ JSON.stringify({ url: 5, comment: "c", categoryIds: ["a", 3, null] }),
+ );
+ expect(loadDumpDraft()).toEqual({
+ ...EMPTY_DUMP_DRAFT,
+ comment: "c",
+ categoryIds: ["a"],
+ });
+ localStorage.setItem("gerbeur.dumpDraft", "nope");
+ expect(loadDumpDraft()).toBeNull();
+ });
+
+ test("storage that throws never breaks posting", () => {
+ vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
+ throw new Error("QuotaExceeded");
+ });
+ vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => {
+ throw new Error("SecurityError");
+ });
+ expect(() => saveDumpDraft(draft)).not.toThrow();
+ expect(loadDumpDraft()).toBeNull();
+ });
+});
diff --git a/src/test/utils/editor/text.test.ts b/src/test/utils/editor/text.test.ts
new file mode 100644
index 00000000..0d13c5ed
--- /dev/null
+++ b/src/test/utils/editor/text.test.ts
@@ -0,0 +1,49 @@
+import { describe, expect, test } from "vitest";
+import { insideCode, isEmojiOnly } from "../../../utils/editor/text.ts";
+
+describe("insideCode", () => {
+ test("plain text is not code", () => {
+ expect(insideCode("hello @bob")).toBe(false);
+ });
+ test("an unclosed inline span is code", () => {
+ expect(insideCode("try `@bob")).toBe(true);
+ expect(insideCode("try `a` then `@bob")).toBe(true);
+ });
+ test("a closed inline span is not", () => {
+ expect(insideCode("try `a` @bob")).toBe(false);
+ });
+ test("an open fence is code, a closed one is not", () => {
+ expect(insideCode("```\n@bob")).toBe(true);
+ expect(insideCode("```js\nx\n```\n@bob")).toBe(false);
+ });
+ test("the fence line itself is not an inline span", () => {
+ expect(insideCode("text\n```\ncode\n```")).toBe(false);
+ });
+ test("backticks on an earlier line don't carry over", () => {
+ expect(insideCode("a ` b\n@bob")).toBe(false);
+ });
+});
+
+describe("isEmojiOnly", () => {
+ test("one to three emoji", () => {
+ expect(isEmojiOnly("🎉")).toBe(true);
+ expect(isEmojiOnly("👍👍")).toBe(true);
+ expect(isEmojiOnly(" 🎉 🎉 🎉 ")).toBe(true);
+ });
+ test("more than the max", () => {
+ expect(isEmojiOnly("🎉🎉🎉🎉")).toBe(false);
+ expect(isEmojiOnly("🎉🎉🎉🎉", 4)).toBe(true);
+ });
+ test("a ZWJ sequence, a skin tone and a flag count as one each", () => {
+ expect(isEmojiOnly("👨👩👧👦")).toBe(true);
+ expect(isEmojiOnly("👍🏽")).toBe(true);
+ expect(isEmojiOnly("🇫🇷🇫🇷🇫🇷")).toBe(true);
+ });
+ test("text, digits and symbols are not emoji", () => {
+ expect(isEmojiOnly("")).toBe(false);
+ expect(isEmojiOnly("hi 🎉")).toBe(false);
+ expect(isEmojiOnly("123")).toBe(false);
+ expect(isEmojiOnly("#")).toBe(false);
+ expect(isEmojiOnly("1🎉")).toBe(false);
+ });
+});
diff --git a/src/test/utils/feed/hotScore.test.ts b/src/test/utils/feed/hotScore.test.ts
new file mode 100644
index 00000000..0ae6eb6d
--- /dev/null
+++ b/src/test/utils/feed/hotScore.test.ts
@@ -0,0 +1,15 @@
+import { describe, expect, test } from "vitest";
+import { hotScore } from "../../../utils/feed/hotScore.ts";
+import { makeDump } from "../../fixtures.ts";
+
+const HOUR = 3_600_000;
+
+describe("hotScore", () => {
+ test("more votes is hotter, older is colder", () => {
+ const now = makeDump({ createdAt: new Date(), voteCount: 0 });
+ const voted = { ...now, voteCount: 5 };
+ const old = { ...now, createdAt: new Date(Date.now() - 48 * HOUR) };
+ expect(hotScore(voted)).toBeGreaterThan(hotScore(now));
+ expect(hotScore(old)).toBeLessThan(hotScore(now));
+ });
+});
diff --git a/src/test/utils/journal.test.ts b/src/test/utils/feed/journalLayout.test.ts
similarity index 90%
rename from src/test/utils/journal.test.ts
rename to src/test/utils/feed/journalLayout.test.ts
index b5feb33d..4ebf64d5 100644
--- a/src/test/utils/journal.test.ts
+++ b/src/test/utils/feed/journalLayout.test.ts
@@ -4,10 +4,9 @@ import {
hasQuote,
hasThumbnail,
type JournalShape,
-} from "../../utils/journalLayout.ts";
-import { hotScore } from "../../utils/hotScore.ts";
-import type { Dump } from "../../model.ts";
-import { makeDump } from "../fixtures.ts";
+} from "../../../utils/feed/journalLayout.ts";
+import type { Dump } from "../../../model.ts";
+import { makeDump } from "../../fixtures.ts";
const HOUR = 3_600_000;
const SPAN: Record = {
@@ -51,16 +50,6 @@ function feed(n: number, withArt: (i: number) => boolean): Dump[] {
}));
}
-describe("hotScore", () => {
- test("more votes is hotter, older is colder", () => {
- const now = makeDump({ createdAt: new Date(), voteCount: 0 });
- const voted = { ...now, voteCount: 5 };
- const old = { ...now, createdAt: new Date(Date.now() - 48 * HOUR) };
- expect(hotScore(voted)).toBeGreaterThan(hotScore(now));
- expect(hotScore(old)).toBeLessThan(hotScore(now));
- });
-});
-
describe("hasThumbnail / hasQuote", () => {
test("image and video files have art; other files don't", () => {
expect(hasThumbnail(makeDump({ kind: "file", fileMime: "image/png" })))
diff --git a/src/test/utils/misc.test.ts b/src/test/utils/misc.test.ts
deleted file mode 100644
index f6b3fa86..00000000
--- a/src/test/utils/misc.test.ts
+++ /dev/null
@@ -1,178 +0,0 @@
-import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
-import { can } from "../../utils/permissions.ts";
-import { canProxyImage, proxyImageUrl } from "../../utils/proxyImage.ts";
-import { relativeTime } from "../../utils/relativeTime.ts";
-import {
- hueFromString,
- initialsFor,
- isSafeHex,
- tintFor,
-} from "../../utils/thumbnailTint.ts";
-import {
- dumpFileUrl,
- dumpThumbnailUrl,
- dumpUrl,
- normalizeUrl,
- playlistUrl,
-} from "../../utils/urls.ts";
-import { API_URL } from "../../config/api.ts";
-import { i18n } from "../../i18n.ts";
-
-describe("can", () => {
- test("admins can do everything, moderators moderate, users nothing", () => {
- expect(can({ role: "admin" }, "category:manage")).toBe(true);
- expect(can({ role: "admin" }, "user:manage")).toBe(true);
- expect(can({ role: "moderator" }, "dump:moderate")).toBe(true);
- expect(can({ role: "moderator" }, "chat:moderate")).toBe(true);
- expect(can({ role: "moderator" }, "category:manage")).toBe(false);
- expect(can({ role: "user" }, "dump:moderate")).toBe(false);
- });
- test("no user, no permission", () => {
- expect(can(null, "dump:moderate")).toBe(false);
- expect(can(undefined, "dump:moderate")).toBe(false);
- });
-});
-
-describe("proxyImageUrl", () => {
- test("http images go through the proxy; https ones don't", () => {
- expect(proxyImageUrl("http://img.test/a.png")).toBe(
- `${API_URL}/api/proxy-image?url=${
- encodeURIComponent("http://img.test/a.png")
- }`,
- );
- expect(proxyImageUrl("https://img.test/a.png")).toBe(
- "https://img.test/a.png",
- );
- });
- test("force proxies https too", () => {
- expect(proxyImageUrl("https://img.test/a.png", { force: true })).toContain(
- "/api/proxy-image?url=",
- );
- });
- test("relative, local and non-http URLs are left alone", () => {
- expect(proxyImageUrl("/api/files/x")).toBe("/api/files/x");
- expect(proxyImageUrl("http://localhost:8000/a.png")).toBe(
- "http://localhost:8000/a.png",
- );
- expect(proxyImageUrl("http://127.0.0.1/a.png")).toBe(
- "http://127.0.0.1/a.png",
- );
- expect(proxyImageUrl("data:image/png;base64,AA")).toBe(
- "data:image/png;base64,AA",
- );
- });
-});
-
-describe("canProxyImage", () => {
- test("third-party http(s) only", () => {
- expect(canProxyImage("https://elsewhere.test/a.png")).toBe(true);
- expect(canProxyImage(`${API_URL}/api/files/x`)).toBe(false);
- expect(canProxyImage("/relative.png")).toBe(false);
- expect(canProxyImage("data:image/png;base64,AA")).toBe(false);
- expect(canProxyImage("http://[bad")).toBe(false);
- });
-});
-
-describe("relativeTime", () => {
- beforeEach(() => {
- vi.useFakeTimers();
- vi.setSystemTime(new Date("2026-06-15T12:00:00Z"));
- });
- afterEach(() => vi.useRealTimers());
-
- const ago = (s: number) => new Date(Date.now() - s * 1000);
-
- test("picks the largest sensible unit", () => {
- expect(relativeTime(ago(0))).toBe("now");
- expect(relativeTime(ago(30))).toBe("30 seconds ago");
- expect(relativeTime(ago(120))).toBe("2 minutes ago");
- expect(relativeTime(ago(3 * 3600))).toBe("3 hours ago");
- expect(relativeTime(ago(86400))).toBe("yesterday");
- expect(relativeTime(ago(14 * 86400))).toBe("2 weeks ago");
- expect(relativeTime(ago(60 * 86400))).toBe("2 months ago");
- expect(relativeTime(ago(800 * 86400))).toBe("2 years ago");
- });
-
- test("follows the active locale", () => {
- try {
- i18n.load("fr", {});
- i18n.activate("fr");
- expect(relativeTime(ago(120))).toBe("il y a 2 minutes");
- } finally {
- i18n.activate("en");
- }
- });
-});
-
-describe("thumbnail tint", () => {
- test("isSafeHex accepts only canonical #rrggbb", () => {
- expect(isSafeHex("#a1b2c3")).toBe(true);
- expect(isSafeHex("#A1B2C3")).toBe(false);
- expect(isSafeHex("#abc")).toBe(false);
- expect(isSafeHex("red")).toBe(false);
- expect(isSafeHex("#000000;background:url(x)")).toBe(false);
- expect(isSafeHex(undefined)).toBe(false);
- });
-
- test("hueFromString is stable and in range", () => {
- expect(hueFromString("example.com")).toBe(hueFromString("example.com"));
- const hue = Number(/hsl\((\d+)/.exec(hueFromString("x"))![1]);
- expect(hue).toBeGreaterThanOrEqual(0);
- expect(hue).toBeLessThan(360);
- });
-
- test("tintFor prefers the page colour, then the hostname sans www", () => {
- expect(tintFor({ accentColor: "#112233", url: "https://a.b" })).toBe(
- "#112233",
- );
- expect(tintFor({ accentColor: "evil", url: "https://www.a.b/x" })).toBe(
- hueFromString("a.b"),
- );
- expect(tintFor({ seed: "file.mp3" })).toBe(hueFromString("file.mp3"));
- expect(tintFor({ url: "not a url" })).toBe(hueFromString("not a url"));
- });
-
- test("initialsFor takes the first letter or digit", () => {
- expect(initialsFor(" bandcamp", undefined)).toBe("B");
- expect(initialsFor(undefined, "https://www.youtube.com/x")).toBe("Y");
- expect(initialsFor("«Élan»", undefined)).toBe("É");
- expect(initialsFor("", "")).toBe("?");
- });
-});
-
-describe("urls", () => {
- test("dumpFileUrl versions by size and last change, and carries the token", () => {
- const createdAt = new Date(1000);
- const updatedAt = new Date(2000);
- expect(dumpFileUrl({ id: "d1", fileSize: 42, createdAt })).toBe(
- `${API_URL}/api/files/d1?v=42-1000`,
- );
- // An edit (a replaced file among them) moves the version on.
- expect(dumpFileUrl({ id: "d1", fileSize: 42, createdAt, updatedAt }))
- .toBe(`${API_URL}/api/files/d1?v=42-2000`);
- expect(dumpFileUrl({ id: "d1" }, "t o+k")).toBe(
- `${API_URL}/api/files/d1?v=0-0&token=t%20o%2Bk`,
- );
- });
- test("dumpThumbnailUrl", () => {
- expect(dumpThumbnailUrl({ id: "d1" })).toBe(`${API_URL}/api/thumbnails/d1`);
- expect(dumpThumbnailUrl({ id: "d1" }, "tk")).toBe(
- `${API_URL}/api/thumbnails/d1?token=tk`,
- );
- expect(
- dumpThumbnailUrl({ id: "d1", fileSize: 7, createdAt: new Date(5) }, "tk"),
- ).toBe(`${API_URL}/api/thumbnails/d1?v=7-5&token=tk`);
- });
- test("normalizeUrl adds a scheme when there's none", () => {
- expect(normalizeUrl(" example.com/x ")).toBe("https://example.com/x");
- expect(normalizeUrl("//cdn.test/a")).toBe("https://cdn.test/a");
- expect(normalizeUrl("HTTP://A.B")).toBe("HTTP://A.B");
- expect(normalizeUrl(" ")).toBe("");
- });
- test("slug beats id in page URLs", () => {
- expect(dumpUrl({ id: "1", slug: "nice-title" })).toBe("/dumps/nice-title");
- expect(dumpUrl({ id: "1" })).toBe("/dumps/1");
- expect(playlistUrl({ id: "2", slug: "mix" })).toBe("/playlists/mix");
- expect(playlistUrl({ id: "2" })).toBe("/playlists/2");
- });
-});
diff --git a/src/test/utils/player/bandcamp.test.ts b/src/test/utils/player/bandcamp.test.ts
new file mode 100644
index 00000000..8428b386
--- /dev/null
+++ b/src/test/utils/player/bandcamp.test.ts
@@ -0,0 +1,102 @@
+import { describe, expect, test } from "vitest";
+import { resolveBandcampQueue } from "../../../utils/player/bandcamp.ts";
+import { calls, fail, route } from "../../fetch.ts";
+
+const album = {
+ sourceUrl: "https://artist.bandcamp.com/album/a",
+ itemType: "album",
+ title: "A",
+ artist: "Artist",
+ artworkUrl: "https://f4.bcbits.com/a.jpg",
+ resolvedAt: 1000,
+ tracks: [
+ {
+ index: 0,
+ trackNum: 1,
+ title: "One",
+ duration: 100,
+ streamUrl: "https://s/1",
+ streamable: true,
+ capped: false,
+ },
+ {
+ index: 1,
+ trackNum: 2,
+ title: "Two",
+ streamUrl: null,
+ streamable: false,
+ capped: true,
+ },
+ {
+ index: 2,
+ trackNum: 3,
+ title: "Three",
+ duration: 50,
+ streamUrl: "https://s/3",
+ streamable: true,
+ capped: false,
+ },
+ ],
+};
+
+describe("resolveBandcampQueue", () => {
+ test("turns the streamable tracks into a queue", async () => {
+ route("GET /api/bandcamp/tracks", () => album);
+ const items = await resolveBandcampQueue(album.sourceUrl, {
+ dumpHref: "/dumps/x",
+ embedUrl: "https://bandcamp.com/EmbeddedPlayer/album=1",
+ });
+ expect(items).toHaveLength(2);
+ expect(items[0]).toMatchObject({
+ kind: "stream",
+ type: "bandcamp",
+ streamUrl: "https://s/1",
+ title: "One",
+ subtitle: "Artist",
+ trackNum: 1,
+ duration: 100,
+ artworkUrl: album.artworkUrl,
+ dumpHref: "/dumps/x",
+ resolveUrl: album.sourceUrl,
+ resolveIndex: 0,
+ resolvedAt: 1000,
+ embedUrl: "https://bandcamp.com/EmbeddedPlayer/album=1",
+ });
+ expect(items[1]).toMatchObject({ resolveIndex: 2 });
+ const [req] = calls("GET /api/bandcamp/tracks");
+ expect(req.url.searchParams.get("url")).toBe(album.sourceUrl);
+ expect(req.url.searchParams.has("force")).toBe(false);
+ });
+
+ test("force asks the server to skip its cache", async () => {
+ route("GET /api/bandcamp/tracks", () => album);
+ await resolveBandcampQueue(album.sourceUrl, {}, { force: true });
+ expect(calls("GET /api/bandcamp/tracks")[0].url.searchParams.get("force"))
+ .toBe("1");
+ });
+
+ test("falls back to the given artwork", async () => {
+ route(
+ "GET /api/bandcamp/tracks",
+ () => ({ ...album, artworkUrl: undefined }),
+ );
+ const [item] = await resolveBandcampQueue(album.sourceUrl, {
+ fallbackArtworkUrl: "https://mine/art.png",
+ });
+ expect(item.artworkUrl).toBe("https://mine/art.png");
+ });
+
+ test("throws on an HTTP error, an error envelope, or nothing to play", async () => {
+ route("GET /api/bandcamp/tracks", () => fail("down", 502), { once: true });
+ await expect(resolveBandcampQueue("u")).rejects.toThrow(/502/);
+
+ route("GET /api/bandcamp/tracks", () => fail("nope", 200), { once: true });
+ await expect(resolveBandcampQueue("u")).rejects.toThrow(/no data/);
+
+ route("GET /api/bandcamp/tracks", () => ({
+ ...album,
+ tracks: [album.tracks[1]],
+ }));
+ await expect(resolveBandcampQueue("u")).rejects.toThrow(/no streamable/);
+ });
+});
diff --git a/src/test/utils/player/duration.test.ts b/src/test/utils/player/duration.test.ts
new file mode 100644
index 00000000..e5ee4ec2
--- /dev/null
+++ b/src/test/utils/player/duration.test.ts
@@ -0,0 +1,14 @@
+import { describe, expect, test } from "vitest";
+import { fmt } from "../../../utils/player/duration.ts";
+
+describe("fmt", () => {
+ test("m:ss", () => {
+ expect(fmt(0)).toBe("0:00");
+ expect(fmt(65.9)).toBe("1:05");
+ expect(fmt(3600)).toBe("60:00");
+ });
+ test("non-finite reads as zero", () => {
+ expect(fmt(NaN)).toBe("0:00");
+ expect(fmt(Infinity)).toBe("0:00");
+ });
+});
diff --git a/src/test/utils/player/streamSources.test.ts b/src/test/utils/player/streamSources.test.ts
new file mode 100644
index 00000000..2114e2d2
--- /dev/null
+++ b/src/test/utils/player/streamSources.test.ts
@@ -0,0 +1,103 @@
+import { afterEach, describe, expect, test, vi } from "vitest";
+import {
+ isStreamStale,
+ resolveStreamQueue,
+ STREAM_STALE_MS,
+} from "../../../utils/player/streamSources.ts";
+import { calls, route } from "../../fetch.ts";
+import type { PlayerItem } from "../../../contexts/player/PlayerContext.ts";
+import { playerItemKey } from "../../../contexts/player/PlayerContext.ts";
+
+afterEach(() => vi.useRealTimers());
+
+const album = {
+ sourceUrl: "https://artist.bandcamp.com/album/a",
+ itemType: "album",
+ title: "A",
+ artist: "Artist",
+ artworkUrl: "https://f4.bcbits.com/a.jpg",
+ resolvedAt: 1000,
+ tracks: [
+ {
+ index: 0,
+ trackNum: 1,
+ title: "One",
+ duration: 100,
+ streamUrl: "https://s/1",
+ streamable: true,
+ capped: false,
+ },
+ {
+ index: 1,
+ trackNum: 2,
+ title: "Two",
+ streamUrl: null,
+ streamable: false,
+ capped: true,
+ },
+ {
+ index: 2,
+ trackNum: 3,
+ title: "Three",
+ duration: 50,
+ streamUrl: "https://s/3",
+ streamable: true,
+ capped: false,
+ },
+ ],
+};
+
+describe("stream sources", () => {
+ const stream: Extract = {
+ kind: "stream",
+ type: "bandcamp",
+ streamUrl: "https://s/1",
+ resolveUrl: album.sourceUrl,
+ resolveIndex: 0,
+ resolvedAt: Date.now(),
+ artworkUrl: "https://old/art.png",
+ dumpHref: "/dumps/x",
+ };
+
+ test("re-resolving a bandcamp stream forces a fresh lookup and keeps the art", async () => {
+ route(
+ "GET /api/bandcamp/tracks",
+ () => ({ ...album, artworkUrl: undefined }),
+ );
+ const items = await resolveStreamQueue(stream);
+ expect(items[0].artworkUrl).toBe("https://old/art.png");
+ expect(calls("GET /api/bandcamp/tracks")[0].url.searchParams.get("force"))
+ .toBe("1");
+ });
+
+ test("an unknown stream type has no resolver", async () => {
+ await expect(resolveStreamQueue({ ...stream, type: "other" })).rejects
+ .toThrow(/no resolver/);
+ });
+
+ test("isStreamStale after the window", () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(10 * STREAM_STALE_MS);
+ expect(isStreamStale({ ...stream, resolvedAt: Date.now() - 1000 })).toBe(
+ false,
+ );
+ expect(
+ isStreamStale({
+ ...stream,
+ resolvedAt: Date.now() - STREAM_STALE_MS - 1,
+ }),
+ ).toBe(true);
+ });
+
+ test("playerItemKey ignores the signed URL", () => {
+ expect(playerItemKey(stream)).toBe(
+ playerItemKey({ ...stream, streamUrl: "https://s/refreshed" }),
+ );
+ expect(
+ playerItemKey({ kind: "file", fileUrl: "f", mimeType: "audio/mpeg" }),
+ )
+ .toBe("file:f");
+ expect(playerItemKey({ kind: "embed", embedUrl: "e", type: "youtube" }))
+ .toBe("embed:e");
+ });
+});
diff --git a/src/test/utils/player/waveform.test.ts b/src/test/utils/player/waveform.test.ts
new file mode 100644
index 00000000..a58b7ddf
--- /dev/null
+++ b/src/test/utils/player/waveform.test.ts
@@ -0,0 +1,33 @@
+import { describe, expect, test, vi } from "vitest";
+import { extractPeaks } from "../../../utils/player/waveform.ts";
+import { calls, fail, route } from "../../fetch.ts";
+
+describe("extractPeaks", () => {
+ test("decodes once per URL and takes each block's peak", async () => {
+ const data = new Float32Array([0.1, -0.5, 0.2, 0.9, -0.3, 0.4]);
+ const decode = vi.fn(async () => ({ getChannelData: () => data }));
+ const close = vi.fn();
+ vi.stubGlobal(
+ "AudioContext",
+ class {
+ decodeAudioData = decode;
+ close = close;
+ },
+ );
+ route("GET /api/files/:id", () => new Response(new Uint8Array([1, 2])));
+
+ const peaks = await extractPeaks("http://localhost:8000/api/files/w1", 3);
+ expect([...peaks].map((p) => +p.toFixed(2))).toEqual([0.5, 0.9, 0.4]);
+ expect(close).toHaveBeenCalled();
+
+ await extractPeaks("http://localhost:8000/api/files/w1", 3);
+ expect(decode).toHaveBeenCalledTimes(1);
+ expect(calls("GET /api/files/:id")).toHaveLength(1);
+ });
+
+ test("a failed fetch throws", async () => {
+ route("GET /api/files/:id", () => fail("gone", 404));
+ await expect(extractPeaks("http://localhost:8000/api/files/w2", 3)).rejects
+ .toThrow(/404/);
+ });
+});
diff --git a/src/test/utils/player/youtube.test.ts b/src/test/utils/player/youtube.test.ts
new file mode 100644
index 00000000..509c041c
--- /dev/null
+++ b/src/test/utils/player/youtube.test.ts
@@ -0,0 +1,43 @@
+import { describe, expect, test } from "vitest";
+import {
+ embedListId,
+ fetchPlaylistTracks,
+ withJsApi,
+} from "../../../utils/player/youtube.ts";
+import { fail, route } from "../../fetch.ts";
+
+describe("youtube", () => {
+ test("embedListId reads ?list= from either embed shape", () => {
+ expect(embedListId("https://www.youtube.com/embed/videoseries?list=PL1"))
+ .toBe("PL1");
+ expect(embedListId("https://www.youtube.com/embed/abc?list=PL2")).toBe(
+ "PL2",
+ );
+ expect(embedListId("https://www.youtube.com/embed/abc")).toBeNull();
+ expect(embedListId("nonsense")).toBeNull();
+ });
+
+ test("withJsApi adds the API parameters and our origin", () => {
+ const url = new URL(withJsApi("https://www.youtube.com/embed/abc?list=PL"));
+ expect(url.searchParams.get("enablejsapi")).toBe("1");
+ expect(url.searchParams.get("origin")).toBe(location.origin);
+ expect(url.searchParams.get("list")).toBe("PL");
+ expect(withJsApi("bad url")).toBe("bad url");
+ });
+
+ test("fetchPlaylistTracks", async () => {
+ route(
+ "GET /api/youtube/playlist",
+ (req) =>
+ req.url.searchParams.get("list") === "PL"
+ ? { tracks: [{ videoId: "a", title: "A", duration: 3 }] }
+ : { tracks: [] },
+ );
+ await expect(fetchPlaylistTracks("PL")).resolves.toEqual([
+ { videoId: "a", title: "A", duration: 3 },
+ ]);
+ await expect(fetchPlaylistTracks("EMPTY")).rejects.toThrow(/no tracks/);
+ route("GET /api/youtube/playlist", () => fail("x", 500));
+ await expect(fetchPlaylistTracks("PL")).rejects.toThrow(/500/);
+ });
+});
diff --git a/src/test/utils/remote.test.ts b/src/test/utils/remote.test.ts
deleted file mode 100644
index df34cd4f..00000000
--- a/src/test/utils/remote.test.ts
+++ /dev/null
@@ -1,238 +0,0 @@
-import { afterEach, describe, expect, test, vi } from "vitest";
-import { resolveBandcampQueue } from "../../utils/bandcamp.ts";
-import {
- isStreamStale,
- resolveStreamQueue,
- STREAM_STALE_MS,
-} from "../../utils/streamSources.ts";
-import {
- embedListId,
- fetchPlaylistTracks,
- withJsApi,
-} from "../../utils/youtube.ts";
-import { extractPeaks } from "../../utils/waveform.ts";
-import { calls, fail, route } from "../fetch.ts";
-import type { PlayerItem } from "../../contexts/PlayerContext.ts";
-import { playerItemKey } from "../../contexts/PlayerContext.ts";
-
-afterEach(() => vi.useRealTimers());
-
-const album = {
- sourceUrl: "https://artist.bandcamp.com/album/a",
- itemType: "album",
- title: "A",
- artist: "Artist",
- artworkUrl: "https://f4.bcbits.com/a.jpg",
- resolvedAt: 1000,
- tracks: [
- {
- index: 0,
- trackNum: 1,
- title: "One",
- duration: 100,
- streamUrl: "https://s/1",
- streamable: true,
- capped: false,
- },
- {
- index: 1,
- trackNum: 2,
- title: "Two",
- streamUrl: null,
- streamable: false,
- capped: true,
- },
- {
- index: 2,
- trackNum: 3,
- title: "Three",
- duration: 50,
- streamUrl: "https://s/3",
- streamable: true,
- capped: false,
- },
- ],
-};
-
-describe("resolveBandcampQueue", () => {
- test("turns the streamable tracks into a queue", async () => {
- route("GET /api/bandcamp/tracks", () => album);
- const items = await resolveBandcampQueue(album.sourceUrl, {
- dumpHref: "/dumps/x",
- embedUrl: "https://bandcamp.com/EmbeddedPlayer/album=1",
- });
- expect(items).toHaveLength(2);
- expect(items[0]).toMatchObject({
- kind: "stream",
- type: "bandcamp",
- streamUrl: "https://s/1",
- title: "One",
- subtitle: "Artist",
- trackNum: 1,
- duration: 100,
- artworkUrl: album.artworkUrl,
- dumpHref: "/dumps/x",
- resolveUrl: album.sourceUrl,
- resolveIndex: 0,
- resolvedAt: 1000,
- embedUrl: "https://bandcamp.com/EmbeddedPlayer/album=1",
- });
- expect(items[1]).toMatchObject({ resolveIndex: 2 });
- const [req] = calls("GET /api/bandcamp/tracks");
- expect(req.url.searchParams.get("url")).toBe(album.sourceUrl);
- expect(req.url.searchParams.has("force")).toBe(false);
- });
-
- test("force asks the server to skip its cache", async () => {
- route("GET /api/bandcamp/tracks", () => album);
- await resolveBandcampQueue(album.sourceUrl, {}, { force: true });
- expect(calls("GET /api/bandcamp/tracks")[0].url.searchParams.get("force"))
- .toBe("1");
- });
-
- test("falls back to the given artwork", async () => {
- route(
- "GET /api/bandcamp/tracks",
- () => ({ ...album, artworkUrl: undefined }),
- );
- const [item] = await resolveBandcampQueue(album.sourceUrl, {
- fallbackArtworkUrl: "https://mine/art.png",
- });
- expect(item.artworkUrl).toBe("https://mine/art.png");
- });
-
- test("throws on an HTTP error, an error envelope, or nothing to play", async () => {
- route("GET /api/bandcamp/tracks", () => fail("down", 502), { once: true });
- await expect(resolveBandcampQueue("u")).rejects.toThrow(/502/);
-
- route("GET /api/bandcamp/tracks", () => fail("nope", 200), { once: true });
- await expect(resolveBandcampQueue("u")).rejects.toThrow(/no data/);
-
- route("GET /api/bandcamp/tracks", () => ({
- ...album,
- tracks: [album.tracks[1]],
- }));
- await expect(resolveBandcampQueue("u")).rejects.toThrow(/no streamable/);
- });
-});
-
-describe("stream sources", () => {
- const stream: Extract = {
- kind: "stream",
- type: "bandcamp",
- streamUrl: "https://s/1",
- resolveUrl: album.sourceUrl,
- resolveIndex: 0,
- resolvedAt: Date.now(),
- artworkUrl: "https://old/art.png",
- dumpHref: "/dumps/x",
- };
-
- test("re-resolving a bandcamp stream forces a fresh lookup and keeps the art", async () => {
- route(
- "GET /api/bandcamp/tracks",
- () => ({ ...album, artworkUrl: undefined }),
- );
- const items = await resolveStreamQueue(stream);
- expect(items[0].artworkUrl).toBe("https://old/art.png");
- expect(calls("GET /api/bandcamp/tracks")[0].url.searchParams.get("force"))
- .toBe("1");
- });
-
- test("an unknown stream type has no resolver", async () => {
- await expect(resolveStreamQueue({ ...stream, type: "other" })).rejects
- .toThrow(/no resolver/);
- });
-
- test("isStreamStale after the window", () => {
- vi.useFakeTimers();
- vi.setSystemTime(10 * STREAM_STALE_MS);
- expect(isStreamStale({ ...stream, resolvedAt: Date.now() - 1000 })).toBe(
- false,
- );
- expect(
- isStreamStale({
- ...stream,
- resolvedAt: Date.now() - STREAM_STALE_MS - 1,
- }),
- ).toBe(true);
- });
-
- test("playerItemKey ignores the signed URL", () => {
- expect(playerItemKey(stream)).toBe(
- playerItemKey({ ...stream, streamUrl: "https://s/refreshed" }),
- );
- expect(
- playerItemKey({ kind: "file", fileUrl: "f", mimeType: "audio/mpeg" }),
- )
- .toBe("file:f");
- expect(playerItemKey({ kind: "embed", embedUrl: "e", type: "youtube" }))
- .toBe("embed:e");
- });
-});
-
-describe("youtube", () => {
- test("embedListId reads ?list= from either embed shape", () => {
- expect(embedListId("https://www.youtube.com/embed/videoseries?list=PL1"))
- .toBe("PL1");
- expect(embedListId("https://www.youtube.com/embed/abc?list=PL2")).toBe(
- "PL2",
- );
- expect(embedListId("https://www.youtube.com/embed/abc")).toBeNull();
- expect(embedListId("nonsense")).toBeNull();
- });
-
- test("withJsApi adds the API parameters and our origin", () => {
- const url = new URL(withJsApi("https://www.youtube.com/embed/abc?list=PL"));
- expect(url.searchParams.get("enablejsapi")).toBe("1");
- expect(url.searchParams.get("origin")).toBe(location.origin);
- expect(url.searchParams.get("list")).toBe("PL");
- expect(withJsApi("bad url")).toBe("bad url");
- });
-
- test("fetchPlaylistTracks", async () => {
- route(
- "GET /api/youtube/playlist",
- (req) =>
- req.url.searchParams.get("list") === "PL"
- ? { tracks: [{ videoId: "a", title: "A", duration: 3 }] }
- : { tracks: [] },
- );
- await expect(fetchPlaylistTracks("PL")).resolves.toEqual([
- { videoId: "a", title: "A", duration: 3 },
- ]);
- await expect(fetchPlaylistTracks("EMPTY")).rejects.toThrow(/no tracks/);
- route("GET /api/youtube/playlist", () => fail("x", 500));
- await expect(fetchPlaylistTracks("PL")).rejects.toThrow(/500/);
- });
-});
-
-describe("extractPeaks", () => {
- test("decodes once per URL and takes each block's peak", async () => {
- const data = new Float32Array([0.1, -0.5, 0.2, 0.9, -0.3, 0.4]);
- const decode = vi.fn(async () => ({ getChannelData: () => data }));
- const close = vi.fn();
- vi.stubGlobal(
- "AudioContext",
- class {
- decodeAudioData = decode;
- close = close;
- },
- );
- route("GET /api/files/:id", () => new Response(new Uint8Array([1, 2])));
-
- const peaks = await extractPeaks("http://localhost:8000/api/files/w1", 3);
- expect([...peaks].map((p) => +p.toFixed(2))).toEqual([0.5, 0.9, 0.4]);
- expect(close).toHaveBeenCalled();
-
- await extractPeaks("http://localhost:8000/api/files/w1", 3);
- expect(decode).toHaveBeenCalledTimes(1);
- expect(calls("GET /api/files/:id")).toHaveLength(1);
- });
-
- test("a failed fetch throws", async () => {
- route("GET /api/files/:id", () => fail("gone", 404));
- await expect(extractPeaks("http://localhost:8000/api/files/w2", 3)).rejects
- .toThrow(/404/);
- });
-});
diff --git a/src/test/utils/shared/api.test.ts b/src/test/utils/shared/api.test.ts
new file mode 100644
index 00000000..23267cd8
--- /dev/null
+++ b/src/test/utils/shared/api.test.ts
@@ -0,0 +1,14 @@
+import { describe, expect, test } from "vitest";
+import { friendlyFetchError } from "../../../utils/shared/api.ts";
+
+describe("friendlyFetchError", () => {
+ test("a TypeError is a connection problem", () => {
+ expect(friendlyFetchError(new TypeError("Failed to fetch"))).toMatch(
+ /Could not connect/,
+ );
+ });
+ test("other errors keep their message", () => {
+ expect(friendlyFetchError(new Error("Nope"))).toBe("Nope");
+ expect(friendlyFetchError("weird")).toBe("An unexpected error occurred.");
+ });
+});
diff --git a/src/test/utils/shared/charCount.test.ts b/src/test/utils/shared/charCount.test.ts
new file mode 100644
index 00000000..239cdca5
--- /dev/null
+++ b/src/test/utils/shared/charCount.test.ts
@@ -0,0 +1,14 @@
+import { describe, expect, test } from "vitest";
+import { charCountClass, overLimit } from "../../../utils/shared/charCount.ts";
+
+describe("character limits", () => {
+ test("charCountClass warns from 85% and flags the limit", () => {
+ expect(charCountClass(10, 100)).toBe("");
+ expect(charCountClass(85, 100)).toBe(" text-editor-count--warn");
+ expect(charCountClass(100, 100)).toBe(" text-editor-count--danger");
+ });
+ test("overLimit is strictly past it", () => {
+ expect(overLimit("abc", 3)).toBe(false);
+ expect(overLimit("abcd", 3)).toBe(true);
+ });
+});
diff --git a/src/test/utils/shared/format.test.ts b/src/test/utils/shared/format.test.ts
new file mode 100644
index 00000000..adb88396
--- /dev/null
+++ b/src/test/utils/shared/format.test.ts
@@ -0,0 +1,27 @@
+import { describe, expect, test } from "vitest";
+import {
+ formatBytes,
+ titleFromFileName,
+} from "../../../utils/shared/format.ts";
+
+describe("formatBytes", () => {
+ test("B, KB and MB", () => {
+ expect(formatBytes(512)).toBe("512 B");
+ expect(formatBytes(1536)).toBe("1.5 KB");
+ expect(formatBytes(5 * 1024 * 1024)).toBe("5.0 MB");
+ });
+});
+
+describe("titleFromFileName", () => {
+ test("drops the extension and turns underscores into spaces", () => {
+ expect(titleFromFileName("holiday_photo-01.final.jpg")).toBe(
+ "holiday photo-01.final",
+ );
+ });
+ test("a dotfile keeps its name", () => {
+ expect(titleFromFileName(".gitignore")).toBe(".gitignore");
+ });
+ test("a name that is only separators falls back to itself", () => {
+ expect(titleFromFileName("___.txt")).toBe("___.txt");
+ });
+});
diff --git a/src/test/utils/shared/proxyImage.test.ts b/src/test/utils/shared/proxyImage.test.ts
new file mode 100644
index 00000000..89bb2b43
--- /dev/null
+++ b/src/test/utils/shared/proxyImage.test.ts
@@ -0,0 +1,46 @@
+import { describe, expect, test } from "vitest";
+import {
+ canProxyImage,
+ proxyImageUrl,
+} from "../../../utils/shared/proxyImage.ts";
+import { API_URL } from "../../../config/api.ts";
+
+describe("proxyImageUrl", () => {
+ test("http images go through the proxy; https ones don't", () => {
+ expect(proxyImageUrl("http://img.test/a.png")).toBe(
+ `${API_URL}/api/proxy-image?url=${
+ encodeURIComponent("http://img.test/a.png")
+ }`,
+ );
+ expect(proxyImageUrl("https://img.test/a.png")).toBe(
+ "https://img.test/a.png",
+ );
+ });
+ test("force proxies https too", () => {
+ expect(proxyImageUrl("https://img.test/a.png", { force: true })).toContain(
+ "/api/proxy-image?url=",
+ );
+ });
+ test("relative, local and non-http URLs are left alone", () => {
+ expect(proxyImageUrl("/api/files/x")).toBe("/api/files/x");
+ expect(proxyImageUrl("http://localhost:8000/a.png")).toBe(
+ "http://localhost:8000/a.png",
+ );
+ expect(proxyImageUrl("http://127.0.0.1/a.png")).toBe(
+ "http://127.0.0.1/a.png",
+ );
+ expect(proxyImageUrl("data:image/png;base64,AA")).toBe(
+ "data:image/png;base64,AA",
+ );
+ });
+});
+
+describe("canProxyImage", () => {
+ test("third-party http(s) only", () => {
+ expect(canProxyImage("https://elsewhere.test/a.png")).toBe(true);
+ expect(canProxyImage(`${API_URL}/api/files/x`)).toBe(false);
+ expect(canProxyImage("/relative.png")).toBe(false);
+ expect(canProxyImage("data:image/png;base64,AA")).toBe(false);
+ expect(canProxyImage("http://[bad")).toBe(false);
+ });
+});
diff --git a/src/test/utils/shared/relativeTime.test.ts b/src/test/utils/shared/relativeTime.test.ts
new file mode 100644
index 00000000..baf3d53e
--- /dev/null
+++ b/src/test/utils/shared/relativeTime.test.ts
@@ -0,0 +1,34 @@
+import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
+import { relativeTime } from "../../../utils/shared/relativeTime.ts";
+import { i18n } from "../../../i18n.ts";
+
+describe("relativeTime", () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date("2026-06-15T12:00:00Z"));
+ });
+ afterEach(() => vi.useRealTimers());
+
+ const ago = (s: number) => new Date(Date.now() - s * 1000);
+
+ test("picks the largest sensible unit", () => {
+ expect(relativeTime(ago(0))).toBe("now");
+ expect(relativeTime(ago(30))).toBe("30 seconds ago");
+ expect(relativeTime(ago(120))).toBe("2 minutes ago");
+ expect(relativeTime(ago(3 * 3600))).toBe("3 hours ago");
+ expect(relativeTime(ago(86400))).toBe("yesterday");
+ expect(relativeTime(ago(14 * 86400))).toBe("2 weeks ago");
+ expect(relativeTime(ago(60 * 86400))).toBe("2 months ago");
+ expect(relativeTime(ago(800 * 86400))).toBe("2 years ago");
+ });
+
+ test("follows the active locale", () => {
+ try {
+ i18n.load("fr", {});
+ i18n.activate("fr");
+ expect(relativeTime(ago(120))).toBe("il y a 2 minutes");
+ } finally {
+ i18n.activate("en");
+ }
+ });
+});
diff --git a/src/test/utils/sound.test.ts b/src/test/utils/shared/sound.test.ts
similarity index 98%
rename from src/test/utils/sound.test.ts
rename to src/test/utils/shared/sound.test.ts
index 0d861cf5..607b788f 100644
--- a/src/test/utils/sound.test.ts
+++ b/src/test/utils/shared/sound.test.ts
@@ -34,7 +34,7 @@ class FakeAudioContext {
async function load() {
vi.resetModules();
- return await import("../../utils/sound.ts");
+ return await import("../../../utils/shared/sound.ts");
}
const notes = () =>
diff --git a/src/test/utils/shared/thumbnailTint.test.ts b/src/test/utils/shared/thumbnailTint.test.ts
new file mode 100644
index 00000000..fbd532bc
--- /dev/null
+++ b/src/test/utils/shared/thumbnailTint.test.ts
@@ -0,0 +1,43 @@
+import { describe, expect, test } from "vitest";
+import {
+ hueFromString,
+ initialsFor,
+ isSafeHex,
+ tintFor,
+} from "../../../utils/shared/thumbnailTint.ts";
+
+describe("thumbnail tint", () => {
+ test("isSafeHex accepts only canonical #rrggbb", () => {
+ expect(isSafeHex("#a1b2c3")).toBe(true);
+ expect(isSafeHex("#A1B2C3")).toBe(false);
+ expect(isSafeHex("#abc")).toBe(false);
+ expect(isSafeHex("red")).toBe(false);
+ expect(isSafeHex("#000000;background:url(x)")).toBe(false);
+ expect(isSafeHex(undefined)).toBe(false);
+ });
+
+ test("hueFromString is stable and in range", () => {
+ expect(hueFromString("example.com")).toBe(hueFromString("example.com"));
+ const hue = Number(/hsl\((\d+)/.exec(hueFromString("x"))![1]);
+ expect(hue).toBeGreaterThanOrEqual(0);
+ expect(hue).toBeLessThan(360);
+ });
+
+ test("tintFor prefers the page colour, then the hostname sans www", () => {
+ expect(tintFor({ accentColor: "#112233", url: "https://a.b" })).toBe(
+ "#112233",
+ );
+ expect(tintFor({ accentColor: "evil", url: "https://www.a.b/x" })).toBe(
+ hueFromString("a.b"),
+ );
+ expect(tintFor({ seed: "file.mp3" })).toBe(hueFromString("file.mp3"));
+ expect(tintFor({ url: "not a url" })).toBe(hueFromString("not a url"));
+ });
+
+ test("initialsFor takes the first letter or digit", () => {
+ expect(initialsFor(" bandcamp", undefined)).toBe("B");
+ expect(initialsFor(undefined, "https://www.youtube.com/x")).toBe("Y");
+ expect(initialsFor("«Élan»", undefined)).toBe("É");
+ expect(initialsFor("", "")).toBe("?");
+ });
+});
diff --git a/src/test/utils/shared/urls.test.ts b/src/test/utils/shared/urls.test.ts
new file mode 100644
index 00000000..1abefa4e
--- /dev/null
+++ b/src/test/utils/shared/urls.test.ts
@@ -0,0 +1,46 @@
+import { describe, expect, test } from "vitest";
+import {
+ dumpFileUrl,
+ dumpThumbnailUrl,
+ dumpUrl,
+ normalizeUrl,
+ playlistUrl,
+} from "../../../utils/shared/urls.ts";
+import { API_URL } from "../../../config/api.ts";
+
+describe("urls", () => {
+ test("dumpFileUrl versions by size and last change, and carries the token", () => {
+ const createdAt = new Date(1000);
+ const updatedAt = new Date(2000);
+ expect(dumpFileUrl({ id: "d1", fileSize: 42, createdAt })).toBe(
+ `${API_URL}/api/files/d1?v=42-1000`,
+ );
+ // An edit (a replaced file among them) moves the version on.
+ expect(dumpFileUrl({ id: "d1", fileSize: 42, createdAt, updatedAt }))
+ .toBe(`${API_URL}/api/files/d1?v=42-2000`);
+ expect(dumpFileUrl({ id: "d1" }, "t o+k")).toBe(
+ `${API_URL}/api/files/d1?v=0-0&token=t%20o%2Bk`,
+ );
+ });
+ test("dumpThumbnailUrl", () => {
+ expect(dumpThumbnailUrl({ id: "d1" })).toBe(`${API_URL}/api/thumbnails/d1`);
+ expect(dumpThumbnailUrl({ id: "d1" }, "tk")).toBe(
+ `${API_URL}/api/thumbnails/d1?token=tk`,
+ );
+ expect(
+ dumpThumbnailUrl({ id: "d1", fileSize: 7, createdAt: new Date(5) }, "tk"),
+ ).toBe(`${API_URL}/api/thumbnails/d1?v=7-5&token=tk`);
+ });
+ test("normalizeUrl adds a scheme when there's none", () => {
+ expect(normalizeUrl(" example.com/x ")).toBe("https://example.com/x");
+ expect(normalizeUrl("//cdn.test/a")).toBe("https://cdn.test/a");
+ expect(normalizeUrl("HTTP://A.B")).toBe("HTTP://A.B");
+ expect(normalizeUrl(" ")).toBe("");
+ });
+ test("slug beats id in page URLs", () => {
+ expect(dumpUrl({ id: "1", slug: "nice-title" })).toBe("/dumps/nice-title");
+ expect(dumpUrl({ id: "1" })).toBe("/dumps/1");
+ expect(playlistUrl({ id: "2", slug: "mix" })).toBe("/playlists/mix");
+ expect(playlistUrl({ id: "2" })).toBe("/playlists/2");
+ });
+});
diff --git a/src/test/utils/shared/visited.test.ts b/src/test/utils/shared/visited.test.ts
new file mode 100644
index 00000000..d8957193
--- /dev/null
+++ b/src/test/utils/shared/visited.test.ts
@@ -0,0 +1,32 @@
+import { describe, expect, test } from "vitest";
+import {
+ isDumpVisited,
+ isPlaylistVisited,
+ isRecent,
+ markDumpVisited,
+ markPlaylistVisited,
+} from "../../../utils/shared/visited.ts";
+
+describe("visited", () => {
+ test("dumps and playlists are tracked apart", () => {
+ markDumpVisited("a");
+ markPlaylistVisited("b");
+ expect(isDumpVisited("a")).toBe(true);
+ expect(isDumpVisited("b")).toBe(false);
+ expect(isPlaylistVisited("b")).toBe(true);
+ expect(isPlaylistVisited("a")).toBe(false);
+ });
+
+ test("a corrupt entry reads as nothing visited", () => {
+ localStorage.setItem("visited_dumps", "{");
+ expect(isDumpVisited("a")).toBe(false);
+ markDumpVisited("a");
+ expect(isDumpVisited("a")).toBe(true);
+ });
+
+ test("isRecent is a seven-day window", () => {
+ const day = 24 * 3600 * 1000;
+ expect(isRecent(new Date(Date.now() - 6 * day))).toBe(true);
+ expect(isRecent(new Date(Date.now() - 8 * day))).toBe(false);
+ });
+});
diff --git a/src/test/utils/storage.test.ts b/src/test/utils/storage.test.ts
deleted file mode 100644
index 94d0ec1c..00000000
--- a/src/test/utils/storage.test.ts
+++ /dev/null
@@ -1,184 +0,0 @@
-import { afterEach, describe, expect, test, vi } from "vitest";
-import {
- AUTH_STORAGE_KEY,
- forgetStoredSession,
- isTokenExpired,
- readStoredSession,
- storeSession,
-} from "../../utils/authStorage.ts";
-import {
- clearDumpDraft,
- EMPTY_DUMP_DRAFT,
- isDumpDraftEmpty,
- loadDumpDraft,
- saveDumpDraft,
-} from "../../utils/dumpDraft.ts";
-import {
- isDumpVisited,
- isPlaylistVisited,
- isRecent,
- markDumpVisited,
- markPlaylistVisited,
-} from "../../utils/visited.ts";
-import { makeSession, makeToken, wire } from "../fixtures.ts";
-
-afterEach(() => vi.useRealTimers());
-
-describe("isTokenExpired", () => {
- test("reads exp from the payload", () => {
- expect(isTokenExpired(makeToken({ expiresIn: 60 }))).toBe(false);
- expect(isTokenExpired(makeToken({ expiresIn: -60 }))).toBe(true);
- });
- test("a token it can't read is treated as expired", () => {
- expect(isTokenExpired("garbage")).toBe(true);
- expect(isTokenExpired("a.b.c")).toBe(true);
- });
- test("no exp means it doesn't expire", () => {
- const token = `x.${btoa(JSON.stringify({ sub: "u" }))}.y`;
- expect(isTokenExpired(token)).toBe(false);
- });
-});
-
-describe("stored session", () => {
- test("round-trips, with dates revived", () => {
- const session = makeSession({ username: "ann" });
- storeSession(session);
- const read = readStoredSession();
- expect(read?.user.username).toBe("ann");
- expect(read?.user.createdAt).toBeInstanceOf(Date);
- });
-
- test("nothing stored", () => {
- expect(readStoredSession()).toBeNull();
- });
-
- test("unparseable, expired and roleless sessions are discarded", () => {
- localStorage.setItem(AUTH_STORAGE_KEY, "{not json");
- expect(readStoredSession()).toBeNull();
- expect(localStorage.getItem(AUTH_STORAGE_KEY)).toBeNull();
-
- const expired = makeSession();
- expired.token = makeToken({ expiresIn: -1 });
- storeSession(expired);
- expect(readStoredSession()).toBeNull();
- expect(localStorage.getItem(AUTH_STORAGE_KEY)).toBeNull();
-
- const stored = wire(makeSession());
- const { role: _role, ...roleless } = stored.user;
- localStorage.setItem(
- AUTH_STORAGE_KEY,
- JSON.stringify({ ...stored, user: roleless }),
- );
- expect(readStoredSession()).toBeNull();
- expect(localStorage.getItem(AUTH_STORAGE_KEY)).toBeNull();
- });
-
- test("forgetStoredSession only forgets the session holding that token", () => {
- const session = makeSession();
- storeSession(session);
- forgetStoredSession("some-older-token");
- expect(localStorage.getItem(AUTH_STORAGE_KEY)).not.toBeNull();
- forgetStoredSession(session.token);
- expect(localStorage.getItem(AUTH_STORAGE_KEY)).toBeNull();
- });
-
- test("forgetStoredSession clears an unreadable entry", () => {
- localStorage.setItem(AUTH_STORAGE_KEY, "{bad");
- forgetStoredSession("anything");
- expect(localStorage.getItem(AUTH_STORAGE_KEY)).toBeNull();
- });
-});
-
-describe("dump draft", () => {
- const draft = {
- ...EMPTY_DUMP_DRAFT,
- url: "https://a.b",
- comment: "why",
- };
-
- test("an empty draft is empty; any content isn't", () => {
- expect(isDumpDraftEmpty(EMPTY_DUMP_DRAFT)).toBe(true);
- expect(isDumpDraftEmpty({ ...EMPTY_DUMP_DRAFT, url: " " })).toBe(true);
- expect(isDumpDraftEmpty({ ...EMPTY_DUMP_DRAFT, isPublic: false })).toBe(
- false,
- );
- expect(isDumpDraftEmpty({ ...EMPTY_DUMP_DRAFT, categoryIds: ["c"] })).toBe(
- false,
- );
- });
-
- test("a title from the preview isn't worth keeping; a typed one is", () => {
- expect(isDumpDraftEmpty({ ...EMPTY_DUMP_DRAFT, title: "Scraped" })).toBe(
- true,
- );
- expect(
- isDumpDraftEmpty({
- ...EMPTY_DUMP_DRAFT,
- title: "Mine",
- titleFromUser: true,
- }),
- ).toBe(false);
- });
-
- test("save, load and clear", () => {
- saveDumpDraft(draft);
- expect(loadDumpDraft()).toEqual(draft);
- clearDumpDraft();
- expect(loadDumpDraft()).toBeNull();
- });
-
- test("saving an empty draft removes the stored one", () => {
- saveDumpDraft(draft);
- saveDumpDraft(EMPTY_DUMP_DRAFT);
- expect(localStorage.getItem("gerbeur.dumpDraft")).toBeNull();
- });
-
- test("a malformed stored draft is sanitised or ignored", () => {
- localStorage.setItem(
- "gerbeur.dumpDraft",
- JSON.stringify({ url: 5, comment: "c", categoryIds: ["a", 3, null] }),
- );
- expect(loadDumpDraft()).toEqual({
- ...EMPTY_DUMP_DRAFT,
- comment: "c",
- categoryIds: ["a"],
- });
- localStorage.setItem("gerbeur.dumpDraft", "nope");
- expect(loadDumpDraft()).toBeNull();
- });
-
- test("storage that throws never breaks posting", () => {
- vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
- throw new Error("QuotaExceeded");
- });
- vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => {
- throw new Error("SecurityError");
- });
- expect(() => saveDumpDraft(draft)).not.toThrow();
- expect(loadDumpDraft()).toBeNull();
- });
-});
-
-describe("visited", () => {
- test("dumps and playlists are tracked apart", () => {
- markDumpVisited("a");
- markPlaylistVisited("b");
- expect(isDumpVisited("a")).toBe(true);
- expect(isDumpVisited("b")).toBe(false);
- expect(isPlaylistVisited("b")).toBe(true);
- expect(isPlaylistVisited("a")).toBe(false);
- });
-
- test("a corrupt entry reads as nothing visited", () => {
- localStorage.setItem("visited_dumps", "{");
- expect(isDumpVisited("a")).toBe(false);
- markDumpVisited("a");
- expect(isDumpVisited("a")).toBe(true);
- });
-
- test("isRecent is a seven-day window", () => {
- const day = 24 * 3600 * 1000;
- expect(isRecent(new Date(Date.now() - 6 * day))).toBe(true);
- expect(isRecent(new Date(Date.now() - 8 * day))).toBe(false);
- });
-});
diff --git a/src/test/utils/text.test.ts b/src/test/utils/text.test.ts
deleted file mode 100644
index c7a743de..00000000
--- a/src/test/utils/text.test.ts
+++ /dev/null
@@ -1,157 +0,0 @@
-import { describe, expect, test } from "vitest";
-import { insideCode, isEmojiOnly } from "../../utils/editorText.ts";
-import { markdownToPlainText, mentionsUser } from "../../utils/chatText.ts";
-import { charCountClass, overLimit } from "../../utils/charCount.ts";
-import { formatBytes, titleFromFileName } from "../../utils/format.ts";
-import { fmt } from "../../utils/duration.ts";
-import { friendlyFetchError } from "../../utils/apiError.ts";
-
-describe("insideCode", () => {
- test("plain text is not code", () => {
- expect(insideCode("hello @bob")).toBe(false);
- });
- test("an unclosed inline span is code", () => {
- expect(insideCode("try `@bob")).toBe(true);
- expect(insideCode("try `a` then `@bob")).toBe(true);
- });
- test("a closed inline span is not", () => {
- expect(insideCode("try `a` @bob")).toBe(false);
- });
- test("an open fence is code, a closed one is not", () => {
- expect(insideCode("```\n@bob")).toBe(true);
- expect(insideCode("```js\nx\n```\n@bob")).toBe(false);
- });
- test("the fence line itself is not an inline span", () => {
- expect(insideCode("text\n```\ncode\n```")).toBe(false);
- });
- test("backticks on an earlier line don't carry over", () => {
- expect(insideCode("a ` b\n@bob")).toBe(false);
- });
-});
-
-describe("isEmojiOnly", () => {
- test("one to three emoji", () => {
- expect(isEmojiOnly("🎉")).toBe(true);
- expect(isEmojiOnly("👍👍")).toBe(true);
- expect(isEmojiOnly(" 🎉 🎉 🎉 ")).toBe(true);
- });
- test("more than the max", () => {
- expect(isEmojiOnly("🎉🎉🎉🎉")).toBe(false);
- expect(isEmojiOnly("🎉🎉🎉🎉", 4)).toBe(true);
- });
- test("a ZWJ sequence, a skin tone and a flag count as one each", () => {
- expect(isEmojiOnly("👨👩👧👦")).toBe(true);
- expect(isEmojiOnly("👍🏽")).toBe(true);
- expect(isEmojiOnly("🇫🇷🇫🇷🇫🇷")).toBe(true);
- });
- test("text, digits and symbols are not emoji", () => {
- expect(isEmojiOnly("")).toBe(false);
- expect(isEmojiOnly("hi 🎉")).toBe(false);
- expect(isEmojiOnly("123")).toBe(false);
- expect(isEmojiOnly("#")).toBe(false);
- expect(isEmojiOnly("1🎉")).toBe(false);
- });
-});
-
-describe("mentionsUser", () => {
- test("case-insensitive whole-word match", () => {
- expect(mentionsUser("hey @Alice!", "alice")).toBe(true);
- expect(mentionsUser("hey @alicex", "alice")).toBe(false);
- });
- test("follows the server's rule: not in an email, a link or brackets", () => {
- expect(mentionsUser("mail bob@alice", "alice")).toBe(false);
- expect(mentionsUser("[@alice](x)", "alice")).toBe(false);
- expect(mentionsUser("(@alice)", "alice")).toBe(false);
- });
- test("any of several mentions", () => {
- expect(mentionsUser("@bob @carol @alice", "alice")).toBe(true);
- });
-});
-
-describe("markdownToPlainText", () => {
- test("links become their text, images their alt or a label", () => {
- expect(markdownToPlainText("see [this](https://x.y) now", "img")).toBe(
- "see this now",
- );
- expect(markdownToPlainText("", "Image")).toBe("cat");
- expect(markdownToPlainText("", "Image")).toBe("Image");
- });
- test("markup characters and block markers go", () => {
- expect(markdownToPlainText("# Title\n> quote\n- **bold** `code`", "")).toBe(
- "Title quote bold code",
- );
- });
- test("a body cut off mid-link doesn't show a half URL", () => {
- expect(markdownToPlainText("look [here](https://exa", "img")).toBe(
- "look here",
- );
- expect(markdownToPlainText("pic ).toBe(
- "pic alt",
- );
- expect(markdownToPlainText("pic ).toBe(
- "pic Image",
- );
- expect(markdownToPlainText("pic ![unfinished", "Image")).toBe("pic");
- });
- test("whitespace collapses to one line", () => {
- expect(markdownToPlainText("a\n\n b\tc", "")).toBe("a b c");
- });
-});
-
-describe("character limits", () => {
- test("charCountClass warns from 85% and flags the limit", () => {
- expect(charCountClass(10, 100)).toBe("");
- expect(charCountClass(85, 100)).toBe(" text-editor-count--warn");
- expect(charCountClass(100, 100)).toBe(" text-editor-count--danger");
- });
- test("overLimit is strictly past it", () => {
- expect(overLimit("abc", 3)).toBe(false);
- expect(overLimit("abcd", 3)).toBe(true);
- });
-});
-
-describe("formatBytes", () => {
- test("B, KB and MB", () => {
- expect(formatBytes(512)).toBe("512 B");
- expect(formatBytes(1536)).toBe("1.5 KB");
- expect(formatBytes(5 * 1024 * 1024)).toBe("5.0 MB");
- });
-});
-
-describe("titleFromFileName", () => {
- test("drops the extension and turns underscores into spaces", () => {
- expect(titleFromFileName("holiday_photo-01.final.jpg")).toBe(
- "holiday photo-01.final",
- );
- });
- test("a dotfile keeps its name", () => {
- expect(titleFromFileName(".gitignore")).toBe(".gitignore");
- });
- test("a name that is only separators falls back to itself", () => {
- expect(titleFromFileName("___.txt")).toBe("___.txt");
- });
-});
-
-describe("fmt", () => {
- test("m:ss", () => {
- expect(fmt(0)).toBe("0:00");
- expect(fmt(65.9)).toBe("1:05");
- expect(fmt(3600)).toBe("60:00");
- });
- test("non-finite reads as zero", () => {
- expect(fmt(NaN)).toBe("0:00");
- expect(fmt(Infinity)).toBe("0:00");
- });
-});
-
-describe("friendlyFetchError", () => {
- test("a TypeError is a connection problem", () => {
- expect(friendlyFetchError(new TypeError("Failed to fetch"))).toMatch(
- /Could not connect/,
- );
- });
- test("other errors keep their message", () => {
- expect(friendlyFetchError(new Error("Nope"))).toBe("Nope");
- expect(friendlyFetchError("weird")).toBe("An unexpected error occurred.");
- });
-});
diff --git a/src/utils/apiError.ts b/src/utils/apiError.ts
deleted file mode 100644
index 45de4b33..00000000
--- a/src/utils/apiError.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-/**
- * Convert a caught fetch error into a human-readable message.
- * TypeError means the request never left the client (no network / CORS / etc.).
- */
-export function friendlyFetchError(err: unknown): string {
- if (err instanceof TypeError) {
- return "Could not connect to the server. Check your connection and try again.";
- }
- return err instanceof Error ? err.message : "An unexpected error occurred.";
-}
diff --git a/src/utils/permissions.ts b/src/utils/auth/permissions.ts
similarity index 93%
rename from src/utils/permissions.ts
rename to src/utils/auth/permissions.ts
index 5fa12583..c443e821 100644
--- a/src/utils/permissions.ts
+++ b/src/utils/auth/permissions.ts
@@ -1,4 +1,4 @@
-import type { PublicUser, Role } from "../model.ts";
+import type { PublicUser, Role } from "../../model.ts";
// Mirror of api/lib/permissions.ts. Keep the two in sync.
export type Permission =
diff --git a/src/utils/authStorage.ts b/src/utils/auth/storage.ts
similarity index 95%
rename from src/utils/authStorage.ts
rename to src/utils/auth/storage.ts
index 2ccaedb0..ed5a8ea4 100644
--- a/src/utils/authStorage.ts
+++ b/src/utils/auth/storage.ts
@@ -1,4 +1,4 @@
-import { type AuthResponse, deserializeAuthResponse } from "../model.ts";
+import { type AuthResponse, deserializeAuthResponse } from "../../model.ts";
// The session is shared by every tab through this one localStorage entry.
export const AUTH_STORAGE_KEY = "authResponse";
diff --git a/src/utils/chatDraft.ts b/src/utils/chat/draft.ts
similarity index 94%
rename from src/utils/chatDraft.ts
rename to src/utils/chat/draft.ts
index 43a122a2..e6c3ab7a 100644
--- a/src/utils/chatDraft.ts
+++ b/src/utils/chat/draft.ts
@@ -1,4 +1,4 @@
-import type { ChatReplyTarget } from "../contexts/WSContext.ts";
+import type { ChatReplyTarget } from "../../contexts/ws/WSContext.ts";
// The draft and reply target outlive the chat — closing it, or reloading the
// tab, keeps a half-written message.
diff --git a/src/components/chat/message.ts b/src/utils/chat/message.ts
similarity index 87%
rename from src/components/chat/message.ts
rename to src/utils/chat/message.ts
index bde86bab..9b43dcf1 100644
--- a/src/components/chat/message.ts
+++ b/src/utils/chat/message.ts
@@ -1,6 +1,6 @@
import { t } from "@lingui/core/macro";
-import { markdownToPlainText } from "../../utils/chatText.ts";
-import { isEmojiOnly } from "../../utils/editorText.ts";
+import { markdownToPlainText } from "./text.ts";
+import { isEmojiOnly } from "../editor/text.ts";
// Keep in sync with MAX_CHAT_LENGTH in api/services/chat-service.ts.
export const MAX_CHAT_LENGTH = 2000;
diff --git a/src/utils/chatText.ts b/src/utils/chat/text.ts
similarity index 100%
rename from src/utils/chatText.ts
rename to src/utils/chat/text.ts
diff --git a/src/components/chat/time.ts b/src/utils/chat/time.ts
similarity index 100%
rename from src/components/chat/time.ts
rename to src/utils/chat/time.ts
diff --git a/src/utils/dumpDraft.ts b/src/utils/dump/draft.ts
similarity index 100%
rename from src/utils/dumpDraft.ts
rename to src/utils/dump/draft.ts
diff --git a/src/components/editor/skinTone.ts b/src/utils/editor/skinTone.ts
similarity index 100%
rename from src/components/editor/skinTone.ts
rename to src/utils/editor/skinTone.ts
diff --git a/src/utils/editorText.ts b/src/utils/editor/text.ts
similarity index 100%
rename from src/utils/editorText.ts
rename to src/utils/editor/text.ts
diff --git a/src/utils/hotScore.ts b/src/utils/feed/hotScore.ts
similarity index 80%
rename from src/utils/hotScore.ts
rename to src/utils/feed/hotScore.ts
index 5f9f1ceb..05144f78 100644
--- a/src/utils/hotScore.ts
+++ b/src/utils/feed/hotScore.ts
@@ -1,4 +1,4 @@
-import type { Dump } from "../model.ts";
+import type { Dump } from "../../model.ts";
export function hotScore(dump: Dump): number {
const ageHours = (Date.now() - dump.createdAt.getTime()) / 3_600_000;
diff --git a/src/utils/journalLayout.ts b/src/utils/feed/journalLayout.ts
similarity index 99%
rename from src/utils/journalLayout.ts
rename to src/utils/feed/journalLayout.ts
index 18fbbeed..59fb5df1 100644
--- a/src/utils/journalLayout.ts
+++ b/src/utils/feed/journalLayout.ts
@@ -1,4 +1,4 @@
-import type { Dump } from "../model.ts";
+import type { Dump } from "../../model.ts";
import { hotScore } from "./hotScore.ts";
/**
diff --git a/src/utils/bandcamp.ts b/src/utils/player/bandcamp.ts
similarity index 94%
rename from src/utils/bandcamp.ts
rename to src/utils/player/bandcamp.ts
index 11ffe415..5fd5e4b5 100644
--- a/src/utils/bandcamp.ts
+++ b/src/utils/player/bandcamp.ts
@@ -1,5 +1,5 @@
-import { API_URL } from "../config/api.ts";
-import type { PlayerItem } from "../contexts/PlayerContext.ts";
+import { API_URL } from "../../config/api.ts";
+import type { PlayerItem } from "../../contexts/player/PlayerContext.ts";
/** Mirrors the `Tralbum` shape returned by GET /api/bandcamp/tracks. */
interface BandcampTrack {
diff --git a/src/utils/duration.ts b/src/utils/player/duration.ts
similarity index 100%
rename from src/utils/duration.ts
rename to src/utils/player/duration.ts
diff --git a/src/utils/streamSources.ts b/src/utils/player/streamSources.ts
similarity index 93%
rename from src/utils/streamSources.ts
rename to src/utils/player/streamSources.ts
index 9b074150..c4254574 100644
--- a/src/utils/streamSources.ts
+++ b/src/utils/player/streamSources.ts
@@ -1,4 +1,4 @@
-import type { PlayerItem } from "../contexts/PlayerContext.ts";
+import type { PlayerItem } from "../../contexts/player/PlayerContext.ts";
import { resolveBandcampQueue } from "./bandcamp.ts";
/**
diff --git a/src/components/player/volume.ts b/src/utils/player/volume.ts
similarity index 100%
rename from src/components/player/volume.ts
rename to src/utils/player/volume.ts
diff --git a/src/utils/waveform.ts b/src/utils/player/waveform.ts
similarity index 100%
rename from src/utils/waveform.ts
rename to src/utils/player/waveform.ts
diff --git a/src/utils/youtube.ts b/src/utils/player/youtube.ts
similarity index 97%
rename from src/utils/youtube.ts
rename to src/utils/player/youtube.ts
index 2da0d320..45c57a2b 100644
--- a/src/utils/youtube.ts
+++ b/src/utils/player/youtube.ts
@@ -1,4 +1,4 @@
-import { API_URL } from "../config/api.ts";
+import { API_URL } from "../../config/api.ts";
/** Mirrors one entry of GET /api/youtube/playlist. */
export interface YouTubeTrack {
diff --git a/src/utils/shared/api.ts b/src/utils/shared/api.ts
new file mode 100644
index 00000000..c068e4ed
--- /dev/null
+++ b/src/utils/shared/api.ts
@@ -0,0 +1,24 @@
+import { parseAPIResponse } from "../../model.ts";
+
+/**
+ * Convert a caught fetch error into a human-readable message.
+ * TypeError means the request never left the client (no network / CORS / etc.).
+ */
+export function friendlyFetchError(err: unknown): string {
+ if (err instanceof TypeError) {
+ return "Could not connect to the server. Check your connection and try again.";
+ }
+ return err instanceof Error ? err.message : "An unexpected error occurred.";
+}
+
+/**
+ * Reads a fetch `Response` through the standard API envelope and returns the
+ * payload, throwing the server's message on `success: false`. A thrown error
+ * here (or a network `TypeError` from the `fetch` itself) is caught by
+ * `useApiForm`'s `submit` and rendered as the form's root error.
+ */
+export async function expectOk(res: Response): Promise {
+ const body = parseAPIResponse(await res.json());
+ if (!body.success) throw new Error(body.error.message);
+ return body.data;
+}
diff --git a/src/utils/charCount.ts b/src/utils/shared/charCount.ts
similarity index 100%
rename from src/utils/charCount.ts
rename to src/utils/shared/charCount.ts
diff --git a/src/utils/format.ts b/src/utils/shared/format.ts
similarity index 100%
rename from src/utils/format.ts
rename to src/utils/shared/format.ts
diff --git a/src/utils/proxyImage.ts b/src/utils/shared/proxyImage.ts
similarity index 97%
rename from src/utils/proxyImage.ts
rename to src/utils/shared/proxyImage.ts
index e20eadad..47bbf637 100644
--- a/src/utils/proxyImage.ts
+++ b/src/utils/shared/proxyImage.ts
@@ -1,4 +1,4 @@
-import { API_URL } from "../config/api.ts";
+import { API_URL } from "../../config/api.ts";
/** Our own API origin — `API_URL` is relative ("") when we're same-origin. */
function apiOrigin(): string {
diff --git a/src/utils/relativeTime.ts b/src/utils/shared/relativeTime.ts
similarity index 95%
rename from src/utils/relativeTime.ts
rename to src/utils/shared/relativeTime.ts
index f90a11ec..90ce9527 100644
--- a/src/utils/relativeTime.ts
+++ b/src/utils/shared/relativeTime.ts
@@ -1,4 +1,4 @@
-import { i18n } from "../i18n.ts";
+import { i18n } from "../../i18n.ts";
export function relativeTime(date: Date): string {
const rtf = new Intl.RelativeTimeFormat(i18n.locale, { numeric: "auto" });
diff --git a/src/utils/sound.ts b/src/utils/shared/sound.ts
similarity index 100%
rename from src/utils/sound.ts
rename to src/utils/shared/sound.ts
diff --git a/src/utils/thumbnailTint.ts b/src/utils/shared/thumbnailTint.ts
similarity index 100%
rename from src/utils/thumbnailTint.ts
rename to src/utils/shared/thumbnailTint.ts
diff --git a/src/utils/urls.ts b/src/utils/shared/urls.ts
similarity index 98%
rename from src/utils/urls.ts
rename to src/utils/shared/urls.ts
index 4cc7ec0c..13e21cb4 100644
--- a/src/utils/urls.ts
+++ b/src/utils/shared/urls.ts
@@ -1,4 +1,4 @@
-import { API_URL } from "../config/api.ts";
+import { API_URL } from "../../config/api.ts";
type VersionedDump = {
id: string;
diff --git a/src/utils/visited.ts b/src/utils/shared/visited.ts
similarity index 100%
rename from src/utils/visited.ts
rename to src/utils/shared/visited.ts