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 c240d553..3d52ac30 100644 Binary files a/src/components/category/CategoryManager.tsx and b/src/components/category/CategoryManager.tsx differ 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 `
`. */ @@ -46,15 +45,3 @@ export function useApiForm( return { ...form, submit }; } - -/** - * 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 - * {@link 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/hooks/usePlayRichContent.ts b/src/hooks/player/usePlayRichContent.ts similarity index 92% rename from src/hooks/usePlayRichContent.ts rename to src/hooks/player/usePlayRichContent.ts index 2b771f03..0d02cd8b 100644 --- a/src/hooks/usePlayRichContent.ts +++ b/src/hooks/player/usePlayRichContent.ts @@ -1,7 +1,10 @@ import { useCallback, useContext, useState } from "react"; -import { PlayerContext, type PlayerItem } from "../contexts/PlayerContext.ts"; -import { BANDCAMP_PLAYER } from "../config/playerMode.ts"; -import { resolveBandcampQueue } from "../utils/bandcamp.ts"; +import { + PlayerContext, + type PlayerItem, +} from "../../contexts/player/PlayerContext.ts"; +import { BANDCAMP_PLAYER } from "../../config/playerMode.ts"; +import { resolveBandcampQueue } from "../../utils/player/bandcamp.ts"; export interface PlayableRichContent { type: string; diff --git a/src/hooks/useYouTubePlaylist.ts b/src/hooks/player/useYouTubePlaylist.ts similarity index 99% rename from src/hooks/useYouTubePlaylist.ts rename to src/hooks/player/useYouTubePlaylist.ts index aaec68bf..3a7190ff 100644 --- a/src/hooks/useYouTubePlaylist.ts +++ b/src/hooks/player/useYouTubePlaylist.ts @@ -10,7 +10,7 @@ import { embedListId, fetchPlaylistTracks, type YouTubeTrack, -} from "../utils/youtube.ts"; +} from "../../utils/player/youtube.ts"; /** * Tracklist and transport for a YouTube playlist embed. diff --git a/src/hooks/usePlaylistListSync.ts b/src/hooks/playlist/usePlaylistListSync.ts similarity index 97% rename from src/hooks/usePlaylistListSync.ts rename to src/hooks/playlist/usePlaylistListSync.ts index 2992191d..a7255f94 100644 --- a/src/hooks/usePlaylistListSync.ts +++ b/src/hooks/playlist/usePlaylistListSync.ts @@ -1,6 +1,6 @@ import { useEffect, useLayoutEffect, useRef } from "react"; -import type { Playlist } from "../model.ts"; -import { useWS } from "./useWS.ts"; +import type { Playlist } from "../../model.ts"; +import { useWS } from "../ws/useWS.ts"; interface PlaylistListSyncOptions { /** Keep private playlists visible (caller is the owner). */ diff --git a/src/hooks/useAnchoredFloat.ts b/src/hooks/shared/useAnchoredFloat.ts similarity index 100% rename from src/hooks/useAnchoredFloat.ts rename to src/hooks/shared/useAnchoredFloat.ts diff --git a/src/hooks/useDocumentTitle.ts b/src/hooks/shared/useDocumentTitle.ts similarity index 100% rename from src/hooks/useDocumentTitle.ts rename to src/hooks/shared/useDocumentTitle.ts diff --git a/src/hooks/useHeightTransition.ts b/src/hooks/shared/useHeightTransition.ts similarity index 100% rename from src/hooks/useHeightTransition.ts rename to src/hooks/shared/useHeightTransition.ts diff --git a/src/hooks/useRoomBeside.ts b/src/hooks/shared/useRoomBeside.ts similarity index 100% rename from src/hooks/useRoomBeside.ts rename to src/hooks/shared/useRoomBeside.ts diff --git a/src/hooks/useScrollLock.ts b/src/hooks/shared/useScrollLock.ts similarity index 100% rename from src/hooks/useScrollLock.ts rename to src/hooks/shared/useScrollLock.ts diff --git a/src/hooks/useSoundsEnabled.ts b/src/hooks/shared/useSoundsEnabled.ts similarity index 86% rename from src/hooks/useSoundsEnabled.ts rename to src/hooks/shared/useSoundsEnabled.ts index 98357fce..fee0a84c 100644 --- a/src/hooks/useSoundsEnabled.ts +++ b/src/hooks/shared/useSoundsEnabled.ts @@ -1,5 +1,8 @@ import { useCallback, useState } from "react"; -import { readSoundsEnabled, writeSoundsEnabled } from "../utils/sound.ts"; +import { + readSoundsEnabled, + writeSoundsEnabled, +} from "../../utils/shared/sound.ts"; /** * User preference for notification and chat sounds, persisted to localStorage diff --git a/src/hooks/useTabParam.ts b/src/hooks/shared/useTabParam.ts similarity index 100% rename from src/hooks/useTabParam.ts rename to src/hooks/shared/useTabParam.ts diff --git a/src/hooks/useFollows.ts b/src/hooks/social/useFollows.ts similarity index 57% rename from src/hooks/useFollows.ts rename to src/hooks/social/useFollows.ts index 5554659c..89f5fef3 100644 --- a/src/hooks/useFollows.ts +++ b/src/hooks/social/useFollows.ts @@ -1,4 +1,4 @@ import { useContext } from "react"; -import { FollowContext } from "../contexts/FollowContext.ts"; +import { FollowContext } from "../../contexts/social/FollowContext.ts"; export const useFollows = () => useContext(FollowContext); diff --git a/src/hooks/useTheme.ts b/src/hooks/theme/useTheme.ts similarity index 59% rename from src/hooks/useTheme.ts rename to src/hooks/theme/useTheme.ts index 1013944b..0775a38c 100644 --- a/src/hooks/useTheme.ts +++ b/src/hooks/theme/useTheme.ts @@ -1,5 +1,5 @@ import { useContext } from "react"; -import { ThemeContext } from "../contexts/ThemeContext.ts"; +import { ThemeContext } from "../../contexts/theme/ThemeContext.ts"; export function useTheme() { return useContext(ThemeContext); diff --git a/src/hooks/useWS.ts b/src/hooks/ws/useWS.ts similarity index 91% rename from src/hooks/useWS.ts rename to src/hooks/ws/useWS.ts index 5222e3b9..9225f7f4 100644 --- a/src/hooks/useWS.ts +++ b/src/hooks/ws/useWS.ts @@ -3,7 +3,7 @@ import { WSChatContext, WSContext, WSPresenceContext, -} from "../contexts/WSContext.ts"; +} from "../../contexts/ws/WSContext.ts"; /** Votes, likes, live dumps, playlists, comments and notifications. */ export function useWS() { diff --git a/src/locales/en.po b/src/locales/en.po index 9794b6f2..17efa44c 100644 --- a/src/locales/en.po +++ b/src/locales/en.po @@ -18,8 +18,8 @@ msgid "[deleted]" msgstr "[deleted]" #. placeholder {0}: dump.commentCount -#: src/components/dump/DumpCard.tsx:112 -#: src/components/dump/JournalCard.tsx:116 +#: src/components/dump/DumpCard.tsx:116 +#: src/components/dump/JournalCard.tsx:120 msgid "{0, plural, one {# comment} other {# comments}}" msgstr "{0, plural, one {# comment} other {# comments}}" @@ -35,23 +35,23 @@ msgstr "{0, plural, one {and # earlier dump} other {and # earlier dumps}}" #. placeholder {0}: typingNames[0] #. placeholder {1}: typingNames[1] -#: src/components/chat/ChatModal.tsx:607 +#: src/components/chat/ChatModal.tsx:612 msgid "{0} and {1} are typing…" msgstr "{0} and {1} are typing…" #. placeholder {0}: typingNames[0] -#: src/components/chat/ChatModal.tsx:605 +#: src/components/chat/ChatModal.tsx:610 msgid "{0} is typing…" msgstr "{0} is typing…" #. placeholder {0}: rest.length -#: src/pages/index/PresenceRow.tsx:66 +#: src/components/feed/PresenceRow.tsx:66 msgid "{0} more online: {restNames}" msgstr "{0} more online: {restNames}" #. placeholder {0}: VALIDATION.USERNAME_MIN #. placeholder {1}: VALIDATION.USERNAME_MAX -#: src/pages/UserRegister.tsx:130 +#: src/pages/UserRegister.tsx:128 msgid "{0}–{1} characters: letters, numbers, or underscores" msgstr "{0}–{1} characters: letters, numbers, or underscores" @@ -59,7 +59,7 @@ msgstr "{0}–{1} characters: letters, numbers, or underscores" msgid "{label} ({count})" msgstr "{label} ({count})" -#: src/components/chat/ChatModal.tsx:736 +#: src/components/chat/ChatModal.tsx:741 msgid "{unseenCount} new messages" msgstr "{unseenCount} new messages" @@ -67,15 +67,15 @@ msgstr "{unseenCount} new messages" msgid "{visibleCount, plural, one {# comment} other {# comments}}" msgstr "{visibleCount, plural, one {# comment} other {# comments}}" -#: src/components/dump/create/DumpCreateModal.tsx:769 -#: src/pages/PlaylistDetail.tsx:607 -#: src/pages/UserPublicProfile.tsx:819 +#: src/components/dump/create/DumpCreateModal.tsx:767 +#: src/pages/PlaylistDetail.tsx:604 +#: src/pages/UserPublicProfile.tsx:816 msgid "← Back" msgstr "← Back" #: src/pages/Dump.tsx:290 #: src/pages/Dump.tsx:533 -#: src/pages/DumpEdit.tsx:187 +#: src/pages/DumpEdit.tsx:189 msgid "← Back to all dumps" msgstr "← Back to all dumps" @@ -85,7 +85,7 @@ msgstr "← Back to all dumps" msgid "← Back to profile" msgstr "← Back to profile" -#: src/pages/UserPublicProfile.tsx:135 +#: src/pages/UserPublicProfile.tsx:132 msgid "+ Invite someone" msgstr "+ Invite someone" @@ -143,7 +143,7 @@ msgstr "<0>{0} upvoted <1>{1}" msgid "<0>{0} was added to <1>{1}" msgstr "<0>{0} was added to <1>{1}" -#: src/components/chat/ChatModal.tsx:735 +#: src/components/chat/ChatModal.tsx:740 msgid "1 new message" msgstr "1 new message" @@ -155,7 +155,7 @@ msgstr "a comment" msgid "a post" msgstr "a post" -#: src/pages/UserPublicProfile.tsx:1227 +#: src/pages/UserPublicProfile.tsx:1224 msgid "Account" msgstr "Account" @@ -163,21 +163,21 @@ msgstr "Account" msgid "Add a comment…" msgstr "Add a comment…" -#: src/components/category/CategoryManager.tsx:96 -#: src/components/category/CategoryManager.tsx:97 +#: src/components/category/CategoryManager.tsx:94 +#: src/components/category/CategoryManager.tsx:95 msgid "Add category" msgstr "Add category" -#: src/pages/UserPublicProfile.tsx:905 +#: src/pages/UserPublicProfile.tsx:902 msgid "Add email…" msgstr "Add email…" -#: src/components/dump/create/DumpCreateModal.tsx:490 +#: src/components/dump/create/DumpCreateModal.tsx:488 #: src/components/playlist/AddToPlaylistModal.tsx:59 msgid "Add to playlist" msgstr "Add to playlist" -#: src/pages/UserPublicProfile.tsx:1697 +#: src/pages/UserPublicProfile.tsx:1694 msgid "Admin" msgstr "Admin" @@ -192,18 +192,18 @@ msgstr "All" msgid "Already dumped by {0} {1}" msgstr "Already dumped by {0} {1}" -#: src/pages/UserRegister.tsx:160 +#: src/pages/UserRegister.tsx:158 msgid "Already have an account? <0>Log in" msgstr "Already have an account? <0>Log in" -#: src/pages/UserPublicProfile.tsx:1246 +#: src/pages/UserPublicProfile.tsx:1243 msgid "Appearance" msgstr "Appearance" #. placeholder {0}: VALIDATION.PASSWORD_MIN -#: src/components/profile/ChangePasswordModal.tsx:89 -#: src/pages/ResetPassword.tsx:111 -#: src/pages/UserRegister.tsx:149 +#: src/components/profile/ChangePasswordModal.tsx:87 +#: src/pages/ResetPassword.tsx:109 +#: src/pages/UserRegister.tsx:147 msgid "At least {0} characters" msgstr "At least {0} characters" @@ -211,50 +211,50 @@ msgstr "At least {0} characters" msgid "At most {maxLength} characters." msgstr "At most {maxLength} characters." -#: src/pages/UserPublicProfile.tsx:1308 +#: src/pages/UserPublicProfile.tsx:1305 msgid "Auto" msgstr "Auto" -#: src/pages/ResetPassword.tsx:61 -#: src/pages/ResetPassword.tsx:134 +#: src/pages/ResetPassword.tsx:59 +#: src/pages/ResetPassword.tsx:132 msgid "Back to login" msgstr "Back to login" -#: src/contexts/WSProvider.tsx:332 -#: src/contexts/WSProvider.tsx:715 +#: src/contexts/ws/WSProvider.tsx:332 +#: src/contexts/ws/WSProvider.tsx:715 msgid "Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects." msgstr "Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects." -#: src/components/category/CategoryManager.tsx:213 -#: src/components/category/CategoryManager.tsx:214 -#: src/components/chat/ChatEditBox.tsx:69 -#: src/components/comment/CommentForm.tsx:108 -#: src/components/dump/create/DumpCreateModal.tsx:779 +#: src/components/category/CategoryManager.tsx:211 +#: src/components/category/CategoryManager.tsx:212 +#: src/components/chat/ChatEditBox.tsx:73 +#: src/components/comment/CommentForm.tsx:105 +#: src/components/dump/create/DumpCreateModal.tsx:777 #: src/components/form/FormActions.tsx:32 -#: src/components/ui/ConfirmModal.tsx:47 +#: src/components/shared/ConfirmModal.tsx:47 #: src/pages/Dump.tsx:409 -#: src/pages/DumpEdit.tsx:502 -#: src/pages/PlaylistDetail.tsx:975 -#: src/pages/UserPublicProfile.tsx:1826 -#: src/pages/UserPublicProfile.tsx:1901 +#: src/pages/DumpEdit.tsx:504 +#: src/pages/PlaylistDetail.tsx:972 +#: src/pages/UserPublicProfile.tsx:1823 +#: src/pages/UserPublicProfile.tsx:1898 msgid "Cancel" msgstr "Cancel" -#: src/pages/PlaylistDetail.tsx:786 +#: src/pages/PlaylistDetail.tsx:783 msgid "Cancel removal" msgstr "Cancel removal" -#: src/components/chat/ChatModal.tsx:796 -#: src/components/chat/ChatModal.tsx:797 +#: src/components/chat/ChatModal.tsx:801 +#: src/components/chat/ChatModal.tsx:802 msgid "Cancel reply" msgstr "Cancel reply" -#: src/components/layout/AppHeader.tsx:128 +#: src/components/layout/AppHeader.tsx:130 msgid "Cancel search" msgstr "Cancel search" #: src/components/category/CategorySelect.tsx:22 -#: src/pages/UserPublicProfile.tsx:1423 +#: src/pages/UserPublicProfile.tsx:1420 msgid "Categories" msgstr "Categories" @@ -262,16 +262,16 @@ msgstr "Categories" msgid "Category" msgstr "Category" -#: src/pages/UserPublicProfile.tsx:846 +#: src/pages/UserPublicProfile.tsx:843 msgid "Change avatar" msgstr "Change avatar" -#: src/components/profile/ChangePasswordModal.tsx:54 -#: src/components/profile/ChangePasswordModal.tsx:108 +#: src/components/profile/ChangePasswordModal.tsx:52 +#: src/components/profile/ChangePasswordModal.tsx:106 msgid "Change password" msgstr "Change password" -#: src/pages/UserPublicProfile.tsx:1239 +#: src/pages/UserPublicProfile.tsx:1236 msgid "Change password…" msgstr "Change password…" @@ -279,7 +279,7 @@ msgstr "Change password…" msgid "Change skin tone" msgstr "Change skin tone" -#: src/components/chat/ChatModal.tsx:696 +#: src/components/chat/ChatModal.tsx:701 #: src/components/layout/ChatButton.tsx:17 #: src/components/layout/ChatFab.tsx:49 #: src/components/layout/ChatFab.tsx:50 @@ -291,16 +291,16 @@ msgstr "Chat" msgid "Chat ({unreadChatCount} unread)" msgstr "Chat ({unreadChatCount} unread)" -#: src/pages/UserRegister.tsx:89 +#: src/pages/UserRegister.tsx:87 msgid "Checking invite…" msgstr "Checking invite…" -#: src/components/profile/ChangePasswordModal.tsx:63 -#: src/components/ui/Modal.tsx:60 +#: src/components/profile/ChangePasswordModal.tsx:61 +#: src/components/shared/Modal.tsx:60 msgid "Close" msgstr "Close" -#: src/components/dump/create/DumpCreateModal.tsx:521 +#: src/components/dump/create/DumpCreateModal.tsx:519 msgid "Close anyway" msgstr "Close anyway" @@ -312,7 +312,7 @@ msgstr "Close emoji picker" msgid "Close player" msgstr "Close player" -#: src/components/dump/create/DumpCreateModal.tsx:511 +#: src/components/dump/create/DumpCreateModal.tsx:509 msgid "Close without posting? The attached file won't be kept — everything you typed will." msgstr "Close without posting? The attached file won't be kept — everything you typed will." @@ -320,32 +320,32 @@ msgstr "Close without posting? The attached file won't be kept — everything yo msgid "Collapse player" msgstr "Collapse player" -#: src/pages/UserPublicProfile.tsx:1300 +#: src/pages/UserPublicProfile.tsx:1297 msgid "Color scheme" msgstr "Color scheme" -#: src/components/profile/ChangePasswordModal.tsx:96 -#: src/pages/ResetPassword.tsx:118 +#: src/components/profile/ChangePasswordModal.tsx:94 +#: src/pages/ResetPassword.tsx:116 msgid "Confirm new password" msgstr "Confirm new password" -#: src/contexts/WSProvider.tsx:708 +#: src/contexts/ws/WSProvider.tsx:708 msgid "Connection lost — this message may not have been sent" msgstr "Connection lost — this message may not have been sent" -#: src/pages/UserPublicProfile.tsx:160 +#: src/pages/UserPublicProfile.tsx:157 msgid "Copied!" msgstr "Copied!" -#: src/pages/UserPublicProfile.tsx:160 +#: src/pages/UserPublicProfile.tsx:157 msgid "Copy" msgstr "Copy" -#: src/components/profile/ChangePasswordModal.tsx:105 +#: src/components/profile/ChangePasswordModal.tsx:103 msgid "Could not change password" msgstr "Could not change password" -#: src/pages/DumpEdit.tsx:490 +#: src/pages/DumpEdit.tsx:492 #: src/pages/UserPlaylists.tsx:400 msgid "Could not delete" msgstr "Could not delete" @@ -354,23 +354,23 @@ msgstr "Could not delete" msgid "Could not load." msgstr "Could not load." -#: src/pages/DumpEdit.tsx:450 +#: src/pages/DumpEdit.tsx:452 msgid "Could not save" msgstr "Could not save" -#: src/components/dump/create/DumpCreateModal.tsx:633 +#: src/components/dump/create/DumpCreateModal.tsx:631 msgid "Couldn't load a preview for this link — check it, or post it as-is and refresh the preview later." msgstr "Couldn't load a preview for this link — check it, or post it as-is and refresh the preview later." -#: src/components/chat/ChatModal.tsx:622 +#: src/components/chat/ChatModal.tsx:627 msgid "Couldn't load the chat." msgstr "Couldn't load the chat." -#: src/components/playlist/PlaylistCreateForm.tsx:90 +#: src/components/playlist/PlaylistCreateForm.tsx:92 msgid "Create" msgstr "Create" -#: src/components/playlist/PlaylistCreateForm.tsx:90 +#: src/components/playlist/PlaylistCreateForm.tsx:92 msgid "Create & Add" msgstr "Create & Add" @@ -380,55 +380,55 @@ msgstr "Create & Add" msgid "Created ({0}{1})" msgstr "Created ({0}{1})" -#: src/components/playlist/PlaylistCreateForm.tsx:89 +#: src/components/playlist/PlaylistCreateForm.tsx:91 msgid "Creating…" msgstr "Creating…" -#: src/components/profile/ChangePasswordModal.tsx:74 +#: src/components/profile/ChangePasswordModal.tsx:72 msgid "Current password" msgstr "Current password" -#: src/pages/UserPublicProfile.tsx:1322 +#: src/pages/UserPublicProfile.tsx:1319 msgid "Dark" msgstr "Dark" -#: src/pages/UserPublicProfile.tsx:1335 +#: src/pages/UserPublicProfile.tsx:1332 msgid "Default tab" msgstr "Default tab" -#: src/components/category/CategoryManager.tsx:224 -#: src/components/category/CategoryManager.tsx:225 +#: src/components/category/CategoryManager.tsx:222 +#: src/components/category/CategoryManager.tsx:223 #: src/components/chat/ChatMessageItem.tsx:88 #: src/components/chat/ChatMessageItem.tsx:89 #: src/components/chat/ChatMessageItem.tsx:187 #: src/components/comment/CommentNode.tsx:248 #: src/components/comment/CommentNode.tsx:254 -#: src/components/ui/ConfirmModal.tsx:17 -#: src/pages/PlaylistDetail.tsx:971 +#: src/components/shared/ConfirmModal.tsx:17 +#: src/pages/PlaylistDetail.tsx:968 msgid "Delete" msgstr "Delete" -#: src/components/category/CategoryManager.tsx:241 +#: src/components/category/CategoryManager.tsx:239 msgid "Delete category" msgstr "Delete category" #. placeholder {0}: category.name -#: src/components/category/CategoryManager.tsx:240 +#: src/components/category/CategoryManager.tsx:238 msgid "Delete category \"{0}\"? This cannot be undone." msgstr "Delete category \"{0}\"? This cannot be undone." -#: src/pages/DumpEdit.tsx:267 -#: src/pages/DumpEdit.tsx:498 +#: src/pages/DumpEdit.tsx:269 +#: src/pages/DumpEdit.tsx:500 msgid "Delete dump" msgstr "Delete dump" #. placeholder {0}: res.status -#: src/pages/DumpEdit.tsx:149 +#: src/pages/DumpEdit.tsx:151 msgid "Delete failed ({0})" msgstr "Delete failed ({0})" #: src/components/playlist/PlaylistCard.tsx:110 -#: src/pages/PlaylistDetail.tsx:799 +#: src/pages/PlaylistDetail.tsx:796 #: src/pages/UserPlaylists.tsx:483 msgid "Delete playlist" msgstr "Delete playlist" @@ -437,7 +437,7 @@ msgstr "Delete playlist" msgid "Delete this comment?" msgstr "Delete this comment?" -#: src/pages/DumpEdit.tsx:266 +#: src/pages/DumpEdit.tsx:268 msgid "Delete this dump? This cannot be undone." msgstr "Delete this dump? This cannot be undone." @@ -445,7 +445,7 @@ msgstr "Delete this dump? This cannot be undone." msgid "Delete this message?" msgstr "Delete this message?" -#: src/pages/PlaylistDetail.tsx:798 +#: src/pages/PlaylistDetail.tsx:795 #: src/pages/UserPlaylists.tsx:482 msgid "Delete this playlist? This cannot be undone." msgstr "Delete this playlist? This cannot be undone." @@ -454,8 +454,8 @@ msgstr "Delete this playlist? This cannot be undone." msgid "deleted message" msgstr "deleted message" -#: src/components/playlist/PlaylistCreateForm.tsx:82 -#: src/pages/PlaylistDetail.tsx:932 +#: src/components/playlist/PlaylistCreateForm.tsx:84 +#: src/pages/PlaylistDetail.tsx:929 msgid "Description (optional)" msgstr "Description (optional)" @@ -463,21 +463,21 @@ msgstr "Description (optional)" msgid "Discard" msgstr "Discard" -#: src/components/chat/ChatModal.tsx:700 +#: src/components/chat/ChatModal.tsx:705 msgid "Disconnected — reconnecting…" msgstr "Disconnected — reconnecting…" -#: src/components/chat/ChatModal.tsx:772 -#: src/components/chat/ChatModal.tsx:773 +#: src/components/chat/ChatModal.tsx:777 +#: src/components/chat/ChatModal.tsx:778 msgid "Dismiss" msgstr "Dismiss" -#: src/components/dump/create/DumpCreateModal.tsx:572 +#: src/components/dump/create/DumpCreateModal.tsx:570 msgid "Done" msgstr "Done" #. placeholder {0}: dump.fileName -#: src/components/dump/FilePreview.tsx:330 +#: src/components/dump/FilePreview.tsx:334 msgid "Download {0}" msgstr "Download {0}" @@ -485,35 +485,35 @@ msgstr "Download {0}" msgid "Drop a file here" msgstr "Drop a file here" -#: src/components/dump/create/DumpCreateModal.tsx:680 +#: src/components/dump/create/DumpCreateModal.tsx:678 msgid "Drop a file here, or paste one" msgstr "Drop a file here, or paste one" -#: src/pages/DumpEdit.tsx:442 +#: src/pages/DumpEdit.tsx:444 msgid "Drop a replacement here" msgstr "Drop a replacement here" -#: src/components/layout/AppHeader.tsx:168 +#: src/components/layout/AppHeader.tsx:170 msgid "Dump" msgstr "Dump" -#: src/components/dump/create/DumpCreateModal.tsx:792 +#: src/components/dump/create/DumpCreateModal.tsx:790 msgid "Dump it" msgstr "Dump it" -#: src/components/dump/create/DumpCreateModal.tsx:549 +#: src/components/dump/create/DumpCreateModal.tsx:547 msgid "Dumped!" msgstr "Dumped!" #: src/pages/Search.tsx:188 #: src/pages/UserDumps.tsx:80 -#: src/pages/UserPublicProfile.tsx:992 +#: src/pages/UserPublicProfile.tsx:989 msgid "Dumps" msgstr "Dumps" #. placeholder {0}: dumps.items.length #. placeholder {1}: dumps.hasMore ? "+" : "" -#: src/pages/UserPublicProfile.tsx:1012 +#: src/pages/UserPublicProfile.tsx:1009 msgid "Dumps ({0}{1})" msgstr "Dumps ({0}{1})" @@ -526,20 +526,20 @@ msgstr "Earlier" #: src/components/chat/PendingItem.tsx:77 #: src/components/comment/CommentNode.tsx:239 #: src/pages/Dump.tsx:529 -#: src/pages/PlaylistDetail.tsx:662 +#: src/pages/PlaylistDetail.tsx:659 msgid "Edit" msgstr "Edit" #. placeholder {0}: category.name -#: src/components/category/CategoryManager.tsx:76 +#: src/components/category/CategoryManager.tsx:74 msgid "Edit {0}" msgstr "Edit {0}" -#: src/pages/UserPublicProfile.tsx:956 +#: src/pages/UserPublicProfile.tsx:953 msgid "Edit description" msgstr "Edit description" -#: src/pages/UserPublicProfile.tsx:903 +#: src/pages/UserPublicProfile.tsx:900 msgid "Edit email" msgstr "Edit email" @@ -558,7 +558,7 @@ msgstr "edited" #: src/components/chat/ChatMessageItem.tsx:102 #: src/components/comment/CommentNode.tsx:188 #: src/pages/Dump.tsx:468 -#: src/pages/PlaylistDetail.tsx:701 +#: src/pages/PlaylistDetail.tsx:698 msgid "edited {0}" msgstr "edited {0}" @@ -568,20 +568,20 @@ msgstr "edited {0}" #: src/components/chat/ChatMessageItem.tsx:98 #: src/components/comment/CommentNode.tsx:186 #: src/pages/Dump.tsx:466 -#: src/pages/PlaylistDetail.tsx:698 +#: src/pages/PlaylistDetail.tsx:695 msgid "Edited {0}" msgstr "Edited {0}" -#: src/pages/DumpEdit.tsx:212 +#: src/pages/DumpEdit.tsx:214 msgid "Editing" msgstr "Editing" #. placeholder {0}: state.dump.title -#: src/pages/DumpEdit.tsx:49 +#: src/pages/DumpEdit.tsx:51 msgid "Editing {0}" msgstr "Editing {0}" -#: src/pages/UserRegister.tsx:137 +#: src/pages/UserRegister.tsx:135 msgid "Email address" msgstr "Email address" @@ -593,36 +593,35 @@ msgstr "Enter a query to search." msgid "Expand player" msgstr "Expand player" -#: src/components/category/CategoryManager.tsx:234 +#: src/components/category/CategoryManager.tsx:232 msgid "Failed to create category" msgstr "Failed to create category" -#: src/components/playlist/PlaylistCreateForm.tsx:87 +#: src/components/playlist/PlaylistCreateForm.tsx:89 msgid "Failed to create playlist" msgstr "Failed to create playlist" -#: src/components/category/CategoryManager.tsx:157 +#: src/components/category/CategoryManager.tsx:155 msgid "Failed to delete" msgstr "Failed to delete" +#: src/pages/UserPublicProfile.tsx:102 #: src/pages/UserPublicProfile.tsx:105 -#: src/pages/UserPublicProfile.tsx:108 -#: src/pages/UserPublicProfile.tsx:169 +#: src/pages/UserPublicProfile.tsx:166 msgid "Failed to generate invite" msgstr "Failed to generate invite" -#: src/pages/index/FollowedFeed.tsx:93 -#: src/pages/index/HotFeed.tsx:36 -#: src/pages/index/JournalFeed.tsx:33 -#: src/pages/index/NewFeed.tsx:36 +#: src/components/feed/DumpFeed.tsx:58 +#: src/components/feed/FollowedFeed.tsx:93 +#: src/components/feed/JournalFeed.tsx:33 #: src/pages/Notifications.tsx:410 -#: src/pages/UserPublicProfile.tsx:1114 -#: src/pages/UserPublicProfile.tsx:1160 -#: src/pages/UserPublicProfile.tsx:1205 +#: src/pages/UserPublicProfile.tsx:1111 +#: src/pages/UserPublicProfile.tsx:1157 +#: src/pages/UserPublicProfile.tsx:1202 msgid "Failed to load" msgstr "Failed to load" -#: src/components/dump/create/DumpCreateModal.tsx:581 +#: src/components/dump/create/DumpCreateModal.tsx:579 msgid "Failed to post" msgstr "Failed to post" @@ -634,13 +633,13 @@ msgstr "Failed to post comment" msgid "Failed to post reply" msgstr "Failed to post reply" -#: src/pages/PlaylistDetail.tsx:963 -#: src/pages/UserPublicProfile.tsx:1829 -#: src/pages/UserPublicProfile.tsx:1903 +#: src/pages/PlaylistDetail.tsx:960 +#: src/pages/UserPublicProfile.tsx:1826 +#: src/pages/UserPublicProfile.tsx:1900 msgid "Failed to save" msgstr "Failed to save" -#: src/components/category/CategoryManager.tsx:235 +#: src/components/category/CategoryManager.tsx:233 msgid "Failed to save category" msgstr "Failed to save category" @@ -648,23 +647,23 @@ msgstr "Failed to save category" msgid "Failed to save edit" msgstr "Failed to save edit" -#: src/pages/UserPublicProfile.tsx:914 +#: src/pages/UserPublicProfile.tsx:911 msgid "Failed to update avatar" msgstr "Failed to update avatar" -#: src/pages/UserPublicProfile.tsx:1767 +#: src/pages/UserPublicProfile.tsx:1764 msgid "Failed to update role" msgstr "Failed to update role" -#: src/pages/UserPublicProfile.tsx:1330 +#: src/pages/UserPublicProfile.tsx:1327 msgid "Feeds" msgstr "Feeds" -#: src/components/dump/create/DumpCreateModal.tsx:620 +#: src/components/dump/create/DumpCreateModal.tsx:618 msgid "Fetching preview…" msgstr "Fetching preview…" -#: src/components/dump/create/DumpCreateModal.tsx:407 +#: src/components/dump/create/DumpCreateModal.tsx:405 msgid "File too large (max 50 MB)." msgstr "File too large (max 50 MB)." @@ -681,17 +680,17 @@ msgstr "Follow {targetUsername}" msgid "Follow playlist" msgstr "Follow playlist" -#: src/pages/index/FollowedFeed.tsx:379 +#: src/components/feed/FollowedFeed.tsx:379 msgid "Follow some public playlists to see their dumps here." msgstr "Follow some public playlists to see their dumps here." -#: src/pages/index/FollowedFeed.tsx:365 +#: src/components/feed/FollowedFeed.tsx:365 msgid "Follow some users to see their dumps here." msgstr "Follow some users to see their dumps here." -#: src/pages/index/FeedTabBar.tsx:40 -#: src/pages/UserPublicProfile.tsx:994 -#: src/pages/UserPublicProfile.tsx:1364 +#: src/components/feed/FeedTabBar.tsx:40 +#: src/pages/UserPublicProfile.tsx:991 +#: src/pages/UserPublicProfile.tsx:1361 msgid "Followed" msgstr "Followed" @@ -701,29 +700,29 @@ msgstr "Followed" msgid "Followed ({0}{1})" msgstr "Followed ({0}{1})" -#: src/pages/UserPublicProfile.tsx:1149 +#: src/pages/UserPublicProfile.tsx:1146 msgid "Followed playlists" msgstr "Followed playlists" #: src/components/social/FollowButton.tsx:37 #: src/components/social/FollowButton.tsx:64 -#: src/pages/UserPublicProfile.tsx:1103 +#: src/pages/UserPublicProfile.tsx:1100 msgid "Following" msgstr "Following" -#: src/pages/UserLogin.tsx:91 +#: src/pages/UserLogin.tsx:89 msgid "Forgot password?" msgstr "Forgot password?" -#: src/pages/index/FollowedFeed.tsx:348 +#: src/components/feed/FollowedFeed.tsx:348 msgid "From people" msgstr "From people" -#: src/pages/index/FollowedFeed.tsx:349 +#: src/components/feed/FollowedFeed.tsx:349 msgid "From playlists" msgstr "From playlists" -#: src/components/player/MediaPlayer.tsx:348 +#: src/components/player/MediaPlayer.tsx:353 msgid "Fullscreen" msgstr "Fullscreen" @@ -731,20 +730,20 @@ msgstr "Fullscreen" msgid "Go home" msgstr "Go home" -#: src/pages/ResetPassword.tsx:83 +#: src/pages/ResetPassword.tsx:81 msgid "Go to login" msgstr "Go to login" -#: src/pages/index/FeedTabBar.tsx:36 -#: src/pages/UserPublicProfile.tsx:1343 +#: src/components/feed/FeedTabBar.tsx:36 +#: src/pages/UserPublicProfile.tsx:1340 msgid "Hot" msgstr "Hot" -#: src/pages/UserLogin.tsx:126 +#: src/pages/UserLogin.tsx:124 msgid "If that address is registered you'll receive a reset link shortly." msgstr "If that address is registered you'll receive a reset link shortly." -#: src/components/chat/message.ts:25 +#: src/utils/chat/message.ts:25 msgid "image" msgstr "image" @@ -757,38 +756,38 @@ msgstr "In collections" msgid "Insert emoji" msgstr "Insert emoji" -#: src/pages/UserRegister.tsx:100 +#: src/pages/UserRegister.tsx:98 msgid "Invalid invite" msgstr "Invalid invite" -#: src/pages/ResetPassword.tsx:51 +#: src/pages/ResetPassword.tsx:49 msgid "Invalid link" msgstr "Invalid link" -#: src/pages/UserPublicProfile.tsx:143 -#: src/pages/UserPublicProfile.tsx:150 +#: src/pages/UserPublicProfile.tsx:140 +#: src/pages/UserPublicProfile.tsx:147 msgid "Invite link" msgstr "Invite link" -#: src/pages/UserPublicProfile.tsx:868 +#: src/pages/UserPublicProfile.tsx:865 msgid "invited by" msgstr "invited by" -#: src/pages/UserPublicProfile.tsx:995 -#: src/pages/UserPublicProfile.tsx:1194 +#: src/pages/UserPublicProfile.tsx:992 +#: src/pages/UserPublicProfile.tsx:1191 msgid "Invitees" msgstr "Invitees" -#: src/pages/index/FeedTabBar.tsx:38 -#: src/pages/UserPublicProfile.tsx:1357 +#: src/components/feed/FeedTabBar.tsx:38 +#: src/pages/UserPublicProfile.tsx:1354 msgid "Journal" msgstr "Journal" -#: src/components/dump/create/DumpCreateModal.tsx:518 +#: src/components/dump/create/DumpCreateModal.tsx:516 msgid "Keep editing" msgstr "Keep editing" -#: src/pages/UserPublicProfile.tsx:1315 +#: src/pages/UserPublicProfile.tsx:1312 msgid "Light" msgstr "Light" @@ -796,7 +795,7 @@ msgstr "Light" msgid "Like" msgstr "Like" -#: src/components/dump/create/DumpCreateModal.tsx:595 +#: src/components/dump/create/DumpCreateModal.tsx:593 msgid "Link" msgstr "Link" @@ -804,37 +803,36 @@ msgstr "Link" msgid "Link or file" msgstr "Link or file" -#: src/contexts/WSProvider.tsx:714 +#: src/contexts/ws/WSProvider.tsx:714 msgid "Live updates are temporarily disconnected. Trying to reconnect…" msgstr "Live updates are temporarily disconnected. Trying to reconnect…" -#: src/components/layout/AppHeader.tsx:203 +#: src/components/layout/AppHeader.tsx:205 msgid "Live updates unavailable." msgstr "Live updates unavailable." #: src/components/social/UserListPopover.tsx:237 #: src/pages/Notifications.tsx:483 -#: src/pages/UserPublicProfile.tsx:1140 +#: src/pages/UserPublicProfile.tsx:1137 msgid "Load more" msgstr "Load more" -#: src/components/chat/ChatModal.tsx:721 +#: src/components/chat/ChatModal.tsx:726 msgid "Load older messages" msgstr "Load older messages" #: src/pages/Dump.tsx:266 -#: src/pages/DumpEdit.tsx:163 +#: src/pages/DumpEdit.tsx:165 msgid "Loading dump…" msgstr "Loading dump…" -#: src/components/chat/ChatModal.tsx:614 +#: src/components/chat/ChatModal.tsx:619 msgid "Loading messages…" msgstr "Loading messages…" -#: src/pages/index/FollowedFeed.tsx:121 -#: src/pages/index/HotFeed.tsx:64 -#: src/pages/index/JournalFeed.tsx:75 -#: src/pages/index/NewFeed.tsx:64 +#: src/components/feed/DumpFeed.tsx:86 +#: src/components/feed/FollowedFeed.tsx:121 +#: src/components/feed/JournalFeed.tsx:75 #: src/pages/Search.tsx:258 #: src/pages/UserDumps.tsx:121 #: src/pages/UserPlaylists.tsx:435 @@ -843,40 +841,39 @@ msgstr "Loading messages…" msgid "Loading more…" msgstr "Loading more…" -#: src/pages/PlaylistDetail.tsx:591 +#: src/pages/PlaylistDetail.tsx:588 msgid "Loading playlist…" msgstr "Loading playlist…" -#: src/pages/UserPublicProfile.tsx:802 +#: src/pages/UserPublicProfile.tsx:799 msgid "Loading profile…" msgstr "Loading profile…" -#: src/components/category/CategoryManager.tsx:52 -#: src/components/chat/ChatModal.tsx:720 +#: src/components/category/CategoryManager.tsx:50 +#: src/components/chat/ChatModal.tsx:725 #: src/components/editor/EmojiPickerPopup.tsx:102 +#: src/components/feed/DumpFeed.tsx:54 +#: src/components/feed/FollowedFeed.tsx:88 +#: src/components/feed/JournalFeed.tsx:29 #: src/components/playlist/PlaylistMembershipPanel.tsx:28 #: src/components/social/UserListPopover.tsx:198 #: src/components/social/UserListPopover.tsx:236 -#: src/pages/index/FollowedFeed.tsx:88 -#: src/pages/index/HotFeed.tsx:32 -#: src/pages/index/JournalFeed.tsx:29 -#: src/pages/index/NewFeed.tsx:32 #: src/pages/Notifications.tsx:406 #: src/pages/Notifications.tsx:482 #: src/pages/UserDumps.tsx:54 #: src/pages/UserPlaylists.tsx:356 -#: src/pages/UserPublicProfile.tsx:1108 -#: src/pages/UserPublicProfile.tsx:1139 -#: src/pages/UserPublicProfile.tsx:1154 -#: src/pages/UserPublicProfile.tsx:1199 +#: src/pages/UserPublicProfile.tsx:1105 +#: src/pages/UserPublicProfile.tsx:1136 +#: src/pages/UserPublicProfile.tsx:1151 +#: src/pages/UserPublicProfile.tsx:1196 #: src/pages/UserUpvoted.tsx:132 msgid "Loading…" msgstr "Loading…" -#: src/components/layout/AppHeader.tsx:176 -#: src/pages/UserLogin.tsx:35 -#: src/pages/UserLogin.tsx:61 -#: src/pages/UserLogin.tsx:80 +#: src/components/layout/AppHeader.tsx:178 +#: src/pages/UserLogin.tsx:33 +#: src/pages/UserLogin.tsx:59 +#: src/pages/UserLogin.tsx:78 msgid "Log in" msgstr "Log in" @@ -888,20 +885,20 @@ msgstr "Log in to like" msgid "Log in to vote" msgstr "Log in to vote" -#: src/pages/UserPublicProfile.tsx:823 -#: src/pages/UserPublicProfile.tsx:927 +#: src/pages/UserPublicProfile.tsx:820 +#: src/pages/UserPublicProfile.tsx:924 msgid "Log out" msgstr "Log out" -#: src/pages/UserLogin.tsx:79 +#: src/pages/UserLogin.tsx:77 msgid "Logging in…" msgstr "Logging in…" -#: src/pages/UserLogin.tsx:66 +#: src/pages/UserLogin.tsx:64 msgid "Login failed" msgstr "Login failed" -#: src/pages/UserPublicProfile.tsx:1000 +#: src/pages/UserPublicProfile.tsx:997 msgid "Manage" msgstr "Manage" @@ -909,16 +906,16 @@ msgstr "Manage" msgid "Max 50 MB" msgstr "Max 50 MB" -#: src/pages/UserPublicProfile.tsx:1696 +#: src/pages/UserPublicProfile.tsx:1693 msgid "Moderator" msgstr "Moderator" -#: src/components/player/MediaPlayer.tsx:321 +#: src/components/player/MediaPlayer.tsx:326 msgid "Mute" msgstr "Mute" -#: src/components/category/CategoryManager.tsx:180 -#: src/components/category/CategoryManager.tsx:181 +#: src/components/category/CategoryManager.tsx:178 +#: src/components/category/CategoryManager.tsx:179 msgid "Name" msgstr "Name" @@ -926,22 +923,22 @@ msgstr "Name" msgid "new" msgstr "new" -#: src/components/chat/ChatModal.tsx:649 -#: src/pages/index/FeedTabBar.tsx:37 -#: src/pages/UserPublicProfile.tsx:1350 +#: src/components/chat/ChatModal.tsx:654 +#: src/components/feed/FeedTabBar.tsx:37 +#: src/pages/UserPublicProfile.tsx:1347 msgid "New" msgstr "New" -#: src/components/dump/create/DumpCreateModal.tsx:490 -#: src/components/layout/DumpFab.tsx:65 -#: src/components/layout/DumpFab.tsx:66 +#: src/components/dump/create/DumpCreateModal.tsx:488 +#: src/components/layout/DumpFab.tsx:67 +#: src/components/layout/DumpFab.tsx:68 #: src/pages/UserDumps.tsx:88 -#: src/pages/UserPublicProfile.tsx:1469 +#: src/pages/UserPublicProfile.tsx:1466 msgid "New dump" msgstr "New dump" -#: src/components/profile/ChangePasswordModal.tsx:82 -#: src/pages/ResetPassword.tsx:103 +#: src/components/profile/ChangePasswordModal.tsx:80 +#: src/pages/ResetPassword.tsx:101 msgid "New password" msgstr "New password" @@ -950,7 +947,7 @@ msgstr "New password" msgid "New playlist" msgstr "New playlist" -#: src/components/dump/create/DumpCreateModal.tsx:791 +#: src/components/dump/create/DumpCreateModal.tsx:789 msgid "Next →" msgstr "Next →" @@ -958,7 +955,7 @@ msgstr "Next →" msgid "Next track" msgstr "Next track" -#: src/pages/PlaylistDetail.tsx:721 +#: src/pages/PlaylistDetail.tsx:718 msgid "No dumps in this playlist yet." msgstr "No dumps in this playlist yet." @@ -966,9 +963,8 @@ msgstr "No dumps in this playlist yet." msgid "No dumps match \"{q}\"." msgstr "No dumps match \"{q}\"." -#: src/pages/index/HotFeed.tsx:40 -#: src/pages/index/JournalFeed.tsx:37 -#: src/pages/index/NewFeed.tsx:40 +#: src/components/feed/DumpFeed.tsx:62 +#: src/components/feed/JournalFeed.tsx:37 msgid "No dumps yet. Be the first!" msgstr "No dumps yet. Be the first!" @@ -977,15 +973,15 @@ msgid "No emoji found." msgstr "No emoji found." #: src/pages/UserPlaylists.tsx:457 -#: src/pages/UserPublicProfile.tsx:1167 +#: src/pages/UserPublicProfile.tsx:1164 msgid "No followed playlists yet." msgstr "No followed playlists yet." -#: src/pages/UserPublicProfile.tsx:1212 +#: src/pages/UserPublicProfile.tsx:1209 msgid "No invitees yet." msgstr "No invitees yet." -#: src/components/chat/ChatModal.tsx:633 +#: src/components/chat/ChatModal.tsx:638 msgid "No messages yet. Say hello!" msgstr "No messages yet. Say hello!" @@ -999,11 +995,11 @@ msgstr "No playlists match \"{q}\"." #: src/components/playlist/PlaylistMembershipPanel.tsx:34 #: src/pages/UserPlaylists.tsx:415 -#: src/pages/UserPublicProfile.tsx:1074 +#: src/pages/UserPublicProfile.tsx:1071 msgid "No playlists yet." msgstr "No playlists yet." -#: src/contexts/WSProvider.tsx:948 +#: src/contexts/ws/WSProvider.tsx:948 msgid "No reply from the server — this message may not have been sent" msgstr "No reply from the server — this message may not have been sent" @@ -1011,14 +1007,14 @@ msgstr "No reply from the server — this message may not have been sent" msgid "No users match \"{q}\"." msgstr "No users match \"{q}\"." -#: src/pages/UserPublicProfile.tsx:1121 +#: src/pages/UserPublicProfile.tsx:1118 msgid "Not following anyone yet." msgstr "Not following anyone yet." #: src/pages/Notifications.tsx:417 #: src/pages/UserDumps.tsx:99 -#: src/pages/UserPublicProfile.tsx:1480 -#: src/pages/UserPublicProfile.tsx:1608 +#: src/pages/UserPublicProfile.tsx:1477 +#: src/pages/UserPublicProfile.tsx:1605 #: src/pages/UserUpvoted.tsx:166 msgid "Nothing here yet." msgstr "Nothing here yet." @@ -1026,7 +1022,7 @@ msgstr "Nothing here yet." #: src/components/layout/NotificationBell.tsx:42 #: src/pages/Notifications.tsx:269 #: src/pages/Notifications.tsx:395 -#: src/pages/UserPublicProfile.tsx:1372 +#: src/pages/UserPublicProfile.tsx:1369 msgid "Notifications" msgstr "Notifications" @@ -1034,11 +1030,11 @@ msgstr "Notifications" msgid "Notifications ({unreadNotificationCount} unread)" msgstr "Notifications ({unreadNotificationCount} unread)" -#: src/pages/UserPublicProfile.tsx:1396 +#: src/pages/UserPublicProfile.tsx:1393 msgid "Off" msgstr "Off" -#: src/pages/UserPublicProfile.tsx:1389 +#: src/pages/UserPublicProfile.tsx:1386 msgid "On" msgstr "On" @@ -1063,70 +1059,70 @@ msgstr "or <0>browse files" msgid "Page not found" msgstr "Page not found" -#: src/pages/UserLogin.tsx:76 -#: src/pages/UserPublicProfile.tsx:1232 +#: src/pages/UserLogin.tsx:74 +#: src/pages/UserPublicProfile.tsx:1229 msgid "Password" msgstr "Password" #. placeholder {0}: VALIDATION.PASSWORD_MIN -#: src/pages/UserRegister.tsx:143 +#: src/pages/UserRegister.tsx:141 msgid "Password (min. {0} characters)" msgstr "Password (min. {0} characters)" -#: src/components/profile/ChangePasswordModal.tsx:59 +#: src/components/profile/ChangePasswordModal.tsx:57 msgid "Password changed successfully." msgstr "Password changed successfully." -#: src/pages/ResetPassword.tsx:73 +#: src/pages/ResetPassword.tsx:71 msgid "Password updated" msgstr "Password updated" -#: src/components/profile/ChangePasswordModal.tsx:102 -#: src/pages/ResetPassword.tsx:123 +#: src/components/profile/ChangePasswordModal.tsx:100 +#: src/pages/ResetPassword.tsx:121 msgid "Passwords do not match" msgstr "Passwords do not match" -#: src/components/dump/create/DumpCreateModal.tsx:613 +#: src/components/dump/create/DumpCreateModal.tsx:611 msgid "Paste a link…" msgstr "Paste a link…" -#: src/components/dump/FilePreview.tsx:115 -#: src/components/dump/FilePreview.tsx:282 +#: src/components/dump/FilePreview.tsx:119 +#: src/components/dump/FilePreview.tsx:286 #: src/components/dump/RichContentCard.tsx:83 -#: src/components/player/MediaPlayer.tsx:305 +#: src/components/player/MediaPlayer.tsx:310 msgid "Pause" msgstr "Pause" -#: src/components/dump/create/DumpCreateModal.tsx:530 +#: src/components/dump/create/DumpCreateModal.tsx:528 msgid "Picked up where you left off." msgstr "Picked up where you left off." -#: src/components/dump/FilePreview.tsx:115 -#: src/components/dump/FilePreview.tsx:226 -#: src/components/dump/FilePreview.tsx:247 -#: src/components/dump/FilePreview.tsx:282 +#: src/components/dump/FilePreview.tsx:119 +#: src/components/dump/FilePreview.tsx:230 +#: src/components/dump/FilePreview.tsx:251 +#: src/components/dump/FilePreview.tsx:286 #: src/components/dump/RichContentCard.tsx:83 #: src/components/dump/RichContentCard.tsx:129 -#: src/components/player/MediaPlayer.tsx:305 +#: src/components/player/MediaPlayer.tsx:310 msgid "Play" msgstr "Play" -#: src/pages/PlaylistDetail.tsx:916 +#: src/pages/PlaylistDetail.tsx:913 msgid "Playlist title" msgstr "Playlist title" #: src/components/dump/create/StepIndicator.tsx:14 -#: src/components/layout/AppHeader.tsx:149 +#: src/components/layout/AppHeader.tsx:151 #: src/components/layout/UserMenu.tsx:62 #: src/pages/Search.tsx:191 #: src/pages/UserPlaylists.tsx:382 -#: src/pages/UserPublicProfile.tsx:993 +#: src/pages/UserPublicProfile.tsx:990 msgid "Playlists" msgstr "Playlists" #. placeholder {0}: playlists.items.length #. placeholder {1}: playlists.hasMore ? "+" : "" -#: src/pages/UserPublicProfile.tsx:1043 +#: src/pages/UserPublicProfile.tsx:1040 msgid "Playlists ({0}{1})" msgstr "Playlists ({0}{1})" @@ -1140,7 +1136,7 @@ msgstr "Post reply" #: src/components/comment/CommentNode.tsx:273 #: src/components/comment/CommentThread.tsx:82 -#: src/components/dump/create/DumpCreateModal.tsx:785 +#: src/components/dump/create/DumpCreateModal.tsx:783 msgid "Posting…" msgstr "Posting…" @@ -1148,31 +1144,31 @@ msgstr "Posting…" msgid "Previous track" msgstr "Previous track" -#: src/components/dump/DumpCard.tsx:121 -#: src/components/dump/JournalCard.tsx:125 +#: src/components/dump/DumpCard.tsx:125 +#: src/components/dump/JournalCard.tsx:129 #: src/components/playlist/PlaylistCard.tsx:74 #: src/components/playlist/PlaylistMembershipPanel.tsx:64 #: src/pages/Dump.tsx:474 -#: src/pages/PlaylistDetail.tsx:681 +#: src/pages/PlaylistDetail.tsx:678 msgid "private" msgstr "private" #: src/components/form/SegmentedField.tsx:81 -#: src/pages/PlaylistDetail.tsx:957 +#: src/pages/PlaylistDetail.tsx:954 msgid "Private" msgstr "Private" #: src/components/playlist/PlaylistCard.tsx:73 -#: src/pages/PlaylistDetail.tsx:680 +#: src/pages/PlaylistDetail.tsx:677 msgid "public" msgstr "public" #: src/components/form/SegmentedField.tsx:80 -#: src/pages/PlaylistDetail.tsx:950 +#: src/pages/PlaylistDetail.tsx:947 msgid "Public" msgstr "Public" -#: src/pages/DumpEdit.tsx:245 +#: src/pages/DumpEdit.tsx:247 msgid "Refresh metadata" msgstr "Refresh metadata" @@ -1180,21 +1176,21 @@ msgstr "Refresh metadata" msgid "Refreshing stream…" msgstr "Refreshing stream…" -#: src/pages/DumpEdit.tsx:244 +#: src/pages/DumpEdit.tsx:246 msgid "Refreshing…" msgstr "Refreshing…" -#: src/pages/UserRegister.tsx:37 -#: src/pages/UserRegister.tsx:112 -#: src/pages/UserRegister.tsx:154 +#: src/pages/UserRegister.tsx:35 +#: src/pages/UserRegister.tsx:110 +#: src/pages/UserRegister.tsx:152 msgid "Register" msgstr "Register" -#: src/pages/UserRegister.tsx:153 +#: src/pages/UserRegister.tsx:151 msgid "Registering…" msgstr "Registering…" -#: src/pages/UserRegister.tsx:117 +#: src/pages/UserRegister.tsx:115 msgid "Registration failed" msgstr "Registration failed" @@ -1206,7 +1202,7 @@ msgstr "Related" msgid "Remove file" msgstr "Remove file" -#: src/pages/PlaylistDetail.tsx:776 +#: src/pages/PlaylistDetail.tsx:773 msgid "Remove from playlist" msgstr "Remove from playlist" @@ -1218,7 +1214,7 @@ msgstr "Remove like" msgid "Remove vote" msgstr "Remove vote" -#: src/pages/DumpEdit.tsx:434 +#: src/pages/DumpEdit.tsx:436 msgid "Replace file" msgstr "Replace file" @@ -1228,24 +1224,24 @@ msgstr "Replace file" msgid "Reply" msgstr "Reply" -#: src/components/chat/ChatModal.tsx:784 +#: src/components/chat/ChatModal.tsx:789 msgid "Replying to" msgstr "Replying to" -#: src/pages/UserLogin.tsx:138 +#: src/pages/UserLogin.tsx:136 msgid "Request failed" msgstr "Request failed" -#: src/pages/ResetPassword.tsx:99 +#: src/pages/ResetPassword.tsx:97 msgid "Reset failed" msgstr "Reset failed" -#: src/pages/ResetPassword.tsx:24 +#: src/pages/ResetPassword.tsx:22 msgid "Reset password" msgstr "Reset password" -#: src/pages/DumpEdit.tsx:410 -#: src/pages/DumpEdit.tsx:464 +#: src/pages/DumpEdit.tsx:412 +#: src/pages/DumpEdit.tsx:466 msgid "Reset to default" msgstr "Reset to default" @@ -1256,36 +1252,36 @@ msgstr "Reset to the suggested title" #: src/components/chat/PendingItem.tsx:70 #: src/pages/Dump.tsx:283 -#: src/pages/DumpEdit.tsx:180 +#: src/pages/DumpEdit.tsx:182 msgid "Retry" msgstr "Retry" -#: src/pages/UserPublicProfile.tsx:1751 +#: src/pages/UserPublicProfile.tsx:1748 msgid "Role" msgstr "Role" -#: src/components/category/CategoryManager.tsx:204 -#: src/components/chat/ChatEditBox.tsx:66 +#: src/components/category/CategoryManager.tsx:202 +#: src/components/chat/ChatEditBox.tsx:70 #: src/components/comment/CommentNode.tsx:199 #: src/pages/Dump.tsx:401 -#: src/pages/DumpEdit.tsx:505 -#: src/pages/PlaylistDetail.tsx:982 -#: src/pages/UserPublicProfile.tsx:1818 -#: src/pages/UserPublicProfile.tsx:1893 +#: src/pages/DumpEdit.tsx:507 +#: src/pages/PlaylistDetail.tsx:979 +#: src/pages/UserPublicProfile.tsx:1815 +#: src/pages/UserPublicProfile.tsx:1890 msgid "Save" msgstr "Save" #: src/components/comment/CommentNode.tsx:200 -#: src/components/profile/ChangePasswordModal.tsx:107 +#: src/components/profile/ChangePasswordModal.tsx:105 #: src/pages/Dump.tsx:400 -#: src/pages/PlaylistDetail.tsx:978 -#: src/pages/ResetPassword.tsx:126 -#: src/pages/UserPublicProfile.tsx:1815 -#: src/pages/UserPublicProfile.tsx:1890 +#: src/pages/PlaylistDetail.tsx:975 +#: src/pages/ResetPassword.tsx:124 +#: src/pages/UserPublicProfile.tsx:1812 +#: src/pages/UserPublicProfile.tsx:1887 msgid "Saving…" msgstr "Saving…" -#: src/components/layout/AppHeader.tsx:128 +#: src/components/layout/AppHeader.tsx:130 #: src/components/layout/SearchBar.tsx:73 #: src/pages/Search.tsx:51 msgid "Search" @@ -1315,41 +1311,41 @@ msgstr "Searching…" msgid "Seek" msgstr "Seek" -#: src/components/chat/ChatModal.tsx:824 +#: src/components/chat/ChatModal.tsx:829 msgid "Send" msgstr "Send" -#: src/pages/UserLogin.tsx:147 +#: src/pages/UserLogin.tsx:145 msgid "Send reset link" msgstr "Send reset link" #: src/components/chat/PendingItem.tsx:88 -#: src/pages/UserLogin.tsx:146 +#: src/pages/UserLogin.tsx:144 msgid "Sending…" msgstr "Sending…" -#: src/components/layout/AppHeader.tsx:165 +#: src/components/layout/AppHeader.tsx:167 msgid "Server unreachable" msgstr "Server unreachable" -#: src/pages/ResetPassword.tsx:94 -#: src/pages/ResetPassword.tsx:127 +#: src/pages/ResetPassword.tsx:92 +#: src/pages/ResetPassword.tsx:125 msgid "Set new password" msgstr "Set new password" -#: src/pages/UserPublicProfile.tsx:997 +#: src/pages/UserPublicProfile.tsx:994 msgid "Settings" msgstr "Settings" -#: src/components/chat/ChatModal.tsx:608 +#: src/components/chat/ChatModal.tsx:613 msgid "Several people are typing…" msgstr "Several people are typing…" -#: src/pages/UserPublicProfile.tsx:163 +#: src/pages/UserPublicProfile.tsx:160 msgid "Single use, valid for 7 days." msgstr "Single use, valid for 7 days." -#: src/components/category/CategoryManager.tsx:193 +#: src/components/category/CategoryManager.tsx:191 msgid "slug" msgstr "slug" @@ -1359,20 +1355,20 @@ msgstr "Some images couldn't be uploaded." #: src/components/form/FormError.tsx:16 #: src/components/layout/PageError.tsx:13 -#: src/contexts/WSProvider.tsx:668 -#: src/pages/PlaylistDetail.tsx:715 +#: src/contexts/ws/WSProvider.tsx:668 +#: src/pages/PlaylistDetail.tsx:712 msgid "Something went wrong" msgstr "Something went wrong" -#: src/pages/UserPublicProfile.tsx:1377 +#: src/pages/UserPublicProfile.tsx:1374 msgid "Sounds" msgstr "Sounds" -#: src/components/dump/create/DumpCreateModal.tsx:537 +#: src/components/dump/create/DumpCreateModal.tsx:535 msgid "Start fresh" msgstr "Start fresh" -#: src/pages/UserPublicProfile.tsx:1251 +#: src/pages/UserPublicProfile.tsx:1248 msgid "Style" msgstr "Style" @@ -1384,7 +1380,7 @@ msgstr "Submit search" msgid "the chat" msgstr "the chat" -#: src/pages/PlaylistDetail.tsx:505 +#: src/pages/PlaylistDetail.tsx:502 msgid "The dump couldn't be removed. Try again." msgstr "The dump couldn't be removed. Try again." @@ -1396,11 +1392,11 @@ msgstr "The image couldn't be uploaded." msgid "This comment couldn't be deleted. Try again." msgstr "This comment couldn't be deleted. Try again." -#: src/pages/UserRegister.tsx:101 +#: src/pages/UserRegister.tsx:99 msgid "This invite link is missing, expired, or already used." msgstr "This invite link is missing, expired, or already used." -#: src/pages/UserLogin.tsx:98 +#: src/pages/UserLogin.tsx:96 msgid "This is a mirage." msgstr "This is a mirage." @@ -1408,26 +1404,26 @@ msgstr "This is a mirage." msgid "This page does not exist." msgstr "This page does not exist." -#: src/pages/ResetPassword.tsx:54 +#: src/pages/ResetPassword.tsx:52 msgid "This reset link is missing or malformed." msgstr "This reset link is missing or malformed." -#: src/pages/DumpEdit.tsx:394 +#: src/pages/DumpEdit.tsx:396 msgid "Thumbnail" msgstr "Thumbnail" #: src/components/dump/create/TitleField.tsx:32 -#: src/components/playlist/PlaylistCreateForm.tsx:74 -#: src/pages/DumpEdit.tsx:454 +#: src/components/playlist/PlaylistCreateForm.tsx:76 +#: src/pages/DumpEdit.tsx:456 msgid "Title" msgstr "Title" -#: src/components/dump/create/DumpCreateModal.tsx:405 +#: src/components/dump/create/DumpCreateModal.tsx:403 msgid "Title is required." msgstr "Title is required." -#: src/components/chat/time.ts:16 #: src/pages/Notifications.tsx:433 +#: src/utils/chat/time.ts:16 msgid "Today" msgstr "Today" @@ -1436,15 +1432,15 @@ msgstr "Today" msgid "Track {0}" msgstr "Track {0}" -#: src/components/chat/ChatModal.tsx:625 +#: src/components/chat/ChatModal.tsx:630 msgid "Try again" msgstr "Try again" -#: src/components/chat/ChatModal.tsx:809 +#: src/components/chat/ChatModal.tsx:814 msgid "Type a message…" msgstr "Type a message…" -#: src/pages/PlaylistDetail.tsx:788 +#: src/pages/PlaylistDetail.tsx:785 msgid "Undo" msgstr "Undo" @@ -1456,16 +1452,16 @@ msgstr "Unfollow {targetUsername}" msgid "Unfollow playlist" msgstr "Unfollow playlist" -#: src/components/player/MediaPlayer.tsx:321 +#: src/components/player/MediaPlayer.tsx:326 msgid "Unmute" msgstr "Unmute" -#: src/pages/UserPublicProfile.tsx:773 +#: src/pages/UserPublicProfile.tsx:770 msgid "Upload failed" msgstr "Upload failed" #. placeholder {0}: Math.round((uploadProgress ?? 0) * 100) -#: src/components/dump/create/DumpCreateModal.tsx:473 +#: src/components/dump/create/DumpCreateModal.tsx:471 msgid "Uploading {0}%" msgstr "Uploading {0}%" @@ -1473,7 +1469,7 @@ msgstr "Uploading {0}%" msgid "Uploading image…" msgstr "Uploading image…" -#: src/components/dump/create/DumpCreateModal.tsx:788 +#: src/components/dump/create/DumpCreateModal.tsx:786 msgid "Uploading…" msgstr "Uploading…" @@ -1487,27 +1483,27 @@ msgstr "Upvoted" #. placeholder {0}: votes.items.length #. placeholder {1}: votes.hasMore ? "+" : "" -#: src/pages/UserPublicProfile.tsx:1023 +#: src/pages/UserPublicProfile.tsx:1020 msgid "Upvoted ({0}{1})" msgstr "Upvoted ({0}{1})" -#: src/pages/DumpEdit.tsx:426 +#: src/pages/DumpEdit.tsx:428 msgid "URL" msgstr "URL" -#: src/components/dump/create/DumpCreateModal.tsx:389 +#: src/components/dump/create/DumpCreateModal.tsx:387 msgid "URL is required." msgstr "URL is required." -#: src/components/category/CategoryManager.tsx:194 +#: src/components/category/CategoryManager.tsx:192 msgid "URL slug" msgstr "URL slug" -#: src/pages/UserPublicProfile.tsx:1695 +#: src/pages/UserPublicProfile.tsx:1692 msgid "User" msgstr "User" -#: src/pages/UserPublicProfile.tsx:1408 +#: src/pages/UserPublicProfile.tsx:1405 msgid "User management" msgstr "User management" @@ -1515,8 +1511,8 @@ msgstr "User management" msgid "User menu" msgstr "User menu" -#: src/pages/UserLogin.tsx:69 -#: src/pages/UserRegister.tsx:120 +#: src/pages/UserLogin.tsx:67 +#: src/pages/UserRegister.tsx:118 msgid "Username" msgstr "Username" @@ -1524,32 +1520,32 @@ msgstr "Username" msgid "Users" msgstr "Users" -#: src/pages/UserPublicProfile.tsx:1093 -#: src/pages/UserPublicProfile.tsx:1182 -#: src/pages/UserPublicProfile.tsx:1501 -#: src/pages/UserPublicProfile.tsx:1638 +#: src/pages/UserPublicProfile.tsx:1090 +#: src/pages/UserPublicProfile.tsx:1179 +#: src/pages/UserPublicProfile.tsx:1498 +#: src/pages/UserPublicProfile.tsx:1635 msgid "View all →" msgstr "View all →" -#: src/components/dump/create/DumpCreateModal.tsx:551 +#: src/components/dump/create/DumpCreateModal.tsx:549 msgid "View dump →" msgstr "View dump →" -#: src/components/player/MediaPlayer.tsx:338 +#: src/components/player/MediaPlayer.tsx:343 msgid "Volume" msgstr "Volume" -#: src/components/dump/create/DumpCreateModal.tsx:739 -#: src/pages/DumpEdit.tsx:475 +#: src/components/dump/create/DumpCreateModal.tsx:737 +#: src/pages/DumpEdit.tsx:477 msgid "What makes it worth it?" msgstr "What makes it worth it?" -#: src/pages/UserPublicProfile.tsx:977 -#: src/pages/UserPublicProfile.tsx:1882 +#: src/pages/UserPublicProfile.tsx:974 +#: src/pages/UserPublicProfile.tsx:1879 msgid "Who am I?" msgstr "Who am I?" -#: src/components/dump/create/DumpCreateModal.tsx:754 +#: src/components/dump/create/DumpCreateModal.tsx:752 msgid "Who can see it" msgstr "Who can see it" @@ -1557,8 +1553,8 @@ msgstr "Who can see it" msgid "Why & where" msgstr "Why & where" -#: src/components/dump/create/DumpCreateModal.tsx:738 -#: src/pages/DumpEdit.tsx:474 +#: src/components/dump/create/DumpCreateModal.tsx:736 +#: src/pages/DumpEdit.tsx:476 msgid "Why?" msgstr "Why?" @@ -1566,8 +1562,8 @@ msgstr "Why?" msgid "Write a reply…" msgstr "Write a reply…" -#: src/components/chat/time.ts:19 #: src/pages/Notifications.tsx:435 +#: src/utils/chat/time.ts:19 msgid "Yesterday" msgstr "Yesterday" @@ -1579,14 +1575,13 @@ msgstr "You can post it anyway." msgid "You'll be notified when someone follows your playlists, upvotes your dumps, or posts new content." msgstr "You'll be notified when someone follows your playlists, upvotes your dumps, or posts new content." -#: src/contexts/WSProvider.tsx:929 +#: src/contexts/ws/WSProvider.tsx:929 msgid "You're offline — not sent" msgstr "You're offline — not sent" -#: src/pages/index/FollowedFeed.tsx:126 -#: src/pages/index/HotFeed.tsx:69 -#: src/pages/index/JournalFeed.tsx:80 -#: src/pages/index/NewFeed.tsx:69 +#: src/components/feed/DumpFeed.tsx:91 +#: src/components/feed/FollowedFeed.tsx:126 +#: src/components/feed/JournalFeed.tsx:80 #: src/pages/Search.tsx:263 #: src/pages/UserDumps.tsx:126 #: src/pages/UserPlaylists.tsx:440 @@ -1595,10 +1590,10 @@ msgstr "You're offline — not sent" msgid "You've reached the end." msgstr "You've reached the end." -#: src/pages/UserLogin.tsx:142 +#: src/pages/UserLogin.tsx:140 msgid "Your email address" msgstr "Your email address" -#: src/pages/ResetPassword.tsx:76 +#: src/pages/ResetPassword.tsx:74 msgid "Your password has been changed. You can now log in." msgstr "Your password has been changed. You can now log in." diff --git a/src/locales/fr.po b/src/locales/fr.po index 3a70aad8..97345d2d 100644 --- a/src/locales/fr.po +++ b/src/locales/fr.po @@ -18,8 +18,8 @@ msgid "[deleted]" msgstr "[supprimé]" #. placeholder {0}: dump.commentCount -#: src/components/dump/DumpCard.tsx:112 -#: src/components/dump/JournalCard.tsx:116 +#: src/components/dump/DumpCard.tsx:116 +#: src/components/dump/JournalCard.tsx:120 msgid "{0, plural, one {# comment} other {# comments}}" msgstr "{0, plural, one {# commentaire} other {# commentaires}}" @@ -35,23 +35,23 @@ msgstr "{0, plural, one {et # reco plus ancienne} other {et # recos plus ancienn #. placeholder {0}: typingNames[0] #. placeholder {1}: typingNames[1] -#: src/components/chat/ChatModal.tsx:607 +#: src/components/chat/ChatModal.tsx:612 msgid "{0} and {1} are typing…" msgstr "{0} et {1} sont en train d'écrire…" #. placeholder {0}: typingNames[0] -#: src/components/chat/ChatModal.tsx:605 +#: src/components/chat/ChatModal.tsx:610 msgid "{0} is typing…" msgstr "{0} est en train d'écrire…" #. placeholder {0}: rest.length -#: src/pages/index/PresenceRow.tsx:66 +#: src/components/feed/PresenceRow.tsx:66 msgid "{0} more online: {restNames}" msgstr "{0} de plus en ligne : {restNames}" #. placeholder {0}: VALIDATION.USERNAME_MIN #. placeholder {1}: VALIDATION.USERNAME_MAX -#: src/pages/UserRegister.tsx:130 +#: src/pages/UserRegister.tsx:128 msgid "{0}–{1} characters: letters, numbers, or underscores" msgstr "{0}–{1} caractères : lettres, chiffres ou tirets bas" @@ -59,7 +59,7 @@ msgstr "{0}–{1} caractères : lettres, chiffres ou tirets bas" msgid "{label} ({count})" msgstr "{label} ({count})" -#: src/components/chat/ChatModal.tsx:736 +#: src/components/chat/ChatModal.tsx:741 msgid "{unseenCount} new messages" msgstr "{unseenCount} nouveaux messages" @@ -67,15 +67,15 @@ msgstr "{unseenCount} nouveaux messages" msgid "{visibleCount, plural, one {# comment} other {# comments}}" msgstr "{visibleCount, plural, one {# commentaire} other {# commentaires}}" -#: src/components/dump/create/DumpCreateModal.tsx:769 -#: src/pages/PlaylistDetail.tsx:607 -#: src/pages/UserPublicProfile.tsx:819 +#: src/components/dump/create/DumpCreateModal.tsx:767 +#: src/pages/PlaylistDetail.tsx:604 +#: src/pages/UserPublicProfile.tsx:816 msgid "← Back" msgstr "← Retour" #: src/pages/Dump.tsx:290 #: src/pages/Dump.tsx:533 -#: src/pages/DumpEdit.tsx:187 +#: src/pages/DumpEdit.tsx:189 msgid "← Back to all dumps" msgstr "← Retour à toutes les recos" @@ -85,7 +85,7 @@ msgstr "← Retour à toutes les recos" msgid "← Back to profile" msgstr "← Retour au profil" -#: src/pages/UserPublicProfile.tsx:135 +#: src/pages/UserPublicProfile.tsx:132 msgid "+ Invite someone" msgstr "+ Inviter quelqu'un" @@ -143,7 +143,7 @@ msgstr "<0>{0} a voté pour <1>{1}" msgid "<0>{0} was added to <1>{1}" msgstr "<0>{0} a été ajouté à <1>{1}" -#: src/components/chat/ChatModal.tsx:735 +#: src/components/chat/ChatModal.tsx:740 msgid "1 new message" msgstr "1 nouveau message" @@ -155,7 +155,7 @@ msgstr "un commentaire" msgid "a post" msgstr "une publication" -#: src/pages/UserPublicProfile.tsx:1227 +#: src/pages/UserPublicProfile.tsx:1224 msgid "Account" msgstr "Compte" @@ -163,21 +163,21 @@ msgstr "Compte" msgid "Add a comment…" msgstr "Ajouter un commentaire…" -#: src/components/category/CategoryManager.tsx:96 -#: src/components/category/CategoryManager.tsx:97 +#: src/components/category/CategoryManager.tsx:94 +#: src/components/category/CategoryManager.tsx:95 msgid "Add category" msgstr "Ajouter une catégorie" -#: src/pages/UserPublicProfile.tsx:905 +#: src/pages/UserPublicProfile.tsx:902 msgid "Add email…" msgstr "Ajouter un e-mail…" -#: src/components/dump/create/DumpCreateModal.tsx:490 +#: src/components/dump/create/DumpCreateModal.tsx:488 #: src/components/playlist/AddToPlaylistModal.tsx:59 msgid "Add to playlist" msgstr "Ajouter à la collection" -#: src/pages/UserPublicProfile.tsx:1697 +#: src/pages/UserPublicProfile.tsx:1694 msgid "Admin" msgstr "Administrateur" @@ -192,18 +192,18 @@ msgstr "Tout" msgid "Already dumped by {0} {1}" msgstr "Déjà recommandé par {0} {1}" -#: src/pages/UserRegister.tsx:160 +#: src/pages/UserRegister.tsx:158 msgid "Already have an account? <0>Log in" msgstr "Vous avez déjà un compte ? <0>Se connecter" -#: src/pages/UserPublicProfile.tsx:1246 +#: src/pages/UserPublicProfile.tsx:1243 msgid "Appearance" msgstr "Apparence" #. placeholder {0}: VALIDATION.PASSWORD_MIN -#: src/components/profile/ChangePasswordModal.tsx:89 -#: src/pages/ResetPassword.tsx:111 -#: src/pages/UserRegister.tsx:149 +#: src/components/profile/ChangePasswordModal.tsx:87 +#: src/pages/ResetPassword.tsx:109 +#: src/pages/UserRegister.tsx:147 msgid "At least {0} characters" msgstr "Au moins {0} caractères" @@ -211,50 +211,50 @@ msgstr "Au moins {0} caractères" msgid "At most {maxLength} characters." msgstr "{maxLength} caractères au maximum." -#: src/pages/UserPublicProfile.tsx:1308 +#: src/pages/UserPublicProfile.tsx:1305 msgid "Auto" msgstr "Auto" -#: src/pages/ResetPassword.tsx:61 -#: src/pages/ResetPassword.tsx:134 +#: src/pages/ResetPassword.tsx:59 +#: src/pages/ResetPassword.tsx:132 msgid "Back to login" msgstr "Retour à la connexion" -#: src/contexts/WSProvider.tsx:332 -#: src/contexts/WSProvider.tsx:715 +#: src/contexts/ws/WSProvider.tsx:332 +#: src/contexts/ws/WSProvider.tsx:715 msgid "Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects." msgstr "Impossible de se connecter au serveur de mises à jour en direct. Les votes et les notifications pourraient ne pas se synchroniser avant la reconnexion." -#: src/components/category/CategoryManager.tsx:213 -#: src/components/category/CategoryManager.tsx:214 -#: src/components/chat/ChatEditBox.tsx:69 -#: src/components/comment/CommentForm.tsx:108 -#: src/components/dump/create/DumpCreateModal.tsx:779 +#: src/components/category/CategoryManager.tsx:211 +#: src/components/category/CategoryManager.tsx:212 +#: src/components/chat/ChatEditBox.tsx:73 +#: src/components/comment/CommentForm.tsx:105 +#: src/components/dump/create/DumpCreateModal.tsx:777 #: src/components/form/FormActions.tsx:32 -#: src/components/ui/ConfirmModal.tsx:47 +#: src/components/shared/ConfirmModal.tsx:47 #: src/pages/Dump.tsx:409 -#: src/pages/DumpEdit.tsx:502 -#: src/pages/PlaylistDetail.tsx:975 -#: src/pages/UserPublicProfile.tsx:1826 -#: src/pages/UserPublicProfile.tsx:1901 +#: src/pages/DumpEdit.tsx:504 +#: src/pages/PlaylistDetail.tsx:972 +#: src/pages/UserPublicProfile.tsx:1823 +#: src/pages/UserPublicProfile.tsx:1898 msgid "Cancel" msgstr "Annuler" -#: src/pages/PlaylistDetail.tsx:786 +#: src/pages/PlaylistDetail.tsx:783 msgid "Cancel removal" msgstr "Annuler la suppression" -#: src/components/chat/ChatModal.tsx:796 -#: src/components/chat/ChatModal.tsx:797 +#: src/components/chat/ChatModal.tsx:801 +#: src/components/chat/ChatModal.tsx:802 msgid "Cancel reply" msgstr "Annuler la réponse" -#: src/components/layout/AppHeader.tsx:128 +#: src/components/layout/AppHeader.tsx:130 msgid "Cancel search" msgstr "Annuler la recherche" #: src/components/category/CategorySelect.tsx:22 -#: src/pages/UserPublicProfile.tsx:1423 +#: src/pages/UserPublicProfile.tsx:1420 msgid "Categories" msgstr "Catégories" @@ -262,16 +262,16 @@ msgstr "Catégories" msgid "Category" msgstr "Catégorie" -#: src/pages/UserPublicProfile.tsx:846 +#: src/pages/UserPublicProfile.tsx:843 msgid "Change avatar" msgstr "Changer l'avatar" -#: src/components/profile/ChangePasswordModal.tsx:54 -#: src/components/profile/ChangePasswordModal.tsx:108 +#: src/components/profile/ChangePasswordModal.tsx:52 +#: src/components/profile/ChangePasswordModal.tsx:106 msgid "Change password" msgstr "Changer le mot de passe" -#: src/pages/UserPublicProfile.tsx:1239 +#: src/pages/UserPublicProfile.tsx:1236 msgid "Change password…" msgstr "Changer le mot de passe…" @@ -279,7 +279,7 @@ msgstr "Changer le mot de passe…" msgid "Change skin tone" msgstr "Changer la couleur de peau" -#: src/components/chat/ChatModal.tsx:696 +#: src/components/chat/ChatModal.tsx:701 #: src/components/layout/ChatButton.tsx:17 #: src/components/layout/ChatFab.tsx:49 #: src/components/layout/ChatFab.tsx:50 @@ -291,16 +291,16 @@ msgstr "Tribune" msgid "Chat ({unreadChatCount} unread)" msgstr "{unreadChatCount} messages non lus" -#: src/pages/UserRegister.tsx:89 +#: src/pages/UserRegister.tsx:87 msgid "Checking invite…" msgstr "Vérification de l'invitation…" -#: src/components/profile/ChangePasswordModal.tsx:63 -#: src/components/ui/Modal.tsx:60 +#: src/components/profile/ChangePasswordModal.tsx:61 +#: src/components/shared/Modal.tsx:60 msgid "Close" msgstr "Fermer" -#: src/components/dump/create/DumpCreateModal.tsx:521 +#: src/components/dump/create/DumpCreateModal.tsx:519 msgid "Close anyway" msgstr "Fermer quand même" @@ -312,7 +312,7 @@ msgstr "Fermer le sélecteur d'emoji" msgid "Close player" msgstr "Fermer le lecteur" -#: src/components/dump/create/DumpCreateModal.tsx:511 +#: src/components/dump/create/DumpCreateModal.tsx:509 msgid "Close without posting? The attached file won't be kept — everything you typed will." msgstr "Fermer sans publier ? Le fichier joint sera perdu — ce que vous avez écrit sera conservé." @@ -320,32 +320,32 @@ msgstr "Fermer sans publier ? Le fichier joint sera perdu — ce que vous avez msgid "Collapse player" msgstr "Réduire le lecteur" -#: src/pages/UserPublicProfile.tsx:1300 +#: src/pages/UserPublicProfile.tsx:1297 msgid "Color scheme" msgstr "Thème de couleur" -#: src/components/profile/ChangePasswordModal.tsx:96 -#: src/pages/ResetPassword.tsx:118 +#: src/components/profile/ChangePasswordModal.tsx:94 +#: src/pages/ResetPassword.tsx:116 msgid "Confirm new password" msgstr "Confirmer le nouveau mot de passe" -#: src/contexts/WSProvider.tsx:708 +#: src/contexts/ws/WSProvider.tsx:708 msgid "Connection lost — this message may not have been sent" msgstr "Connexion perdue — ce message n'a peut-être pas été envoyé" -#: src/pages/UserPublicProfile.tsx:160 +#: src/pages/UserPublicProfile.tsx:157 msgid "Copied!" msgstr "Copié !" -#: src/pages/UserPublicProfile.tsx:160 +#: src/pages/UserPublicProfile.tsx:157 msgid "Copy" msgstr "Copier" -#: src/components/profile/ChangePasswordModal.tsx:105 +#: src/components/profile/ChangePasswordModal.tsx:103 msgid "Could not change password" msgstr "Impossible de changer le mot de passe" -#: src/pages/DumpEdit.tsx:490 +#: src/pages/DumpEdit.tsx:492 #: src/pages/UserPlaylists.tsx:400 msgid "Could not delete" msgstr "Suppression impossible" @@ -354,23 +354,23 @@ msgstr "Suppression impossible" msgid "Could not load." msgstr "Impossible de charger." -#: src/pages/DumpEdit.tsx:450 +#: src/pages/DumpEdit.tsx:452 msgid "Could not save" msgstr "Sauvegarde impossible" -#: src/components/dump/create/DumpCreateModal.tsx:633 +#: src/components/dump/create/DumpCreateModal.tsx:631 msgid "Couldn't load a preview for this link — check it, or post it as-is and refresh the preview later." msgstr "Impossible de charger un aperçu pour ce lien — vérifiez-le, ou publiez-le tel quel et actualisez l'aperçu plus tard." -#: src/components/chat/ChatModal.tsx:622 +#: src/components/chat/ChatModal.tsx:627 msgid "Couldn't load the chat." msgstr "Impossible de charger le chat." -#: src/components/playlist/PlaylistCreateForm.tsx:90 +#: src/components/playlist/PlaylistCreateForm.tsx:92 msgid "Create" msgstr "Créer" -#: src/components/playlist/PlaylistCreateForm.tsx:90 +#: src/components/playlist/PlaylistCreateForm.tsx:92 msgid "Create & Add" msgstr "Créer et ajouter" @@ -380,55 +380,55 @@ msgstr "Créer et ajouter" msgid "Created ({0}{1})" msgstr "Créées ({0}{1})" -#: src/components/playlist/PlaylistCreateForm.tsx:89 +#: src/components/playlist/PlaylistCreateForm.tsx:91 msgid "Creating…" msgstr "Création…" -#: src/components/profile/ChangePasswordModal.tsx:74 +#: src/components/profile/ChangePasswordModal.tsx:72 msgid "Current password" msgstr "Mot de passe actuel" -#: src/pages/UserPublicProfile.tsx:1322 +#: src/pages/UserPublicProfile.tsx:1319 msgid "Dark" msgstr "Sombre" -#: src/pages/UserPublicProfile.tsx:1335 +#: src/pages/UserPublicProfile.tsx:1332 msgid "Default tab" msgstr "Onglet par défaut" -#: src/components/category/CategoryManager.tsx:224 -#: src/components/category/CategoryManager.tsx:225 +#: src/components/category/CategoryManager.tsx:222 +#: src/components/category/CategoryManager.tsx:223 #: src/components/chat/ChatMessageItem.tsx:88 #: src/components/chat/ChatMessageItem.tsx:89 #: src/components/chat/ChatMessageItem.tsx:187 #: src/components/comment/CommentNode.tsx:248 #: src/components/comment/CommentNode.tsx:254 -#: src/components/ui/ConfirmModal.tsx:17 -#: src/pages/PlaylistDetail.tsx:971 +#: src/components/shared/ConfirmModal.tsx:17 +#: src/pages/PlaylistDetail.tsx:968 msgid "Delete" msgstr "Supprimer" -#: src/components/category/CategoryManager.tsx:241 +#: src/components/category/CategoryManager.tsx:239 msgid "Delete category" msgstr "Supprimer la catégorie" #. placeholder {0}: category.name -#: src/components/category/CategoryManager.tsx:240 +#: src/components/category/CategoryManager.tsx:238 msgid "Delete category \"{0}\"? This cannot be undone." msgstr "Supprimer la catégorie \"{0}\" ? Cette action est irréversible." -#: src/pages/DumpEdit.tsx:267 -#: src/pages/DumpEdit.tsx:498 +#: src/pages/DumpEdit.tsx:269 +#: src/pages/DumpEdit.tsx:500 msgid "Delete dump" msgstr "Supprimer la reco" #. placeholder {0}: res.status -#: src/pages/DumpEdit.tsx:149 +#: src/pages/DumpEdit.tsx:151 msgid "Delete failed ({0})" msgstr "Échec de la suppression ({0})" #: src/components/playlist/PlaylistCard.tsx:110 -#: src/pages/PlaylistDetail.tsx:799 +#: src/pages/PlaylistDetail.tsx:796 #: src/pages/UserPlaylists.tsx:483 msgid "Delete playlist" msgstr "Supprimer la collection" @@ -437,7 +437,7 @@ msgstr "Supprimer la collection" msgid "Delete this comment?" msgstr "Supprimer ce commentaire ?" -#: src/pages/DumpEdit.tsx:266 +#: src/pages/DumpEdit.tsx:268 msgid "Delete this dump? This cannot be undone." msgstr "Supprimer cette reco ? Cette action est irréversible." @@ -445,7 +445,7 @@ msgstr "Supprimer cette reco ? Cette action est irréversible." msgid "Delete this message?" msgstr "Supprimer ce message ?" -#: src/pages/PlaylistDetail.tsx:798 +#: src/pages/PlaylistDetail.tsx:795 #: src/pages/UserPlaylists.tsx:482 msgid "Delete this playlist? This cannot be undone." msgstr "Supprimer cette collection ? Cette action est irréversible." @@ -454,8 +454,8 @@ msgstr "Supprimer cette collection ? Cette action est irréversible." msgid "deleted message" msgstr "message supprimé" -#: src/components/playlist/PlaylistCreateForm.tsx:82 -#: src/pages/PlaylistDetail.tsx:932 +#: src/components/playlist/PlaylistCreateForm.tsx:84 +#: src/pages/PlaylistDetail.tsx:929 msgid "Description (optional)" msgstr "Description (facultatif)" @@ -463,21 +463,21 @@ msgstr "Description (facultatif)" msgid "Discard" msgstr "Abandonner" -#: src/components/chat/ChatModal.tsx:700 +#: src/components/chat/ChatModal.tsx:705 msgid "Disconnected — reconnecting…" msgstr "Déconnecté — reconnexion…" -#: src/components/chat/ChatModal.tsx:772 -#: src/components/chat/ChatModal.tsx:773 +#: src/components/chat/ChatModal.tsx:777 +#: src/components/chat/ChatModal.tsx:778 msgid "Dismiss" msgstr "Fermer" -#: src/components/dump/create/DumpCreateModal.tsx:572 +#: src/components/dump/create/DumpCreateModal.tsx:570 msgid "Done" msgstr "Terminé" #. placeholder {0}: dump.fileName -#: src/components/dump/FilePreview.tsx:330 +#: src/components/dump/FilePreview.tsx:334 msgid "Download {0}" msgstr "Télécharger {0}" @@ -485,35 +485,35 @@ msgstr "Télécharger {0}" msgid "Drop a file here" msgstr "Déposez un fichier ici" -#: src/components/dump/create/DumpCreateModal.tsx:680 +#: src/components/dump/create/DumpCreateModal.tsx:678 msgid "Drop a file here, or paste one" msgstr "Déposez un fichier ici, ou collez-en un" -#: src/pages/DumpEdit.tsx:442 +#: src/pages/DumpEdit.tsx:444 msgid "Drop a replacement here" msgstr "Déposez un fichier de remplacement ici" -#: src/components/layout/AppHeader.tsx:168 +#: src/components/layout/AppHeader.tsx:170 msgid "Dump" msgstr "Reco" -#: src/components/dump/create/DumpCreateModal.tsx:792 +#: src/components/dump/create/DumpCreateModal.tsx:790 msgid "Dump it" msgstr "Recommander" -#: src/components/dump/create/DumpCreateModal.tsx:549 +#: src/components/dump/create/DumpCreateModal.tsx:547 msgid "Dumped!" msgstr "Recommandé !" #: src/pages/Search.tsx:188 #: src/pages/UserDumps.tsx:80 -#: src/pages/UserPublicProfile.tsx:992 +#: src/pages/UserPublicProfile.tsx:989 msgid "Dumps" msgstr "Recos" #. placeholder {0}: dumps.items.length #. placeholder {1}: dumps.hasMore ? "+" : "" -#: src/pages/UserPublicProfile.tsx:1012 +#: src/pages/UserPublicProfile.tsx:1009 msgid "Dumps ({0}{1})" msgstr "Recos ({0}{1})" @@ -526,20 +526,20 @@ msgstr "Plus tôt" #: src/components/chat/PendingItem.tsx:77 #: src/components/comment/CommentNode.tsx:239 #: src/pages/Dump.tsx:529 -#: src/pages/PlaylistDetail.tsx:662 +#: src/pages/PlaylistDetail.tsx:659 msgid "Edit" msgstr "Modifier" #. placeholder {0}: category.name -#: src/components/category/CategoryManager.tsx:76 +#: src/components/category/CategoryManager.tsx:74 msgid "Edit {0}" msgstr "Modifier {0}" -#: src/pages/UserPublicProfile.tsx:956 +#: src/pages/UserPublicProfile.tsx:953 msgid "Edit description" msgstr "Modifier la description" -#: src/pages/UserPublicProfile.tsx:903 +#: src/pages/UserPublicProfile.tsx:900 msgid "Edit email" msgstr "Modifier l'e-mail" @@ -558,7 +558,7 @@ msgstr "modifié" #: src/components/chat/ChatMessageItem.tsx:102 #: src/components/comment/CommentNode.tsx:188 #: src/pages/Dump.tsx:468 -#: src/pages/PlaylistDetail.tsx:701 +#: src/pages/PlaylistDetail.tsx:698 msgid "edited {0}" msgstr "modifié {0}" @@ -568,20 +568,20 @@ msgstr "modifié {0}" #: src/components/chat/ChatMessageItem.tsx:98 #: src/components/comment/CommentNode.tsx:186 #: src/pages/Dump.tsx:466 -#: src/pages/PlaylistDetail.tsx:698 +#: src/pages/PlaylistDetail.tsx:695 msgid "Edited {0}" msgstr "Modifié le {0}" -#: src/pages/DumpEdit.tsx:212 +#: src/pages/DumpEdit.tsx:214 msgid "Editing" msgstr "Modification" #. placeholder {0}: state.dump.title -#: src/pages/DumpEdit.tsx:49 +#: src/pages/DumpEdit.tsx:51 msgid "Editing {0}" msgstr "Édition de {0}" -#: src/pages/UserRegister.tsx:137 +#: src/pages/UserRegister.tsx:135 msgid "Email address" msgstr "Adresse e-mail" @@ -593,36 +593,35 @@ msgstr "Saisissez une recherche." msgid "Expand player" msgstr "Agrandir le lecteur" -#: src/components/category/CategoryManager.tsx:234 +#: src/components/category/CategoryManager.tsx:232 msgid "Failed to create category" msgstr "Échec de la création de la catégorie" -#: src/components/playlist/PlaylistCreateForm.tsx:87 +#: src/components/playlist/PlaylistCreateForm.tsx:89 msgid "Failed to create playlist" msgstr "Impossible de créer la collection" -#: src/components/category/CategoryManager.tsx:157 +#: src/components/category/CategoryManager.tsx:155 msgid "Failed to delete" msgstr "Suppression échouée" +#: src/pages/UserPublicProfile.tsx:102 #: src/pages/UserPublicProfile.tsx:105 -#: src/pages/UserPublicProfile.tsx:108 -#: src/pages/UserPublicProfile.tsx:169 +#: src/pages/UserPublicProfile.tsx:166 msgid "Failed to generate invite" msgstr "Impossible de générer une invitation" -#: src/pages/index/FollowedFeed.tsx:93 -#: src/pages/index/HotFeed.tsx:36 -#: src/pages/index/JournalFeed.tsx:33 -#: src/pages/index/NewFeed.tsx:36 +#: src/components/feed/DumpFeed.tsx:58 +#: src/components/feed/FollowedFeed.tsx:93 +#: src/components/feed/JournalFeed.tsx:33 #: src/pages/Notifications.tsx:410 -#: src/pages/UserPublicProfile.tsx:1114 -#: src/pages/UserPublicProfile.tsx:1160 -#: src/pages/UserPublicProfile.tsx:1205 +#: src/pages/UserPublicProfile.tsx:1111 +#: src/pages/UserPublicProfile.tsx:1157 +#: src/pages/UserPublicProfile.tsx:1202 msgid "Failed to load" msgstr "Chargement échoué" -#: src/components/dump/create/DumpCreateModal.tsx:581 +#: src/components/dump/create/DumpCreateModal.tsx:579 msgid "Failed to post" msgstr "Publication échouée" @@ -634,13 +633,13 @@ msgstr "Impossible de publier le commentaire" msgid "Failed to post reply" msgstr "Impossible de publier la réponse" -#: src/pages/PlaylistDetail.tsx:963 -#: src/pages/UserPublicProfile.tsx:1829 -#: src/pages/UserPublicProfile.tsx:1903 +#: src/pages/PlaylistDetail.tsx:960 +#: src/pages/UserPublicProfile.tsx:1826 +#: src/pages/UserPublicProfile.tsx:1900 msgid "Failed to save" msgstr "Enregistrement échoué" -#: src/components/category/CategoryManager.tsx:235 +#: src/components/category/CategoryManager.tsx:233 msgid "Failed to save category" msgstr "Échec de l’enregistrement de la catégorie" @@ -648,23 +647,23 @@ msgstr "Échec de l’enregistrement de la catégorie" msgid "Failed to save edit" msgstr "Impossible d'enregistrer la modification" -#: src/pages/UserPublicProfile.tsx:914 +#: src/pages/UserPublicProfile.tsx:911 msgid "Failed to update avatar" msgstr "Impossible de mettre à jour l'avatar" -#: src/pages/UserPublicProfile.tsx:1767 +#: src/pages/UserPublicProfile.tsx:1764 msgid "Failed to update role" msgstr "Erreur lors de la mise à jour du rôle" -#: src/pages/UserPublicProfile.tsx:1330 +#: src/pages/UserPublicProfile.tsx:1327 msgid "Feeds" msgstr "Flux" -#: src/components/dump/create/DumpCreateModal.tsx:620 +#: src/components/dump/create/DumpCreateModal.tsx:618 msgid "Fetching preview…" msgstr "Récupération de l'aperçu…" -#: src/components/dump/create/DumpCreateModal.tsx:407 +#: src/components/dump/create/DumpCreateModal.tsx:405 msgid "File too large (max 50 MB)." msgstr "Fichier trop volumineux (max 50 Mo)." @@ -681,17 +680,17 @@ msgstr "Suivre {targetUsername}" msgid "Follow playlist" msgstr "Suivre la collection" -#: src/pages/index/FollowedFeed.tsx:379 +#: src/components/feed/FollowedFeed.tsx:379 msgid "Follow some public playlists to see their dumps here." msgstr "Suivez des collections publiques pour voir leurs recos ici." -#: src/pages/index/FollowedFeed.tsx:365 +#: src/components/feed/FollowedFeed.tsx:365 msgid "Follow some users to see their dumps here." msgstr "Suivez des utilisateurs pour voir leurs recos ici." -#: src/pages/index/FeedTabBar.tsx:40 -#: src/pages/UserPublicProfile.tsx:994 -#: src/pages/UserPublicProfile.tsx:1364 +#: src/components/feed/FeedTabBar.tsx:40 +#: src/pages/UserPublicProfile.tsx:991 +#: src/pages/UserPublicProfile.tsx:1361 msgid "Followed" msgstr "Suivi" @@ -701,29 +700,29 @@ msgstr "Suivi" msgid "Followed ({0}{1})" msgstr "Suivies ({0}{1})" -#: src/pages/UserPublicProfile.tsx:1149 +#: src/pages/UserPublicProfile.tsx:1146 msgid "Followed playlists" msgstr "Collections suivies" #: src/components/social/FollowButton.tsx:37 #: src/components/social/FollowButton.tsx:64 -#: src/pages/UserPublicProfile.tsx:1103 +#: src/pages/UserPublicProfile.tsx:1100 msgid "Following" msgstr "Abonné" -#: src/pages/UserLogin.tsx:91 +#: src/pages/UserLogin.tsx:89 msgid "Forgot password?" msgstr "Mot de passe oublié ?" -#: src/pages/index/FollowedFeed.tsx:348 +#: src/components/feed/FollowedFeed.tsx:348 msgid "From people" msgstr "De personnes" -#: src/pages/index/FollowedFeed.tsx:349 +#: src/components/feed/FollowedFeed.tsx:349 msgid "From playlists" msgstr "De collections" -#: src/components/player/MediaPlayer.tsx:348 +#: src/components/player/MediaPlayer.tsx:353 msgid "Fullscreen" msgstr "Plein écran" @@ -731,20 +730,20 @@ msgstr "Plein écran" msgid "Go home" msgstr "Accueil" -#: src/pages/ResetPassword.tsx:83 +#: src/pages/ResetPassword.tsx:81 msgid "Go to login" msgstr "Aller à la connexion" -#: src/pages/index/FeedTabBar.tsx:36 -#: src/pages/UserPublicProfile.tsx:1343 +#: src/components/feed/FeedTabBar.tsx:36 +#: src/pages/UserPublicProfile.tsx:1340 msgid "Hot" msgstr "Tendances" -#: src/pages/UserLogin.tsx:126 +#: src/pages/UserLogin.tsx:124 msgid "If that address is registered you'll receive a reset link shortly." msgstr "Si cette adresse est enregistrée, vous recevrez un lien de réinitialisation sous peu." -#: src/components/chat/message.ts:25 +#: src/utils/chat/message.ts:25 msgid "image" msgstr "image" @@ -757,38 +756,38 @@ msgstr "Dans les collections" msgid "Insert emoji" msgstr "Insérer un emoji" -#: src/pages/UserRegister.tsx:100 +#: src/pages/UserRegister.tsx:98 msgid "Invalid invite" msgstr "Invitation invalide" -#: src/pages/ResetPassword.tsx:51 +#: src/pages/ResetPassword.tsx:49 msgid "Invalid link" msgstr "Lien invalide" -#: src/pages/UserPublicProfile.tsx:143 -#: src/pages/UserPublicProfile.tsx:150 +#: src/pages/UserPublicProfile.tsx:140 +#: src/pages/UserPublicProfile.tsx:147 msgid "Invite link" msgstr "Lien d'invitation" -#: src/pages/UserPublicProfile.tsx:868 +#: src/pages/UserPublicProfile.tsx:865 msgid "invited by" msgstr "invité par" -#: src/pages/UserPublicProfile.tsx:995 -#: src/pages/UserPublicProfile.tsx:1194 +#: src/pages/UserPublicProfile.tsx:992 +#: src/pages/UserPublicProfile.tsx:1191 msgid "Invitees" msgstr "Invités" -#: src/pages/index/FeedTabBar.tsx:38 -#: src/pages/UserPublicProfile.tsx:1357 +#: src/components/feed/FeedTabBar.tsx:38 +#: src/pages/UserPublicProfile.tsx:1354 msgid "Journal" msgstr "Journal" -#: src/components/dump/create/DumpCreateModal.tsx:518 +#: src/components/dump/create/DumpCreateModal.tsx:516 msgid "Keep editing" msgstr "Continuer l'édition" -#: src/pages/UserPublicProfile.tsx:1315 +#: src/pages/UserPublicProfile.tsx:1312 msgid "Light" msgstr "Clair" @@ -796,7 +795,7 @@ msgstr "Clair" msgid "Like" msgstr "Aimer" -#: src/components/dump/create/DumpCreateModal.tsx:595 +#: src/components/dump/create/DumpCreateModal.tsx:593 msgid "Link" msgstr "Lien" @@ -804,37 +803,36 @@ msgstr "Lien" msgid "Link or file" msgstr "Lien ou fichier" -#: src/contexts/WSProvider.tsx:714 +#: src/contexts/ws/WSProvider.tsx:714 msgid "Live updates are temporarily disconnected. Trying to reconnect…" msgstr "Les mises à jour en direct sont temporairement interrompues. Tentative de reconnexion…" -#: src/components/layout/AppHeader.tsx:203 +#: src/components/layout/AppHeader.tsx:205 msgid "Live updates unavailable." msgstr "Mises à jour en direct indisponibles." #: src/components/social/UserListPopover.tsx:237 #: src/pages/Notifications.tsx:483 -#: src/pages/UserPublicProfile.tsx:1140 +#: src/pages/UserPublicProfile.tsx:1137 msgid "Load more" msgstr "Charger plus" -#: src/components/chat/ChatModal.tsx:721 +#: src/components/chat/ChatModal.tsx:726 msgid "Load older messages" msgstr "Charger les messages plus anciens" #: src/pages/Dump.tsx:266 -#: src/pages/DumpEdit.tsx:163 +#: src/pages/DumpEdit.tsx:165 msgid "Loading dump…" msgstr "Chargement de la reco…" -#: src/components/chat/ChatModal.tsx:614 +#: src/components/chat/ChatModal.tsx:619 msgid "Loading messages…" msgstr "Chargement des messages…" -#: src/pages/index/FollowedFeed.tsx:121 -#: src/pages/index/HotFeed.tsx:64 -#: src/pages/index/JournalFeed.tsx:75 -#: src/pages/index/NewFeed.tsx:64 +#: src/components/feed/DumpFeed.tsx:86 +#: src/components/feed/FollowedFeed.tsx:121 +#: src/components/feed/JournalFeed.tsx:75 #: src/pages/Search.tsx:258 #: src/pages/UserDumps.tsx:121 #: src/pages/UserPlaylists.tsx:435 @@ -843,40 +841,39 @@ msgstr "Chargement des messages…" msgid "Loading more…" msgstr "Chargement…" -#: src/pages/PlaylistDetail.tsx:591 +#: src/pages/PlaylistDetail.tsx:588 msgid "Loading playlist…" msgstr "Chargement de la collection…" -#: src/pages/UserPublicProfile.tsx:802 +#: src/pages/UserPublicProfile.tsx:799 msgid "Loading profile…" msgstr "Chargement du profil…" -#: src/components/category/CategoryManager.tsx:52 -#: src/components/chat/ChatModal.tsx:720 +#: src/components/category/CategoryManager.tsx:50 +#: src/components/chat/ChatModal.tsx:725 #: src/components/editor/EmojiPickerPopup.tsx:102 +#: src/components/feed/DumpFeed.tsx:54 +#: src/components/feed/FollowedFeed.tsx:88 +#: src/components/feed/JournalFeed.tsx:29 #: src/components/playlist/PlaylistMembershipPanel.tsx:28 #: src/components/social/UserListPopover.tsx:198 #: src/components/social/UserListPopover.tsx:236 -#: src/pages/index/FollowedFeed.tsx:88 -#: src/pages/index/HotFeed.tsx:32 -#: src/pages/index/JournalFeed.tsx:29 -#: src/pages/index/NewFeed.tsx:32 #: src/pages/Notifications.tsx:406 #: src/pages/Notifications.tsx:482 #: src/pages/UserDumps.tsx:54 #: src/pages/UserPlaylists.tsx:356 -#: src/pages/UserPublicProfile.tsx:1108 -#: src/pages/UserPublicProfile.tsx:1139 -#: src/pages/UserPublicProfile.tsx:1154 -#: src/pages/UserPublicProfile.tsx:1199 +#: src/pages/UserPublicProfile.tsx:1105 +#: src/pages/UserPublicProfile.tsx:1136 +#: src/pages/UserPublicProfile.tsx:1151 +#: src/pages/UserPublicProfile.tsx:1196 #: src/pages/UserUpvoted.tsx:132 msgid "Loading…" msgstr "Chargement…" -#: src/components/layout/AppHeader.tsx:176 -#: src/pages/UserLogin.tsx:35 -#: src/pages/UserLogin.tsx:61 -#: src/pages/UserLogin.tsx:80 +#: src/components/layout/AppHeader.tsx:178 +#: src/pages/UserLogin.tsx:33 +#: src/pages/UserLogin.tsx:59 +#: src/pages/UserLogin.tsx:78 msgid "Log in" msgstr "Se connecter" @@ -888,20 +885,20 @@ msgstr "Se connecter pour aimer" msgid "Log in to vote" msgstr "Se connecter pour voter" -#: src/pages/UserPublicProfile.tsx:823 -#: src/pages/UserPublicProfile.tsx:927 +#: src/pages/UserPublicProfile.tsx:820 +#: src/pages/UserPublicProfile.tsx:924 msgid "Log out" msgstr "Se déconnecter" -#: src/pages/UserLogin.tsx:79 +#: src/pages/UserLogin.tsx:77 msgid "Logging in…" msgstr "Connexion…" -#: src/pages/UserLogin.tsx:66 +#: src/pages/UserLogin.tsx:64 msgid "Login failed" msgstr "Connexion échouée" -#: src/pages/UserPublicProfile.tsx:1000 +#: src/pages/UserPublicProfile.tsx:997 msgid "Manage" msgstr "Administration" @@ -909,16 +906,16 @@ msgstr "Administration" msgid "Max 50 MB" msgstr "Max 50 Mo" -#: src/pages/UserPublicProfile.tsx:1696 +#: src/pages/UserPublicProfile.tsx:1693 msgid "Moderator" msgstr "Modérateur" -#: src/components/player/MediaPlayer.tsx:321 +#: src/components/player/MediaPlayer.tsx:326 msgid "Mute" msgstr "Couper le son" -#: src/components/category/CategoryManager.tsx:180 -#: src/components/category/CategoryManager.tsx:181 +#: src/components/category/CategoryManager.tsx:178 +#: src/components/category/CategoryManager.tsx:179 msgid "Name" msgstr "Nom" @@ -926,22 +923,22 @@ msgstr "Nom" msgid "new" msgstr "nouveau" -#: src/components/chat/ChatModal.tsx:649 -#: src/pages/index/FeedTabBar.tsx:37 -#: src/pages/UserPublicProfile.tsx:1350 +#: src/components/chat/ChatModal.tsx:654 +#: src/components/feed/FeedTabBar.tsx:37 +#: src/pages/UserPublicProfile.tsx:1347 msgid "New" msgstr "Nouveau" -#: src/components/dump/create/DumpCreateModal.tsx:490 -#: src/components/layout/DumpFab.tsx:65 -#: src/components/layout/DumpFab.tsx:66 +#: src/components/dump/create/DumpCreateModal.tsx:488 +#: src/components/layout/DumpFab.tsx:67 +#: src/components/layout/DumpFab.tsx:68 #: src/pages/UserDumps.tsx:88 -#: src/pages/UserPublicProfile.tsx:1469 +#: src/pages/UserPublicProfile.tsx:1466 msgid "New dump" msgstr "Nouvelle reco" -#: src/components/profile/ChangePasswordModal.tsx:82 -#: src/pages/ResetPassword.tsx:103 +#: src/components/profile/ChangePasswordModal.tsx:80 +#: src/pages/ResetPassword.tsx:101 msgid "New password" msgstr "Nouveau mot de passe" @@ -950,7 +947,7 @@ msgstr "Nouveau mot de passe" msgid "New playlist" msgstr "Nouvelle collection" -#: src/components/dump/create/DumpCreateModal.tsx:791 +#: src/components/dump/create/DumpCreateModal.tsx:789 msgid "Next →" msgstr "Suivant →" @@ -958,7 +955,7 @@ msgstr "Suivant →" msgid "Next track" msgstr "Piste suivante" -#: src/pages/PlaylistDetail.tsx:721 +#: src/pages/PlaylistDetail.tsx:718 msgid "No dumps in this playlist yet." msgstr "Aucune reco dans cette collection pour l'instant." @@ -966,9 +963,8 @@ msgstr "Aucune reco dans cette collection pour l'instant." msgid "No dumps match \"{q}\"." msgstr "Aucune reco ne correspond à « {q} »." -#: src/pages/index/HotFeed.tsx:40 -#: src/pages/index/JournalFeed.tsx:37 -#: src/pages/index/NewFeed.tsx:40 +#: src/components/feed/DumpFeed.tsx:62 +#: src/components/feed/JournalFeed.tsx:37 msgid "No dumps yet. Be the first!" msgstr "Pas encore de recos. Soyez le premier !" @@ -977,15 +973,15 @@ msgid "No emoji found." msgstr "Aucun emoji trouvé." #: src/pages/UserPlaylists.tsx:457 -#: src/pages/UserPublicProfile.tsx:1167 +#: src/pages/UserPublicProfile.tsx:1164 msgid "No followed playlists yet." msgstr "Pas encore de collections suivies." -#: src/pages/UserPublicProfile.tsx:1212 +#: src/pages/UserPublicProfile.tsx:1209 msgid "No invitees yet." msgstr "Aucun invité pour le moment." -#: src/components/chat/ChatModal.tsx:633 +#: src/components/chat/ChatModal.tsx:638 msgid "No messages yet. Say hello!" msgstr "Aucun message pour l'instant. Dites bonjour !" @@ -999,11 +995,11 @@ msgstr "Aucune collection ne correspond à « {q} »." #: src/components/playlist/PlaylistMembershipPanel.tsx:34 #: src/pages/UserPlaylists.tsx:415 -#: src/pages/UserPublicProfile.tsx:1074 +#: src/pages/UserPublicProfile.tsx:1071 msgid "No playlists yet." msgstr "Pas encore de collections." -#: src/contexts/WSProvider.tsx:948 +#: src/contexts/ws/WSProvider.tsx:948 msgid "No reply from the server — this message may not have been sent" msgstr "Pas de réponse du serveur — ce message n'a peut-être pas été envoyé" @@ -1011,14 +1007,14 @@ msgstr "Pas de réponse du serveur — ce message n'a peut-être pas été envoy msgid "No users match \"{q}\"." msgstr "Aucun utilisateur ne correspond à « {q} »." -#: src/pages/UserPublicProfile.tsx:1121 +#: src/pages/UserPublicProfile.tsx:1118 msgid "Not following anyone yet." msgstr "Aucun abonnement pour le moment." #: src/pages/Notifications.tsx:417 #: src/pages/UserDumps.tsx:99 -#: src/pages/UserPublicProfile.tsx:1480 -#: src/pages/UserPublicProfile.tsx:1608 +#: src/pages/UserPublicProfile.tsx:1477 +#: src/pages/UserPublicProfile.tsx:1605 #: src/pages/UserUpvoted.tsx:166 msgid "Nothing here yet." msgstr "Rien ici pour l'instant." @@ -1026,7 +1022,7 @@ msgstr "Rien ici pour l'instant." #: src/components/layout/NotificationBell.tsx:42 #: src/pages/Notifications.tsx:269 #: src/pages/Notifications.tsx:395 -#: src/pages/UserPublicProfile.tsx:1372 +#: src/pages/UserPublicProfile.tsx:1369 msgid "Notifications" msgstr "Notifications" @@ -1034,11 +1030,11 @@ msgstr "Notifications" msgid "Notifications ({unreadNotificationCount} unread)" msgstr "Notifications ({unreadNotificationCount} non lues)" -#: src/pages/UserPublicProfile.tsx:1396 +#: src/pages/UserPublicProfile.tsx:1393 msgid "Off" msgstr "Désactivés" -#: src/pages/UserPublicProfile.tsx:1389 +#: src/pages/UserPublicProfile.tsx:1386 msgid "On" msgstr "Activés" @@ -1063,70 +1059,70 @@ msgstr "ou <0>parcourir les fichiers" msgid "Page not found" msgstr "Page introuvable" -#: src/pages/UserLogin.tsx:76 -#: src/pages/UserPublicProfile.tsx:1232 +#: src/pages/UserLogin.tsx:74 +#: src/pages/UserPublicProfile.tsx:1229 msgid "Password" msgstr "Mot de passe" #. placeholder {0}: VALIDATION.PASSWORD_MIN -#: src/pages/UserRegister.tsx:143 +#: src/pages/UserRegister.tsx:141 msgid "Password (min. {0} characters)" msgstr "Mot de passe (min. {0} caractères)" -#: src/components/profile/ChangePasswordModal.tsx:59 +#: src/components/profile/ChangePasswordModal.tsx:57 msgid "Password changed successfully." msgstr "Mot de passe modifié avec succès." -#: src/pages/ResetPassword.tsx:73 +#: src/pages/ResetPassword.tsx:71 msgid "Password updated" msgstr "Mot de passe mis à jour" -#: src/components/profile/ChangePasswordModal.tsx:102 -#: src/pages/ResetPassword.tsx:123 +#: src/components/profile/ChangePasswordModal.tsx:100 +#: src/pages/ResetPassword.tsx:121 msgid "Passwords do not match" msgstr "Les mots de passe ne correspondent pas" -#: src/components/dump/create/DumpCreateModal.tsx:613 +#: src/components/dump/create/DumpCreateModal.tsx:611 msgid "Paste a link…" msgstr "Collez un lien…" -#: src/components/dump/FilePreview.tsx:115 -#: src/components/dump/FilePreview.tsx:282 +#: src/components/dump/FilePreview.tsx:119 +#: src/components/dump/FilePreview.tsx:286 #: src/components/dump/RichContentCard.tsx:83 -#: src/components/player/MediaPlayer.tsx:305 +#: src/components/player/MediaPlayer.tsx:310 msgid "Pause" msgstr "Pause" -#: src/components/dump/create/DumpCreateModal.tsx:530 +#: src/components/dump/create/DumpCreateModal.tsx:528 msgid "Picked up where you left off." msgstr "Reprise de votre brouillon." -#: src/components/dump/FilePreview.tsx:115 -#: src/components/dump/FilePreview.tsx:226 -#: src/components/dump/FilePreview.tsx:247 -#: src/components/dump/FilePreview.tsx:282 +#: src/components/dump/FilePreview.tsx:119 +#: src/components/dump/FilePreview.tsx:230 +#: src/components/dump/FilePreview.tsx:251 +#: src/components/dump/FilePreview.tsx:286 #: src/components/dump/RichContentCard.tsx:83 #: src/components/dump/RichContentCard.tsx:129 -#: src/components/player/MediaPlayer.tsx:305 +#: src/components/player/MediaPlayer.tsx:310 msgid "Play" msgstr "Lecture" -#: src/pages/PlaylistDetail.tsx:916 +#: src/pages/PlaylistDetail.tsx:913 msgid "Playlist title" msgstr "Titre de la collection" #: src/components/dump/create/StepIndicator.tsx:14 -#: src/components/layout/AppHeader.tsx:149 +#: src/components/layout/AppHeader.tsx:151 #: src/components/layout/UserMenu.tsx:62 #: src/pages/Search.tsx:191 #: src/pages/UserPlaylists.tsx:382 -#: src/pages/UserPublicProfile.tsx:993 +#: src/pages/UserPublicProfile.tsx:990 msgid "Playlists" msgstr "Collections" #. placeholder {0}: playlists.items.length #. placeholder {1}: playlists.hasMore ? "+" : "" -#: src/pages/UserPublicProfile.tsx:1043 +#: src/pages/UserPublicProfile.tsx:1040 msgid "Playlists ({0}{1})" msgstr "Collections ({0}{1})" @@ -1140,7 +1136,7 @@ msgstr "Publier la réponse" #: src/components/comment/CommentNode.tsx:273 #: src/components/comment/CommentThread.tsx:82 -#: src/components/dump/create/DumpCreateModal.tsx:785 +#: src/components/dump/create/DumpCreateModal.tsx:783 msgid "Posting…" msgstr "Publication…" @@ -1148,31 +1144,31 @@ msgstr "Publication…" msgid "Previous track" msgstr "Piste précédente" -#: src/components/dump/DumpCard.tsx:121 -#: src/components/dump/JournalCard.tsx:125 +#: src/components/dump/DumpCard.tsx:125 +#: src/components/dump/JournalCard.tsx:129 #: src/components/playlist/PlaylistCard.tsx:74 #: src/components/playlist/PlaylistMembershipPanel.tsx:64 #: src/pages/Dump.tsx:474 -#: src/pages/PlaylistDetail.tsx:681 +#: src/pages/PlaylistDetail.tsx:678 msgid "private" msgstr "privé" #: src/components/form/SegmentedField.tsx:81 -#: src/pages/PlaylistDetail.tsx:957 +#: src/pages/PlaylistDetail.tsx:954 msgid "Private" msgstr "Privé" #: src/components/playlist/PlaylistCard.tsx:73 -#: src/pages/PlaylistDetail.tsx:680 +#: src/pages/PlaylistDetail.tsx:677 msgid "public" msgstr "public" #: src/components/form/SegmentedField.tsx:80 -#: src/pages/PlaylistDetail.tsx:950 +#: src/pages/PlaylistDetail.tsx:947 msgid "Public" msgstr "Public" -#: src/pages/DumpEdit.tsx:245 +#: src/pages/DumpEdit.tsx:247 msgid "Refresh metadata" msgstr "Actualiser les métadonnées" @@ -1180,21 +1176,21 @@ msgstr "Actualiser les métadonnées" msgid "Refreshing stream…" msgstr "Actualisation du flux…" -#: src/pages/DumpEdit.tsx:244 +#: src/pages/DumpEdit.tsx:246 msgid "Refreshing…" msgstr "Actualisation…" -#: src/pages/UserRegister.tsx:37 -#: src/pages/UserRegister.tsx:112 -#: src/pages/UserRegister.tsx:154 +#: src/pages/UserRegister.tsx:35 +#: src/pages/UserRegister.tsx:110 +#: src/pages/UserRegister.tsx:152 msgid "Register" msgstr "S'inscrire" -#: src/pages/UserRegister.tsx:153 +#: src/pages/UserRegister.tsx:151 msgid "Registering…" msgstr "Inscription…" -#: src/pages/UserRegister.tsx:117 +#: src/pages/UserRegister.tsx:115 msgid "Registration failed" msgstr "Inscription échouée" @@ -1206,7 +1202,7 @@ msgstr "Connexe" msgid "Remove file" msgstr "Supprimer le fichier" -#: src/pages/PlaylistDetail.tsx:776 +#: src/pages/PlaylistDetail.tsx:773 msgid "Remove from playlist" msgstr "Retirer de la collection" @@ -1218,7 +1214,7 @@ msgstr "Retirer le j'aime" msgid "Remove vote" msgstr "Retirer le vote" -#: src/pages/DumpEdit.tsx:434 +#: src/pages/DumpEdit.tsx:436 msgid "Replace file" msgstr "Remplacer le fichier" @@ -1228,24 +1224,24 @@ msgstr "Remplacer le fichier" msgid "Reply" msgstr "Répondre" -#: src/components/chat/ChatModal.tsx:784 +#: src/components/chat/ChatModal.tsx:789 msgid "Replying to" msgstr "En réponse à" -#: src/pages/UserLogin.tsx:138 +#: src/pages/UserLogin.tsx:136 msgid "Request failed" msgstr "Échec de la demande" -#: src/pages/ResetPassword.tsx:99 +#: src/pages/ResetPassword.tsx:97 msgid "Reset failed" msgstr "Échec de la réinitialisation" -#: src/pages/ResetPassword.tsx:24 +#: src/pages/ResetPassword.tsx:22 msgid "Reset password" msgstr "Réinitialiser le mot de passe" -#: src/pages/DumpEdit.tsx:410 -#: src/pages/DumpEdit.tsx:464 +#: src/pages/DumpEdit.tsx:412 +#: src/pages/DumpEdit.tsx:466 msgid "Reset to default" msgstr "Réinitialiser par défaut" @@ -1256,36 +1252,36 @@ msgstr "Rétablir le titre suggéré" #: src/components/chat/PendingItem.tsx:70 #: src/pages/Dump.tsx:283 -#: src/pages/DumpEdit.tsx:180 +#: src/pages/DumpEdit.tsx:182 msgid "Retry" msgstr "Réessayer" -#: src/pages/UserPublicProfile.tsx:1751 +#: src/pages/UserPublicProfile.tsx:1748 msgid "Role" msgstr "Rôle" -#: src/components/category/CategoryManager.tsx:204 -#: src/components/chat/ChatEditBox.tsx:66 +#: src/components/category/CategoryManager.tsx:202 +#: src/components/chat/ChatEditBox.tsx:70 #: src/components/comment/CommentNode.tsx:199 #: src/pages/Dump.tsx:401 -#: src/pages/DumpEdit.tsx:505 -#: src/pages/PlaylistDetail.tsx:982 -#: src/pages/UserPublicProfile.tsx:1818 -#: src/pages/UserPublicProfile.tsx:1893 +#: src/pages/DumpEdit.tsx:507 +#: src/pages/PlaylistDetail.tsx:979 +#: src/pages/UserPublicProfile.tsx:1815 +#: src/pages/UserPublicProfile.tsx:1890 msgid "Save" msgstr "Enregistrer" #: src/components/comment/CommentNode.tsx:200 -#: src/components/profile/ChangePasswordModal.tsx:107 +#: src/components/profile/ChangePasswordModal.tsx:105 #: src/pages/Dump.tsx:400 -#: src/pages/PlaylistDetail.tsx:978 -#: src/pages/ResetPassword.tsx:126 -#: src/pages/UserPublicProfile.tsx:1815 -#: src/pages/UserPublicProfile.tsx:1890 +#: src/pages/PlaylistDetail.tsx:975 +#: src/pages/ResetPassword.tsx:124 +#: src/pages/UserPublicProfile.tsx:1812 +#: src/pages/UserPublicProfile.tsx:1887 msgid "Saving…" msgstr "Enregistrement…" -#: src/components/layout/AppHeader.tsx:128 +#: src/components/layout/AppHeader.tsx:130 #: src/components/layout/SearchBar.tsx:73 #: src/pages/Search.tsx:51 msgid "Search" @@ -1315,41 +1311,41 @@ msgstr "Recherche…" msgid "Seek" msgstr "Position" -#: src/components/chat/ChatModal.tsx:824 +#: src/components/chat/ChatModal.tsx:829 msgid "Send" msgstr "Envoyer" -#: src/pages/UserLogin.tsx:147 +#: src/pages/UserLogin.tsx:145 msgid "Send reset link" msgstr "Envoyer le lien de réinitialisation" #: src/components/chat/PendingItem.tsx:88 -#: src/pages/UserLogin.tsx:146 +#: src/pages/UserLogin.tsx:144 msgid "Sending…" msgstr "Envoi…" -#: src/components/layout/AppHeader.tsx:165 +#: src/components/layout/AppHeader.tsx:167 msgid "Server unreachable" msgstr "Serveur inaccessible" -#: src/pages/ResetPassword.tsx:94 -#: src/pages/ResetPassword.tsx:127 +#: src/pages/ResetPassword.tsx:92 +#: src/pages/ResetPassword.tsx:125 msgid "Set new password" msgstr "Définir un nouveau mot de passe" -#: src/pages/UserPublicProfile.tsx:997 +#: src/pages/UserPublicProfile.tsx:994 msgid "Settings" msgstr "Paramètres" -#: src/components/chat/ChatModal.tsx:608 +#: src/components/chat/ChatModal.tsx:613 msgid "Several people are typing…" msgstr "Plusieurs personnes écrivent…" -#: src/pages/UserPublicProfile.tsx:163 +#: src/pages/UserPublicProfile.tsx:160 msgid "Single use, valid for 7 days." msgstr "Usage unique, valable 7 jours." -#: src/components/category/CategoryManager.tsx:193 +#: src/components/category/CategoryManager.tsx:191 msgid "slug" msgstr "identifiant" @@ -1359,20 +1355,20 @@ msgstr "Certaines images n'ont pas pu être envoyées." #: src/components/form/FormError.tsx:16 #: src/components/layout/PageError.tsx:13 -#: src/contexts/WSProvider.tsx:668 -#: src/pages/PlaylistDetail.tsx:715 +#: src/contexts/ws/WSProvider.tsx:668 +#: src/pages/PlaylistDetail.tsx:712 msgid "Something went wrong" msgstr "Une erreur est survenue" -#: src/pages/UserPublicProfile.tsx:1377 +#: src/pages/UserPublicProfile.tsx:1374 msgid "Sounds" msgstr "Sons" -#: src/components/dump/create/DumpCreateModal.tsx:537 +#: src/components/dump/create/DumpCreateModal.tsx:535 msgid "Start fresh" msgstr "Repartir de zéro" -#: src/pages/UserPublicProfile.tsx:1251 +#: src/pages/UserPublicProfile.tsx:1248 msgid "Style" msgstr "Style" @@ -1384,7 +1380,7 @@ msgstr "Lancer la recherche" msgid "the chat" msgstr "la tribune" -#: src/pages/PlaylistDetail.tsx:505 +#: src/pages/PlaylistDetail.tsx:502 msgid "The dump couldn't be removed. Try again." msgstr "Le dump n'a pas pu être retiré. Réessayez." @@ -1396,11 +1392,11 @@ msgstr "L'image n'a pas pu être envoyée." msgid "This comment couldn't be deleted. Try again." msgstr "Ce commentaire n'a pas pu être supprimé. Réessayez." -#: src/pages/UserRegister.tsx:101 +#: src/pages/UserRegister.tsx:99 msgid "This invite link is missing, expired, or already used." msgstr "Ce lien d'invitation est manquant, expiré ou déjà utilisé." -#: src/pages/UserLogin.tsx:98 +#: src/pages/UserLogin.tsx:96 msgid "This is a mirage." msgstr "C'est un mirage." @@ -1408,26 +1404,26 @@ msgstr "C'est un mirage." msgid "This page does not exist." msgstr "Rien à voir, circulez." -#: src/pages/ResetPassword.tsx:54 +#: src/pages/ResetPassword.tsx:52 msgid "This reset link is missing or malformed." msgstr "Ce lien de réinitialisation est absent ou malformé." -#: src/pages/DumpEdit.tsx:394 +#: src/pages/DumpEdit.tsx:396 msgid "Thumbnail" msgstr "Miniature" #: src/components/dump/create/TitleField.tsx:32 -#: src/components/playlist/PlaylistCreateForm.tsx:74 -#: src/pages/DumpEdit.tsx:454 +#: src/components/playlist/PlaylistCreateForm.tsx:76 +#: src/pages/DumpEdit.tsx:456 msgid "Title" msgstr "Titre" -#: src/components/dump/create/DumpCreateModal.tsx:405 +#: src/components/dump/create/DumpCreateModal.tsx:403 msgid "Title is required." msgstr "Un titre est requis." -#: src/components/chat/time.ts:16 #: src/pages/Notifications.tsx:433 +#: src/utils/chat/time.ts:16 msgid "Today" msgstr "Aujourd'hui" @@ -1436,15 +1432,15 @@ msgstr "Aujourd'hui" msgid "Track {0}" msgstr "Piste {0}" -#: src/components/chat/ChatModal.tsx:625 +#: src/components/chat/ChatModal.tsx:630 msgid "Try again" msgstr "Réessayer" -#: src/components/chat/ChatModal.tsx:809 +#: src/components/chat/ChatModal.tsx:814 msgid "Type a message…" msgstr "Écrivez un message…" -#: src/pages/PlaylistDetail.tsx:788 +#: src/pages/PlaylistDetail.tsx:785 msgid "Undo" msgstr "Annuler" @@ -1456,16 +1452,16 @@ msgstr "Ne plus suivre {targetUsername}" msgid "Unfollow playlist" msgstr "Ne plus suivre la collection" -#: src/components/player/MediaPlayer.tsx:321 +#: src/components/player/MediaPlayer.tsx:326 msgid "Unmute" msgstr "Rétablir le son" -#: src/pages/UserPublicProfile.tsx:773 +#: src/pages/UserPublicProfile.tsx:770 msgid "Upload failed" msgstr "Envoi échoué" #. placeholder {0}: Math.round((uploadProgress ?? 0) * 100) -#: src/components/dump/create/DumpCreateModal.tsx:473 +#: src/components/dump/create/DumpCreateModal.tsx:471 msgid "Uploading {0}%" msgstr "Envoi {0} %" @@ -1473,7 +1469,7 @@ msgstr "Envoi {0} %" msgid "Uploading image…" msgstr "Envoi de l'image…" -#: src/components/dump/create/DumpCreateModal.tsx:788 +#: src/components/dump/create/DumpCreateModal.tsx:786 msgid "Uploading…" msgstr "Envoi…" @@ -1487,27 +1483,27 @@ msgstr "Voté" #. placeholder {0}: votes.items.length #. placeholder {1}: votes.hasMore ? "+" : "" -#: src/pages/UserPublicProfile.tsx:1023 +#: src/pages/UserPublicProfile.tsx:1020 msgid "Upvoted ({0}{1})" msgstr "Votés ({0}{1})" -#: src/pages/DumpEdit.tsx:426 +#: src/pages/DumpEdit.tsx:428 msgid "URL" msgstr "URL" -#: src/components/dump/create/DumpCreateModal.tsx:389 +#: src/components/dump/create/DumpCreateModal.tsx:387 msgid "URL is required." msgstr "L'URL est obligatoire." -#: src/components/category/CategoryManager.tsx:194 +#: src/components/category/CategoryManager.tsx:192 msgid "URL slug" msgstr "Identifiant d’URL" -#: src/pages/UserPublicProfile.tsx:1695 +#: src/pages/UserPublicProfile.tsx:1692 msgid "User" msgstr "Utilisateur" -#: src/pages/UserPublicProfile.tsx:1408 +#: src/pages/UserPublicProfile.tsx:1405 msgid "User management" msgstr "Gestion utilisateur" @@ -1515,8 +1511,8 @@ msgstr "Gestion utilisateur" msgid "User menu" msgstr "Menu utilisateur" -#: src/pages/UserLogin.tsx:69 -#: src/pages/UserRegister.tsx:120 +#: src/pages/UserLogin.tsx:67 +#: src/pages/UserRegister.tsx:118 msgid "Username" msgstr "Nom d'utilisateur" @@ -1524,32 +1520,32 @@ msgstr "Nom d'utilisateur" msgid "Users" msgstr "Utilisateurs" -#: src/pages/UserPublicProfile.tsx:1093 -#: src/pages/UserPublicProfile.tsx:1182 -#: src/pages/UserPublicProfile.tsx:1501 -#: src/pages/UserPublicProfile.tsx:1638 +#: src/pages/UserPublicProfile.tsx:1090 +#: src/pages/UserPublicProfile.tsx:1179 +#: src/pages/UserPublicProfile.tsx:1498 +#: src/pages/UserPublicProfile.tsx:1635 msgid "View all →" msgstr "Tout voir →" -#: src/components/dump/create/DumpCreateModal.tsx:551 +#: src/components/dump/create/DumpCreateModal.tsx:549 msgid "View dump →" msgstr "Voir la reco →" -#: src/components/player/MediaPlayer.tsx:338 +#: src/components/player/MediaPlayer.tsx:343 msgid "Volume" msgstr "Volume" -#: src/components/dump/create/DumpCreateModal.tsx:739 -#: src/pages/DumpEdit.tsx:475 +#: src/components/dump/create/DumpCreateModal.tsx:737 +#: src/pages/DumpEdit.tsx:477 msgid "What makes it worth it?" msgstr "Pourquoi on en voudrait ?" -#: src/pages/UserPublicProfile.tsx:977 -#: src/pages/UserPublicProfile.tsx:1882 +#: src/pages/UserPublicProfile.tsx:974 +#: src/pages/UserPublicProfile.tsx:1879 msgid "Who am I?" msgstr "Qui suis-je ?" -#: src/components/dump/create/DumpCreateModal.tsx:754 +#: src/components/dump/create/DumpCreateModal.tsx:752 msgid "Who can see it" msgstr "Qui peut la voir" @@ -1557,8 +1553,8 @@ msgstr "Qui peut la voir" msgid "Why & where" msgstr "Pourquoi & où" -#: src/components/dump/create/DumpCreateModal.tsx:738 -#: src/pages/DumpEdit.tsx:474 +#: src/components/dump/create/DumpCreateModal.tsx:736 +#: src/pages/DumpEdit.tsx:476 msgid "Why?" msgstr "Pourquoi ?" @@ -1566,8 +1562,8 @@ msgstr "Pourquoi ?" msgid "Write a reply…" msgstr "Écrire une réponse…" -#: src/components/chat/time.ts:19 #: src/pages/Notifications.tsx:435 +#: src/utils/chat/time.ts:19 msgid "Yesterday" msgstr "Hier" @@ -1579,14 +1575,13 @@ msgstr "Vous pouvez la publier quand même." msgid "You'll be notified when someone follows your playlists, upvotes your dumps, or posts new content." msgstr "Vous serez notifié lorsque quelqu'un suit vos collections, vote pour vos recos ou publie du nouveau contenu." -#: src/contexts/WSProvider.tsx:929 +#: src/contexts/ws/WSProvider.tsx:929 msgid "You're offline — not sent" msgstr "Hors ligne — message non envoyé" -#: src/pages/index/FollowedFeed.tsx:126 -#: src/pages/index/HotFeed.tsx:69 -#: src/pages/index/JournalFeed.tsx:80 -#: src/pages/index/NewFeed.tsx:69 +#: src/components/feed/DumpFeed.tsx:91 +#: src/components/feed/FollowedFeed.tsx:126 +#: src/components/feed/JournalFeed.tsx:80 #: src/pages/Search.tsx:263 #: src/pages/UserDumps.tsx:126 #: src/pages/UserPlaylists.tsx:440 @@ -1595,10 +1590,10 @@ msgstr "Hors ligne — message non envoyé" msgid "You've reached the end." msgstr "Vous avez tout lu, tout vu, tout bu." -#: src/pages/UserLogin.tsx:142 +#: src/pages/UserLogin.tsx:140 msgid "Your email address" msgstr "Votre adresse e-mail" -#: src/pages/ResetPassword.tsx:76 +#: src/pages/ResetPassword.tsx:74 msgid "Your password has been changed. You can now log in." msgstr "Votre mot de passe a été modifié. Vous pouvez maintenant vous connecter." diff --git a/src/main.tsx b/src/main.tsx index 9f79ab2f..844d9542 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -2,7 +2,7 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { I18nProvider } from "@lingui/react"; -import App from "./App.tsx"; +import { App } from "./App.tsx"; import { i18n, loadCatalog } from "./i18n.ts"; import "./fonts.css"; diff --git a/src/pages/Dump.tsx b/src/pages/Dump.tsx index 44664cf5..650e231a 100644 --- a/src/pages/Dump.tsx +++ b/src/pages/Dump.tsx @@ -2,8 +2,8 @@ import { useEffect, useState } from "react"; import { Link, useLocation, useNavigate, useParams } from "react-router"; import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; -import { dumpThumbnailUrl, dumpUrl } from "../utils/urls.ts"; -import { can } from "../utils/permissions.ts"; +import { dumpThumbnailUrl, dumpUrl } from "../utils/shared/urls.ts"; +import { can } from "../utils/auth/permissions.ts"; import { AddToPlaylistModal } from "../components/playlist/AddToPlaylistModal.tsx"; import { API_URL, VALIDATION } from "../config/api.ts"; @@ -26,13 +26,13 @@ import { parseAPIResponse, } from "../model.ts"; -import { useAuth } from "../hooks/useAuth.ts"; -import { useCategories } from "../hooks/useCategories.ts"; -import { useDocumentTitle } from "../hooks/useDocumentTitle.ts"; -import { relativeTime } from "../utils/relativeTime.ts"; -import { useHeightTransition } from "../hooks/useHeightTransition.ts"; -import { useWS } from "../hooks/useWS.ts"; -import { Avatar } from "../components/ui/Avatar.tsx"; +import { useAuth } from "../hooks/auth/useAuth.ts"; +import { useCategories } from "../hooks/category/useCategories.ts"; +import { useDocumentTitle } from "../hooks/shared/useDocumentTitle.ts"; +import { relativeTime } from "../utils/shared/relativeTime.ts"; +import { useHeightTransition } from "../hooks/shared/useHeightTransition.ts"; +import { useWS } from "../hooks/ws/useWS.ts"; +import { Avatar } from "../components/shared/Avatar.tsx"; import { RichContentCard } from "../components/dump/RichContentCard.tsx"; import { FilePreview } from "../components/dump/FilePreview.tsx"; import { VoteButton } from "../components/social/VoteButton.tsx"; @@ -42,8 +42,8 @@ import { PageShell } from "../components/layout/PageShell.tsx"; import { PageError } from "../components/layout/PageError.tsx"; import { Markdown } from "../components/editor/Markdown.tsx"; import { CommentThread } from "../components/comment/CommentThread.tsx"; -import { Tooltip } from "../components/ui/Tooltip.tsx"; -import { friendlyFetchError } from "../utils/apiError.ts"; +import { Tooltip } from "../components/shared/Tooltip.tsx"; +import { friendlyFetchError } from "../utils/shared/api.ts"; type DumpState = | { status: "loading" } diff --git a/src/pages/DumpEdit.tsx b/src/pages/DumpEdit.tsx index 17d2927e..450a5bdb 100644 --- a/src/pages/DumpEdit.tsx +++ b/src/pages/DumpEdit.tsx @@ -6,31 +6,33 @@ import { Trans } from "@lingui/react/macro"; import { API_URL, VALIDATION } from "../config/api.ts"; import type { Dump, RawDump, UpdateDumpRequest } from "../model.ts"; import { deserializeDump, parseAPIResponse } from "../model.ts"; -import { useRequiredAuth } from "../hooks/useAuth.ts"; -import { useDocumentTitle } from "../hooks/useDocumentTitle.ts"; -import { formatBytes } from "../utils/format.ts"; -import { dumpFileUrl, dumpThumbnailUrl, dumpUrl } from "../utils/urls.ts"; +import { useRequiredAuth } from "../hooks/auth/useAuth.ts"; +import { useDocumentTitle } from "../hooks/shared/useDocumentTitle.ts"; +import { formatBytes } from "../utils/shared/format.ts"; +import { + dumpFileUrl, + dumpThumbnailUrl, + dumpUrl, +} from "../utils/shared/urls.ts"; import { PageShell } from "../components/layout/PageShell.tsx"; import { PageError } from "../components/layout/PageError.tsx"; -import { ErrorCard } from "../components/ui/ErrorCard.tsx"; -import { friendlyFetchError } from "../utils/apiError.ts"; -import { ConfirmModal } from "../components/ui/ConfirmModal.tsx"; +import { ErrorCard } from "../components/shared/ErrorCard.tsx"; +import { friendlyFetchError } from "../utils/shared/api.ts"; +import { ConfirmModal } from "../components/shared/ConfirmModal.tsx"; import { RichContentCard } from "../components/dump/RichContentCard.tsx"; import { FilePreview } from "../components/dump/FilePreview.tsx"; -import { ImagePicker } from "../components/ui/ImagePicker.tsx"; +import { ImagePicker } from "../components/shared/ImagePicker.tsx"; import { CategorySelect } from "../components/category/CategorySelect.tsx"; -import { - expectOk, - FileField, - FormError, - FormProvider, - RichTextField, - SubmitButton, - TextField, - useApiForm, - VisibilityToggle, -} from "../components/form/index.ts"; -import { useAuth } from "../hooks/useAuth.ts"; +import { expectOk } from "../utils/shared/api.ts"; +import { FileField } from "../components/form/FileField.tsx"; +import { FormError } from "../components/form/FormError.tsx"; +import { FormProvider } from "react-hook-form"; +import { RichTextField } from "../components/form/RichTextField.tsx"; +import { SubmitButton } from "../components/form/SubmitButton.tsx"; +import { TextField } from "../components/form/TextField.tsx"; +import { useApiForm } from "../hooks/form/useApiForm.ts"; +import { VisibilityToggle } from "../components/form/SegmentedField.tsx"; +import { useAuth } from "../hooks/auth/useAuth.ts"; type DumpEditState = | { status: "loading" } diff --git a/src/pages/Index.tsx b/src/pages/Index.tsx index a6461325..c4de04ca 100644 --- a/src/pages/Index.tsx +++ b/src/pages/Index.tsx @@ -9,14 +9,14 @@ import { import { useLocation, useParams } from "react-router"; import { AppHeader } from "../components/layout/AppHeader.tsx"; -import { PresenceRow } from "./index/PresenceRow.tsx"; -import { FeedTabBar } from "./index/FeedTabBar.tsx"; +import { PresenceRow } from "../components/feed/PresenceRow.tsx"; +import { FeedTabBar } from "../components/feed/FeedTabBar.tsx"; import { DEFAULT_FEED_TAB, FEED_TABS, type FeedTab, } from "../config/feedTabs.ts"; -import { useDefaultFeedTab } from "../hooks/useDefaultFeedTab.ts"; +import { useDefaultFeedTab } from "../hooks/feed/useDefaultFeedTab.ts"; import { API_URL, DEFAULT_PAGE_SIZE } from "../config/api.ts"; @@ -28,22 +28,20 @@ import { type RawDump, } from "../model.ts"; -import { friendlyFetchError } from "../utils/apiError.ts"; -import { useFeedCache } from "../hooks/useFeedCache.ts"; -import { useScrollSave } from "../hooks/useScrollSave.ts"; -import { useDocumentTitle } from "../hooks/useDocumentTitle.ts"; -import { useAuth } from "../hooks/useAuth.ts"; -import { useWS } from "../hooks/useWS.ts"; -import { useCategories } from "../hooks/useCategories.ts"; -import { useDumpListSync } from "../hooks/useDumpListSync.ts"; -import { useInfiniteScroll } from "../hooks/useInfiniteScroll.ts"; +import { friendlyFetchError } from "../utils/shared/api.ts"; +import { useFeedCache } from "../hooks/feed/useFeedCache.ts"; +import { useScrollSave } from "../hooks/feed/useScrollSave.ts"; +import { useDocumentTitle } from "../hooks/shared/useDocumentTitle.ts"; +import { useAuth } from "../hooks/auth/useAuth.ts"; +import { useWS } from "../hooks/ws/useWS.ts"; +import { useCategories } from "../hooks/category/useCategories.ts"; +import { useDumpListSync } from "../hooks/dump/useDumpListSync.ts"; +import { useInfiniteScroll } from "../hooks/feed/useInfiniteScroll.ts"; -import { HotFeed } from "./index/HotFeed.tsx"; -import { NewFeed } from "./index/NewFeed.tsx"; -import { JournalFeed } from "./index/JournalFeed.tsx"; -import { FollowedFeed } from "./index/FollowedFeed.tsx"; +import { DumpFeed, type MainFeedProps } from "../components/feed/DumpFeed.tsx"; +import { JournalFeed } from "../components/feed/JournalFeed.tsx"; +import { FollowedFeed } from "../components/feed/FollowedFeed.tsx"; import { NotFound } from "./NotFound.tsx"; -import type { MainFeedProps } from "./index/types.ts"; type DumpsState = | { status: "loading" } @@ -345,8 +343,9 @@ export function Index({ categorySlug }: { categorySlug?: string }) { - {tab === "hot" && } - {tab === "new" && } + {(tab === "hot" || tab === "new") && ( + + )} {tab === "journal" && } {tab === "followed" && user && ( [...dumps].sort((a, b) => hotScore(b) - hotScore(a)), - [dumps], - ); - - if (loading) { - return ( -

- Loading… -

- ); - } - if (error) return ; - if (sorted.length === 0) { - return ( -

- No dumps yet. Be the first! -

- ); - } - - return ( - <> -
    - {sorted.map((dump) => ( - - ))} -
-
- {loadingMore && ( -

- Loading more… -

- )} - {!hasMore && sorted.length > 0 && ( -

- You've reached the end. -

- )} - - ); -} diff --git a/src/pages/index/types.ts b/src/pages/index/types.ts deleted file mode 100644 index d6bd403c..00000000 --- a/src/pages/index/types.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { Ref } from "react"; -import type { Dump, User } from "../../model.ts"; - -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; -} diff --git a/src/test/components/dump/DumpCard.test.tsx b/src/test/components/dump/DumpCard.test.tsx index 8bdec961..ab0e51c4 100644 --- a/src/test/components/dump/DumpCard.test.tsx +++ b/src/test/components/dump/DumpCard.test.tsx @@ -2,7 +2,7 @@ import { describe, expect, test, vi } from "vitest"; import { screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { DumpCard } from "../../../components/dump/DumpCard.tsx"; -import { markDumpVisited } from "../../../utils/visited.ts"; +import { markDumpVisited } from "../../../utils/shared/visited.ts"; import { makeCategory, makeDump, makeSession } from "../../fixtures.ts"; import { currentPath, renderWithContexts } from "../../render.tsx"; diff --git a/src/test/components/dump/FilePreview.test.tsx b/src/test/components/dump/FilePreview.test.tsx index 937b99a7..cd6c2c5e 100644 --- a/src/test/components/dump/FilePreview.test.tsx +++ b/src/test/components/dump/FilePreview.test.tsx @@ -8,7 +8,7 @@ import { dumpFileUrl, dumpFileVersion, dumpThumbnailUrl, -} from "../../../utils/urls.ts"; +} from "../../../utils/shared/urls.ts"; describe("FilePreview", () => { const session = makeSession(); diff --git a/src/test/components/dump/JournalCard.test.tsx b/src/test/components/dump/JournalCard.test.tsx index 9790cf2b..0a302706 100644 --- a/src/test/components/dump/JournalCard.test.tsx +++ b/src/test/components/dump/JournalCard.test.tsx @@ -4,7 +4,7 @@ import userEvent from "@testing-library/user-event"; import { JournalCard } from "../../../components/dump/JournalCard.tsx"; import { makeDump } from "../../fixtures.ts"; import { currentPath, renderWithContexts } from "../../render.tsx"; -import { dumpThumbnailUrl } from "../../../utils/urls.ts"; +import { dumpThumbnailUrl } from "../../../utils/shared/urls.ts"; describe("JournalCard", () => { const base = { diff --git a/src/test/pages/index/FeedTabBar.test.tsx b/src/test/components/feed/FeedTabBar.test.tsx similarity index 95% rename from src/test/pages/index/FeedTabBar.test.tsx rename to src/test/components/feed/FeedTabBar.test.tsx index 35fab933..8f07ff60 100644 --- a/src/test/pages/index/FeedTabBar.test.tsx +++ b/src/test/components/feed/FeedTabBar.test.tsx @@ -1,7 +1,7 @@ import { describe, expect, test } from "vitest"; import { screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { FeedTabBar } from "../../../pages/index/FeedTabBar.tsx"; +import { FeedTabBar } from "../../../components/feed/FeedTabBar.tsx"; import type { Category } from "../../../model.ts"; import { makeCategory, makeSession } from "../../fixtures.ts"; import { currentPath, renderWithContexts } from "../../render.tsx"; diff --git a/src/test/pages/index/PresenceRow.test.tsx b/src/test/components/feed/PresenceRow.test.tsx similarity index 96% rename from src/test/pages/index/PresenceRow.test.tsx rename to src/test/components/feed/PresenceRow.test.tsx index d052a964..21a88c94 100644 --- a/src/test/pages/index/PresenceRow.test.tsx +++ b/src/test/components/feed/PresenceRow.test.tsx @@ -1,6 +1,6 @@ import { describe, expect, test, vi } from "vitest"; import { act, screen } from "@testing-library/react"; -import { PresenceRow } from "../../../pages/index/PresenceRow.tsx"; +import { PresenceRow } from "../../../components/feed/PresenceRow.tsx"; import { renderWithContexts } from "../../render.tsx"; describe("PresenceRow", () => { diff --git a/src/test/components/layout/AppHeader.test.tsx b/src/test/components/layout/AppHeader.test.tsx index e3d1cddc..01670669 100644 --- a/src/test/components/layout/AppHeader.test.tsx +++ b/src/test/components/layout/AppHeader.test.tsx @@ -6,8 +6,8 @@ import { i18n } from "@lingui/core"; import { BrowserRouter, Route, Routes, useLocation } from "react-router"; import { useState } from "react"; import { AppHeader } from "../../../components/layout/AppHeader.tsx"; -import { AuthContext } from "../../../contexts/AuthContext.ts"; -import { defaultWSValue, WSContext } from "../../../contexts/WSContext.ts"; +import { AuthContext } from "../../../contexts/auth/AuthContext.ts"; +import { defaultWSValue, WSContext } from "../../../contexts/ws/WSContext.ts"; import type { AuthResponse } from "../../../model.ts"; import { makeSession } from "../../fixtures.ts"; import { currentPath, renderWithContexts } from "../../render.tsx"; diff --git a/src/test/components/layout/ChatButton.test.tsx b/src/test/components/layout/ChatButton.test.tsx index 30e59f3d..da1689af 100644 --- a/src/test/components/layout/ChatButton.test.tsx +++ b/src/test/components/layout/ChatButton.test.tsx @@ -2,7 +2,7 @@ import { describe, expect, test, vi } from "vitest"; import { screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { ChatButton } from "../../../components/layout/ChatButton.tsx"; -import { ChatContext } from "../../../contexts/ChatContext.ts"; +import { ChatContext } from "../../../contexts/chat/ChatContext.ts"; import { renderWithContexts } from "../../render.tsx"; describe("ChatButton", () => { diff --git a/src/test/components/layout/Fabs.test.tsx b/src/test/components/layout/ChatFab.test.tsx similarity index 63% rename from src/test/components/layout/Fabs.test.tsx rename to src/test/components/layout/ChatFab.test.tsx index 53e158c5..684fd156 100644 --- a/src/test/components/layout/Fabs.test.tsx +++ b/src/test/components/layout/ChatFab.test.tsx @@ -1,44 +1,36 @@ import { describe, expect, test, vi } from "vitest"; import { act, screen } from "@testing-library/react"; import { ChatFab } from "../../../components/layout/ChatFab.tsx"; -import { DumpFab } from "../../../components/layout/DumpFab.tsx"; -import { ChatContext } from "../../../contexts/ChatContext.ts"; +import { ChatContext } from "../../../contexts/chat/ChatContext.ts"; import { makeSession } from "../../fixtures.ts"; import { renderWithContexts } from "../../render.tsx"; -describe("floating buttons", () => { - function withChat(openChat = vi.fn()) { - return (ui: React.ReactNode) => ( - {} }} - > - {ui} - - ); - } +function withChat(ui: React.ReactNode, openChat = vi.fn()) { + return ( + {} }} + > + {ui} + + ); +} - test("guests get neither", () => { - renderWithContexts( - withChat()( - <> - - - , - ), - ); +describe("ChatFab", () => { + test("guests don't get it", () => { + renderWithContexts(withChat()); expect(screen.queryByRole("button", { hidden: true })).not .toBeInTheDocument(); }); - test("they show once the header scrolls away", () => { + test("shows once the header scrolls away, and opens the chat", () => { const openChat = vi.fn(); renderWithContexts( - withChat(openChat)( + withChat( <>
- , + openChat, ), { session: makeSession(), ws: { unreadChatCount: 2 } }, ); @@ -52,7 +44,6 @@ describe("floating buttons", () => { expect(chat).toHaveAttribute("aria-label", "Chat (2 unread)"); expect(chat).toHaveAttribute("aria-hidden", "true"); expect(chat).toHaveAttribute("tabindex", "-1"); - expect(document.body).toHaveClass("has-fab"); header.getBoundingClientRect = () => ({ bottom: -10 }) as DOMRect; act(() => { @@ -62,8 +53,5 @@ describe("floating buttons", () => { expect(chat).toHaveClass("chat-fab--visible"); act(() => chat.click()); expect(openChat).toHaveBeenCalled(); - expect(screen.getByRole("button", { name: "New dump" })).toHaveClass( - "dump-fab--visible", - ); }); }); diff --git a/src/test/components/layout/DumpFab.test.tsx b/src/test/components/layout/DumpFab.test.tsx new file mode 100644 index 00000000..ac0e0216 --- /dev/null +++ b/src/test/components/layout/DumpFab.test.tsx @@ -0,0 +1,41 @@ +import { describe, expect, test } from "vitest"; +import { act, screen } from "@testing-library/react"; +import { DumpFab } from "../../../components/layout/DumpFab.tsx"; +import { makeSession } from "../../fixtures.ts"; +import { renderWithContexts } from "../../render.tsx"; + +describe("DumpFab", () => { + test("guests don't get it", () => { + renderWithContexts(); + expect(screen.queryByRole("button", { hidden: true })).not + .toBeInTheDocument(); + expect(document.body).not.toHaveClass("has-fab"); + }); + + test("shows once the header scrolls away", () => { + renderWithContexts( + <> +
+ + , + { session: makeSession() }, + ); + const header = document.querySelector(".app-header")!; + // jsdom lays nothing out: put the header on screen first. + header.getBoundingClientRect = () => ({ bottom: 60 }) as DOMRect; + act(() => { + globalThis.dispatchEvent(new Event("scroll")); + }); + const button = document.querySelector(".dump-fab")!; + expect(button).toHaveAttribute("aria-label", "New dump"); + expect(button).toHaveAttribute("aria-hidden", "true"); + expect(document.body).toHaveClass("has-fab"); + + header.getBoundingClientRect = () => ({ bottom: -10 }) as DOMRect; + act(() => { + globalThis.dispatchEvent(new Event("scroll")); + }); + expect(button).toHaveAttribute("aria-hidden", "false"); + expect(button).toHaveClass("dump-fab--visible"); + }); +}); diff --git a/src/test/components/layout/NotificationBell.test.tsx b/src/test/components/layout/NotificationBell.test.tsx index 1663819c..005c4b7b 100644 --- a/src/test/components/layout/NotificationBell.test.tsx +++ b/src/test/components/layout/NotificationBell.test.tsx @@ -4,7 +4,7 @@ import userEvent from "@testing-library/user-event"; import { NotificationBell } from "../../../components/layout/NotificationBell.tsx"; import { makeNotification } from "../../fixtures.ts"; import { currentPath, renderWithContexts } from "../../render.tsx"; -import { defaultWSValue, WSContext } from "../../../contexts/WSContext.ts"; +import { defaultWSValue, WSContext } from "../../../contexts/ws/WSContext.ts"; describe("NotificationBell", () => { test("the bell shows the unread count and goes to notifications", async () => { diff --git a/src/test/components/player/GlobalPlayer.test.tsx b/src/test/components/player/GlobalPlayer.test.tsx index e2a344e8..ca60b072 100644 --- a/src/test/components/player/GlobalPlayer.test.tsx +++ b/src/test/components/player/GlobalPlayer.test.tsx @@ -3,11 +3,11 @@ import { act, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { useContext } from "react"; import { GlobalPlayer } from "../../../components/player/GlobalPlayer.tsx"; -import { PlayerProvider } from "../../../contexts/PlayerProvider.tsx"; +import { PlayerProvider } from "../../../contexts/player/PlayerProvider.tsx"; import { PlayerContext, type PlayerItem, -} from "../../../contexts/PlayerContext.ts"; +} from "../../../contexts/player/PlayerContext.ts"; import { renderWithContexts } from "../../render.tsx"; let player!: React.ContextType; diff --git a/src/test/components/playlist/PlaylistCard.test.tsx b/src/test/components/playlist/PlaylistCard.test.tsx index 50f17246..dd1a9726 100644 --- a/src/test/components/playlist/PlaylistCard.test.tsx +++ b/src/test/components/playlist/PlaylistCard.test.tsx @@ -4,7 +4,7 @@ import userEvent from "@testing-library/user-event"; import { PlaylistCard } from "../../../components/playlist/PlaylistCard.tsx"; import { makePlaylist } from "../../fixtures.ts"; import { currentPath, renderWithContexts } from "../../render.tsx"; -import { markPlaylistVisited } from "../../../utils/visited.ts"; +import { markPlaylistVisited } from "../../../utils/shared/visited.ts"; describe("PlaylistCard", () => { test("title, badge, owner, count; clicking opens it and marks it seen", async () => { diff --git a/src/test/components/ui/Avatar.test.tsx b/src/test/components/shared/Avatar.test.tsx similarity index 91% rename from src/test/components/ui/Avatar.test.tsx rename to src/test/components/shared/Avatar.test.tsx index d12a6439..ee85338e 100644 --- a/src/test/components/ui/Avatar.test.tsx +++ b/src/test/components/shared/Avatar.test.tsx @@ -1,7 +1,7 @@ import { describe, expect, test } from "vitest"; import { fireEvent, screen } from "@testing-library/react"; -import { Avatar } from "../../../components/ui/Avatar.tsx"; -import { AvatarOverrideContext } from "../../../contexts/AvatarOverrideContext.ts"; +import { Avatar } from "../../../components/shared/Avatar.tsx"; +import { AvatarOverrideContext } from "../../../contexts/ws/AvatarOverrideContext.ts"; import { renderWithContexts } from "../../render.tsx"; describe("Avatar", () => { diff --git a/src/test/components/ui/ConfirmModal.test.tsx b/src/test/components/shared/ConfirmModal.test.tsx similarity index 94% rename from src/test/components/ui/ConfirmModal.test.tsx rename to src/test/components/shared/ConfirmModal.test.tsx index 2df2f573..5d81c785 100644 --- a/src/test/components/ui/ConfirmModal.test.tsx +++ b/src/test/components/shared/ConfirmModal.test.tsx @@ -4,8 +4,8 @@ import userEvent from "@testing-library/user-event"; import { I18nProvider } from "@lingui/react"; import { i18n } from "@lingui/core"; import { useState } from "react"; -import { Modal } from "../../../components/ui/Modal.tsx"; -import { ConfirmModal } from "../../../components/ui/ConfirmModal.tsx"; +import { Modal } from "../../../components/shared/Modal.tsx"; +import { ConfirmModal } from "../../../components/shared/ConfirmModal.tsx"; const wrap = (ui: React.ReactNode) => render({ui}); diff --git a/src/test/components/ui/CountedInput.test.tsx b/src/test/components/shared/CountedInput.test.tsx similarity index 88% rename from src/test/components/ui/CountedInput.test.tsx rename to src/test/components/shared/CountedInput.test.tsx index 582b091e..5754aaad 100644 --- a/src/test/components/ui/CountedInput.test.tsx +++ b/src/test/components/shared/CountedInput.test.tsx @@ -1,6 +1,6 @@ import { describe, expect, test } from "vitest"; import { screen } from "@testing-library/react"; -import { CountedInput } from "../../../components/ui/CountedInput.tsx"; +import { CountedInput } from "../../../components/shared/CountedInput.tsx"; import { renderWithContexts } from "../../render.tsx"; describe("CountedInput", () => { diff --git a/src/test/components/ui/Modal.test.tsx b/src/test/components/shared/Modal.test.tsx similarity index 97% rename from src/test/components/ui/Modal.test.tsx rename to src/test/components/shared/Modal.test.tsx index 219586ee..6f210be4 100644 --- a/src/test/components/ui/Modal.test.tsx +++ b/src/test/components/shared/Modal.test.tsx @@ -3,7 +3,7 @@ import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { I18nProvider } from "@lingui/react"; import { i18n } from "@lingui/core"; -import { Modal } from "../../../components/ui/Modal.tsx"; +import { Modal } from "../../../components/shared/Modal.tsx"; const wrap = (ui: React.ReactNode) => render({ui}); diff --git a/src/test/components/ui/TabBar.test.tsx b/src/test/components/shared/TabBar.test.tsx similarity index 92% rename from src/test/components/ui/TabBar.test.tsx rename to src/test/components/shared/TabBar.test.tsx index 1f0e77b8..2f599915 100644 --- a/src/test/components/ui/TabBar.test.tsx +++ b/src/test/components/shared/TabBar.test.tsx @@ -1,7 +1,7 @@ import { describe, expect, test, vi } from "vitest"; import { screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { TabBar } from "../../../components/ui/TabBar.tsx"; +import { TabBar } from "../../../components/shared/TabBar.tsx"; import { renderWithContexts } from "../../render.tsx"; describe("TabBar", () => { diff --git a/src/test/components/ui/Thumbnail.test.tsx b/src/test/components/shared/Thumbnail.test.tsx similarity index 96% rename from src/test/components/ui/Thumbnail.test.tsx rename to src/test/components/shared/Thumbnail.test.tsx index 53179503..7c540df2 100644 --- a/src/test/components/ui/Thumbnail.test.tsx +++ b/src/test/components/shared/Thumbnail.test.tsx @@ -1,6 +1,6 @@ import { describe, expect, test } from "vitest"; import { fireEvent, screen } from "@testing-library/react"; -import { Thumbnail } from "../../../components/ui/Thumbnail.tsx"; +import { Thumbnail } from "../../../components/shared/Thumbnail.tsx"; import { renderWithContexts } from "../../render.tsx"; describe("Thumbnail", () => { diff --git a/src/test/components/ui/ThumbnailPlaceholder.test.tsx b/src/test/components/shared/ThumbnailPlaceholder.test.tsx similarity index 89% rename from src/test/components/ui/ThumbnailPlaceholder.test.tsx rename to src/test/components/shared/ThumbnailPlaceholder.test.tsx index ff0f87ca..14a31568 100644 --- a/src/test/components/ui/ThumbnailPlaceholder.test.tsx +++ b/src/test/components/shared/ThumbnailPlaceholder.test.tsx @@ -1,7 +1,7 @@ import { describe, expect, test } from "vitest"; import { fireEvent, screen } from "@testing-library/react"; -import { ThumbnailPlaceholder } from "../../../components/ui/ThumbnailPlaceholder.tsx"; -import { hueFromString } from "../../../utils/thumbnailTint.ts"; +import { ThumbnailPlaceholder } from "../../../components/shared/ThumbnailPlaceholder.tsx"; +import { hueFromString } from "../../../utils/shared/thumbnailTint.ts"; import { renderWithContexts } from "../../render.tsx"; describe("ThumbnailPlaceholder", () => { diff --git a/src/test/components/ui/Tooltip.test.tsx b/src/test/components/shared/Tooltip.test.tsx similarity index 91% rename from src/test/components/ui/Tooltip.test.tsx rename to src/test/components/shared/Tooltip.test.tsx index fb314cfd..6468faff 100644 --- a/src/test/components/ui/Tooltip.test.tsx +++ b/src/test/components/shared/Tooltip.test.tsx @@ -1,7 +1,7 @@ import { describe, expect, test } from "vitest"; import { screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { Tooltip } from "../../../components/ui/Tooltip.tsx"; +import { Tooltip } from "../../../components/shared/Tooltip.tsx"; import { renderWithContexts } from "../../render.tsx"; describe("Tooltip", () => { diff --git a/src/test/contexts/auth/AuthProvider.test.tsx b/src/test/contexts/auth/AuthProvider.test.tsx new file mode 100644 index 00000000..4a169503 --- /dev/null +++ b/src/test/contexts/auth/AuthProvider.test.tsx @@ -0,0 +1,123 @@ +import { describe, expect, test } from "vitest"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { AuthProvider } from "../../../contexts/auth/AuthProvider.tsx"; +import { useAuth } from "../../../hooks/auth/useAuth.ts"; +import { AUTH_STORAGE_KEY } from "../../../utils/auth/storage.ts"; +import { calls, deferred, ok, route } from "../../fetch.ts"; +import { makeSession, makeToken, storeSession, wire } from "../../fixtures.ts"; + +const authWrapper = ({ children }: { children: ReactNode }) => ( + {children} +); + +describe("AuthProvider", () => { + test("starts from the stored session", () => { + const session = makeSession({ username: "ann" }); + storeSession(session); + route("GET /api/users/me", () => wire(session.user)); + const { result } = renderHook(useAuth, { wrapper: authWrapper }); + expect(result.current.user?.username).toBe("ann"); + }); + + test("a token the server rejects is dropped", async () => { + const session = makeSession(); + storeSession(session); + route("GET /api/users/me", () => new Response(null, { status: 401 })); + const { result } = renderHook(useAuth, { wrapper: authWrapper }); + await waitFor(() => expect(result.current.user).toBeNull()); + expect(localStorage.getItem(AUTH_STORAGE_KEY)).toBeNull(); + expect(calls("GET /api/users/me")[0].headers.get("Authorization")).toBe( + `Bearer ${session.token}`, + ); + }); + + test("a role changed on the server is picked up", async () => { + const session = makeSession({ role: "user" }); + storeSession(session); + route( + "GET /api/users/me", + () => wire({ ...session.user, role: "moderator" }), + ); + const { result } = renderHook(useAuth, { wrapper: authWrapper }); + await waitFor(() => expect(result.current.user?.role).toBe("moderator")); + expect(JSON.parse(localStorage.getItem(AUTH_STORAGE_KEY)!).user.role).toBe( + "moderator", + ); + }); + + test("offline doesn't sign anyone out", async () => { + storeSession(makeSession()); + route("GET /api/users/me", () => new TypeError("offline")); + const { result } = renderHook(useAuth, { wrapper: authWrapper }); + await waitFor(() => expect(calls("GET /api/users/me")).toHaveLength(1)); + expect(result.current.user).not.toBeNull(); + }); + + test("revalidates when the tab regains focus", async () => { + const session = makeSession(); + storeSession(session); + route("GET /api/users/me", () => wire(session.user)); + renderHook(useAuth, { wrapper: authWrapper }); + await waitFor(() => expect(calls("GET /api/users/me")).toHaveLength(1)); + act(() => { + globalThis.dispatchEvent(new Event("focus")); + }); + await waitFor(() => expect(calls("GET /api/users/me")).toHaveLength(2)); + }); + + test("a session without a media token fetches one", async () => { + const session = { ...makeSession(), mediaToken: undefined }; + storeSession(session); + route("GET /api/users/me", () => wire(session.user)); + route("GET /api/users/me/media-token", () => ({ mediaToken: "m-1" })); + const { result } = renderHook(useAuth, { wrapper: authWrapper }); + await waitFor(() => expect(result.current.mediaToken).toBe("m-1")); + expect(JSON.parse(localStorage.getItem(AUTH_STORAGE_KEY)!).mediaToken).toBe( + "m-1", + ); + }); + + test("follows sign-in and sign-out from other tabs", async () => { + route("GET /api/users/me", () => ok(null)); + const { result } = renderHook(useAuth, { wrapper: authWrapper }); + expect(result.current.user).toBeNull(); + + const session = makeSession({ username: "elsewhere" }); + storeSession(session); + act(() => { + globalThis.dispatchEvent( + new StorageEvent("storage", { key: AUTH_STORAGE_KEY }), + ); + }); + expect(result.current.user?.username).toBe("elsewhere"); + + localStorage.removeItem(AUTH_STORAGE_KEY); + act(() => { + globalThis.dispatchEvent( + new StorageEvent("storage", { key: "unrelated" }), + ); + }); + expect(result.current.user).not.toBeNull(); + act(() => { + globalThis.dispatchEvent(new StorageEvent("storage", { key: null })); + }); + expect(result.current.user).toBeNull(); + }); + + test("a 401 for an old token doesn't sign out a newer session", async () => { + const first = makeSession(); + storeSession(first); + const pending = deferred(); + route("GET /api/users/me", () => pending.promise, { once: true }); + route("GET /api/users/me", () => wire(first.user)); + const { result } = renderHook(useAuth, { wrapper: authWrapper }); + const second = { ...first, token: makeToken() }; + act(() => result.current.login(second)); + await act(async () => pending.resolve(new Response(null, { status: 401 }))); + expect(result.current.token).toBe(second.token); + expect(JSON.parse(localStorage.getItem(AUTH_STORAGE_KEY)!).token).toBe( + second.token, + ); + }); +}); diff --git a/src/test/contexts/category/CategoriesProvider.test.tsx b/src/test/contexts/category/CategoriesProvider.test.tsx new file mode 100644 index 00000000..f4aa49ca --- /dev/null +++ b/src/test/contexts/category/CategoriesProvider.test.tsx @@ -0,0 +1,39 @@ +import { describe, expect, test } from "vitest"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { CategoriesProvider } from "../../../contexts/category/CategoriesProvider.tsx"; +import { useCategories } from "../../../hooks/category/useCategories.ts"; +import { fail, route } from "../../fetch.ts"; +import { makeCategory, wire } from "../../fixtures.ts"; + +describe("CategoriesProvider", () => { + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + + test("loads and indexes categories", async () => { + const music = makeCategory({ id: "c1", slug: "music" }); + route("GET /api/categories", () => wire([music])); + const { result } = renderHook(useCategories, { wrapper }); + await waitFor(() => expect(result.current.isLoaded).toBe(true)); + expect(result.current.byId.get("c1")?.slug).toBe("music"); + expect(result.current.categories[0].createdAt).toBeInstanceOf(Date); + }); + + test("refresh refetches", async () => { + let list = [makeCategory({ id: "a" })]; + route("GET /api/categories", () => wire(list)); + const { result } = renderHook(useCategories, { wrapper }); + await waitFor(() => expect(result.current.categories).toHaveLength(1)); + list = [...list, makeCategory({ id: "b" })]; + await act(() => result.current.refresh()); + expect(result.current.categories).toHaveLength(2); + }); + + test("a failure still ends loading", async () => { + route("GET /api/categories", () => fail("x", 500)); + const { result } = renderHook(useCategories, { wrapper }); + await waitFor(() => expect(result.current.isLoaded).toBe(true)); + expect(result.current.categories).toEqual([]); + }); +}); diff --git a/src/test/contexts/chat/ChatProvider.test.tsx b/src/test/contexts/chat/ChatProvider.test.tsx new file mode 100644 index 00000000..7f9addb1 --- /dev/null +++ b/src/test/contexts/chat/ChatProvider.test.tsx @@ -0,0 +1,56 @@ +import { describe, expect, test } from "vitest"; +import { act, renderHook, screen } from "@testing-library/react"; +import { MemoryRouter } from "react-router"; +import { AuthProvider } from "../../../contexts/auth/AuthProvider.tsx"; +import { ChatProvider } from "../../../contexts/chat/ChatProvider.tsx"; +import { useChat } from "../../../hooks/chat/useChat.ts"; +import { route } from "../../fetch.ts"; +import { makeSession } from "../../fixtures.ts"; +import { currentPath, renderApp } from "../../render.tsx"; + +describe("ChatProvider", () => { + test("opens only for signed-in users", async () => { + const guest = renderHook(useChat, { + wrapper: ({ children }) => ( + + + {children} + + + ), + }); + act(() => guest.result.current.openChat()); + expect(guest.result.current.isChatOpen).toBe(false); + }); + + test("?chat=open deep-links into it, and closing strips the param", async () => { + const session = makeSession(); + route("GET /api/chat", () => ({ items: [], hasMore: false })); + let chat!: ReturnType; + function Probe() { + chat = useChat(); + return null; + } + renderApp(, { session, route: "/somewhere?chat=open&x=1" }); + expect(chat.isChatOpen).toBe(true); + await screen.findByRole("dialog", { name: "Chat" }); + act(() => chat.closeChat()); + expect(chat.isChatOpen).toBe(false); + expect(currentPath()).toBe("/somewhere?x=1"); + }); + + test("openChat / closeChat", () => { + const session = makeSession(); + route("GET /api/chat", () => ({ items: [], hasMore: false })); + let chat!: ReturnType; + function Probe() { + chat = useChat(); + return null; + } + renderApp(, { session }); + act(() => chat.openChat()); + expect(chat.isChatOpen).toBe(true); + act(() => chat.closeChat()); + expect(chat.isChatOpen).toBe(false); + }); +}); diff --git a/src/test/contexts/PlayerProvider.test.tsx b/src/test/contexts/player/PlayerProvider.test.tsx similarity index 96% rename from src/test/contexts/PlayerProvider.test.tsx rename to src/test/contexts/player/PlayerProvider.test.tsx index 2880bb4b..f779ffd7 100644 --- a/src/test/contexts/PlayerProvider.test.tsx +++ b/src/test/contexts/player/PlayerProvider.test.tsx @@ -1,17 +1,17 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { act, render, renderHook, waitFor } from "@testing-library/react"; import { memo, type ReactNode, useContext, useState } from "react"; -import { AuthContext } from "../../contexts/AuthContext.ts"; -import { PlayerProvider } from "../../contexts/PlayerProvider.tsx"; +import { AuthContext } from "../../../contexts/auth/AuthContext.ts"; +import { PlayerProvider } from "../../../contexts/player/PlayerProvider.tsx"; import { PlayerContext, type PlayerItem, PlayerTimeContext, -} from "../../contexts/PlayerContext.ts"; -import type { AuthResponse } from "../../model.ts"; -import { STREAM_STALE_MS } from "../../utils/streamSources.ts"; -import { calls, deferred, fail, route } from "../fetch.ts"; -import { makeSession } from "../fixtures.ts"; +} from "../../../contexts/player/PlayerContext.ts"; +import type { AuthResponse } from "../../../model.ts"; +import { STREAM_STALE_MS } from "../../../utils/player/streamSources.ts"; +import { calls, deferred, fail, route } from "../../fetch.ts"; +import { makeSession } from "../../fixtures.ts"; const embed = (n: number): PlayerItem => ({ kind: "embed", diff --git a/src/test/contexts/providers.test.tsx b/src/test/contexts/providers.test.tsx deleted file mode 100644 index 5eae892f..00000000 --- a/src/test/contexts/providers.test.tsx +++ /dev/null @@ -1,323 +0,0 @@ -import { describe, expect, test } from "vitest"; -import { act, renderHook, screen, waitFor } from "@testing-library/react"; -import type { ReactNode } from "react"; -import { MemoryRouter } from "react-router"; -import { AuthProvider } from "../../contexts/AuthProvider.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 { useAuth } from "../../hooks/useAuth.ts"; -import { useFollows } from "../../hooks/useFollows.ts"; -import { useCategories } from "../../hooks/useCategories.ts"; -import { useTheme } from "../../hooks/useTheme.ts"; -import { useChat } from "../../hooks/useChat.ts"; -import { AUTH_STORAGE_KEY } from "../../utils/authStorage.ts"; -import { calls, deferred, fail, ok, route } from "../fetch.ts"; -import { - makeCategory, - makeSession, - makeToken, - storeSession, - wire, -} from "../fixtures.ts"; -import { currentPath, renderApp } from "../render.tsx"; - -const authWrapper = ({ children }: { children: ReactNode }) => ( - {children} -); - -describe("AuthProvider", () => { - test("starts from the stored session", () => { - const session = makeSession({ username: "ann" }); - storeSession(session); - route("GET /api/users/me", () => wire(session.user)); - const { result } = renderHook(useAuth, { wrapper: authWrapper }); - expect(result.current.user?.username).toBe("ann"); - }); - - test("a token the server rejects is dropped", async () => { - const session = makeSession(); - storeSession(session); - route("GET /api/users/me", () => new Response(null, { status: 401 })); - const { result } = renderHook(useAuth, { wrapper: authWrapper }); - await waitFor(() => expect(result.current.user).toBeNull()); - expect(localStorage.getItem(AUTH_STORAGE_KEY)).toBeNull(); - expect(calls("GET /api/users/me")[0].headers.get("Authorization")).toBe( - `Bearer ${session.token}`, - ); - }); - - test("a role changed on the server is picked up", async () => { - const session = makeSession({ role: "user" }); - storeSession(session); - route( - "GET /api/users/me", - () => wire({ ...session.user, role: "moderator" }), - ); - const { result } = renderHook(useAuth, { wrapper: authWrapper }); - await waitFor(() => expect(result.current.user?.role).toBe("moderator")); - expect(JSON.parse(localStorage.getItem(AUTH_STORAGE_KEY)!).user.role).toBe( - "moderator", - ); - }); - - test("offline doesn't sign anyone out", async () => { - storeSession(makeSession()); - route("GET /api/users/me", () => new TypeError("offline")); - const { result } = renderHook(useAuth, { wrapper: authWrapper }); - await waitFor(() => expect(calls("GET /api/users/me")).toHaveLength(1)); - expect(result.current.user).not.toBeNull(); - }); - - test("revalidates when the tab regains focus", async () => { - const session = makeSession(); - storeSession(session); - route("GET /api/users/me", () => wire(session.user)); - renderHook(useAuth, { wrapper: authWrapper }); - await waitFor(() => expect(calls("GET /api/users/me")).toHaveLength(1)); - act(() => { - globalThis.dispatchEvent(new Event("focus")); - }); - await waitFor(() => expect(calls("GET /api/users/me")).toHaveLength(2)); - }); - - test("a session without a media token fetches one", async () => { - const session = { ...makeSession(), mediaToken: undefined }; - storeSession(session); - route("GET /api/users/me", () => wire(session.user)); - route("GET /api/users/me/media-token", () => ({ mediaToken: "m-1" })); - const { result } = renderHook(useAuth, { wrapper: authWrapper }); - await waitFor(() => expect(result.current.mediaToken).toBe("m-1")); - expect(JSON.parse(localStorage.getItem(AUTH_STORAGE_KEY)!).mediaToken).toBe( - "m-1", - ); - }); - - test("follows sign-in and sign-out from other tabs", async () => { - route("GET /api/users/me", () => ok(null)); - const { result } = renderHook(useAuth, { wrapper: authWrapper }); - expect(result.current.user).toBeNull(); - - const session = makeSession({ username: "elsewhere" }); - storeSession(session); - act(() => { - globalThis.dispatchEvent( - new StorageEvent("storage", { key: AUTH_STORAGE_KEY }), - ); - }); - expect(result.current.user?.username).toBe("elsewhere"); - - localStorage.removeItem(AUTH_STORAGE_KEY); - act(() => { - globalThis.dispatchEvent( - new StorageEvent("storage", { key: "unrelated" }), - ); - }); - expect(result.current.user).not.toBeNull(); - act(() => { - globalThis.dispatchEvent(new StorageEvent("storage", { key: null })); - }); - expect(result.current.user).toBeNull(); - }); - - test("a 401 for an old token doesn't sign out a newer session", async () => { - const first = makeSession(); - storeSession(first); - const pending = deferred(); - route("GET /api/users/me", () => pending.promise, { once: true }); - route("GET /api/users/me", () => wire(first.user)); - const { result } = renderHook(useAuth, { wrapper: authWrapper }); - const second = { ...first, token: makeToken() }; - act(() => result.current.login(second)); - await act(async () => pending.resolve(new Response(null, { status: 401 }))); - expect(result.current.token).toBe(second.token); - expect(JSON.parse(localStorage.getItem(AUTH_STORAGE_KEY)!).token).toBe( - second.token, - ); - }); -}); - -describe("FollowProvider", () => { - function setup( - status = { followedUserIds: ["u1"], followedPlaylistIds: ["p1"] }, - ) { - const session = makeSession(); - storeSession(session); - route("GET /api/users/me", () => wire(session.user)); - route("GET /api/follows/status", () => status); - return renderHook(useFollows, { - wrapper: ({ children }) => ( - - {children} - - ), - }); - } - - test("loads what the user follows", async () => { - const { result } = setup(); - await waitFor(() => expect(result.current.isLoaded).toBe(true)); - expect(result.current.followedUserIds.has("u1")).toBe(true); - expect(result.current.followedPlaylistIds.has("p1")).toBe(true); - }); - - test("guests follow nobody and never ask", async () => { - const { result } = renderHook(useFollows, { - wrapper: ({ children }) => ( - - {children} - - ), - }); - await act(async () => {}); - expect(result.current.isLoaded).toBe(false); - expect(calls("GET /api/follows/status")).toHaveLength(0); - }); - - test("follow and unfollow are optimistic and roll back on failure", async () => { - const { result } = setup({ followedUserIds: [], followedPlaylistIds: [] }); - await waitFor(() => expect(result.current.isLoaded).toBe(true)); - - route("POST /api/follows/users/:id", () => ({})); - await act(() => result.current.followUser("u2")); - expect(result.current.followedUserIds.has("u2")).toBe(true); - - route("DELETE /api/follows/users/:id", () => fail("no", 500)); - await act(() => result.current.unfollowUser("u2")); - expect(result.current.followedUserIds.has("u2")).toBe(true); - - route("POST /api/follows/playlists/:id", () => new TypeError("offline")); - await act(() => result.current.followPlaylist("p2")); - expect(result.current.followedPlaylistIds.has("p2")).toBe(false); - - route("POST /api/follows/playlists/:id", () => ({})); - await act(() => result.current.followPlaylist("p2")); - route("DELETE /api/follows/playlists/:id", () => ({})); - await act(() => result.current.unfollowPlaylist("p2")); - expect(result.current.followedPlaylistIds.has("p2")).toBe(false); - expect(calls("DELETE /api/follows/playlists/:id")[0].path).toBe( - "/api/follows/playlists/p2", - ); - }); -}); - -describe("CategoriesProvider", () => { - const wrapper = ({ children }: { children: ReactNode }) => ( - {children} - ); - - test("loads and indexes categories", async () => { - const music = makeCategory({ id: "c1", slug: "music" }); - route("GET /api/categories", () => wire([music])); - const { result } = renderHook(useCategories, { wrapper }); - await waitFor(() => expect(result.current.isLoaded).toBe(true)); - expect(result.current.byId.get("c1")?.slug).toBe("music"); - expect(result.current.categories[0].createdAt).toBeInstanceOf(Date); - }); - - test("refresh refetches", async () => { - let list = [makeCategory({ id: "a" })]; - route("GET /api/categories", () => wire(list)); - const { result } = renderHook(useCategories, { wrapper }); - await waitFor(() => expect(result.current.categories).toHaveLength(1)); - list = [...list, makeCategory({ id: "b" })]; - await act(() => result.current.refresh()); - expect(result.current.categories).toHaveLength(2); - }); - - test("a failure still ends loading", async () => { - route("GET /api/categories", () => fail("x", 500)); - const { result } = renderHook(useCategories, { wrapper }); - await waitFor(() => expect(result.current.isLoaded).toBe(true)); - expect(result.current.categories).toEqual([]); - }); -}); - -describe("ThemeProvider", () => { - const wrapper = ({ children }: { children: ReactNode }) => ( - {children} - ); - const root = document.documentElement; - - test("smooth and auto by default, with no attributes", () => { - const { result } = renderHook(useTheme, { wrapper }); - expect(result.current.style).toBe("smooth"); - expect(result.current.colorScheme).toBe("auto"); - expect(root.hasAttribute("data-style")).toBe(false); - expect(root.hasAttribute("data-color-scheme")).toBe(false); - }); - - test("applies and persists a choice", () => { - const { result } = renderHook(useTheme, { wrapper }); - act(() => result.current.setStyle("brutalist")); - act(() => result.current.setColorScheme("dark")); - expect(root.getAttribute("data-style")).toBe("brutalist"); - expect(root.getAttribute("data-color-scheme")).toBe("dark"); - expect(localStorage.getItem("style")).toBe("brutalist"); - expect(localStorage.getItem("color-scheme")).toBe("dark"); - act(() => result.current.setStyle("smooth")); - act(() => result.current.setColorScheme("auto")); - expect(root.hasAttribute("data-style")).toBe(false); - expect(root.hasAttribute("data-color-scheme")).toBe(false); - }); - - test("restores the stored theme, migrating the legacy name", () => { - localStorage.setItem("style", "nyt"); - localStorage.setItem("color-scheme", "light"); - const { result } = renderHook(useTheme, { wrapper }); - expect(result.current.style).toBe("nyt"); - expect(result.current.colorScheme).toBe("light"); - localStorage.setItem("style", "default"); - expect(renderHook(useTheme, { wrapper }).result.current.style).toBe( - "smooth", - ); - }); -}); - -describe("ChatProvider", () => { - test("opens only for signed-in users", async () => { - const guest = renderHook(useChat, { - wrapper: ({ children }) => ( - - - {children} - - - ), - }); - act(() => guest.result.current.openChat()); - expect(guest.result.current.isChatOpen).toBe(false); - }); - - test("?chat=open deep-links into it, and closing strips the param", async () => { - const session = makeSession(); - route("GET /api/chat", () => ({ items: [], hasMore: false })); - let chat!: ReturnType; - function Probe() { - chat = useChat(); - return null; - } - renderApp(, { session, route: "/somewhere?chat=open&x=1" }); - expect(chat.isChatOpen).toBe(true); - await screen.findByRole("dialog", { name: "Chat" }); - act(() => chat.closeChat()); - expect(chat.isChatOpen).toBe(false); - expect(currentPath()).toBe("/somewhere?x=1"); - }); - - test("openChat / closeChat", () => { - const session = makeSession(); - route("GET /api/chat", () => ({ items: [], hasMore: false })); - let chat!: ReturnType; - function Probe() { - chat = useChat(); - return null; - } - renderApp(, { session }); - act(() => chat.openChat()); - expect(chat.isChatOpen).toBe(true); - act(() => chat.closeChat()); - expect(chat.isChatOpen).toBe(false); - }); -}); diff --git a/src/test/contexts/social/FollowProvider.test.tsx b/src/test/contexts/social/FollowProvider.test.tsx new file mode 100644 index 00000000..e8da0127 --- /dev/null +++ b/src/test/contexts/social/FollowProvider.test.tsx @@ -0,0 +1,71 @@ +import { describe, expect, test } from "vitest"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import { AuthProvider } from "../../../contexts/auth/AuthProvider.tsx"; +import { FollowProvider } from "../../../contexts/social/FollowProvider.tsx"; +import { useFollows } from "../../../hooks/social/useFollows.ts"; +import { calls, fail, route } from "../../fetch.ts"; +import { makeSession, storeSession, wire } from "../../fixtures.ts"; + +describe("FollowProvider", () => { + function setup( + status = { followedUserIds: ["u1"], followedPlaylistIds: ["p1"] }, + ) { + const session = makeSession(); + storeSession(session); + route("GET /api/users/me", () => wire(session.user)); + route("GET /api/follows/status", () => status); + return renderHook(useFollows, { + wrapper: ({ children }) => ( + + {children} + + ), + }); + } + + test("loads what the user follows", async () => { + const { result } = setup(); + await waitFor(() => expect(result.current.isLoaded).toBe(true)); + expect(result.current.followedUserIds.has("u1")).toBe(true); + expect(result.current.followedPlaylistIds.has("p1")).toBe(true); + }); + + test("guests follow nobody and never ask", async () => { + const { result } = renderHook(useFollows, { + wrapper: ({ children }) => ( + + {children} + + ), + }); + await act(async () => {}); + expect(result.current.isLoaded).toBe(false); + expect(calls("GET /api/follows/status")).toHaveLength(0); + }); + + test("follow and unfollow are optimistic and roll back on failure", async () => { + const { result } = setup({ followedUserIds: [], followedPlaylistIds: [] }); + await waitFor(() => expect(result.current.isLoaded).toBe(true)); + + route("POST /api/follows/users/:id", () => ({})); + await act(() => result.current.followUser("u2")); + expect(result.current.followedUserIds.has("u2")).toBe(true); + + route("DELETE /api/follows/users/:id", () => fail("no", 500)); + await act(() => result.current.unfollowUser("u2")); + expect(result.current.followedUserIds.has("u2")).toBe(true); + + route("POST /api/follows/playlists/:id", () => new TypeError("offline")); + await act(() => result.current.followPlaylist("p2")); + expect(result.current.followedPlaylistIds.has("p2")).toBe(false); + + route("POST /api/follows/playlists/:id", () => ({})); + await act(() => result.current.followPlaylist("p2")); + route("DELETE /api/follows/playlists/:id", () => ({})); + await act(() => result.current.unfollowPlaylist("p2")); + expect(result.current.followedPlaylistIds.has("p2")).toBe(false); + expect(calls("DELETE /api/follows/playlists/:id")[0].path).toBe( + "/api/follows/playlists/p2", + ); + }); +}); diff --git a/src/test/contexts/theme/ThemeProvider.test.tsx b/src/test/contexts/theme/ThemeProvider.test.tsx new file mode 100644 index 00000000..db06dae2 --- /dev/null +++ b/src/test/contexts/theme/ThemeProvider.test.tsx @@ -0,0 +1,46 @@ +import { describe, expect, test } from "vitest"; +import { act, renderHook } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { ThemeProvider } from "../../../contexts/theme/ThemeProvider.tsx"; +import { useTheme } from "../../../hooks/theme/useTheme.ts"; + +describe("ThemeProvider", () => { + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + const root = document.documentElement; + + test("smooth and auto by default, with no attributes", () => { + const { result } = renderHook(useTheme, { wrapper }); + expect(result.current.style).toBe("smooth"); + expect(result.current.colorScheme).toBe("auto"); + expect(root.hasAttribute("data-style")).toBe(false); + expect(root.hasAttribute("data-color-scheme")).toBe(false); + }); + + test("applies and persists a choice", () => { + const { result } = renderHook(useTheme, { wrapper }); + act(() => result.current.setStyle("brutalist")); + act(() => result.current.setColorScheme("dark")); + expect(root.getAttribute("data-style")).toBe("brutalist"); + expect(root.getAttribute("data-color-scheme")).toBe("dark"); + expect(localStorage.getItem("style")).toBe("brutalist"); + expect(localStorage.getItem("color-scheme")).toBe("dark"); + act(() => result.current.setStyle("smooth")); + act(() => result.current.setColorScheme("auto")); + expect(root.hasAttribute("data-style")).toBe(false); + expect(root.hasAttribute("data-color-scheme")).toBe(false); + }); + + test("restores the stored theme, migrating the legacy name", () => { + localStorage.setItem("style", "nyt"); + localStorage.setItem("color-scheme", "light"); + const { result } = renderHook(useTheme, { wrapper }); + expect(result.current.style).toBe("nyt"); + expect(result.current.colorScheme).toBe("light"); + localStorage.setItem("style", "default"); + expect(renderHook(useTheme, { wrapper }).result.current.style).toBe( + "smooth", + ); + }); +}); diff --git a/src/test/contexts/WSProvider.test.tsx b/src/test/contexts/ws/WSProvider.test.tsx similarity index 98% rename from src/test/contexts/WSProvider.test.tsx rename to src/test/contexts/ws/WSProvider.test.tsx index 22afca30..fedbb041 100644 --- a/src/test/contexts/WSProvider.test.tsx +++ b/src/test/contexts/ws/WSProvider.test.tsx @@ -2,13 +2,13 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { act, renderHook } from "@testing-library/react"; import type { ReactNode } from "react"; import { useContext } from "react"; -import { AuthProvider } from "../../contexts/AuthProvider.tsx"; -import { WSProvider } from "../../contexts/WSProvider.tsx"; -import { AvatarOverrideContext } from "../../contexts/AvatarOverrideContext.ts"; -import { useWS, useWSChat, useWSPresence } from "../../hooks/useWS.ts"; -import { useAuth } from "../../hooks/useAuth.ts"; -import { FakeWebSocket } from "../ws.ts"; -import { defaultRoutes } from "../render.tsx"; +import { AuthProvider } from "../../../contexts/auth/AuthProvider.tsx"; +import { WSProvider } from "../../../contexts/ws/WSProvider.tsx"; +import { AvatarOverrideContext } from "../../../contexts/ws/AvatarOverrideContext.ts"; +import { useWS, useWSChat, useWSPresence } from "../../../hooks/ws/useWS.ts"; +import { useAuth } from "../../../hooks/auth/useAuth.ts"; +import { FakeWebSocket } from "../../ws.ts"; +import { defaultRoutes } from "../../render.tsx"; import { makeChatMessage, makeComment, @@ -19,7 +19,7 @@ import { makeUser, storeSession, wire, -} from "../fixtures.ts"; +} from "../../fixtures.ts"; const me = makeSession({ id: "user-me", username: "me" }); diff --git a/src/test/fixtures.ts b/src/test/fixtures.ts index 5f54a82d..e07f63c0 100644 --- a/src/test/fixtures.ts +++ b/src/test/fixtures.ts @@ -8,7 +8,7 @@ import type { Playlist, PublicUser, } from "../model.ts"; -import { AUTH_STORAGE_KEY } from "../utils/authStorage.ts"; +import { AUTH_STORAGE_KEY } from "../utils/auth/storage.ts"; let seq = 0; const nextId = (prefix: string) => `${prefix}-${++seq}`; diff --git a/src/test/hooks/useAuth.test.tsx b/src/test/hooks/auth/useAuth.test.tsx similarity index 96% rename from src/test/hooks/useAuth.test.tsx rename to src/test/hooks/auth/useAuth.test.tsx index 89f56bd0..28a6ed44 100644 --- a/src/test/hooks/useAuth.test.tsx +++ b/src/test/hooks/auth/useAuth.test.tsx @@ -2,12 +2,12 @@ import { afterEach, describe, expect, test, vi } from "vitest"; import { act, renderHook, waitFor } from "@testing-library/react"; import type { ReactNode } from "react"; import { useState } from "react"; -import { AuthContext } from "../../contexts/AuthContext.ts"; -import type { AuthResponse } from "../../model.ts"; -import { useAuth, useRequiredAuth } from "../../hooks/useAuth.ts"; -import { AUTH_STORAGE_KEY } from "../../utils/authStorage.ts"; -import { calls, route } from "../fetch.ts"; -import { makeSession, makeToken, storeSession } from "../fixtures.ts"; +import { AuthContext } from "../../../contexts/auth/AuthContext.ts"; +import type { AuthResponse } from "../../../model.ts"; +import { useAuth, useRequiredAuth } from "../../../hooks/auth/useAuth.ts"; +import { AUTH_STORAGE_KEY } from "../../../utils/auth/storage.ts"; +import { calls, route } from "../../fetch.ts"; +import { makeSession, makeToken, storeSession } from "../../fixtures.ts"; function wrapperFor(initial: AuthResponse | null) { return function Wrapper({ children }: { children: ReactNode }) { diff --git a/src/test/hooks/data.test.tsx b/src/test/hooks/data.test.tsx deleted file mode 100644 index fab4e062..00000000 --- a/src/test/hooks/data.test.tsx +++ /dev/null @@ -1,377 +0,0 @@ -import { afterEach, describe, expect, test, vi } from "vitest"; -import { act, renderHook, waitFor } from "@testing-library/react"; -import { type ReactNode, useRef, useState } from "react"; -import { MemoryRouter } from "react-router"; -import { AuthContext } from "../../contexts/AuthContext.ts"; -import { useUserDumpFeed } from "../../hooks/useUserDumpFeed.ts"; -import { calls, fail, route } from "../fetch.ts"; -import { makeDump, makeSession, makeUser, page, wire } from "../fixtures.ts"; -import { FakeIntersectionObserver } from "../setup.ts"; - -afterEach(() => { - vi.unstubAllGlobals(); - document.head.querySelectorAll("script").forEach((s) => s.remove()); -}); - -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/usePlayRichContent.ts"); - const ctxMod = await import("../../contexts/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); - }); -}); - -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"]); - }); -}); - -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/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/dump/useDumpCardLink.test.tsx b/src/test/hooks/dump/useDumpCardLink.test.tsx new file mode 100644 index 00000000..06a9f3e5 --- /dev/null +++ b/src/test/hooks/dump/useDumpCardLink.test.tsx @@ -0,0 +1,85 @@ +import { describe, expect, test, vi } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { MemoryRouter, Route, Routes } from "react-router"; +import { useDumpCardLink } from "../../../hooks/dump/useDumpCardLink.ts"; +import { isDumpVisited } from "../../../utils/shared/visited.ts"; + +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"); + }); +}); 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; - } - }} - /> - {JSON.stringify(style)} - - ); - } - 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; + } + }} + /> + {JSON.stringify(style)} + + ); + } + 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("![cat](a.png)", "Image")).toBe("cat"); + expect(markdownToPlainText("![](a.png)", "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 ![alt](https://exa", "Image")).toBe( + "pic alt", + ); + expect(markdownToPlainText("pic ![](https://exa", "Image")).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("![cat](a.png)", "Image")).toBe("cat"); - expect(markdownToPlainText("![](a.png)", "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 ![alt](https://exa", "Image")).toBe( - "pic alt", - ); - expect(markdownToPlainText("pic ![](https://exa", "Image")).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