v3: src/components sorted into feature folders, the four largest files split, and the component tests mirror the new layout — 48 components sat in one flat folder, ChatModal ran to 1301 lines, DumpCreateModal to 983, and the tests were grouped by theme, so there was no telling which component had none. Components now live in category/, chat/, comment/, dump/ (with dump/create/ for the new-dump wizard), editor/, form/, layout/ (header, page shell, floating buttons), player/, playlist/, profile/, social/ (vote, like, follow, the who-voted popover) and ui/; FeedTabBar and PresenceRow, used by the index page alone, move to pages/index/. ChatModal gives up ChatMessageItem, PendingItem, ChatEditBox and ReplyRef, the message limits and text helpers (chat/message.ts), the day and clock labels (chat/time.ts) and the draft storage (utils/chatDraft.ts, next to dumpDraft.ts). DumpCreateModal gives up StepIndicator, LocalFilePreview, TitleField and DuplicateNotice; CommentThread gives up CommentForm and CommentNode; TextEditor gives up the emoji picker popup and its skin-tone storage; MediaPlayer gives up its icons (which FilePreview imported from it), Waveform, SeekBar and the remembered volume. NewPlaylistForm, a button that opens a modal holding the form, is NewPlaylistButton now, and Thumbnail, ThumbnailPlaceholder, FilePreview and RichContentCard use named exports like everything else. The tests are split one file per component under src/test/components/<folder>/ (and src/test/pages/index/), the same 625 of them. No behaviour changes; the catalogues only move their source references.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
17
README.md
17
README.md
@@ -125,9 +125,22 @@ api/
|
||||
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)
|
||||
components/ # Shared UI 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
|
||||
|
||||
@@ -21,9 +21,9 @@ 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 { GlobalPlayer } from "./components/GlobalPlayer.tsx";
|
||||
import { DumpFab } from "./components/DumpFab.tsx";
|
||||
import { ChatFab } from "./components/ChatFab.tsx";
|
||||
import { GlobalPlayer } from "./components/player/GlobalPlayer.tsx";
|
||||
import { DumpFab } from "./components/layout/DumpFab.tsx";
|
||||
import { ChatFab } from "./components/layout/ChatFab.tsx";
|
||||
|
||||
import "./App.css";
|
||||
|
||||
|
||||
Binary file not shown.
@@ -1,5 +1,5 @@
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { useCategories } from "../hooks/useCategories.ts";
|
||||
import { useCategories } from "../../hooks/useCategories.ts";
|
||||
|
||||
/**
|
||||
* Multi-select of categories rendered as toggle chips. Renders nothing when no
|
||||
@@ -8,8 +8,8 @@ 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 { DEFAULT_FEED_TAB } from "../config/feedTabs.ts";
|
||||
import { useCategories } from "../../hooks/useCategories.ts";
|
||||
import { DEFAULT_FEED_TAB } from "../../config/feedTabs.ts";
|
||||
|
||||
/**
|
||||
* Header dropdown that switches the feed scope between "All" and a category,
|
||||
74
src/components/chat/ChatEditBox.tsx
Normal file
74
src/components/chat/ChatEditBox.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
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";
|
||||
|
||||
export interface ChatEditBoxProps {
|
||||
initial: string;
|
||||
onSave: (body: string) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function ChatEditBox({ initial, onSave, onCancel }: ChatEditBoxProps) {
|
||||
const [draft, setDraft] = useState(initial);
|
||||
const editorRef = useRef<TextEditorHandle>(null);
|
||||
const boxRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Focus without letting the browser scroll the list mid-transition (the
|
||||
// message box is still easing to its new height); once it has settled, bring
|
||||
// the whole edit box, Save included, into view.
|
||||
useEffect(() => {
|
||||
editorRef.current?.focus({ atEnd: true, preventScroll: true });
|
||||
const box = boxRef.current;
|
||||
if (!box) return;
|
||||
const settle = parseFloat(
|
||||
getComputedStyle(box).getPropertyValue("--dur-reveal"),
|
||||
) * 1000 || 250;
|
||||
const id = setTimeout(
|
||||
() => box.scrollIntoView({ block: "nearest", behavior: "smooth" }),
|
||||
settle + 20,
|
||||
);
|
||||
return () => clearTimeout(id);
|
||||
}, []);
|
||||
|
||||
const save = () => {
|
||||
if (!sendable(draft)) return;
|
||||
onSave(draft.trim());
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="chat-edit animate-in" ref={boxRef}>
|
||||
<TextEditor
|
||||
ref={editorRef}
|
||||
value={draft}
|
||||
onChange={setDraft}
|
||||
rows={2}
|
||||
autoResize
|
||||
maxLength={MAX_CHAT_LENGTH}
|
||||
countFrom={COUNT_FROM}
|
||||
className="comment-reply-textarea chat-input"
|
||||
onSubmit={save}
|
||||
onCancel={onCancel}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
save();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="chat-edit-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary"
|
||||
onClick={save}
|
||||
disabled={!sendable(draft)}
|
||||
>
|
||||
<Trans>Save</Trans>
|
||||
</button>
|
||||
<button type="button" className="btn-secondary" onClick={onCancel}>
|
||||
<Trans>Cancel</Trans>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
197
src/components/chat/ChatMessageItem.tsx
Normal file
197
src/components/chat/ChatMessageItem.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
import { useState } from "react";
|
||||
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 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";
|
||||
|
||||
export interface ChatMessageItemProps {
|
||||
message: ChatMessage;
|
||||
/** Continues the previous message's run: no avatar or author line. */
|
||||
grouped: boolean;
|
||||
mentionsMe: boolean;
|
||||
/** The replied-to message has been deleted since this one loaded. */
|
||||
replyDeleted: boolean;
|
||||
canEdit: boolean;
|
||||
canDelete: boolean;
|
||||
flashed: boolean;
|
||||
editing: boolean;
|
||||
onStartEdit: (id: string) => void;
|
||||
onEndEdit: () => void;
|
||||
onEdit: (id: string, body: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onReply: (message: ChatMessage) => void;
|
||||
onJumpTo: (id: string) => void;
|
||||
}
|
||||
|
||||
export function ChatMessageItem(
|
||||
{
|
||||
message,
|
||||
grouped,
|
||||
mentionsMe,
|
||||
replyDeleted,
|
||||
canEdit,
|
||||
canDelete,
|
||||
flashed,
|
||||
editing,
|
||||
onStartEdit,
|
||||
onEndEdit,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onReply,
|
||||
onJumpTo,
|
||||
}: ChatMessageItemProps,
|
||||
) {
|
||||
const bodyRef = useHeightTransition<HTMLDivElement>(editing);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
|
||||
const actions = !editing && (
|
||||
<span
|
||||
className={`chat-message-actions${
|
||||
grouped ? " chat-message-actions--float" : ""
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="chat-icon-btn"
|
||||
onClick={() => onReply(message)}
|
||||
title={t`Reply`}
|
||||
aria-label={t`Reply`}
|
||||
>
|
||||
↩
|
||||
</button>
|
||||
{canEdit && (
|
||||
<button
|
||||
type="button"
|
||||
className="chat-icon-btn"
|
||||
onClick={() => onStartEdit(message.id)}
|
||||
title={t`Edit`}
|
||||
aria-label={t`Edit`}
|
||||
>
|
||||
✎
|
||||
</button>
|
||||
)}
|
||||
{canDelete && (
|
||||
<button
|
||||
type="button"
|
||||
className="chat-icon-btn chat-icon-btn--delete"
|
||||
onClick={() => setConfirmDelete(true)}
|
||||
title={t`Delete`}
|
||||
aria-label={t`Delete`}
|
||||
>
|
||||
🗑
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
|
||||
const edited = message.updatedAt && (
|
||||
<Tooltip text={t`Edited ${message.updatedAt.toLocaleString()}`}>
|
||||
<span className="chat-message-edited">
|
||||
{grouped
|
||||
? <Trans>edited</Trans>
|
||||
: <Trans>edited {relativeTime(message.updatedAt)}</Trans>}
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`chat-message${grouped ? " chat-message--grouped" : ""}${
|
||||
mentionsMe ? " chat-message--mention" : ""
|
||||
}${flashed ? " chat-message--flash" : ""}`}
|
||||
data-mid={message.id}
|
||||
>
|
||||
{grouped
|
||||
? (
|
||||
// The avatar column holds the time instead, shown on hover.
|
||||
<span className="chat-message-gutter">
|
||||
<Tooltip text={message.createdAt.toLocaleString()}>
|
||||
<time dateTime={message.createdAt.toISOString()}>
|
||||
{clockTime(message.createdAt)}
|
||||
</time>
|
||||
</Tooltip>
|
||||
</span>
|
||||
)
|
||||
: (
|
||||
<Avatar
|
||||
userId={message.userId}
|
||||
username={message.authorUsername}
|
||||
hasAvatar={!!message.authorAvatarMime}
|
||||
size={32}
|
||||
/>
|
||||
)}
|
||||
<div className="chat-message-body" ref={bodyRef}>
|
||||
{message.replyToId && (
|
||||
<ReplyRef
|
||||
replyToId={message.replyToId}
|
||||
author={replyDeleted ? undefined : message.replyToAuthor}
|
||||
body={message.replyToBody}
|
||||
onJumpTo={onJumpTo}
|
||||
/>
|
||||
)}
|
||||
{!grouped && (
|
||||
<div className="chat-message-meta">
|
||||
<Link
|
||||
to={`/users/${message.authorUsername}`}
|
||||
className="chat-message-author"
|
||||
>
|
||||
{message.authorUsername}
|
||||
</Link>
|
||||
<Tooltip text={message.createdAt.toLocaleString()}>
|
||||
<time
|
||||
className="chat-message-time"
|
||||
dateTime={message.createdAt.toISOString()}
|
||||
>
|
||||
{relativeTime(message.createdAt)}
|
||||
</time>
|
||||
</Tooltip>
|
||||
{edited}
|
||||
{actions}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editing
|
||||
? (
|
||||
<ChatEditBox
|
||||
initial={message.body}
|
||||
onSave={(body) => {
|
||||
if (body !== message.body) onEdit(message.id, body);
|
||||
onEndEdit();
|
||||
}}
|
||||
onCancel={onEndEdit}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<Markdown className={messageTextClass(message.body)}>
|
||||
{message.body}
|
||||
</Markdown>
|
||||
)}
|
||||
|
||||
{grouped && !editing && edited}
|
||||
</div>
|
||||
{grouped && actions}
|
||||
|
||||
{confirmDelete && (
|
||||
<ConfirmModal
|
||||
message={t`Delete this message?`}
|
||||
confirmLabel={t`Delete`}
|
||||
onConfirm={() => {
|
||||
setConfirmDelete(false);
|
||||
onDelete(message.id);
|
||||
}}
|
||||
onCancel={() => setConfirmDelete(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,37 +9,29 @@ import {
|
||||
} from "react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { Link } from "react-router";
|
||||
import { Modal } from "./Modal.tsx";
|
||||
import { TextEditor, type TextEditorHandle } from "./TextEditor.tsx";
|
||||
import { Markdown } from "./Markdown.tsx";
|
||||
import { Avatar } from "./Avatar.tsx";
|
||||
import { ConfirmModal } from "./ConfirmModal.tsx";
|
||||
import { Tooltip } from "./Tooltip.tsx";
|
||||
import { useHeightTransition } from "../hooks/useHeightTransition.ts";
|
||||
import { useWS, useWSChat } from "../hooks/useWS.ts";
|
||||
import { useAuth } from "../hooks/useAuth.ts";
|
||||
import { can } from "../utils/permissions.ts";
|
||||
import { relativeTime } from "../utils/relativeTime.ts";
|
||||
import { markdownToPlainText, mentionsUser } from "../utils/chatText.ts";
|
||||
import { isEmojiOnly } from "../utils/editorText.ts";
|
||||
import { i18n } from "../i18n.ts";
|
||||
import { API_URL } from "../config/api.ts";
|
||||
import { Avatar } from "../ui/Avatar.tsx";
|
||||
import { Modal } from "../ui/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 { API_URL } from "../../config/api.ts";
|
||||
import type {
|
||||
ChatReplyTarget,
|
||||
PendingChatMessage,
|
||||
} from "../contexts/WSContext.ts";
|
||||
} from "../../contexts/WSContext.ts";
|
||||
import {
|
||||
type ChatMessage,
|
||||
deserializeChatMessage,
|
||||
type PublicUser,
|
||||
type RawChatMessage,
|
||||
} from "../model.ts";
|
||||
} 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";
|
||||
|
||||
// Keep in sync with MAX_CHAT_LENGTH in api/services/chat-service.ts.
|
||||
const MAX_CHAT_LENGTH = 2000;
|
||||
// The counter only shows once a message is getting near the limit.
|
||||
const COUNT_FROM = 1800;
|
||||
// Matches the server's default page size; a full page means there may be more.
|
||||
const PAGE_SIZE = 50;
|
||||
// Resync after a reconnect walks forward from the oldest loaded message in
|
||||
@@ -78,469 +70,6 @@ function compareMessages(a: ChatMessage, b: ChatMessage): number {
|
||||
(a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
|
||||
}
|
||||
|
||||
// What the server holds a message to: its length once trimmed.
|
||||
function sendable(draft: string): boolean {
|
||||
const body = draft.trim();
|
||||
return body.length > 0 && body.length <= MAX_CHAT_LENGTH;
|
||||
}
|
||||
|
||||
// A message of nothing but a few emoji is shown large, as chats do.
|
||||
function messageTextClass(body: string): string {
|
||||
return isEmojiOnly(body)
|
||||
? "chat-message-text chat-message-text--jumbo"
|
||||
: "chat-message-text";
|
||||
}
|
||||
|
||||
function snippet(body: string): string {
|
||||
return markdownToPlainText(body, t`image`);
|
||||
}
|
||||
|
||||
// The draft and reply target outlive the chat — closing it, or reloading the
|
||||
// tab, keeps a half-written message.
|
||||
interface StoredDraft {
|
||||
draft: string;
|
||||
replyTarget: ChatReplyTarget | null;
|
||||
}
|
||||
|
||||
function draftKey(userId: string): string {
|
||||
return `chat-draft:${userId}`;
|
||||
}
|
||||
|
||||
function readDraft(userId: string | undefined): StoredDraft {
|
||||
if (userId) {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(draftKey(userId));
|
||||
const parsed = raw ? JSON.parse(raw) : null;
|
||||
if (parsed && typeof parsed.draft === "string") {
|
||||
return { draft: parsed.draft, replyTarget: parsed.replyTarget ?? null };
|
||||
}
|
||||
} catch {
|
||||
// Blocked storage or a mangled entry: start empty.
|
||||
}
|
||||
}
|
||||
return { draft: "", replyTarget: null };
|
||||
}
|
||||
|
||||
function writeDraft(userId: string | undefined, value: StoredDraft): void {
|
||||
if (!userId) return;
|
||||
try {
|
||||
if (value.draft || value.replyTarget) {
|
||||
sessionStorage.setItem(draftKey(userId), JSON.stringify(value));
|
||||
} else {
|
||||
sessionStorage.removeItem(draftKey(userId));
|
||||
}
|
||||
} catch {
|
||||
// The draft just won't survive a reload.
|
||||
}
|
||||
}
|
||||
|
||||
function isSameDay(a: Date, b: Date): boolean {
|
||||
return a.getFullYear() === b.getFullYear() &&
|
||||
a.getMonth() === b.getMonth() &&
|
||||
a.getDate() === b.getDate();
|
||||
}
|
||||
|
||||
// Human label for a day separator: "Today"/"Yesterday" for the two most recent
|
||||
// days, otherwise a localized full date.
|
||||
function dayLabel(date: Date): string {
|
||||
const now = new Date();
|
||||
if (isSameDay(date, now)) return t`Today`;
|
||||
const yesterday = new Date(now);
|
||||
yesterday.setDate(now.getDate() - 1);
|
||||
if (isSameDay(date, yesterday)) return t`Yesterday`;
|
||||
return date.toLocaleDateString(i18n.locale, {
|
||||
weekday: "long",
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: now.getFullYear() === date.getFullYear() ? undefined : "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
// Hours and minutes only, for the narrow gutter beside a grouped message — the
|
||||
// day period ("PM") is dropped to fit; its tooltip has the full timestamp.
|
||||
function clockTime(date: Date): string {
|
||||
return new Intl.DateTimeFormat(i18n.locale, {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
}).formatToParts(date)
|
||||
.filter((part) => part.type !== "dayPeriod")
|
||||
.map((part) => part.value)
|
||||
.join("")
|
||||
.trim();
|
||||
}
|
||||
|
||||
interface ChatEditBoxProps {
|
||||
initial: string;
|
||||
onSave: (body: string) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
function ChatEditBox({ initial, onSave, onCancel }: ChatEditBoxProps) {
|
||||
const [draft, setDraft] = useState(initial);
|
||||
const editorRef = useRef<TextEditorHandle>(null);
|
||||
const boxRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Focus without letting the browser scroll the list mid-transition (the
|
||||
// message box is still easing to its new height); once it has settled, bring
|
||||
// the whole edit box, Save included, into view.
|
||||
useEffect(() => {
|
||||
editorRef.current?.focus({ atEnd: true, preventScroll: true });
|
||||
const box = boxRef.current;
|
||||
if (!box) return;
|
||||
const settle = parseFloat(
|
||||
getComputedStyle(box).getPropertyValue("--dur-reveal"),
|
||||
) * 1000 || 250;
|
||||
const id = setTimeout(
|
||||
() => box.scrollIntoView({ block: "nearest", behavior: "smooth" }),
|
||||
settle + 20,
|
||||
);
|
||||
return () => clearTimeout(id);
|
||||
}, []);
|
||||
|
||||
const save = () => {
|
||||
if (!sendable(draft)) return;
|
||||
onSave(draft.trim());
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="chat-edit animate-in" ref={boxRef}>
|
||||
<TextEditor
|
||||
ref={editorRef}
|
||||
value={draft}
|
||||
onChange={setDraft}
|
||||
rows={2}
|
||||
autoResize
|
||||
maxLength={MAX_CHAT_LENGTH}
|
||||
countFrom={COUNT_FROM}
|
||||
className="comment-reply-textarea chat-input"
|
||||
onSubmit={save}
|
||||
onCancel={onCancel}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
save();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="chat-edit-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary"
|
||||
onClick={save}
|
||||
disabled={!sendable(draft)}
|
||||
>
|
||||
<Trans>Save</Trans>
|
||||
</button>
|
||||
<button type="button" className="btn-secondary" onClick={onCancel}>
|
||||
<Trans>Cancel</Trans>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ReplyRefProps {
|
||||
replyToId: string;
|
||||
author?: string;
|
||||
body?: string;
|
||||
onJumpTo: (id: string) => void;
|
||||
}
|
||||
|
||||
// Subtle one-line reference to the replied-to message; clicking jumps to it.
|
||||
// Falls back to a muted note when the target was deleted.
|
||||
function ReplyRef({ replyToId, author, body, onJumpTo }: ReplyRefProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="chat-reply-ref"
|
||||
onClick={() => onJumpTo(replyToId)}
|
||||
disabled={!author}
|
||||
>
|
||||
<span className="chat-reply-ref-mark">↩</span>
|
||||
{author
|
||||
? (
|
||||
<>
|
||||
<span className="chat-reply-ref-author">{author}</span>
|
||||
<span className="chat-reply-ref-text">{snippet(body ?? "")}</span>
|
||||
</>
|
||||
)
|
||||
: (
|
||||
<span className="chat-reply-ref-text">
|
||||
<Trans>deleted message</Trans>
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
interface ChatMessageItemProps {
|
||||
message: ChatMessage;
|
||||
/** Continues the previous message's run: no avatar or author line. */
|
||||
grouped: boolean;
|
||||
mentionsMe: boolean;
|
||||
/** The replied-to message has been deleted since this one loaded. */
|
||||
replyDeleted: boolean;
|
||||
canEdit: boolean;
|
||||
canDelete: boolean;
|
||||
flashed: boolean;
|
||||
editing: boolean;
|
||||
onStartEdit: (id: string) => void;
|
||||
onEndEdit: () => void;
|
||||
onEdit: (id: string, body: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onReply: (message: ChatMessage) => void;
|
||||
onJumpTo: (id: string) => void;
|
||||
}
|
||||
|
||||
function ChatMessageItem(
|
||||
{
|
||||
message,
|
||||
grouped,
|
||||
mentionsMe,
|
||||
replyDeleted,
|
||||
canEdit,
|
||||
canDelete,
|
||||
flashed,
|
||||
editing,
|
||||
onStartEdit,
|
||||
onEndEdit,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onReply,
|
||||
onJumpTo,
|
||||
}: ChatMessageItemProps,
|
||||
) {
|
||||
const bodyRef = useHeightTransition<HTMLDivElement>(editing);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
|
||||
const actions = !editing && (
|
||||
<span
|
||||
className={`chat-message-actions${
|
||||
grouped ? " chat-message-actions--float" : ""
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="chat-icon-btn"
|
||||
onClick={() => onReply(message)}
|
||||
title={t`Reply`}
|
||||
aria-label={t`Reply`}
|
||||
>
|
||||
↩
|
||||
</button>
|
||||
{canEdit && (
|
||||
<button
|
||||
type="button"
|
||||
className="chat-icon-btn"
|
||||
onClick={() => onStartEdit(message.id)}
|
||||
title={t`Edit`}
|
||||
aria-label={t`Edit`}
|
||||
>
|
||||
✎
|
||||
</button>
|
||||
)}
|
||||
{canDelete && (
|
||||
<button
|
||||
type="button"
|
||||
className="chat-icon-btn chat-icon-btn--delete"
|
||||
onClick={() => setConfirmDelete(true)}
|
||||
title={t`Delete`}
|
||||
aria-label={t`Delete`}
|
||||
>
|
||||
🗑
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
|
||||
const edited = message.updatedAt && (
|
||||
<Tooltip text={t`Edited ${message.updatedAt.toLocaleString()}`}>
|
||||
<span className="chat-message-edited">
|
||||
{grouped
|
||||
? <Trans>edited</Trans>
|
||||
: <Trans>edited {relativeTime(message.updatedAt)}</Trans>}
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`chat-message${grouped ? " chat-message--grouped" : ""}${
|
||||
mentionsMe ? " chat-message--mention" : ""
|
||||
}${flashed ? " chat-message--flash" : ""}`}
|
||||
data-mid={message.id}
|
||||
>
|
||||
{grouped
|
||||
? (
|
||||
// The avatar column holds the time instead, shown on hover.
|
||||
<span className="chat-message-gutter">
|
||||
<Tooltip text={message.createdAt.toLocaleString()}>
|
||||
<time dateTime={message.createdAt.toISOString()}>
|
||||
{clockTime(message.createdAt)}
|
||||
</time>
|
||||
</Tooltip>
|
||||
</span>
|
||||
)
|
||||
: (
|
||||
<Avatar
|
||||
userId={message.userId}
|
||||
username={message.authorUsername}
|
||||
hasAvatar={!!message.authorAvatarMime}
|
||||
size={32}
|
||||
/>
|
||||
)}
|
||||
<div className="chat-message-body" ref={bodyRef}>
|
||||
{message.replyToId && (
|
||||
<ReplyRef
|
||||
replyToId={message.replyToId}
|
||||
author={replyDeleted ? undefined : message.replyToAuthor}
|
||||
body={message.replyToBody}
|
||||
onJumpTo={onJumpTo}
|
||||
/>
|
||||
)}
|
||||
{!grouped && (
|
||||
<div className="chat-message-meta">
|
||||
<Link
|
||||
to={`/users/${message.authorUsername}`}
|
||||
className="chat-message-author"
|
||||
>
|
||||
{message.authorUsername}
|
||||
</Link>
|
||||
<Tooltip text={message.createdAt.toLocaleString()}>
|
||||
<time
|
||||
className="chat-message-time"
|
||||
dateTime={message.createdAt.toISOString()}
|
||||
>
|
||||
{relativeTime(message.createdAt)}
|
||||
</time>
|
||||
</Tooltip>
|
||||
{edited}
|
||||
{actions}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editing
|
||||
? (
|
||||
<ChatEditBox
|
||||
initial={message.body}
|
||||
onSave={(body) => {
|
||||
if (body !== message.body) onEdit(message.id, body);
|
||||
onEndEdit();
|
||||
}}
|
||||
onCancel={onEndEdit}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<Markdown className={messageTextClass(message.body)}>
|
||||
{message.body}
|
||||
</Markdown>
|
||||
)}
|
||||
|
||||
{grouped && !editing && edited}
|
||||
</div>
|
||||
{grouped && actions}
|
||||
|
||||
{confirmDelete && (
|
||||
<ConfirmModal
|
||||
message={t`Delete this message?`}
|
||||
confirmLabel={t`Delete`}
|
||||
onConfirm={() => {
|
||||
setConfirmDelete(false);
|
||||
onDelete(message.id);
|
||||
}}
|
||||
onCancel={() => setConfirmDelete(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface PendingItemProps {
|
||||
pending: PendingChatMessage;
|
||||
grouped: boolean;
|
||||
user: PublicUser;
|
||||
onRetry: (nonce: string) => void;
|
||||
onDiscard: (nonce: string) => void;
|
||||
onRestore: (pending: PendingChatMessage) => void;
|
||||
onJumpTo: (id: string) => void;
|
||||
}
|
||||
|
||||
// A message on its way: dimmed while sending; if it fails, it says why and
|
||||
// offers to try again, take it back into the composer, or drop it.
|
||||
function PendingItem(
|
||||
{ pending, grouped, user, onRetry, onDiscard, onRestore, onJumpTo }:
|
||||
PendingItemProps,
|
||||
) {
|
||||
const failed = pending.status === "failed";
|
||||
return (
|
||||
<div
|
||||
className={`chat-message chat-message--pending${
|
||||
grouped ? " chat-message--grouped" : ""
|
||||
}${failed ? " chat-message--failed" : ""}`}
|
||||
>
|
||||
{grouped ? <span className="chat-message-gutter" /> : (
|
||||
<Avatar
|
||||
userId={user.id}
|
||||
username={user.username}
|
||||
hasAvatar={!!user.avatarMime}
|
||||
size={32}
|
||||
/>
|
||||
)}
|
||||
<div className="chat-message-body">
|
||||
{pending.replyTo && (
|
||||
<ReplyRef
|
||||
replyToId={pending.replyTo.id}
|
||||
author={pending.replyTo.author}
|
||||
body={pending.replyTo.body}
|
||||
onJumpTo={onJumpTo}
|
||||
/>
|
||||
)}
|
||||
{!grouped && (
|
||||
<div className="chat-message-meta">
|
||||
<span className="chat-message-author">{user.username}</span>
|
||||
</div>
|
||||
)}
|
||||
<Markdown className={messageTextClass(pending.body)}>
|
||||
{pending.body}
|
||||
</Markdown>
|
||||
<div
|
||||
className="chat-pending-status"
|
||||
role={failed ? "alert" : undefined}
|
||||
>
|
||||
{failed
|
||||
? (
|
||||
<>
|
||||
<span className="chat-pending-error">{pending.error}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="chat-text-btn"
|
||||
onClick={() => onRetry(pending.nonce)}
|
||||
>
|
||||
<Trans>Retry</Trans>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="chat-text-btn"
|
||||
onClick={() => onRestore(pending)}
|
||||
>
|
||||
<Trans>Edit</Trans>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="chat-text-btn"
|
||||
onClick={() => onDiscard(pending.nonce)}
|
||||
>
|
||||
<Trans>Discard</Trans>
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
: <Trans>Sending…</Trans>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type Row =
|
||||
| { kind: "sep"; key: string; label: string }
|
||||
| { kind: "new"; key: string }
|
||||
93
src/components/chat/PendingItem.tsx
Normal file
93
src/components/chat/PendingItem.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
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 type { PublicUser } from "../../model.ts";
|
||||
import { ReplyRef } from "./ReplyRef.tsx";
|
||||
import { messageTextClass } from "./message.ts";
|
||||
|
||||
export interface PendingItemProps {
|
||||
pending: PendingChatMessage;
|
||||
grouped: boolean;
|
||||
user: PublicUser;
|
||||
onRetry: (nonce: string) => void;
|
||||
onDiscard: (nonce: string) => void;
|
||||
onRestore: (pending: PendingChatMessage) => void;
|
||||
onJumpTo: (id: string) => void;
|
||||
}
|
||||
|
||||
// A message on its way: dimmed while sending; if it fails, it says why and
|
||||
// offers to try again, take it back into the composer, or drop it.
|
||||
export function PendingItem(
|
||||
{ pending, grouped, user, onRetry, onDiscard, onRestore, onJumpTo }:
|
||||
PendingItemProps,
|
||||
) {
|
||||
const failed = pending.status === "failed";
|
||||
return (
|
||||
<div
|
||||
className={`chat-message chat-message--pending${
|
||||
grouped ? " chat-message--grouped" : ""
|
||||
}${failed ? " chat-message--failed" : ""}`}
|
||||
>
|
||||
{grouped ? <span className="chat-message-gutter" /> : (
|
||||
<Avatar
|
||||
userId={user.id}
|
||||
username={user.username}
|
||||
hasAvatar={!!user.avatarMime}
|
||||
size={32}
|
||||
/>
|
||||
)}
|
||||
<div className="chat-message-body">
|
||||
{pending.replyTo && (
|
||||
<ReplyRef
|
||||
replyToId={pending.replyTo.id}
|
||||
author={pending.replyTo.author}
|
||||
body={pending.replyTo.body}
|
||||
onJumpTo={onJumpTo}
|
||||
/>
|
||||
)}
|
||||
{!grouped && (
|
||||
<div className="chat-message-meta">
|
||||
<span className="chat-message-author">{user.username}</span>
|
||||
</div>
|
||||
)}
|
||||
<Markdown className={messageTextClass(pending.body)}>
|
||||
{pending.body}
|
||||
</Markdown>
|
||||
<div
|
||||
className="chat-pending-status"
|
||||
role={failed ? "alert" : undefined}
|
||||
>
|
||||
{failed
|
||||
? (
|
||||
<>
|
||||
<span className="chat-pending-error">{pending.error}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="chat-text-btn"
|
||||
onClick={() => onRetry(pending.nonce)}
|
||||
>
|
||||
<Trans>Retry</Trans>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="chat-text-btn"
|
||||
onClick={() => onRestore(pending)}
|
||||
>
|
||||
<Trans>Edit</Trans>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="chat-text-btn"
|
||||
onClick={() => onDiscard(pending.nonce)}
|
||||
>
|
||||
<Trans>Discard</Trans>
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
: <Trans>Sending…</Trans>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
36
src/components/chat/ReplyRef.tsx
Normal file
36
src/components/chat/ReplyRef.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { snippet } from "./message.ts";
|
||||
|
||||
export interface ReplyRefProps {
|
||||
replyToId: string;
|
||||
author?: string;
|
||||
body?: string;
|
||||
onJumpTo: (id: string) => void;
|
||||
}
|
||||
|
||||
// Subtle one-line reference to the replied-to message; clicking jumps to it.
|
||||
// Falls back to a muted note when the target was deleted.
|
||||
export function ReplyRef({ replyToId, author, body, onJumpTo }: ReplyRefProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="chat-reply-ref"
|
||||
onClick={() => onJumpTo(replyToId)}
|
||||
disabled={!author}
|
||||
>
|
||||
<span className="chat-reply-ref-mark">↩</span>
|
||||
{author
|
||||
? (
|
||||
<>
|
||||
<span className="chat-reply-ref-author">{author}</span>
|
||||
<span className="chat-reply-ref-text">{snippet(body ?? "")}</span>
|
||||
</>
|
||||
)
|
||||
: (
|
||||
<span className="chat-reply-ref-text">
|
||||
<Trans>deleted message</Trans>
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
26
src/components/chat/message.ts
Normal file
26
src/components/chat/message.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { markdownToPlainText } from "../../utils/chatText.ts";
|
||||
import { isEmojiOnly } from "../../utils/editorText.ts";
|
||||
|
||||
// Keep in sync with MAX_CHAT_LENGTH in api/services/chat-service.ts.
|
||||
export const MAX_CHAT_LENGTH = 2000;
|
||||
// The counter only shows once a message is getting near the limit.
|
||||
export const COUNT_FROM = 1800;
|
||||
|
||||
// What the server holds a message to: its length once trimmed.
|
||||
export function sendable(draft: string): boolean {
|
||||
const body = draft.trim();
|
||||
return body.length > 0 && body.length <= MAX_CHAT_LENGTH;
|
||||
}
|
||||
|
||||
// A message of nothing but a few emoji is shown large, as chats do.
|
||||
export function messageTextClass(body: string): string {
|
||||
return isEmojiOnly(body)
|
||||
? "chat-message-text chat-message-text--jumbo"
|
||||
: "chat-message-text";
|
||||
}
|
||||
|
||||
// One line of plain text, for reply previews.
|
||||
export function snippet(body: string): string {
|
||||
return markdownToPlainText(body, t`image`);
|
||||
}
|
||||
39
src/components/chat/time.ts
Normal file
39
src/components/chat/time.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
// Timestamps in the chat: day separators and the grouped-message gutter.
|
||||
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { i18n } from "../../i18n.ts";
|
||||
|
||||
export function isSameDay(a: Date, b: Date): boolean {
|
||||
return a.getFullYear() === b.getFullYear() &&
|
||||
a.getMonth() === b.getMonth() &&
|
||||
a.getDate() === b.getDate();
|
||||
}
|
||||
|
||||
// Human label for a day separator: "Today"/"Yesterday" for the two most recent
|
||||
// days, otherwise a localized full date.
|
||||
export function dayLabel(date: Date): string {
|
||||
const now = new Date();
|
||||
if (isSameDay(date, now)) return t`Today`;
|
||||
const yesterday = new Date(now);
|
||||
yesterday.setDate(now.getDate() - 1);
|
||||
if (isSameDay(date, yesterday)) return t`Yesterday`;
|
||||
return date.toLocaleDateString(i18n.locale, {
|
||||
weekday: "long",
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: now.getFullYear() === date.getFullYear() ? undefined : "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
// Hours and minutes only, for the narrow gutter beside a grouped message — the
|
||||
// day period ("PM") is dropped to fit; its tooltip has the full timestamp.
|
||||
export function clockTime(date: Date): string {
|
||||
return new Intl.DateTimeFormat(i18n.locale, {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
}).formatToParts(date)
|
||||
.filter((part) => part.type !== "dayPeriod")
|
||||
.map((part) => part.value)
|
||||
.join("")
|
||||
.trim();
|
||||
}
|
||||
136
src/components/comment/CommentForm.tsx
Normal file
136
src/components/comment/CommentForm.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import React from "react";
|
||||
import { Controller } 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";
|
||||
|
||||
export interface CommentFormProps {
|
||||
/** Async submit; throw to surface a root error, resolve to clear/close. */
|
||||
onSubmit: (body: string) => Promise<void>;
|
||||
submitLabel: React.ReactNode;
|
||||
pendingLabel: React.ReactNode;
|
||||
errorTitle: string;
|
||||
placeholder?: string;
|
||||
defaultValue?: string;
|
||||
onCancel?: () => void;
|
||||
/** Top-level form: only show Cancel once there's text to discard. */
|
||||
cancelWhenEmpty?: boolean;
|
||||
editorRef?: React.Ref<TextEditorHandle>;
|
||||
/** `top` renders the avatar-flanked layout; `inline` is for reply/edit. */
|
||||
layout?: "inline" | "top";
|
||||
leading?: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single-field comment editor (post / reply / edit). Wraps the shared
|
||||
* react-hook-form lifecycle so the three call sites stay identical.
|
||||
*/
|
||||
export function CommentForm({
|
||||
onSubmit,
|
||||
submitLabel,
|
||||
pendingLabel,
|
||||
errorTitle,
|
||||
placeholder,
|
||||
defaultValue = "",
|
||||
onCancel,
|
||||
cancelWhenEmpty,
|
||||
editorRef,
|
||||
layout = "inline",
|
||||
leading,
|
||||
className,
|
||||
}: CommentFormProps) {
|
||||
const form = useApiForm<{ body: string }>({
|
||||
defaultValues: { body: defaultValue },
|
||||
});
|
||||
const body = form.watch("body");
|
||||
const disabled = !body.trim() || body.length > VALIDATION.COMMENT_BODY_MAX;
|
||||
|
||||
const submit = form.submit(async ({ body }) => {
|
||||
if (!body.trim() || body.length > VALIDATION.COMMENT_BODY_MAX) return;
|
||||
await onSubmit(body);
|
||||
form.reset({ body: "" });
|
||||
});
|
||||
|
||||
const showCancel = onCancel && (!cancelWhenEmpty || body.trim().length > 0);
|
||||
|
||||
const inner = (
|
||||
<>
|
||||
<Controller
|
||||
name="body"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<TextEditor
|
||||
ref={editorRef}
|
||||
className="comment-reply-textarea"
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
onSubmit={() => void submit()}
|
||||
// Escape backs out of a reply or an edit; the top-level box has
|
||||
// nothing to back out of.
|
||||
onCancel={onCancel && !cancelWhenEmpty
|
||||
? () => {
|
||||
form.reset({ body: "" });
|
||||
onCancel();
|
||||
}
|
||||
: undefined}
|
||||
placeholder={placeholder}
|
||||
autoResize
|
||||
rows={1}
|
||||
maxLength={VALIDATION.COMMENT_BODY_MAX}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<FormError title={errorTitle} />
|
||||
<div className="comment-form-actions">
|
||||
<SubmitButton
|
||||
className="comment-submit-btn"
|
||||
pendingLabel={pendingLabel}
|
||||
disabled={disabled}
|
||||
>
|
||||
{submitLabel}
|
||||
</SubmitButton>
|
||||
{showCancel && (
|
||||
<button
|
||||
type="button"
|
||||
className="comment-cancel-btn"
|
||||
onClick={() => {
|
||||
form.reset({ body: "" });
|
||||
onCancel?.();
|
||||
}}
|
||||
>
|
||||
<Trans>Cancel</Trans>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
{layout === "top"
|
||||
? (
|
||||
<form className="comment-top-form" onSubmit={submit}>
|
||||
<div className="comment-top-form-inner">
|
||||
{leading}
|
||||
<div className="comment-top-form-body">{inner}</div>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
: (
|
||||
<form
|
||||
className={className ? `comment-form ${className}` : "comment-form"}
|
||||
onSubmit={submit}
|
||||
>
|
||||
{inner}
|
||||
</form>
|
||||
)}
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,180 +1,29 @@
|
||||
import React, { useMemo, useRef, useState } from "react";
|
||||
import React, { useRef, useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { Controller } from "react-hook-form";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Plural, Trans } from "@lingui/react/macro";
|
||||
import { API_URL, VALIDATION } from "../config/api.ts";
|
||||
import { can } from "../utils/permissions.ts";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { API_URL } from "../../config/api.ts";
|
||||
import { can } from "../../utils/permissions.ts";
|
||||
import type {
|
||||
Comment,
|
||||
CreateCommentRequest,
|
||||
RawComment,
|
||||
UpdateCommentRequest,
|
||||
User,
|
||||
} from "../model.ts";
|
||||
import { deserializeComment } from "../model.ts";
|
||||
import { Avatar } from "./Avatar.tsx";
|
||||
import { Markdown } from "./Markdown.tsx";
|
||||
import { TextEditor, type TextEditorHandle } from "./TextEditor.tsx";
|
||||
import { LikeButton } from "./LikeButton.tsx";
|
||||
import { relativeTime } from "../utils/relativeTime.ts";
|
||||
import { Tooltip } from "./Tooltip.tsx";
|
||||
import { ConfirmModal } from "./ConfirmModal.tsx";
|
||||
import { useHeightTransition } from "../hooks/useHeightTransition.ts";
|
||||
import { useWS } from "../hooks/useWS.ts";
|
||||
import { useAuth } from "../hooks/useAuth.ts";
|
||||
import {
|
||||
expectOk,
|
||||
FormError,
|
||||
FormProvider,
|
||||
SubmitButton,
|
||||
useApiForm,
|
||||
} from "./form/index.ts";
|
||||
|
||||
interface CommentFormProps {
|
||||
/** Async submit; throw to surface a root error, resolve to clear/close. */
|
||||
onSubmit: (body: string) => Promise<void>;
|
||||
submitLabel: React.ReactNode;
|
||||
pendingLabel: React.ReactNode;
|
||||
errorTitle: string;
|
||||
placeholder?: string;
|
||||
defaultValue?: string;
|
||||
onCancel?: () => void;
|
||||
/** Top-level form: only show Cancel once there's text to discard. */
|
||||
cancelWhenEmpty?: boolean;
|
||||
editorRef?: React.Ref<TextEditorHandle>;
|
||||
/** `top` renders the avatar-flanked layout; `inline` is for reply/edit. */
|
||||
layout?: "inline" | "top";
|
||||
leading?: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single-field comment editor (post / reply / edit). Wraps the shared
|
||||
* react-hook-form lifecycle so the three call sites stay identical.
|
||||
*/
|
||||
function CommentForm({
|
||||
onSubmit,
|
||||
submitLabel,
|
||||
pendingLabel,
|
||||
errorTitle,
|
||||
placeholder,
|
||||
defaultValue = "",
|
||||
onCancel,
|
||||
cancelWhenEmpty,
|
||||
editorRef,
|
||||
layout = "inline",
|
||||
leading,
|
||||
className,
|
||||
}: CommentFormProps) {
|
||||
const form = useApiForm<{ body: string }>({
|
||||
defaultValues: { body: defaultValue },
|
||||
});
|
||||
const body = form.watch("body");
|
||||
const disabled = !body.trim() || body.length > VALIDATION.COMMENT_BODY_MAX;
|
||||
|
||||
const submit = form.submit(async ({ body }) => {
|
||||
if (!body.trim() || body.length > VALIDATION.COMMENT_BODY_MAX) return;
|
||||
await onSubmit(body);
|
||||
form.reset({ body: "" });
|
||||
});
|
||||
|
||||
const showCancel = onCancel && (!cancelWhenEmpty || body.trim().length > 0);
|
||||
|
||||
const inner = (
|
||||
<>
|
||||
<Controller
|
||||
name="body"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<TextEditor
|
||||
ref={editorRef}
|
||||
className="comment-reply-textarea"
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
onSubmit={() => void submit()}
|
||||
// Escape backs out of a reply or an edit; the top-level box has
|
||||
// nothing to back out of.
|
||||
onCancel={onCancel && !cancelWhenEmpty
|
||||
? () => {
|
||||
form.reset({ body: "" });
|
||||
onCancel();
|
||||
}
|
||||
: undefined}
|
||||
placeholder={placeholder}
|
||||
autoResize
|
||||
rows={1}
|
||||
maxLength={VALIDATION.COMMENT_BODY_MAX}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<FormError title={errorTitle} />
|
||||
<div className="comment-form-actions">
|
||||
<SubmitButton
|
||||
className="comment-submit-btn"
|
||||
pendingLabel={pendingLabel}
|
||||
disabled={disabled}
|
||||
>
|
||||
{submitLabel}
|
||||
</SubmitButton>
|
||||
{showCancel && (
|
||||
<button
|
||||
type="button"
|
||||
className="comment-cancel-btn"
|
||||
onClick={() => {
|
||||
form.reset({ body: "" });
|
||||
onCancel?.();
|
||||
}}
|
||||
>
|
||||
<Trans>Cancel</Trans>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
{layout === "top"
|
||||
? (
|
||||
<form className="comment-top-form" onSubmit={submit}>
|
||||
<div className="comment-top-form-inner">
|
||||
{leading}
|
||||
<div className="comment-top-form-body">{inner}</div>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
: (
|
||||
<form
|
||||
className={className ? `comment-form ${className}` : "comment-form"}
|
||||
onSubmit={submit}
|
||||
>
|
||||
{inner}
|
||||
</form>
|
||||
)}
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
|
||||
interface CommentThreadProps {
|
||||
dumpId: string;
|
||||
comments: Comment[];
|
||||
currentUser: User | null;
|
||||
token: string | null;
|
||||
onCommentCreated: (comment: Comment) => void;
|
||||
onCommentDeleted: (commentId: string) => void;
|
||||
onCommentUpdated: (comment: Comment) => void;
|
||||
}
|
||||
|
||||
function buildTree(comments: Comment[]): Map<string, Comment[]> {
|
||||
const map = new Map<string, Comment[]>();
|
||||
for (const c of comments) {
|
||||
const key = c.parentId ?? "root";
|
||||
if (!map.has(key)) map.set(key, []);
|
||||
map.get(key)!.push(c);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
} from "../../model.ts";
|
||||
import { deserializeComment } from "../../model.ts";
|
||||
import { Avatar } from "../ui/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 { CommentForm } from "./CommentForm.tsx";
|
||||
|
||||
interface CommentNodeProps {
|
||||
comment: Comment;
|
||||
@@ -188,7 +37,7 @@ interface CommentNodeProps {
|
||||
onCommentUpdated: (comment: Comment) => void;
|
||||
}
|
||||
|
||||
function CommentNode({
|
||||
export function CommentNode({
|
||||
comment,
|
||||
tree,
|
||||
depth,
|
||||
@@ -454,77 +303,3 @@ function CommentNode({
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export function CommentThread({
|
||||
dumpId,
|
||||
comments,
|
||||
currentUser,
|
||||
token,
|
||||
onCommentCreated,
|
||||
onCommentDeleted,
|
||||
onCommentUpdated,
|
||||
}: CommentThreadProps) {
|
||||
const tree = useMemo(() => buildTree(comments), [comments]);
|
||||
const roots = tree.get("root") ?? [];
|
||||
const { authFetch } = useAuth();
|
||||
|
||||
async function handleTopLevelSubmit(body: string) {
|
||||
const res = await authFetch(`${API_URL}/api/dumps/${dumpId}/comments`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ body } satisfies CreateCommentRequest),
|
||||
});
|
||||
onCommentCreated(deserializeComment(await expectOk<RawComment>(res)));
|
||||
}
|
||||
|
||||
const visibleCount = comments.filter((c) => !c.deleted).length;
|
||||
|
||||
return (
|
||||
<section className="comment-section">
|
||||
<h2 className="comment-section-title">
|
||||
<Plural value={visibleCount} one="# comment" other="# comments" />
|
||||
</h2>
|
||||
|
||||
{currentUser && (
|
||||
<CommentForm
|
||||
layout="top"
|
||||
leading={
|
||||
<div className="comment-avatar">
|
||||
<Avatar
|
||||
userId={currentUser.id}
|
||||
username={currentUser.username}
|
||||
hasAvatar={!!currentUser.avatarMime}
|
||||
size={28}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
onSubmit={handleTopLevelSubmit}
|
||||
submitLabel={<Trans>Post comment</Trans>}
|
||||
pendingLabel={<Trans>Posting…</Trans>}
|
||||
errorTitle={t`Failed to post comment`}
|
||||
placeholder={t`Add a comment…`}
|
||||
onCancel={() => {}}
|
||||
cancelWhenEmpty
|
||||
/>
|
||||
)}
|
||||
|
||||
{roots.length > 0 && (
|
||||
<ul className="comment-list">
|
||||
{roots.map((comment) => (
|
||||
<CommentNode
|
||||
key={comment.id}
|
||||
comment={comment}
|
||||
tree={tree}
|
||||
depth={0}
|
||||
dumpId={dumpId}
|
||||
currentUser={currentUser}
|
||||
token={token}
|
||||
onCommentCreated={onCommentCreated}
|
||||
onCommentDeleted={onCommentDeleted}
|
||||
onCommentUpdated={onCommentUpdated}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
110
src/components/comment/CommentThread.tsx
Normal file
110
src/components/comment/CommentThread.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { useMemo } from "react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Plural, Trans } from "@lingui/react/macro";
|
||||
import { API_URL } from "../../config/api.ts";
|
||||
import type {
|
||||
Comment,
|
||||
CreateCommentRequest,
|
||||
RawComment,
|
||||
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 { CommentForm } from "./CommentForm.tsx";
|
||||
import { CommentNode } from "./CommentNode.tsx";
|
||||
|
||||
interface CommentThreadProps {
|
||||
dumpId: string;
|
||||
comments: Comment[];
|
||||
currentUser: User | null;
|
||||
token: string | null;
|
||||
onCommentCreated: (comment: Comment) => void;
|
||||
onCommentDeleted: (commentId: string) => void;
|
||||
onCommentUpdated: (comment: Comment) => void;
|
||||
}
|
||||
|
||||
function buildTree(comments: Comment[]): Map<string, Comment[]> {
|
||||
const map = new Map<string, Comment[]>();
|
||||
for (const c of comments) {
|
||||
const key = c.parentId ?? "root";
|
||||
if (!map.has(key)) map.set(key, []);
|
||||
map.get(key)!.push(c);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
export function CommentThread({
|
||||
dumpId,
|
||||
comments,
|
||||
currentUser,
|
||||
token,
|
||||
onCommentCreated,
|
||||
onCommentDeleted,
|
||||
onCommentUpdated,
|
||||
}: CommentThreadProps) {
|
||||
const tree = useMemo(() => buildTree(comments), [comments]);
|
||||
const roots = tree.get("root") ?? [];
|
||||
const { authFetch } = useAuth();
|
||||
|
||||
async function handleTopLevelSubmit(body: string) {
|
||||
const res = await authFetch(`${API_URL}/api/dumps/${dumpId}/comments`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ body } satisfies CreateCommentRequest),
|
||||
});
|
||||
onCommentCreated(deserializeComment(await expectOk<RawComment>(res)));
|
||||
}
|
||||
|
||||
const visibleCount = comments.filter((c) => !c.deleted).length;
|
||||
|
||||
return (
|
||||
<section className="comment-section">
|
||||
<h2 className="comment-section-title">
|
||||
<Plural value={visibleCount} one="# comment" other="# comments" />
|
||||
</h2>
|
||||
|
||||
{currentUser && (
|
||||
<CommentForm
|
||||
layout="top"
|
||||
leading={
|
||||
<div className="comment-avatar">
|
||||
<Avatar
|
||||
userId={currentUser.id}
|
||||
username={currentUser.username}
|
||||
hasAvatar={!!currentUser.avatarMime}
|
||||
size={28}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
onSubmit={handleTopLevelSubmit}
|
||||
submitLabel={<Trans>Post comment</Trans>}
|
||||
pendingLabel={<Trans>Posting…</Trans>}
|
||||
errorTitle={t`Failed to post comment`}
|
||||
placeholder={t`Add a comment…`}
|
||||
onCancel={() => {}}
|
||||
cancelWhenEmpty
|
||||
/>
|
||||
)}
|
||||
|
||||
{roots.length > 0 && (
|
||||
<ul className="comment-list">
|
||||
{roots.map((comment) => (
|
||||
<CommentNode
|
||||
key={comment.id}
|
||||
comment={comment}
|
||||
tree={tree}
|
||||
depth={0}
|
||||
dumpId={dumpId}
|
||||
currentUser={currentUser}
|
||||
token={token}
|
||||
onCommentCreated={onCommentCreated}
|
||||
onCommentDeleted={onCommentDeleted}
|
||||
onCommentUpdated={onCommentUpdated}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +1,23 @@
|
||||
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 { isDumpVisited, isRecent, markDumpVisited } from "../utils/visited.ts";
|
||||
import FilePreview from "./FilePreview.tsx";
|
||||
import RichContentCard from "./RichContentCard.tsx";
|
||||
import ThumbnailPlaceholder from "./ThumbnailPlaceholder.tsx";
|
||||
import { VoteButton } from "./VoteButton.tsx";
|
||||
import { Markdown } from "./Markdown.tsx";
|
||||
import { Tooltip } from "./Tooltip.tsx";
|
||||
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 {
|
||||
isDumpVisited,
|
||||
isRecent,
|
||||
markDumpVisited,
|
||||
} from "../../utils/visited.ts";
|
||||
import { FilePreview } from "./FilePreview.tsx";
|
||||
import { RichContentCard } from "./RichContentCard.tsx";
|
||||
import { ThumbnailPlaceholder } from "../ui/ThumbnailPlaceholder.tsx";
|
||||
import { VoteButton } from "../social/VoteButton.tsx";
|
||||
import { Markdown } from "../editor/Markdown.tsx";
|
||||
import { Tooltip } from "../ui/Tooltip.tsx";
|
||||
|
||||
interface DumpCardProps {
|
||||
dump: Dump;
|
||||
@@ -1,14 +1,15 @@
|
||||
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, MediaPlayer } from "./MediaPlayer.tsx";
|
||||
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 { MediaPlayer } from "../player/MediaPlayer.tsx";
|
||||
import {
|
||||
PlayerContext,
|
||||
type PlayerItem,
|
||||
PlayerTimeContext,
|
||||
} from "../contexts/PlayerContext.ts";
|
||||
} from "../../contexts/PlayerContext.ts";
|
||||
import {
|
||||
BAR_GAP,
|
||||
BAR_W,
|
||||
@@ -16,7 +17,7 @@ import {
|
||||
NUM_BARS,
|
||||
VIEWBOX_W,
|
||||
WAVEFORM_H,
|
||||
} from "../utils/waveform.ts";
|
||||
} from "../../utils/waveform.ts";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
|
||||
@@ -180,7 +181,7 @@ function mimeIcon(mime: string): string {
|
||||
return "📁";
|
||||
}
|
||||
|
||||
export default function FilePreview(
|
||||
export function FilePreview(
|
||||
{ dump, compact = false, global: useGlobal = false }: FilePreviewProps,
|
||||
) {
|
||||
const { current, playing, play, togglePlay } = useContext(PlayerContext);
|
||||
@@ -1,25 +1,29 @@
|
||||
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 { isDumpVisited, isRecent, markDumpVisited } from "../utils/visited.ts";
|
||||
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 {
|
||||
isDumpVisited,
|
||||
isRecent,
|
||||
markDumpVisited,
|
||||
} from "../../utils/visited.ts";
|
||||
import {
|
||||
hasQuote,
|
||||
hasThumbnail,
|
||||
type JournalShape,
|
||||
} from "../utils/journalLayout.ts";
|
||||
import { VoteButton } from "./VoteButton.tsx";
|
||||
import { Markdown } from "./Markdown.tsx";
|
||||
import { Tooltip } from "./Tooltip.tsx";
|
||||
import Thumbnail from "./Thumbnail.tsx";
|
||||
} from "../../utils/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 {
|
||||
canPlayRichContent,
|
||||
usePlayRichContent,
|
||||
} from "../hooks/usePlayRichContent.ts";
|
||||
} from "../../hooks/usePlayRichContent.ts";
|
||||
|
||||
export type { JournalShape };
|
||||
|
||||
@@ -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 type { RichContent } from "../../model.ts";
|
||||
import { PlayerContext } from "../../contexts/PlayerContext.ts";
|
||||
import {
|
||||
canPlayRichContent,
|
||||
usePlayRichContent,
|
||||
} from "../hooks/usePlayRichContent.ts";
|
||||
import Thumbnail from "./Thumbnail.tsx";
|
||||
} from "../../hooks/usePlayRichContent.ts";
|
||||
import { Thumbnail } from "../ui/Thumbnail.tsx";
|
||||
|
||||
interface RichContentCardProps {
|
||||
richContent: RichContent;
|
||||
@@ -17,7 +17,7 @@ interface RichContentCardProps {
|
||||
dumpHref?: string;
|
||||
}
|
||||
|
||||
export default function RichContentCard(
|
||||
export function RichContentCard(
|
||||
{ richContent, compact = false, thumbnailOverrideUrl, dumpHref }:
|
||||
RichContentCardProps,
|
||||
) {
|
||||
@@ -1,10 +1,9 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { UseFormRegisterReturn } from "react-hook-form";
|
||||
import { Link } from "react-router";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Plural, Trans } from "@lingui/react/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
|
||||
import { API_URL, VALIDATION } from "../config/api.ts";
|
||||
import { API_URL, VALIDATION } from "../../../config/api.ts";
|
||||
import type {
|
||||
CreateUrlDumpRequest,
|
||||
Dump,
|
||||
@@ -14,32 +13,30 @@ import type {
|
||||
RawDumpUrlMatch,
|
||||
RawPlaylistMembership,
|
||||
UrlPreviewResponse,
|
||||
} from "../model.ts";
|
||||
} from "../../../model.ts";
|
||||
import {
|
||||
deserializeDump,
|
||||
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 { relativeTime } from "../utils/relativeTime.ts";
|
||||
import { titleFromFileName } from "../utils/format.ts";
|
||||
} 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 {
|
||||
clearDumpDraft,
|
||||
type DumpDraft,
|
||||
EMPTY_DUMP_DRAFT,
|
||||
loadDumpDraft,
|
||||
saveDumpDraft,
|
||||
} from "../utils/dumpDraft.ts";
|
||||
import { MAX_FILE_SIZE } from "../config/upload.ts";
|
||||
import RichContentCard from "./RichContentCard.tsx";
|
||||
import { MediaPlayer } from "./MediaPlayer.tsx";
|
||||
import type { RichContent } from "../model.ts";
|
||||
import { Modal } from "./Modal.tsx";
|
||||
import type { TextEditorHandle } from "./TextEditor.tsx";
|
||||
import { CategorySelect } from "./CategorySelect.tsx";
|
||||
import { PlaylistMembershipPanel } from "./PlaylistMembershipPanel.tsx";
|
||||
} from "../../../utils/dumpDraft.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 type { TextEditorHandle } from "../../editor/TextEditor.tsx";
|
||||
import { CategorySelect } from "../../category/CategorySelect.tsx";
|
||||
import { PlaylistMembershipPanel } from "../../playlist/PlaylistMembershipPanel.tsx";
|
||||
import {
|
||||
expectOk,
|
||||
FileField,
|
||||
@@ -49,54 +46,15 @@ import {
|
||||
SubmitButton,
|
||||
useApiForm,
|
||||
VisibilityToggle,
|
||||
} from "./form/index.ts";
|
||||
|
||||
/**
|
||||
* Posting a dump walks three panels. The split is deliberate: categories and
|
||||
* visibility used to sit behind a disclosure, where they read as optional and
|
||||
* went untouched — they are neither, so "why & where" is a stop on the way to
|
||||
* posting rather than something to go looking for.
|
||||
*/
|
||||
type Step = "what" | "details" | "playlist";
|
||||
} from "../../form/index.ts";
|
||||
import { DuplicateNotice } from "./DuplicateNotice.tsx";
|
||||
import { LocalFilePreview } from "./LocalFilePreview.tsx";
|
||||
import { type Step, StepIndicator } from "./StepIndicator.tsx";
|
||||
import { TitleField } from "./TitleField.tsx";
|
||||
|
||||
/** Stable empty result, so rendering doesn't churn on a fresh array each pass. */
|
||||
const NO_DUPLICATES: DumpUrlMatch[] = [];
|
||||
|
||||
const STEPS: { id: Step; label: () => string }[] = [
|
||||
{ id: "what", label: () => t`Link or file` },
|
||||
{ id: "details", label: () => t`Why & where` },
|
||||
{ id: "playlist", label: () => t`Playlists` },
|
||||
];
|
||||
|
||||
/** Where you are in the three panels — and, past the first, how far you got. */
|
||||
function StepIndicator({ current }: { current: Step }) {
|
||||
const currentIndex = STEPS.findIndex((s) => s.id === current);
|
||||
|
||||
return (
|
||||
<ol className="dump-steps">
|
||||
{STEPS.map((step, i) => {
|
||||
const state = i < currentIndex
|
||||
? "done"
|
||||
: i === currentIndex
|
||||
? "current"
|
||||
: "todo";
|
||||
return (
|
||||
<li
|
||||
key={step.id}
|
||||
className={`dump-step dump-step--${state}`}
|
||||
aria-current={state === "current" ? "step" : undefined}
|
||||
>
|
||||
<span className="dump-step-marker" aria-hidden="true">
|
||||
{state === "done" ? "✓" : i + 1}
|
||||
</span>
|
||||
<span className="dump-step-label">{step.label()}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
|
||||
type UrlPreview =
|
||||
| { status: "idle" }
|
||||
| { status: "loading" }
|
||||
@@ -119,145 +77,6 @@ function prefersNoAutoFocus(): boolean {
|
||||
return globalThis.matchMedia?.("(pointer: coarse)").matches ?? false;
|
||||
}
|
||||
|
||||
function LocalFilePreview({ file }: { file: File }) {
|
||||
const [src, setSrc] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const url = URL.createObjectURL(file);
|
||||
// Blob URL lifecycle requires setState in effect — no synchronous alternative.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setSrc(url);
|
||||
return () => {
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
}, [file]);
|
||||
|
||||
if (!src) return null;
|
||||
const mime = file.type;
|
||||
if (mime.startsWith("image/")) {
|
||||
return <img src={src} alt={file.name} className="local-preview-image" />;
|
||||
}
|
||||
if (mime.startsWith("video/")) {
|
||||
return <MediaPlayer key={src} src={src} kind="video" mime={mime} />;
|
||||
}
|
||||
if (mime.startsWith("audio/")) {
|
||||
return <MediaPlayer key={src} src={src} kind="audio" mime={mime} />;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Title row shared by both kinds of dump: the suggested title (scraped from the
|
||||
* page, or derived from the filename) sits there read-only until the ✎ is used,
|
||||
* which is enough to stop stray typing while keeping a correction one click
|
||||
* away. Once edited, a reset offers the suggestion back.
|
||||
*/
|
||||
function TitleField({
|
||||
registration,
|
||||
editing,
|
||||
onEdit,
|
||||
onReset,
|
||||
disabled,
|
||||
}: {
|
||||
registration: UseFormRegisterReturn<"title">;
|
||||
editing: boolean;
|
||||
onEdit: () => void;
|
||||
onReset?: () => void;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const { ref, ...titleReg } = registration;
|
||||
|
||||
return (
|
||||
<div className="form-field">
|
||||
<label className="form-label" htmlFor="dc-title">
|
||||
<Trans>Title</Trans>
|
||||
</label>
|
||||
<div className="dump-create-title-row">
|
||||
<input
|
||||
id="dc-title"
|
||||
type="text"
|
||||
{...titleReg}
|
||||
ref={(el) => {
|
||||
ref(el);
|
||||
inputRef.current = el;
|
||||
}}
|
||||
disabled={disabled || !editing}
|
||||
maxLength={VALIDATION.DUMP_TITLE_MAX}
|
||||
/>
|
||||
{!editing && (
|
||||
<button
|
||||
type="button"
|
||||
className="dump-create-title-edit-btn"
|
||||
onClick={() => {
|
||||
onEdit();
|
||||
setTimeout(() => inputRef.current?.focus(), 0);
|
||||
}}
|
||||
disabled={disabled}
|
||||
aria-label={t`Edit title`}
|
||||
>
|
||||
✎
|
||||
</button>
|
||||
)}
|
||||
{editing && onReset && (
|
||||
<button
|
||||
type="button"
|
||||
className="dump-create-title-edit-btn"
|
||||
onClick={onReset}
|
||||
disabled={disabled}
|
||||
aria-label={t`Reset to the suggested title`}
|
||||
title={t`Reset to the suggested title`}
|
||||
>
|
||||
↺
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** "Already dumped" hint — never blocking, since reposts are sometimes on purpose. */
|
||||
function DuplicateNotice(
|
||||
{ matches, onNavigate }: {
|
||||
matches: DumpUrlMatch[];
|
||||
onNavigate: () => void;
|
||||
},
|
||||
) {
|
||||
const [first, ...rest] = matches;
|
||||
|
||||
return (
|
||||
<div className="dump-duplicate" role="status">
|
||||
<span className="dump-duplicate-icon" aria-hidden="true">↩</span>
|
||||
<div className="dump-duplicate-body">
|
||||
<p className="dump-duplicate-lead">
|
||||
<Trans>
|
||||
Already dumped by {first.username} {relativeTime(first.createdAt)}
|
||||
</Trans>
|
||||
</p>
|
||||
<Link
|
||||
to={dumpUrl(first)}
|
||||
className="dump-duplicate-link"
|
||||
onClick={onNavigate}
|
||||
>
|
||||
{first.title}
|
||||
</Link>
|
||||
{rest.length > 0 && (
|
||||
<p className="dump-duplicate-more">
|
||||
<Plural
|
||||
value={rest.length}
|
||||
one="and # earlier dump"
|
||||
other="and # earlier dumps"
|
||||
/>
|
||||
</p>
|
||||
)}
|
||||
<p className="dump-duplicate-hint">
|
||||
<Trans>You can post it anyway.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface DumpCreateModalProps {
|
||||
onClose: () => void;
|
||||
initialUrl?: string;
|
||||
47
src/components/dump/create/DuplicateNotice.tsx
Normal file
47
src/components/dump/create/DuplicateNotice.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
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";
|
||||
|
||||
/** "Already dumped" hint — never blocking, since reposts are sometimes on purpose. */
|
||||
export function DuplicateNotice(
|
||||
{ matches, onNavigate }: {
|
||||
matches: DumpUrlMatch[];
|
||||
onNavigate: () => void;
|
||||
},
|
||||
) {
|
||||
const [first, ...rest] = matches;
|
||||
|
||||
return (
|
||||
<div className="dump-duplicate" role="status">
|
||||
<span className="dump-duplicate-icon" aria-hidden="true">↩</span>
|
||||
<div className="dump-duplicate-body">
|
||||
<p className="dump-duplicate-lead">
|
||||
<Trans>
|
||||
Already dumped by {first.username} {relativeTime(first.createdAt)}
|
||||
</Trans>
|
||||
</p>
|
||||
<Link
|
||||
to={dumpUrl(first)}
|
||||
className="dump-duplicate-link"
|
||||
onClick={onNavigate}
|
||||
>
|
||||
{first.title}
|
||||
</Link>
|
||||
{rest.length > 0 && (
|
||||
<p className="dump-duplicate-more">
|
||||
<Plural
|
||||
value={rest.length}
|
||||
one="and # earlier dump"
|
||||
other="and # earlier dumps"
|
||||
/>
|
||||
</p>
|
||||
)}
|
||||
<p className="dump-duplicate-hint">
|
||||
<Trans>You can post it anyway.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
30
src/components/dump/create/LocalFilePreview.tsx
Normal file
30
src/components/dump/create/LocalFilePreview.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { MediaPlayer } from "../../player/MediaPlayer.tsx";
|
||||
|
||||
/** What a picked file will look like, before it's uploaded. */
|
||||
export function LocalFilePreview({ file }: { file: File }) {
|
||||
const [src, setSrc] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const url = URL.createObjectURL(file);
|
||||
// Blob URL lifecycle requires setState in effect — no synchronous alternative.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setSrc(url);
|
||||
return () => {
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
}, [file]);
|
||||
|
||||
if (!src) return null;
|
||||
const mime = file.type;
|
||||
if (mime.startsWith("image/")) {
|
||||
return <img src={src} alt={file.name} className="local-preview-image" />;
|
||||
}
|
||||
if (mime.startsWith("video/")) {
|
||||
return <MediaPlayer key={src} src={src} kind="video" mime={mime} />;
|
||||
}
|
||||
if (mime.startsWith("audio/")) {
|
||||
return <MediaPlayer key={src} src={src} kind="audio" mime={mime} />;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
44
src/components/dump/create/StepIndicator.tsx
Normal file
44
src/components/dump/create/StepIndicator.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
|
||||
/**
|
||||
* Posting a dump walks three panels. The split is deliberate: categories and
|
||||
* visibility used to sit behind a disclosure, where they read as optional and
|
||||
* went untouched — they are neither, so "why & where" is a stop on the way to
|
||||
* posting rather than something to go looking for.
|
||||
*/
|
||||
export type Step = "what" | "details" | "playlist";
|
||||
|
||||
const STEPS: { id: Step; label: () => string }[] = [
|
||||
{ id: "what", label: () => t`Link or file` },
|
||||
{ id: "details", label: () => t`Why & where` },
|
||||
{ id: "playlist", label: () => t`Playlists` },
|
||||
];
|
||||
|
||||
/** Where you are in the three panels — and, past the first, how far you got. */
|
||||
export function StepIndicator({ current }: { current: Step }) {
|
||||
const currentIndex = STEPS.findIndex((s) => s.id === current);
|
||||
|
||||
return (
|
||||
<ol className="dump-steps">
|
||||
{STEPS.map((step, i) => {
|
||||
const state = i < currentIndex
|
||||
? "done"
|
||||
: i === currentIndex
|
||||
? "current"
|
||||
: "todo";
|
||||
return (
|
||||
<li
|
||||
key={step.id}
|
||||
className={`dump-step dump-step--${state}`}
|
||||
aria-current={state === "current" ? "step" : undefined}
|
||||
>
|
||||
<span className="dump-step-marker" aria-hidden="true">
|
||||
{state === "done" ? "✓" : i + 1}
|
||||
</span>
|
||||
<span className="dump-step-label">{step.label()}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
75
src/components/dump/create/TitleField.tsx
Normal file
75
src/components/dump/create/TitleField.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { useRef } from "react";
|
||||
import type { UseFormRegisterReturn } from "react-hook-form";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { VALIDATION } from "../../../config/api.ts";
|
||||
|
||||
/**
|
||||
* Title row shared by both kinds of dump: the suggested title (scraped from the
|
||||
* page, or derived from the filename) sits there read-only until the ✎ is used,
|
||||
* which is enough to stop stray typing while keeping a correction one click
|
||||
* away. Once edited, a reset offers the suggestion back.
|
||||
*/
|
||||
export function TitleField({
|
||||
registration,
|
||||
editing,
|
||||
onEdit,
|
||||
onReset,
|
||||
disabled,
|
||||
}: {
|
||||
registration: UseFormRegisterReturn<"title">;
|
||||
editing: boolean;
|
||||
onEdit: () => void;
|
||||
onReset?: () => void;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const { ref, ...titleReg } = registration;
|
||||
|
||||
return (
|
||||
<div className="form-field">
|
||||
<label className="form-label" htmlFor="dc-title">
|
||||
<Trans>Title</Trans>
|
||||
</label>
|
||||
<div className="dump-create-title-row">
|
||||
<input
|
||||
id="dc-title"
|
||||
type="text"
|
||||
{...titleReg}
|
||||
ref={(el) => {
|
||||
ref(el);
|
||||
inputRef.current = el;
|
||||
}}
|
||||
disabled={disabled || !editing}
|
||||
maxLength={VALIDATION.DUMP_TITLE_MAX}
|
||||
/>
|
||||
{!editing && (
|
||||
<button
|
||||
type="button"
|
||||
className="dump-create-title-edit-btn"
|
||||
onClick={() => {
|
||||
onEdit();
|
||||
setTimeout(() => inputRef.current?.focus(), 0);
|
||||
}}
|
||||
disabled={disabled}
|
||||
aria-label={t`Edit title`}
|
||||
>
|
||||
✎
|
||||
</button>
|
||||
)}
|
||||
{editing && onReset && (
|
||||
<button
|
||||
type="button"
|
||||
className="dump-create-title-edit-btn"
|
||||
onClick={onReset}
|
||||
disabled={disabled}
|
||||
aria-label={t`Reset to the suggested title`}
|
||||
title={t`Reset to the suggested title`}
|
||||
>
|
||||
↺
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
112
src/components/editor/EmojiPickerPopup.tsx
Normal file
112
src/components/editor/EmojiPickerPopup.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import {
|
||||
type CSSProperties,
|
||||
type KeyboardEvent,
|
||||
type Ref,
|
||||
useEffect,
|
||||
} from "react";
|
||||
import {
|
||||
EmojiPicker,
|
||||
type Locale as EmojiLocale,
|
||||
useSkinTone,
|
||||
} from "frimousse";
|
||||
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";
|
||||
|
||||
/** Remembers the skin tone picked in the selector for the next picker. */
|
||||
function SkinTonePersist() {
|
||||
const [skinTone] = useSkinTone();
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(SKIN_TONE_KEY, skinTone);
|
||||
} catch {
|
||||
// Private mode: the tone lasts as long as the picker.
|
||||
}
|
||||
}, [skinTone]);
|
||||
return null;
|
||||
}
|
||||
|
||||
interface EmojiPickerPopupProps {
|
||||
floatRef: Ref<HTMLDivElement>;
|
||||
searchRef: Ref<HTMLInputElement>;
|
||||
viewportRef: Ref<HTMLDivElement>;
|
||||
style: CSSProperties;
|
||||
/** Inline, the query is typed in the textarea and the search only shows it;
|
||||
* browsing, the search field takes it. */
|
||||
inline: boolean;
|
||||
query: string;
|
||||
onQueryChange: (query: string) => void;
|
||||
onSelect: (emoji: string) => void;
|
||||
onClose: () => void;
|
||||
onKeyDownCapture: (e: KeyboardEvent<HTMLDivElement>) => void;
|
||||
}
|
||||
|
||||
/** The floating emoji picker the editor opens from its button or a `:query`. */
|
||||
export function EmojiPickerPopup(
|
||||
{
|
||||
floatRef,
|
||||
searchRef,
|
||||
viewportRef,
|
||||
style,
|
||||
inline,
|
||||
query,
|
||||
onQueryChange,
|
||||
onSelect,
|
||||
onClose,
|
||||
onKeyDownCapture,
|
||||
}: EmojiPickerPopupProps,
|
||||
) {
|
||||
const { i18n } = useLingui();
|
||||
return (
|
||||
<div
|
||||
ref={floatRef}
|
||||
className="emoji-picker-float"
|
||||
style={style}
|
||||
onKeyDownCapture={onKeyDownCapture}
|
||||
>
|
||||
<EmojiPicker.Root
|
||||
onEmojiSelect={(e) => onSelect(e.emoji)}
|
||||
locale={i18n.locale as EmojiLocale}
|
||||
emojibaseUrl={import.meta.env.VITE_EMOJIBASE_URL}
|
||||
skinTone={readSkinTone()}
|
||||
>
|
||||
<SkinTonePersist />
|
||||
<div className="emoji-picker-search-row">
|
||||
<EmojiPicker.Search
|
||||
ref={searchRef}
|
||||
value={query}
|
||||
onChange={(e) => onQueryChange(e.target.value)}
|
||||
placeholder={t`Search emoji…`}
|
||||
aria-label={t`Search emoji`}
|
||||
readOnly={inline}
|
||||
tabIndex={inline ? -1 : undefined}
|
||||
/>
|
||||
<EmojiPicker.SkinToneSelector aria-label={t`Change skin tone`} />
|
||||
<button
|
||||
type="button"
|
||||
className="emoji-picker-close-btn"
|
||||
onClick={onClose}
|
||||
aria-label={t`Close emoji picker`}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<EmojiPicker.Viewport
|
||||
ref={viewportRef}
|
||||
// tabIndex={-1} makes the div programmatically focusable so
|
||||
// frimousse's onFocusCapture can detect it and arm arrow-key nav
|
||||
tabIndex={-1}
|
||||
>
|
||||
<EmojiPicker.Loading>
|
||||
<Trans>Loading…</Trans>
|
||||
</EmojiPicker.Loading>
|
||||
<EmojiPicker.Empty>
|
||||
<Trans>No emoji found.</Trans>
|
||||
</EmojiPicker.Empty>
|
||||
<EmojiPicker.List />
|
||||
</EmojiPicker.Viewport>
|
||||
</EmojiPicker.Root>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { type CSSProperties, useLayoutEffect, useRef } from "react";
|
||||
import { Avatar } from "./Avatar.tsx";
|
||||
import type { UserResult } from "../hooks/useMentionAutocomplete.ts";
|
||||
import { Avatar } from "../ui/Avatar.tsx";
|
||||
import type { UserResult } from "../../hooks/useMentionAutocomplete.ts";
|
||||
|
||||
interface MentionDropdownProps {
|
||||
/** For the textarea's `aria-controls` / `aria-activedescendant`. */
|
||||
@@ -9,22 +9,16 @@ import {
|
||||
useState,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import {
|
||||
EmojiPicker,
|
||||
type Locale as EmojiLocale,
|
||||
type SkinTone,
|
||||
useSkinTone,
|
||||
} from "frimousse";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { useLingui } from "@lingui/react";
|
||||
import { charCountClass } from "../utils/charCount.ts";
|
||||
import { charCountClass } from "../../utils/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 { API_URL } from "../config/api.ts";
|
||||
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 { API_URL } from "../../config/api.ts";
|
||||
|
||||
export interface TextEditorHandle {
|
||||
/** `atEnd` puts the caret after the last character (and scrolls the text
|
||||
@@ -59,29 +53,6 @@ interface TextEditorProps {
|
||||
"aria-describedby"?: string;
|
||||
}
|
||||
|
||||
const SKIN_TONE_KEY = "emoji-skin-tone";
|
||||
|
||||
function readSkinTone(): SkinTone {
|
||||
try {
|
||||
return (localStorage.getItem(SKIN_TONE_KEY) as SkinTone | null) ?? "none";
|
||||
} catch {
|
||||
return "none";
|
||||
}
|
||||
}
|
||||
|
||||
/** Remembers the skin tone picked in the selector for the next picker. */
|
||||
function SkinTonePersist() {
|
||||
const [skinTone] = useSkinTone();
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(SKIN_TONE_KEY, skinTone);
|
||||
} catch {
|
||||
// Private mode: the tone lasts as long as the picker.
|
||||
}
|
||||
}, [skinTone]);
|
||||
return null;
|
||||
}
|
||||
|
||||
function hasFiles(dt: DataTransfer): boolean {
|
||||
return Array.from(dt.types).includes("Files");
|
||||
}
|
||||
@@ -114,7 +85,6 @@ export const TextEditor = forwardRef<TextEditorHandle, TextEditorProps>(
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const { i18n } = useLingui();
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const floatRef = useRef<HTMLDivElement>(null);
|
||||
const emojiButtonRef = useRef<HTMLButtonElement>(null);
|
||||
@@ -577,10 +547,19 @@ export const TextEditor = forwardRef<TextEditorHandle, TextEditorProps>(
|
||||
document.body,
|
||||
)}
|
||||
{emojiOpen && createPortal(
|
||||
<div
|
||||
ref={floatRef}
|
||||
className="emoji-picker-float"
|
||||
<EmojiPickerPopup
|
||||
floatRef={floatRef}
|
||||
searchRef={emojiSearchRef}
|
||||
viewportRef={emojiViewportRef}
|
||||
style={emojiStyle ?? { visibility: "hidden" }}
|
||||
inline={emojiMode === "inline"}
|
||||
query={emojiQuery}
|
||||
onQueryChange={setEmojiQuery}
|
||||
onSelect={selectEmoji}
|
||||
onClose={() => {
|
||||
closeEmoji();
|
||||
textareaRef.current?.focus();
|
||||
}}
|
||||
onKeyDownCapture={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
@@ -624,57 +603,7 @@ export const TextEditor = forwardRef<TextEditorHandle, TextEditorProps>(
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<EmojiPicker.Root
|
||||
onEmojiSelect={(e) => selectEmoji(e.emoji)}
|
||||
locale={i18n.locale as EmojiLocale}
|
||||
emojibaseUrl={import.meta.env.VITE_EMOJIBASE_URL}
|
||||
skinTone={readSkinTone()}
|
||||
>
|
||||
<SkinTonePersist />
|
||||
<div className="emoji-picker-search-row">
|
||||
<EmojiPicker.Search
|
||||
ref={emojiSearchRef}
|
||||
value={emojiQuery}
|
||||
onChange={(e) => setEmojiQuery(e.target.value)}
|
||||
placeholder={t`Search emoji…`}
|
||||
aria-label={t`Search emoji`}
|
||||
// Inline, the query is typed in the textarea; the field
|
||||
// only shows it.
|
||||
readOnly={emojiMode === "inline"}
|
||||
tabIndex={emojiMode === "inline" ? -1 : undefined}
|
||||
/>
|
||||
<EmojiPicker.SkinToneSelector
|
||||
aria-label={t`Change skin tone`}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="emoji-picker-close-btn"
|
||||
onClick={() => {
|
||||
closeEmoji();
|
||||
textareaRef.current?.focus();
|
||||
}}
|
||||
aria-label={t`Close emoji picker`}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<EmojiPicker.Viewport
|
||||
ref={emojiViewportRef}
|
||||
// tabIndex={-1} makes the div programmatically focusable so
|
||||
// frimousse's onFocusCapture can detect it and arm arrow-key nav
|
||||
tabIndex={-1}
|
||||
>
|
||||
<EmojiPicker.Loading>
|
||||
<Trans>Loading…</Trans>
|
||||
</EmojiPicker.Loading>
|
||||
<EmojiPicker.Empty>
|
||||
<Trans>No emoji found.</Trans>
|
||||
</EmojiPicker.Empty>
|
||||
<EmojiPicker.List />
|
||||
</EmojiPicker.Viewport>
|
||||
</EmojiPicker.Root>
|
||||
</div>,
|
||||
/>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
13
src/components/editor/skinTone.ts
Normal file
13
src/components/editor/skinTone.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
// The emoji picker's skin tone, remembered across pickers and reloads.
|
||||
|
||||
import type { SkinTone } from "frimousse";
|
||||
|
||||
export const SKIN_TONE_KEY = "emoji-skin-tone";
|
||||
|
||||
export function readSkinTone(): SkinTone {
|
||||
try {
|
||||
return (localStorage.getItem(SKIN_TONE_KEY) as SkinTone | null) ?? "none";
|
||||
} catch {
|
||||
return "none";
|
||||
}
|
||||
}
|
||||
@@ -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/format.ts";
|
||||
|
||||
function fileIcon(mime: string): string {
|
||||
if (mime.startsWith("image/")) return "🖼";
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
useFormContext,
|
||||
} from "react-hook-form";
|
||||
|
||||
import { FileDropZone } from "../FileDropZone.tsx";
|
||||
import { FileDropZone } from "./FileDropZone.tsx";
|
||||
|
||||
interface FileFieldProps<T extends FieldValues> {
|
||||
name: Path<T>;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useFormState } from "react-hook-form";
|
||||
|
||||
import { ErrorCard } from "../ErrorCard.tsx";
|
||||
import { ErrorCard } from "../ui/ErrorCard.tsx";
|
||||
|
||||
/**
|
||||
* Renders the form's root error (set by {@link useApiForm}'s `submit` on a
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
useFormContext,
|
||||
} from "react-hook-form";
|
||||
|
||||
import { TextEditor, type TextEditorHandle } from "../TextEditor.tsx";
|
||||
import { TextEditor, type TextEditorHandle } from "../editor/TextEditor.tsx";
|
||||
|
||||
interface RichTextFieldProps<T extends FieldValues> {
|
||||
name: Path<T>;
|
||||
|
||||
@@ -2,16 +2,18 @@ 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/useAuth.ts";
|
||||
import { useWS } from "../../hooks/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/useDocumentTitle.ts";
|
||||
|
||||
const DumpCreateModal = lazy(() =>
|
||||
import("./DumpCreateModal.tsx").then((m) => ({ default: m.DumpCreateModal }))
|
||||
import("../dump/create/DumpCreateModal.tsx").then((m) => ({
|
||||
default: m.DumpCreateModal,
|
||||
}))
|
||||
);
|
||||
|
||||
/** Carried in a search entry's history state: the history index of the page
|
||||
@@ -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/useWS.ts";
|
||||
import { useChat } from "../../hooks/useChat.ts";
|
||||
|
||||
/** Header chat button with an unread-message badge. Opens the app-wide chat. */
|
||||
export function ChatButton() {
|
||||
@@ -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/useAuth.ts";
|
||||
import { useWSChat } from "../../hooks/useWS.ts";
|
||||
import { useChat } from "../../hooks/useChat.ts";
|
||||
|
||||
/**
|
||||
* Floating chat button shown once the header has scrolled out of view, mirroring
|
||||
@@ -1,10 +1,12 @@
|
||||
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/useAuth.ts";
|
||||
|
||||
const DumpCreateModal = lazy(() =>
|
||||
import("./DumpCreateModal.tsx").then((m) => ({ default: m.DumpCreateModal }))
|
||||
import("../dump/create/DumpCreateModal.tsx").then((m) => ({
|
||||
default: m.DumpCreateModal,
|
||||
}))
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -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/useWS.ts";
|
||||
|
||||
export function NotificationBell() {
|
||||
const { unreadNotificationCount, lastNotification } = useWS();
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { PageShell } from "./PageShell.tsx";
|
||||
import { ErrorCard } from "./ErrorCard.tsx";
|
||||
import { ErrorCard } from "../ui/ErrorCard.tsx";
|
||||
|
||||
export function PageError(
|
||||
{ title, message, actions }: {
|
||||
@@ -2,8 +2,8 @@ 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 "./Avatar.tsx";
|
||||
import type { User } from "../model.ts";
|
||||
import { Avatar } from "../ui/Avatar.tsx";
|
||||
import type { User } from "../../model.ts";
|
||||
|
||||
export function UserMenu({ user }: { user: User }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -6,12 +6,12 @@ import {
|
||||
PlayerContext,
|
||||
type PlayerItem,
|
||||
playerItemKey,
|
||||
} from "../contexts/PlayerContext.ts";
|
||||
} from "../../contexts/PlayerContext.ts";
|
||||
import { MediaPlayer } from "./MediaPlayer.tsx";
|
||||
import { fmt } from "../utils/duration.ts";
|
||||
import Thumbnail from "./Thumbnail.tsx";
|
||||
import { useYouTubePlaylist } from "../hooks/useYouTubePlaylist.ts";
|
||||
import { embedListId, withJsApi } from "../utils/youtube.ts";
|
||||
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";
|
||||
|
||||
type EmbedItem = Extract<PlayerItem, { kind: "embed" }>;
|
||||
|
||||
@@ -1,189 +1,10 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
BAR_GAP,
|
||||
BAR_W,
|
||||
extractPeaks,
|
||||
NUM_BARS,
|
||||
VIEWBOX_W,
|
||||
WAVEFORM_H,
|
||||
} from "../utils/waveform.ts";
|
||||
import { fmt } from "../utils/duration.ts";
|
||||
import { fmt } from "../../utils/duration.ts";
|
||||
import { t } from "@lingui/core/macro";
|
||||
|
||||
export const IconPlay = () => (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" style={{ marginLeft: "2px" }}>
|
||||
<polygon points="6,3 20,12 6,21" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const IconPause = () => (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" style={{ padding: "1px" }}>
|
||||
<rect x="5" y="3" width="4" height="18" rx="1" />
|
||||
<rect x="15" y="3" width="4" height="18" rx="1" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconVolume = ({ muted }: { muted: boolean }) => (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
{muted
|
||||
? (
|
||||
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3A4.5 4.5 0 0 0 14 7.97v8.05c1.48-.73 2.5-2.25 2.5-4.02zM19 12c0 2.76-1.67 5.12-4 6.19V5.81C17.33 6.88 19 9.24 19 12z M19 12l2.5 2.5M21.5 12 19 14.5" />
|
||||
)
|
||||
: (
|
||||
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3A4.5 4.5 0 0 0 14 7.97v8.05c1.48-.73 2.5-2.25 2.5-4.02zM19 12c0 2.76-1.67 5.12-4 6.19V5.81C17.33 6.88 19 9.24 19 12z" />
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconFullscreen = () => (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
// ── Waveform ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function Waveform(
|
||||
{ src, current, duration, onSeek }: {
|
||||
src: string;
|
||||
current: number;
|
||||
duration: number;
|
||||
onSeek: (t: number) => void;
|
||||
},
|
||||
) {
|
||||
const [peaks, setPeaks] = useState<Float32Array | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
extractPeaks(src, NUM_BARS)
|
||||
.then((p) => {
|
||||
if (!cancelled) setPeaks(p);
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [src]);
|
||||
|
||||
const progress = duration > 0 ? current / duration : 0;
|
||||
|
||||
const handleClick = (e: React.MouseEvent<Element>) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const ratio = Math.max(
|
||||
0,
|
||||
Math.min(1, (e.clientX - rect.left) / rect.width),
|
||||
);
|
||||
onSeek(ratio * duration);
|
||||
};
|
||||
|
||||
if (!peaks) {
|
||||
return (
|
||||
<div className="waveform-skeleton" onClick={handleClick}>
|
||||
<div
|
||||
className="waveform-skeleton-fill"
|
||||
style={{ width: `${progress * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox={`0 0 ${VIEWBOX_W} ${WAVEFORM_H}`}
|
||||
preserveAspectRatio="none"
|
||||
className="waveform-svg"
|
||||
onClick={handleClick}
|
||||
>
|
||||
{Array.from(peaks).map((p, i) => {
|
||||
const barH = Math.max(p * WAVEFORM_H, 2);
|
||||
const x = i * (BAR_W + BAR_GAP);
|
||||
const y = (WAVEFORM_H - barH) / 2;
|
||||
const played = i / NUM_BARS <= progress;
|
||||
return (
|
||||
<rect
|
||||
key={i}
|
||||
x={x}
|
||||
y={y}
|
||||
width={BAR_W}
|
||||
height={barH}
|
||||
className={`waveform-bar${played ? " waveform-bar--played" : ""}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Volume ────────────────────────────────────────────────────────────────────
|
||||
// Each queue advance remounts MediaPlayer (the element is keyed on the source),
|
||||
// which would otherwise snap the volume back to 100% on every track. Hold it
|
||||
// outside the component and mirror it to localStorage so it also survives a
|
||||
// reload.
|
||||
|
||||
const VOLUME_KEY = "player-volume";
|
||||
|
||||
let lastVolume = 1;
|
||||
let lastMuted = false;
|
||||
try {
|
||||
const stored = JSON.parse(localStorage.getItem(VOLUME_KEY) ?? "null");
|
||||
if (stored && typeof stored.volume === "number") {
|
||||
lastVolume = Math.min(Math.max(stored.volume, 0), 1);
|
||||
lastMuted = stored.muted === true;
|
||||
}
|
||||
} catch {
|
||||
// Malformed or unavailable storage — the defaults above stand.
|
||||
}
|
||||
|
||||
function rememberVolume(volume: number, muted: boolean) {
|
||||
lastVolume = volume;
|
||||
lastMuted = muted;
|
||||
try {
|
||||
localStorage.setItem(VOLUME_KEY, JSON.stringify({ volume, muted }));
|
||||
} catch {
|
||||
// Private mode or blocked storage — volume just won't persist.
|
||||
}
|
||||
}
|
||||
|
||||
// ── Seek bar ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Slim progress + scrub bar. Used by video, and by audio sources whose peaks
|
||||
* can't be decoded (cross-origin streams) — where a waveform would be a lie. */
|
||||
function SeekBar(
|
||||
{ current, duration, onSeek, onDragChange, className }: {
|
||||
current: number;
|
||||
duration: number;
|
||||
onSeek: (t: number) => void;
|
||||
onDragChange: (dragging: boolean) => void;
|
||||
className?: string;
|
||||
},
|
||||
) {
|
||||
const progress = duration > 0 ? current / duration : 0;
|
||||
return (
|
||||
<div className={`audio-player-track${className ? ` ${className}` : ""}`}>
|
||||
<div
|
||||
className="audio-player-fill"
|
||||
style={{ width: `${progress * 100}%` }}
|
||||
/>
|
||||
<input
|
||||
type="range"
|
||||
className="audio-player-range"
|
||||
min={0}
|
||||
max={duration || 1}
|
||||
step={0.01}
|
||||
value={current}
|
||||
// Pointer events, so a finger holds the thumb as a mouse does.
|
||||
onPointerDown={() =>
|
||||
onDragChange(true)}
|
||||
onPointerUp={() => onDragChange(false)}
|
||||
onPointerCancel={() => onDragChange(false)}
|
||||
onChange={(e) => onSeek(Number(e.target.value))}
|
||||
aria-label={t`Seek`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── MediaPlayer ───────────────────────────────────────────────────────────────
|
||||
import { IconFullscreen, IconPause, IconPlay, IconVolume } from "./icons.tsx";
|
||||
import { SeekBar } from "./SeekBar.tsx";
|
||||
import { Waveform } from "./Waveform.tsx";
|
||||
import { rememberVolume, storedVolume } from "./volume.ts";
|
||||
|
||||
const HIDE_DELAY = 2500;
|
||||
|
||||
@@ -230,8 +51,8 @@ export function MediaPlayer(
|
||||
const [current, setCurrent] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [volume, setVolume] = useState(lastVolume);
|
||||
const [muted, setMuted] = useState(lastMuted);
|
||||
const [volume, setVolume] = useState(() => storedVolume().volume);
|
||||
const [muted, setMuted] = useState(() => storedVolume().muted);
|
||||
const [controlsVisible, setControlsVisible] = useState(true);
|
||||
const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
38
src/components/player/SeekBar.tsx
Normal file
38
src/components/player/SeekBar.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
|
||||
/** Slim progress + scrub bar. Used by video, and by audio sources whose peaks
|
||||
* can't be decoded (cross-origin streams) — where a waveform would be a lie. */
|
||||
export function SeekBar(
|
||||
{ current, duration, onSeek, onDragChange, className }: {
|
||||
current: number;
|
||||
duration: number;
|
||||
onSeek: (t: number) => void;
|
||||
onDragChange: (dragging: boolean) => void;
|
||||
className?: string;
|
||||
},
|
||||
) {
|
||||
const progress = duration > 0 ? current / duration : 0;
|
||||
return (
|
||||
<div className={`audio-player-track${className ? ` ${className}` : ""}`}>
|
||||
<div
|
||||
className="audio-player-fill"
|
||||
style={{ width: `${progress * 100}%` }}
|
||||
/>
|
||||
<input
|
||||
type="range"
|
||||
className="audio-player-range"
|
||||
min={0}
|
||||
max={duration || 1}
|
||||
step={0.01}
|
||||
value={current}
|
||||
// Pointer events, so a finger holds the thumb as a mouse does.
|
||||
onPointerDown={() =>
|
||||
onDragChange(true)}
|
||||
onPointerUp={() => onDragChange(false)}
|
||||
onPointerCancel={() => onDragChange(false)}
|
||||
onChange={(e) => onSeek(Number(e.target.value))}
|
||||
aria-label={t`Seek`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
80
src/components/player/Waveform.tsx
Normal file
80
src/components/player/Waveform.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
BAR_GAP,
|
||||
BAR_W,
|
||||
extractPeaks,
|
||||
NUM_BARS,
|
||||
VIEWBOX_W,
|
||||
WAVEFORM_H,
|
||||
} from "../../utils/waveform.ts";
|
||||
|
||||
export function Waveform(
|
||||
{ src, current, duration, onSeek }: {
|
||||
src: string;
|
||||
current: number;
|
||||
duration: number;
|
||||
onSeek: (t: number) => void;
|
||||
},
|
||||
) {
|
||||
const [peaks, setPeaks] = useState<Float32Array | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
extractPeaks(src, NUM_BARS)
|
||||
.then((p) => {
|
||||
if (!cancelled) setPeaks(p);
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [src]);
|
||||
|
||||
const progress = duration > 0 ? current / duration : 0;
|
||||
|
||||
const handleClick = (e: React.MouseEvent<Element>) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const ratio = Math.max(
|
||||
0,
|
||||
Math.min(1, (e.clientX - rect.left) / rect.width),
|
||||
);
|
||||
onSeek(ratio * duration);
|
||||
};
|
||||
|
||||
if (!peaks) {
|
||||
return (
|
||||
<div className="waveform-skeleton" onClick={handleClick}>
|
||||
<div
|
||||
className="waveform-skeleton-fill"
|
||||
style={{ width: `${progress * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox={`0 0 ${VIEWBOX_W} ${WAVEFORM_H}`}
|
||||
preserveAspectRatio="none"
|
||||
className="waveform-svg"
|
||||
onClick={handleClick}
|
||||
>
|
||||
{Array.from(peaks).map((p, i) => {
|
||||
const barH = Math.max(p * WAVEFORM_H, 2);
|
||||
const x = i * (BAR_W + BAR_GAP);
|
||||
const y = (WAVEFORM_H - barH) / 2;
|
||||
const played = i / NUM_BARS <= progress;
|
||||
return (
|
||||
<rect
|
||||
key={i}
|
||||
x={x}
|
||||
y={y}
|
||||
width={BAR_W}
|
||||
height={barH}
|
||||
className={`waveform-bar${played ? " waveform-bar--played" : ""}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
32
src/components/player/icons.tsx
Normal file
32
src/components/player/icons.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
// Player control icons, shared by MediaPlayer and FilePreview.
|
||||
|
||||
export const IconPlay = () => (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" style={{ marginLeft: "2px" }}>
|
||||
<polygon points="6,3 20,12 6,21" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const IconPause = () => (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" style={{ padding: "1px" }}>
|
||||
<rect x="5" y="3" width="4" height="18" rx="1" />
|
||||
<rect x="15" y="3" width="4" height="18" rx="1" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const IconVolume = ({ muted }: { muted: boolean }) => (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
{muted
|
||||
? (
|
||||
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3A4.5 4.5 0 0 0 14 7.97v8.05c1.48-.73 2.5-2.25 2.5-4.02zM19 12c0 2.76-1.67 5.12-4 6.19V5.81C17.33 6.88 19 9.24 19 12z M19 12l2.5 2.5M21.5 12 19 14.5" />
|
||||
)
|
||||
: (
|
||||
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3A4.5 4.5 0 0 0 14 7.97v8.05c1.48-.73 2.5-2.25 2.5-4.02zM19 12c0 2.76-1.67 5.12-4 6.19V5.81C17.33 6.88 19 9.24 19 12z" />
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const IconFullscreen = () => (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z" />
|
||||
</svg>
|
||||
);
|
||||
33
src/components/player/volume.ts
Normal file
33
src/components/player/volume.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
// Each queue advance remounts MediaPlayer (the element is keyed on the source),
|
||||
// which would otherwise snap the volume back to 100% on every track. Hold it
|
||||
// outside the component and mirror it to localStorage so it also survives a
|
||||
// reload.
|
||||
|
||||
const VOLUME_KEY = "player-volume";
|
||||
|
||||
let lastVolume = 1;
|
||||
let lastMuted = false;
|
||||
try {
|
||||
const stored = JSON.parse(localStorage.getItem(VOLUME_KEY) ?? "null");
|
||||
if (stored && typeof stored.volume === "number") {
|
||||
lastVolume = Math.min(Math.max(stored.volume, 0), 1);
|
||||
lastMuted = stored.muted === true;
|
||||
}
|
||||
} catch {
|
||||
// Malformed or unavailable storage — the defaults above stand.
|
||||
}
|
||||
|
||||
export function rememberVolume(volume: number, muted: boolean) {
|
||||
lastVolume = volume;
|
||||
lastMuted = muted;
|
||||
try {
|
||||
localStorage.setItem(VOLUME_KEY, JSON.stringify({ volume, muted }));
|
||||
} catch {
|
||||
// Private mode or blocked storage — volume just won't persist.
|
||||
}
|
||||
}
|
||||
|
||||
/** The volume and mute state the last player left behind. */
|
||||
export function storedVolume(): { volume: number; muted: boolean } {
|
||||
return { volume: lastVolume, muted: lastMuted };
|
||||
}
|
||||
@@ -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 type { PlaylistMembership, RawPlaylistMembership } from "../model.ts";
|
||||
import { deserializePlaylistMembership } from "../model.ts";
|
||||
import { Modal } from "./Modal.tsx";
|
||||
import { API_URL } from "../../config/api.ts";
|
||||
import { useAuth } from "../../hooks/useAuth.ts";
|
||||
import type { PlaylistMembership, RawPlaylistMembership } from "../../model.ts";
|
||||
import { deserializePlaylistMembership } from "../../model.ts";
|
||||
import { Modal } from "../ui/Modal.tsx";
|
||||
import { PlaylistMembershipPanel } from "./PlaylistMembershipPanel.tsx";
|
||||
|
||||
interface AddToPlaylistModalProps {
|
||||
@@ -1,22 +1,22 @@
|
||||
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 "./Modal.tsx";
|
||||
import type { Playlist } from "../../model.ts";
|
||||
import { Modal } from "../ui/Modal.tsx";
|
||||
import { PlaylistCreateForm } from "./PlaylistCreateForm.tsx";
|
||||
|
||||
interface NewPlaylistFormProps {
|
||||
interface NewPlaylistButtonProps {
|
||||
onCreated: (playlist: Playlist) => void;
|
||||
toggleLabel?: string;
|
||||
toggleClassName?: string;
|
||||
}
|
||||
|
||||
export function NewPlaylistForm(
|
||||
export function NewPlaylistButton(
|
||||
{
|
||||
onCreated,
|
||||
toggleLabel,
|
||||
toggleClassName = "new-item-toggle",
|
||||
}: NewPlaylistFormProps,
|
||||
}: NewPlaylistButtonProps,
|
||||
) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
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 type { Playlist } from "../../model.ts";
|
||||
import { relativeTime } from "../../utils/relativeTime.ts";
|
||||
import { playlistImageUrl, playlistUrl } from "../../utils/urls.ts";
|
||||
import {
|
||||
isPlaylistVisited,
|
||||
isRecent,
|
||||
markPlaylistVisited,
|
||||
} from "../utils/visited.ts";
|
||||
import { Tooltip } from "./Tooltip.tsx";
|
||||
} from "../../utils/visited.ts";
|
||||
import { Tooltip } from "../ui/Tooltip.tsx";
|
||||
|
||||
interface PlaylistCardProps {
|
||||
playlist: Playlist;
|
||||
@@ -1,9 +1,13 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { API_URL, VALIDATION } from "../config/api.ts";
|
||||
import type { CreatePlaylistRequest, Playlist, RawPlaylist } from "../model.ts";
|
||||
import { deserializePlaylist } from "../model.ts";
|
||||
import { useAuth } from "../hooks/useAuth.ts";
|
||||
import { API_URL, VALIDATION } from "../../config/api.ts";
|
||||
import type {
|
||||
CreatePlaylistRequest,
|
||||
Playlist,
|
||||
RawPlaylist,
|
||||
} from "../../model.ts";
|
||||
import { deserializePlaylist } from "../../model.ts";
|
||||
import { useAuth } from "../../hooks/useAuth.ts";
|
||||
import {
|
||||
expectOk,
|
||||
FormActions,
|
||||
@@ -14,7 +18,7 @@ import {
|
||||
TextField,
|
||||
useApiForm,
|
||||
VisibilityToggle,
|
||||
} from "./form/index.ts";
|
||||
} from "../form/index.ts";
|
||||
|
||||
interface PlaylistCreateFormProps {
|
||||
/** If provided, the new playlist will have this dump added to it. */
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import type { PlaylistMembership } from "../model.ts";
|
||||
import type { PlaylistMembership } from "../../model.ts";
|
||||
import { PlaylistCreateForm } from "./PlaylistCreateForm.tsx";
|
||||
|
||||
interface PlaylistMembershipPanelProps {
|
||||
@@ -1,9 +1,9 @@
|
||||
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 "./Modal.tsx";
|
||||
import { API_URL, VALIDATION } from "../../config/api.ts";
|
||||
import { useAuth } from "../../hooks/useAuth.ts";
|
||||
import { Modal } from "../ui/Modal.tsx";
|
||||
import {
|
||||
expectOk,
|
||||
FormActions,
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
SubmitButton,
|
||||
TextField,
|
||||
useApiForm,
|
||||
} from "./form/index.ts";
|
||||
} from "../form/index.ts";
|
||||
|
||||
interface ChangePasswordModalProps {
|
||||
onClose: () => void;
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Link } from "react-router";
|
||||
import type { PublicUser } from "../model.ts";
|
||||
import { Avatar } from "./Avatar.tsx";
|
||||
import type { PublicUser } from "../../model.ts";
|
||||
import { Avatar } from "../ui/Avatar.tsx";
|
||||
|
||||
interface ProfileSubpageHeaderProps {
|
||||
username: string;
|
||||
@@ -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/useAuth.ts";
|
||||
import { useFollows } from "../../hooks/useFollows.ts";
|
||||
|
||||
interface FollowUserButtonProps {
|
||||
targetUserId: string;
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { API_URL } from "../config/api.ts";
|
||||
import { API_URL } from "../../config/api.ts";
|
||||
import { UserListPopover } from "./UserListPopover.tsx";
|
||||
|
||||
interface LikeButtonProps {
|
||||
@@ -3,16 +3,16 @@ 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 "./Avatar.tsx";
|
||||
import { useAuth } from "../hooks/useAuth.ts";
|
||||
import { DEFAULT_PAGE_SIZE } from "../config/api.ts";
|
||||
import { Avatar } from "../ui/Avatar.tsx";
|
||||
import { useAuth } from "../../hooks/useAuth.ts";
|
||||
import { DEFAULT_PAGE_SIZE } from "../../config/api.ts";
|
||||
import {
|
||||
deserializePublicUser,
|
||||
type PaginatedData,
|
||||
parseAPIResponse,
|
||||
type PublicUser,
|
||||
type RawPublicUser,
|
||||
} from "../model.ts";
|
||||
} from "../../model.ts";
|
||||
|
||||
interface UserListPopoverProps {
|
||||
anchorRef: React.RefObject<HTMLElement | null>;
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { API_URL } from "../config/api.ts";
|
||||
import { API_URL } from "../../config/api.ts";
|
||||
import { UserListPopover } from "./UserListPopover.tsx";
|
||||
|
||||
interface VoteButtonProps {
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useContext, useState } from "react";
|
||||
import { API_URL } from "../config/api.ts";
|
||||
import { AvatarOverrideContext } from "../contexts/AvatarOverrideContext.ts";
|
||||
import { API_URL } from "../../config/api.ts";
|
||||
import { AvatarOverrideContext } from "../../contexts/AvatarOverrideContext.ts";
|
||||
|
||||
interface AvatarProps {
|
||||
userId: string;
|
||||
@@ -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/useScrollLock.ts";
|
||||
|
||||
interface ConfirmModalProps {
|
||||
message: string;
|
||||
@@ -1,5 +1,5 @@
|
||||
import type React from "react";
|
||||
import { charCountClass } from "../utils/charCount.ts";
|
||||
import { charCountClass } from "../../utils/charCount.ts";
|
||||
|
||||
interface CountedInputProps
|
||||
extends
|
||||
@@ -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/useScrollLock.ts";
|
||||
|
||||
interface ModalProps {
|
||||
title: string;
|
||||
@@ -1,6 +1,6 @@
|
||||
import { type ComponentProps, useState } from "react";
|
||||
import { canProxyImage, proxyImageUrl } from "../utils/proxyImage.ts";
|
||||
import ThumbnailPlaceholder from "./ThumbnailPlaceholder.tsx";
|
||||
import { canProxyImage, proxyImageUrl } from "../../utils/proxyImage.ts";
|
||||
import { ThumbnailPlaceholder } from "./ThumbnailPlaceholder.tsx";
|
||||
|
||||
interface ThumbnailProps {
|
||||
/** Raw image URL. Proxying is decided here — don't pre-proxy it. */
|
||||
@@ -28,7 +28,7 @@ interface ThumbnailProps {
|
||||
* Before this existed each of these failures ended at `display: none`, leaving
|
||||
* an empty framed box and no signal anywhere that a load had failed.
|
||||
*/
|
||||
export default function Thumbnail(
|
||||
export function Thumbnail(
|
||||
{
|
||||
src,
|
||||
alt = "",
|
||||
@@ -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/proxyImage.ts";
|
||||
import { initialsFor, tintFor } from "../../utils/thumbnailTint.ts";
|
||||
|
||||
interface ThumbnailPlaceholderProps {
|
||||
/** The dumped URL — seeds the fallback hue and the initial. */
|
||||
@@ -26,7 +26,7 @@ interface ThumbnailPlaceholderProps {
|
||||
* Fills whatever box it's placed in, so the same component serves the 36px
|
||||
* compact thumbnail, the 128×72 feed preview and a 2×2 mosaic tile.
|
||||
*/
|
||||
export default function ThumbnailPlaceholder(
|
||||
export function ThumbnailPlaceholder(
|
||||
{ url, seed, accentColor, faviconUrl, siteName, glyph, className }:
|
||||
ThumbnailPlaceholderProps,
|
||||
) {
|
||||
@@ -11,7 +11,9 @@ import { useAuth } from "../hooks/useAuth.ts";
|
||||
import { ChatContext, type ChatContextValue } from "./ChatContext.ts";
|
||||
|
||||
const ChatModal = lazy(() =>
|
||||
import("../components/ChatModal.tsx").then((m) => ({ default: m.ChatModal }))
|
||||
import("../components/chat/ChatModal.tsx").then((m) => ({
|
||||
default: m.ChatModal,
|
||||
}))
|
||||
);
|
||||
|
||||
/**
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,7 @@ 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 { AddToPlaylistModal } from "../components/AddToPlaylistModal.tsx";
|
||||
import { AddToPlaylistModal } from "../components/playlist/AddToPlaylistModal.tsx";
|
||||
|
||||
import { API_URL, VALIDATION } from "../config/api.ts";
|
||||
|
||||
@@ -32,17 +32,17 @@ 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/Avatar.tsx";
|
||||
import RichContentCard from "../components/RichContentCard.tsx";
|
||||
import FilePreview from "../components/FilePreview.tsx";
|
||||
import { VoteButton } from "../components/VoteButton.tsx";
|
||||
import { DumpCard } from "../components/DumpCard.tsx";
|
||||
import { PlaylistCard } from "../components/PlaylistCard.tsx";
|
||||
import { PageShell } from "../components/PageShell.tsx";
|
||||
import { PageError } from "../components/PageError.tsx";
|
||||
import { Markdown } from "../components/Markdown.tsx";
|
||||
import { CommentThread } from "../components/CommentThread.tsx";
|
||||
import { Tooltip } from "../components/Tooltip.tsx";
|
||||
import { Avatar } from "../components/ui/Avatar.tsx";
|
||||
import { RichContentCard } from "../components/dump/RichContentCard.tsx";
|
||||
import { FilePreview } from "../components/dump/FilePreview.tsx";
|
||||
import { VoteButton } from "../components/social/VoteButton.tsx";
|
||||
import { DumpCard } from "../components/dump/DumpCard.tsx";
|
||||
import { PlaylistCard } from "../components/playlist/PlaylistCard.tsx";
|
||||
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";
|
||||
|
||||
type DumpState =
|
||||
|
||||
@@ -10,15 +10,15 @@ 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 { PageShell } from "../components/PageShell.tsx";
|
||||
import { PageError } from "../components/PageError.tsx";
|
||||
import { ErrorCard } from "../components/ErrorCard.tsx";
|
||||
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/ConfirmModal.tsx";
|
||||
import RichContentCard from "../components/RichContentCard.tsx";
|
||||
import FilePreview from "../components/FilePreview.tsx";
|
||||
import { ImagePicker } from "../components/ImagePicker.tsx";
|
||||
import { CategorySelect } from "../components/CategorySelect.tsx";
|
||||
import { ConfirmModal } from "../components/ui/ConfirmModal.tsx";
|
||||
import { RichContentCard } from "../components/dump/RichContentCard.tsx";
|
||||
import { FilePreview } from "../components/dump/FilePreview.tsx";
|
||||
import { ImagePicker } from "../components/ui/ImagePicker.tsx";
|
||||
import { CategorySelect } from "../components/category/CategorySelect.tsx";
|
||||
import {
|
||||
expectOk,
|
||||
FileField,
|
||||
|
||||
@@ -8,9 +8,9 @@ import {
|
||||
} from "react";
|
||||
import { useLocation, useParams } from "react-router";
|
||||
|
||||
import { AppHeader } from "../components/AppHeader.tsx";
|
||||
import { PresenceRow } from "../components/PresenceRow.tsx";
|
||||
import { FeedTabBar } from "../components/FeedTabBar.tsx";
|
||||
import { AppHeader } from "../components/layout/AppHeader.tsx";
|
||||
import { PresenceRow } from "./index/PresenceRow.tsx";
|
||||
import { FeedTabBar } from "./index/FeedTabBar.tsx";
|
||||
import {
|
||||
DEFAULT_FEED_TAB,
|
||||
FEED_TABS,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Link } from "react-router";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { PageShell } from "../components/PageShell.tsx";
|
||||
import { PageShell } from "../components/layout/PageShell.tsx";
|
||||
import { useDocumentTitle } from "../hooks/useDocumentTitle.ts";
|
||||
|
||||
export function NotFound() {
|
||||
|
||||
@@ -7,8 +7,8 @@ import { relativeTime } from "../utils/relativeTime.ts";
|
||||
import { API_URL, NOTIFICATIONS_PAGE_SIZE } from "../config/api.ts";
|
||||
import { useAuth } from "../hooks/useAuth.ts";
|
||||
import { useDocumentTitle } from "../hooks/useDocumentTitle.ts";
|
||||
import { ErrorCard } from "../components/ErrorCard.tsx";
|
||||
import { Tooltip } from "../components/Tooltip.tsx";
|
||||
import { ErrorCard } from "../components/ui/ErrorCard.tsx";
|
||||
import { Tooltip } from "../components/ui/Tooltip.tsx";
|
||||
import { useWS } from "../hooks/useWS.ts";
|
||||
import type {
|
||||
CommentLikedData,
|
||||
@@ -25,7 +25,7 @@ import type {
|
||||
UserMentionedData,
|
||||
} from "../model.ts";
|
||||
import { deserializeNotification } from "../model.ts";
|
||||
import { PageShell } from "../components/PageShell.tsx";
|
||||
import { PageShell } from "../components/layout/PageShell.tsx";
|
||||
import { friendlyFetchError } from "../utils/apiError.ts";
|
||||
import { expectOk } from "../components/form/index.ts";
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Link, useNavigate, useParams } from "react-router";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { API_URL, VALIDATION } from "../config/api.ts";
|
||||
import { CountedInput } from "../components/CountedInput.tsx";
|
||||
import { CountedInput } from "../components/ui/CountedInput.tsx";
|
||||
import type {
|
||||
Playlist,
|
||||
PlaylistWithDumps,
|
||||
@@ -31,16 +31,16 @@ import { useDocumentTitle } from "../hooks/useDocumentTitle.ts";
|
||||
import { useHeightTransition } from "../hooks/useHeightTransition.ts";
|
||||
import { useWS } from "../hooks/useWS.ts";
|
||||
import { relativeTime } from "../utils/relativeTime.ts";
|
||||
import { DumpCard } from "../components/DumpCard.tsx";
|
||||
import { PageShell } from "../components/PageShell.tsx";
|
||||
import { PageError } from "../components/PageError.tsx";
|
||||
import { ErrorCard } from "../components/ErrorCard.tsx";
|
||||
import { ConfirmModal } from "../components/ConfirmModal.tsx";
|
||||
import { ImagePicker } from "../components/ImagePicker.tsx";
|
||||
import { Markdown } from "../components/Markdown.tsx";
|
||||
import { TextEditor } from "../components/TextEditor.tsx";
|
||||
import { FollowPlaylistButton } from "../components/FollowButton.tsx";
|
||||
import { Tooltip } from "../components/Tooltip.tsx";
|
||||
import { DumpCard } from "../components/dump/DumpCard.tsx";
|
||||
import { PageShell } from "../components/layout/PageShell.tsx";
|
||||
import { PageError } from "../components/layout/PageError.tsx";
|
||||
import { ErrorCard } from "../components/ui/ErrorCard.tsx";
|
||||
import { ConfirmModal } from "../components/ui/ConfirmModal.tsx";
|
||||
import { ImagePicker } from "../components/ui/ImagePicker.tsx";
|
||||
import { Markdown } from "../components/editor/Markdown.tsx";
|
||||
import { TextEditor } from "../components/editor/TextEditor.tsx";
|
||||
import { FollowPlaylistButton } from "../components/social/FollowButton.tsx";
|
||||
import { Tooltip } from "../components/ui/Tooltip.tsx";
|
||||
import { friendlyFetchError } from "../utils/apiError.ts";
|
||||
import { Controller } from "react-hook-form";
|
||||
import {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Trans } from "@lingui/react/macro";
|
||||
|
||||
import { API_URL, VALIDATION } from "../config/api.ts";
|
||||
import { useDocumentTitle } from "../hooks/useDocumentTitle.ts";
|
||||
import { PageShell } from "../components/PageShell.tsx";
|
||||
import { PageShell } from "../components/layout/PageShell.tsx";
|
||||
import {
|
||||
expectOk,
|
||||
FormError,
|
||||
|
||||
@@ -2,11 +2,11 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Link, useSearchParams } from "react-router";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { AppHeader } from "../components/AppHeader.tsx";
|
||||
import { TabBar } from "../components/TabBar.tsx";
|
||||
import { DumpCard } from "../components/DumpCard.tsx";
|
||||
import { PlaylistCard } from "../components/PlaylistCard.tsx";
|
||||
import { ErrorCard } from "../components/ErrorCard.tsx";
|
||||
import { AppHeader } from "../components/layout/AppHeader.tsx";
|
||||
import { TabBar } from "../components/ui/TabBar.tsx";
|
||||
import { DumpCard } from "../components/dump/DumpCard.tsx";
|
||||
import { PlaylistCard } from "../components/playlist/PlaylistCard.tsx";
|
||||
import { ErrorCard } from "../components/ui/ErrorCard.tsx";
|
||||
import { useAuth } from "../hooks/useAuth.ts";
|
||||
import { useDocumentTitle } from "../hooks/useDocumentTitle.ts";
|
||||
import { useWS } from "../hooks/useWS.ts";
|
||||
|
||||
@@ -9,11 +9,11 @@ import { useWS } from "../hooks/useWS.ts";
|
||||
import { useDumpListSync } from "../hooks/useDumpListSync.ts";
|
||||
import { usePositionAwareSync } from "../hooks/usePositionAwareSync.ts";
|
||||
import { useUserDumpFeed } from "../hooks/useUserDumpFeed.ts";
|
||||
import { DumpCard } from "../components/DumpCard.tsx";
|
||||
import { DumpCreateModal } from "../components/DumpCreateModal.tsx";
|
||||
import { ProfileSubpageHeader } from "../components/ProfileSubpageHeader.tsx";
|
||||
import { PageShell } from "../components/PageShell.tsx";
|
||||
import { PageError } from "../components/PageError.tsx";
|
||||
import { DumpCard } from "../components/dump/DumpCard.tsx";
|
||||
import { DumpCreateModal } from "../components/dump/create/DumpCreateModal.tsx";
|
||||
import { ProfileSubpageHeader } from "../components/profile/ProfileSubpageHeader.tsx";
|
||||
import { PageShell } from "../components/layout/PageShell.tsx";
|
||||
import { PageError } from "../components/layout/PageError.tsx";
|
||||
|
||||
export function UserDumps() {
|
||||
const { username } = useParams();
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
import { useAuth } from "../hooks/useAuth.ts";
|
||||
import { useDocumentTitle } from "../hooks/useDocumentTitle.ts";
|
||||
import { useHeightTransition } from "../hooks/useHeightTransition.ts";
|
||||
import { PageShell } from "../components/PageShell.tsx";
|
||||
import { PageShell } from "../components/layout/PageShell.tsx";
|
||||
import {
|
||||
expectOk,
|
||||
FormError,
|
||||
|
||||
@@ -30,13 +30,13 @@ import { usePositionAwareSync } from "../hooks/usePositionAwareSync.ts";
|
||||
import { useInfiniteScroll } from "../hooks/useInfiniteScroll.ts";
|
||||
import { useFeedCache } from "../hooks/useFeedCache.ts";
|
||||
import { useScrollSave } from "../hooks/useScrollSave.ts";
|
||||
import { PlaylistCard } from "../components/PlaylistCard.tsx";
|
||||
import { NewPlaylistForm } from "../components/NewPlaylistForm.tsx";
|
||||
import { ProfileSubpageHeader } from "../components/ProfileSubpageHeader.tsx";
|
||||
import { ConfirmModal } from "../components/ConfirmModal.tsx";
|
||||
import { PageShell } from "../components/PageShell.tsx";
|
||||
import { PageError } from "../components/PageError.tsx";
|
||||
import { ErrorCard } from "../components/ErrorCard.tsx";
|
||||
import { PlaylistCard } from "../components/playlist/PlaylistCard.tsx";
|
||||
import { NewPlaylistButton } from "../components/playlist/NewPlaylistButton.tsx";
|
||||
import { ProfileSubpageHeader } from "../components/profile/ProfileSubpageHeader.tsx";
|
||||
import { ConfirmModal } from "../components/ui/ConfirmModal.tsx";
|
||||
import { PageShell } from "../components/layout/PageShell.tsx";
|
||||
import { PageError } from "../components/layout/PageError.tsx";
|
||||
import { ErrorCard } from "../components/ui/ErrorCard.tsx";
|
||||
import { expectOk } from "../components/form/index.ts";
|
||||
|
||||
interface PlaylistFeed {
|
||||
@@ -381,7 +381,7 @@ export function UserPlaylists() {
|
||||
profileUser={profileUser}
|
||||
title={t`Playlists`}
|
||||
actions={isOwnProfile && (
|
||||
<NewPlaylistForm
|
||||
<NewPlaylistButton
|
||||
toggleClassName="btn-primary"
|
||||
onCreated={(p) =>
|
||||
setState((s) => {
|
||||
|
||||
@@ -30,13 +30,13 @@ import {
|
||||
type RawPublicUser,
|
||||
type UpdateUserRequest,
|
||||
} from "../model.ts";
|
||||
import { Avatar } from "../components/Avatar.tsx";
|
||||
import { DumpCard } from "../components/DumpCard.tsx";
|
||||
import { PlaylistCard } from "../components/PlaylistCard.tsx";
|
||||
import { NewPlaylistForm } from "../components/NewPlaylistForm.tsx";
|
||||
import { PageShell } from "../components/PageShell.tsx";
|
||||
import { PageError } from "../components/PageError.tsx";
|
||||
import { CategoryManager } from "../components/CategoryManager.tsx";
|
||||
import { Avatar } from "../components/ui/Avatar.tsx";
|
||||
import { DumpCard } from "../components/dump/DumpCard.tsx";
|
||||
import { PlaylistCard } from "../components/playlist/PlaylistCard.tsx";
|
||||
import { NewPlaylistButton } from "../components/playlist/NewPlaylistButton.tsx";
|
||||
import { PageShell } from "../components/layout/PageShell.tsx";
|
||||
import { PageError } from "../components/layout/PageError.tsx";
|
||||
import { CategoryManager } from "../components/category/CategoryManager.tsx";
|
||||
import { useAuth } from "../hooks/useAuth.ts";
|
||||
import { useDocumentTitle } from "../hooks/useDocumentTitle.ts";
|
||||
import { useTheme } from "../hooks/useTheme.ts";
|
||||
@@ -52,15 +52,15 @@ import { usePositionAwareSync } from "../hooks/usePositionAwareSync.ts";
|
||||
import type { Playlist, RawPlaylist } from "../model.ts";
|
||||
import { deserializePlaylist } from "../model.ts";
|
||||
import { useFeedCache } from "../hooks/useFeedCache.ts";
|
||||
import { DumpCreateModal } from "../components/DumpCreateModal.tsx";
|
||||
import { FollowUserButton } from "../components/FollowButton.tsx";
|
||||
import { ErrorCard } from "../components/ErrorCard.tsx";
|
||||
import { DumpCreateModal } from "../components/dump/create/DumpCreateModal.tsx";
|
||||
import { FollowUserButton } from "../components/social/FollowButton.tsx";
|
||||
import { ErrorCard } from "../components/ui/ErrorCard.tsx";
|
||||
import { friendlyFetchError } from "../utils/apiError.ts";
|
||||
import { can } from "../utils/permissions.ts";
|
||||
import { TextEditor } from "../components/TextEditor.tsx";
|
||||
import { Markdown } from "../components/Markdown.tsx";
|
||||
import { ChangePasswordModal } from "../components/ChangePasswordModal.tsx";
|
||||
import { TabBar } from "../components/TabBar.tsx";
|
||||
import { TextEditor } from "../components/editor/TextEditor.tsx";
|
||||
import { Markdown } from "../components/editor/Markdown.tsx";
|
||||
import { ChangePasswordModal } from "../components/profile/ChangePasswordModal.tsx";
|
||||
import { TabBar } from "../components/ui/TabBar.tsx";
|
||||
import { useTabParam } from "../hooks/useTabParam.ts";
|
||||
import { Controller } from "react-hook-form";
|
||||
import {
|
||||
@@ -1046,7 +1046,7 @@ export function UserPublicProfile() {
|
||||
</Trans>
|
||||
</h2>
|
||||
{isOwnProfile && (
|
||||
<NewPlaylistForm
|
||||
<NewPlaylistButton
|
||||
onCreated={(p) =>
|
||||
setState((s) => {
|
||||
if (s.status !== "loaded") return s;
|
||||
|
||||
@@ -11,8 +11,8 @@ import {
|
||||
} from "../model.ts";
|
||||
import { useAuth } from "../hooks/useAuth.ts";
|
||||
import { useDocumentTitle } from "../hooks/useDocumentTitle.ts";
|
||||
import { PageShell } from "../components/PageShell.tsx";
|
||||
import { ErrorCard } from "../components/ErrorCard.tsx";
|
||||
import { PageShell } from "../components/layout/PageShell.tsx";
|
||||
import { ErrorCard } from "../components/ui/ErrorCard.tsx";
|
||||
import {
|
||||
expectOk,
|
||||
FormError,
|
||||
|
||||
@@ -12,10 +12,10 @@ import { useWS } from "../hooks/useWS.ts";
|
||||
import { useDumpListSync } from "../hooks/useDumpListSync.ts";
|
||||
import { useFading } from "../hooks/useFading.ts";
|
||||
import { useUserDumpFeed } from "../hooks/useUserDumpFeed.ts";
|
||||
import { DumpCard } from "../components/DumpCard.tsx";
|
||||
import { ProfileSubpageHeader } from "../components/ProfileSubpageHeader.tsx";
|
||||
import { PageShell } from "../components/PageShell.tsx";
|
||||
import { PageError } from "../components/PageError.tsx";
|
||||
import { DumpCard } from "../components/dump/DumpCard.tsx";
|
||||
import { ProfileSubpageHeader } from "../components/profile/ProfileSubpageHeader.tsx";
|
||||
import { PageShell } from "../components/layout/PageShell.tsx";
|
||||
import { PageError } from "../components/layout/PageError.tsx";
|
||||
|
||||
export function UserUpvoted() {
|
||||
const { username } = useParams();
|
||||
|
||||
@@ -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/useAuth.ts";
|
||||
import {
|
||||
DEFAULT_FEED_TAB,
|
||||
FEED_TABS,
|
||||
type FeedTab,
|
||||
} from "../config/feedTabs.ts";
|
||||
import { useDefaultFeedTab } from "../hooks/useDefaultFeedTab.ts";
|
||||
import { TabBar } from "./TabBar.tsx";
|
||||
import { CategorySwitcher } from "./CategorySwitcher.tsx";
|
||||
} from "../../config/feedTabs.ts";
|
||||
import { useDefaultFeedTab } from "../../hooks/useDefaultFeedTab.ts";
|
||||
import { TabBar } from "../../components/ui/TabBar.tsx";
|
||||
import { CategorySwitcher } from "../../components/category/CategorySwitcher.tsx";
|
||||
|
||||
/**
|
||||
* Feed sort tabs (hot/new/journal/followed). Navigation stays within the
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { TabBar } from "../../components/TabBar.tsx";
|
||||
import { TabBar } from "../../components/ui/TabBar.tsx";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { DumpCard } from "../../components/DumpCard.tsx";
|
||||
import { ErrorCard } from "../../components/ErrorCard.tsx";
|
||||
import { DumpCard } from "../../components/dump/DumpCard.tsx";
|
||||
import { ErrorCard } from "../../components/ui/ErrorCard.tsx";
|
||||
import { API_URL, DEFAULT_PAGE_SIZE } from "../../config/api.ts";
|
||||
import { useAuth } from "../../hooks/useAuth.ts";
|
||||
import { useDumpListSync } from "../../hooks/useDumpListSync.ts";
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useMemo } from "react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { DumpCard } from "../../components/DumpCard.tsx";
|
||||
import { ErrorCard } from "../../components/ErrorCard.tsx";
|
||||
import { DumpCard } from "../../components/dump/DumpCard.tsx";
|
||||
import { ErrorCard } from "../../components/ui/ErrorCard.tsx";
|
||||
import { hotScore } from "../../utils/hotScore.ts";
|
||||
import type { MainFeedProps } from "./types.ts";
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Fragment, useMemo } from "react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { ErrorCard } from "../../components/ErrorCard.tsx";
|
||||
import { JournalCard } from "../../components/JournalCard.tsx";
|
||||
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";
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useMemo } from "react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { DumpCard } from "../../components/DumpCard.tsx";
|
||||
import { ErrorCard } from "../../components/ErrorCard.tsx";
|
||||
import { DumpCard } from "../../components/dump/DumpCard.tsx";
|
||||
import { ErrorCard } from "../../components/ui/ErrorCard.tsx";
|
||||
import type { MainFeedProps } from "./types.ts";
|
||||
|
||||
export function NewFeed(
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Avatar } from "./Avatar.tsx";
|
||||
import { useWSPresence } from "../hooks/useWS.ts";
|
||||
import { useRoomBeside } from "../hooks/useRoomBeside.ts";
|
||||
import { Avatar } from "../../components/ui/Avatar.tsx";
|
||||
import { useWSPresence } from "../../hooks/useWS.ts";
|
||||
import { useRoomBeside } from "../../hooks/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.
|
||||
@@ -1,389 +0,0 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { act, fireEvent, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { useRef } from "react";
|
||||
import { Avatar } from "../../components/Avatar.tsx";
|
||||
import { VoteButton } from "../../components/VoteButton.tsx";
|
||||
import { LikeButton } from "../../components/LikeButton.tsx";
|
||||
import {
|
||||
FollowPlaylistButton,
|
||||
FollowUserButton,
|
||||
} from "../../components/FollowButton.tsx";
|
||||
import { UserListPopover } from "../../components/UserListPopover.tsx";
|
||||
import { NotificationBell } from "../../components/NotificationBell.tsx";
|
||||
import { ChatButton } from "../../components/ChatButton.tsx";
|
||||
import { AvatarOverrideContext } from "../../contexts/AvatarOverrideContext.ts";
|
||||
import { ChatContext } from "../../contexts/ChatContext.ts";
|
||||
import { calls, fail, route } from "../fetch.ts";
|
||||
import { makeNotification, makeSession, makeUser, page } from "../fixtures.ts";
|
||||
import { currentPath, renderWithContexts } from "../render.tsx";
|
||||
import { defaultWSValue, WSContext } from "../../contexts/WSContext.ts";
|
||||
|
||||
describe("Avatar", () => {
|
||||
test("an image when the user has one, versioned", () => {
|
||||
renderWithContexts(
|
||||
<Avatar userId="u1" username="ann" hasAvatar version={7} />,
|
||||
);
|
||||
expect(screen.getByRole("img", { name: "ann" })).toHaveAttribute(
|
||||
"src",
|
||||
"http://localhost:8000/api/avatars/u1?v=7",
|
||||
);
|
||||
});
|
||||
|
||||
test("initials otherwise, and after the image fails", () => {
|
||||
renderWithContexts(<Avatar userId="u1" username="bob" hasAvatar />);
|
||||
fireEvent.error(screen.getByRole("img"));
|
||||
expect(screen.queryByRole("img")).not.toBeInTheDocument();
|
||||
expect(screen.getByTitle("bob")).toHaveTextContent("B");
|
||||
});
|
||||
|
||||
test("a live override wins over stale props, and retries a failed image", () => {
|
||||
const { rerender } = renderWithContexts(
|
||||
<AvatarOverrideContext.Provider
|
||||
value={{ u1: { hasAvatar: false, version: 1 } }}
|
||||
>
|
||||
<Avatar userId="u1" username="cy" hasAvatar />
|
||||
</AvatarOverrideContext.Provider>,
|
||||
);
|
||||
expect(screen.queryByRole("img")).not.toBeInTheDocument();
|
||||
rerender(
|
||||
<AvatarOverrideContext.Provider
|
||||
value={{ u1: { hasAvatar: true, version: 2 } }}
|
||||
>
|
||||
<Avatar userId="u1" username="cy" hasAvatar={false} />
|
||||
</AvatarOverrideContext.Provider>,
|
||||
);
|
||||
// rerender replaces the tree under the same router wrapper
|
||||
expect(screen.getByRole("img")).toHaveAttribute(
|
||||
"src",
|
||||
expect.stringContaining("?v=2"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("VoteButton / LikeButton", () => {
|
||||
test("upvote and remove, passing the count on screen", async () => {
|
||||
const onCast = vi.fn();
|
||||
const onRemove = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
const { rerender } = renderWithContexts(
|
||||
<VoteButton
|
||||
dumpId="d"
|
||||
count={0}
|
||||
voted={false}
|
||||
onCast={onCast}
|
||||
onRemove={onRemove}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Upvote" }));
|
||||
expect(onCast).toHaveBeenCalledWith("d", 0);
|
||||
rerender(
|
||||
<VoteButton
|
||||
dumpId="d"
|
||||
count={3}
|
||||
voted
|
||||
onCast={onCast}
|
||||
onRemove={onRemove}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Remove vote" }));
|
||||
expect(onRemove).toHaveBeenCalledWith("d", 3);
|
||||
});
|
||||
|
||||
test("disabled for guests, with a hint", () => {
|
||||
renderWithContexts(
|
||||
<VoteButton
|
||||
dumpId="d"
|
||||
count={1}
|
||||
voted={false}
|
||||
disabled
|
||||
onCast={() => {}}
|
||||
onRemove={() => {}}
|
||||
/>,
|
||||
);
|
||||
const btn = screen.getByRole("button", { name: "Upvote" });
|
||||
expect(btn).toBeDisabled();
|
||||
expect(btn).toHaveAttribute("title", "Log in to vote");
|
||||
});
|
||||
|
||||
test("the count opens who voted, without voting", async () => {
|
||||
const onCast = vi.fn();
|
||||
route(
|
||||
"GET /api/dumps/:id/voters",
|
||||
() => page([makeUser({ username: "fan" })]),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
renderWithContexts(
|
||||
<VoteButton
|
||||
dumpId="d1"
|
||||
count={1}
|
||||
voted={false}
|
||||
onCast={onCast}
|
||||
onRemove={() => {}}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByText("1"));
|
||||
expect(onCast).not.toHaveBeenCalled();
|
||||
expect(await screen.findByRole("menuitem", { name: /fan/ }))
|
||||
.toHaveAttribute(
|
||||
"href",
|
||||
"/users/fan",
|
||||
);
|
||||
});
|
||||
|
||||
test("likes work the same way", async () => {
|
||||
const onLike = vi.fn();
|
||||
const onUnlike = vi.fn();
|
||||
route("GET /api/comments/:id/likers", () => page([]));
|
||||
const user = userEvent.setup();
|
||||
const { rerender } = renderWithContexts(
|
||||
<LikeButton
|
||||
commentId="c"
|
||||
count={2}
|
||||
liked={false}
|
||||
onLike={onLike}
|
||||
onUnlike={onUnlike}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Like" }));
|
||||
expect(onLike).toHaveBeenCalledWith("c", 2);
|
||||
await user.click(screen.getByText("2"));
|
||||
expect(await screen.findByText("No one yet.")).toBeInTheDocument();
|
||||
rerender(
|
||||
<LikeButton
|
||||
commentId="c"
|
||||
count={2}
|
||||
liked
|
||||
onLike={onLike}
|
||||
onUnlike={onUnlike}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Remove like" }));
|
||||
expect(onUnlike).toHaveBeenCalledWith("c", 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("UserListPopover", () => {
|
||||
function Harness({ onClose = () => {} }: { onClose?: () => void }) {
|
||||
const ref = useRef<HTMLSpanElement>(null);
|
||||
return (
|
||||
<>
|
||||
<span ref={ref}>anchor</span>
|
||||
<button type="button">outside</button>
|
||||
<UserListPopover
|
||||
anchorRef={ref}
|
||||
onClose={onClose}
|
||||
fetchUrl="http://localhost:8000/api/x/voters"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
test("pages through the list", async () => {
|
||||
route(
|
||||
"GET /api/x/voters",
|
||||
(req) =>
|
||||
req.url.searchParams.get("page") === "1"
|
||||
? page([makeUser({ username: "a" })], true)
|
||||
: page([makeUser({ username: "b" })], false),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
renderWithContexts(<Harness />, { session: makeSession() });
|
||||
expect(screen.getByText("Loading…")).toBeInTheDocument();
|
||||
await screen.findByText("a");
|
||||
await user.click(screen.getByRole("button", { name: "Load more" }));
|
||||
await screen.findByText("b");
|
||||
expect(screen.queryByRole("button", { name: "Load more" })).not
|
||||
.toBeInTheDocument();
|
||||
expect(calls("GET /api/x/voters")[0].headers.get("Authorization")).toMatch(
|
||||
/^Bearer /,
|
||||
);
|
||||
});
|
||||
|
||||
test("shows the server's error, and a network one", async () => {
|
||||
route("GET /api/x/voters", () => fail("Dump not found", 404), {
|
||||
once: true,
|
||||
});
|
||||
const first = renderWithContexts(<Harness />);
|
||||
await screen.findByText("Dump not found");
|
||||
first.unmount();
|
||||
route("GET /api/x/voters", () => new TypeError("offline"));
|
||||
renderWithContexts(<Harness />);
|
||||
await screen.findByText("Could not load.");
|
||||
});
|
||||
|
||||
test("a failed next page lets you try again", async () => {
|
||||
route(
|
||||
"GET /api/x/voters",
|
||||
() => page([makeUser({ username: "a" })], true),
|
||||
{ once: true },
|
||||
);
|
||||
route("GET /api/x/voters", () => new TypeError("offline"), { once: true });
|
||||
route(
|
||||
"GET /api/x/voters",
|
||||
() => page([makeUser({ username: "b" })], false),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
renderWithContexts(<Harness />);
|
||||
await screen.findByText("a");
|
||||
await user.click(screen.getByRole("button", { name: "Load more" }));
|
||||
const retry = await screen.findByRole("button", { name: "Load more" });
|
||||
expect(retry).toBeEnabled();
|
||||
await user.click(retry);
|
||||
await screen.findByText("b");
|
||||
});
|
||||
|
||||
test("closes on Escape, an outside press and a scroll — not on the anchor", async () => {
|
||||
route("GET /api/x/voters", () => page([]));
|
||||
const onClose = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
renderWithContexts(<Harness onClose={onClose} />);
|
||||
await screen.findByText("No one yet.");
|
||||
await user.click(screen.getByText("anchor"));
|
||||
await user.click(screen.getByText("No one yet."));
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
await user.keyboard("{Escape}");
|
||||
await user.click(screen.getByText("outside"));
|
||||
act(() => {
|
||||
globalThis.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
expect(onClose).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("FollowButton", () => {
|
||||
const me = makeSession({ id: "me" });
|
||||
|
||||
test("hidden for guests and on yourself", () => {
|
||||
const { unmount } = renderWithContexts(
|
||||
<FollowUserButton targetUserId="x" targetUsername="x" />,
|
||||
);
|
||||
expect(screen.queryByRole("button")).not.toBeInTheDocument();
|
||||
unmount();
|
||||
renderWithContexts(
|
||||
<FollowUserButton targetUserId="me" targetUsername="me" />,
|
||||
{ session: me },
|
||||
);
|
||||
expect(screen.queryByRole("button")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("follow and unfollow a user", async () => {
|
||||
const followUser = vi.fn();
|
||||
const unfollowUser = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
renderWithContexts(
|
||||
<FollowUserButton targetUserId="u" targetUsername="ann" />,
|
||||
{ session: me, follows: { followUser, unfollowUser } },
|
||||
);
|
||||
const btn = screen.getByRole("button", { name: "Follow ann" });
|
||||
expect(btn).toHaveTextContent("Follow");
|
||||
await user.click(btn);
|
||||
expect(followUser).toHaveBeenCalledWith("u");
|
||||
expect(unfollowUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("shows Following, and unfollows", async () => {
|
||||
const unfollowUser = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
renderWithContexts(
|
||||
<FollowUserButton targetUserId="u" targetUsername="ann" />,
|
||||
{
|
||||
session: me,
|
||||
follows: { followedUserIds: new Set(["u"]), unfollowUser },
|
||||
},
|
||||
);
|
||||
const btn = screen.getByRole("button", { name: "Unfollow ann" });
|
||||
expect(btn).toHaveTextContent("Following");
|
||||
await user.click(btn);
|
||||
expect(unfollowUser).toHaveBeenCalledWith("u");
|
||||
});
|
||||
|
||||
test("disabled until follows have loaded", () => {
|
||||
renderWithContexts(
|
||||
<FollowUserButton targetUserId="u" targetUsername="ann" />,
|
||||
{
|
||||
session: me,
|
||||
follows: { isLoaded: false },
|
||||
},
|
||||
);
|
||||
expect(screen.getByRole("button")).toBeDisabled();
|
||||
});
|
||||
|
||||
test("playlists: only public ones can be followed", async () => {
|
||||
const followPlaylist = vi.fn();
|
||||
const unfollowPlaylist = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
const priv = renderWithContexts(
|
||||
<FollowPlaylistButton targetPlaylistId="p" isPublic={false} />,
|
||||
{ session: me },
|
||||
);
|
||||
expect(screen.queryByRole("button")).not.toBeInTheDocument();
|
||||
priv.unmount();
|
||||
renderWithContexts(<FollowPlaylistButton targetPlaylistId="p" isPublic />, {
|
||||
session: me,
|
||||
follows: {
|
||||
followPlaylist,
|
||||
unfollowPlaylist,
|
||||
followedPlaylistIds: new Set(["p"]),
|
||||
},
|
||||
});
|
||||
await user.click(screen.getByRole("button", { name: "Unfollow playlist" }));
|
||||
expect(unfollowPlaylist).toHaveBeenCalledWith("p");
|
||||
});
|
||||
});
|
||||
|
||||
describe("NotificationBell / ChatButton", () => {
|
||||
test("the bell shows the unread count and goes to notifications", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithContexts(<NotificationBell />, {
|
||||
ws: { unreadNotificationCount: 120 },
|
||||
});
|
||||
const bell = screen.getByRole("button", {
|
||||
name: "Notifications (120 unread)",
|
||||
});
|
||||
expect(bell).toHaveTextContent("99+");
|
||||
await user.click(bell);
|
||||
expect(currentPath()).toBe("/notifications");
|
||||
});
|
||||
|
||||
test("the bell rings for a new notification, not for one already there", () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const n1 = makeNotification({ id: "n1" });
|
||||
const withLast = (n: ReturnType<typeof makeNotification>) => (
|
||||
<WSContext.Provider
|
||||
value={{ ...defaultWSValue, lastNotification: n }}
|
||||
>
|
||||
<NotificationBell />
|
||||
</WSContext.Provider>
|
||||
);
|
||||
const { rerender } = renderWithContexts(withLast(n1));
|
||||
const bell = () => screen.getByRole("button", { name: "Notifications" });
|
||||
act(() => vi.advanceTimersByTime(10));
|
||||
expect(bell()).not.toHaveClass("notification-bell--ringing");
|
||||
rerender(withLast(makeNotification({ id: "n2" })));
|
||||
act(() => vi.advanceTimersByTime(10));
|
||||
expect(bell()).toHaveClass("notification-bell--ringing");
|
||||
act(() => vi.advanceTimersByTime(700));
|
||||
expect(bell()).not.toHaveClass("notification-bell--ringing");
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
test("the chat button opens the chat and shows unread", async () => {
|
||||
const openChat = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
renderWithContexts(
|
||||
<ChatContext.Provider
|
||||
value={{ isChatOpen: false, openChat, closeChat: () => {} }}
|
||||
>
|
||||
<ChatButton />
|
||||
</ChatContext.Provider>,
|
||||
{ ws: { unreadChatCount: 4 } },
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Chat (4 unread)" }));
|
||||
expect(openChat).toHaveBeenCalled();
|
||||
await waitFor(() => expect(screen.getByText("4")).toBeInTheDocument());
|
||||
});
|
||||
});
|
||||
42
src/test/components/category/CategorySelect.test.tsx
Normal file
42
src/test/components/category/CategorySelect.test.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { CategorySelect } from "../../../components/category/CategorySelect.tsx";
|
||||
import type { Category } from "../../../model.ts";
|
||||
import { makeCategory } from "../../fixtures.ts";
|
||||
import { renderWithContexts } from "../../render.tsx";
|
||||
|
||||
const categories: Category[] = [
|
||||
makeCategory({ id: "c1", slug: "music", name: "Music" }),
|
||||
makeCategory({ id: "c2", slug: "cinema", name: "Cinema" }),
|
||||
];
|
||||
|
||||
describe("CategorySelect", () => {
|
||||
test("toggle chips reflect the selection", async () => {
|
||||
const onToggle = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
renderWithContexts(
|
||||
<CategorySelect selected={new Set(["c2"])} onToggle={onToggle} />,
|
||||
{
|
||||
categories,
|
||||
},
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "Cinema" })).toHaveAttribute(
|
||||
"aria-pressed",
|
||||
"true",
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "Music" })).toHaveAttribute(
|
||||
"aria-pressed",
|
||||
"false",
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Music" }));
|
||||
expect(onToggle).toHaveBeenCalledWith("c1");
|
||||
});
|
||||
|
||||
test("nothing without categories", () => {
|
||||
renderWithContexts(
|
||||
<CategorySelect selected={new Set()} onToggle={() => {}} />,
|
||||
);
|
||||
expect(screen.queryByText("Categories")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
64
src/test/components/category/CategorySwitcher.test.tsx
Normal file
64
src/test/components/category/CategorySwitcher.test.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { CategorySwitcher } from "../../../components/category/CategorySwitcher.tsx";
|
||||
import type { Category } from "../../../model.ts";
|
||||
import { makeCategory } from "../../fixtures.ts";
|
||||
import { currentPath, renderWithContexts } from "../../render.tsx";
|
||||
|
||||
const categories: Category[] = [
|
||||
makeCategory({ id: "c1", slug: "music", name: "Music" }),
|
||||
makeCategory({ id: "c2", slug: "cinema", name: "Cinema" }),
|
||||
];
|
||||
|
||||
describe("CategorySwitcher", () => {
|
||||
test("hidden with no categories", () => {
|
||||
renderWithContexts(<CategorySwitcher />, {
|
||||
route: "/~/hot",
|
||||
path: "/:x/:feedTab",
|
||||
});
|
||||
expect(screen.queryByRole("button")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("switches scope, keeping the tab", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithContexts(<CategorySwitcher />, {
|
||||
categories,
|
||||
route: "/music/new",
|
||||
path: "/:categorySlug/:feedTab",
|
||||
});
|
||||
const trigger = screen.getByRole("button", { name: "Music" });
|
||||
await user.click(trigger);
|
||||
expect(screen.getByRole("option", { name: "Music" })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
await user.click(screen.getByRole("option", { name: "Cinema" }));
|
||||
expect(currentPath()).toBe("/cinema/new");
|
||||
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("All is the ~ scope; an unknown slug shows as itself", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithContexts(<CategorySwitcher />, {
|
||||
categories,
|
||||
route: "/gone/hot",
|
||||
path: "/:categorySlug/:feedTab",
|
||||
});
|
||||
await user.click(screen.getByRole("button", { name: "gone" }));
|
||||
await user.click(screen.getByRole("option", { name: "All" }));
|
||||
expect(currentPath()).toBe("/~/hot");
|
||||
});
|
||||
|
||||
test("Escape closes the menu", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithContexts(<CategorySwitcher />, {
|
||||
categories,
|
||||
route: "/~/hot",
|
||||
path: "/~/:feedTab",
|
||||
});
|
||||
await user.click(screen.getByRole("button", { name: "All" }));
|
||||
await user.keyboard("{Escape}");
|
||||
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,12 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { act, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { ChatModal } from "../../components/ChatModal.tsx";
|
||||
import type { AuthResponse, ChatMessage } from "../../model.ts";
|
||||
import { calls, fail, route } from "../fetch.ts";
|
||||
import { makeChatMessage, makeSession, wire } from "../fixtures.ts";
|
||||
import { renderApp } from "../render.tsx";
|
||||
import { FakeWebSocket } from "../ws.ts";
|
||||
import { ChatModal } from "../../../components/chat/ChatModal.tsx";
|
||||
import type { AuthResponse, ChatMessage } from "../../../model.ts";
|
||||
import { calls, fail, route } from "../../fetch.ts";
|
||||
import { makeChatMessage, makeSession, wire } from "../../fixtures.ts";
|
||||
import { renderApp } from "../../render.tsx";
|
||||
import { FakeWebSocket } from "../../ws.ts";
|
||||
|
||||
const me = makeSession({ id: "me", username: "me" });
|
||||
|
||||
@@ -2,11 +2,11 @@ import { describe, expect, test, vi } from "vitest";
|
||||
import { screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { useState } from "react";
|
||||
import { CommentThread } from "../../components/CommentThread.tsx";
|
||||
import type { AuthResponse, Comment } from "../../model.ts";
|
||||
import { calls, fail, route } from "../fetch.ts";
|
||||
import { makeComment, makeSession, wire } from "../fixtures.ts";
|
||||
import { renderWithContexts } from "../render.tsx";
|
||||
import { CommentThread } from "../../../components/comment/CommentThread.tsx";
|
||||
import type { AuthResponse, Comment } from "../../../model.ts";
|
||||
import { calls, fail, route } from "../../fetch.ts";
|
||||
import { makeComment, makeSession, wire } from "../../fixtures.ts";
|
||||
import { renderWithContexts } from "../../render.tsx";
|
||||
|
||||
const me = makeSession({ id: "me", username: "me" });
|
||||
const mod = makeSession({ id: "mod", username: "mod", role: "moderator" });
|
||||
@@ -1,416 +0,0 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { fireEvent, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { Markdown } from "../../components/Markdown.tsx";
|
||||
import Thumbnail from "../../components/Thumbnail.tsx";
|
||||
import ThumbnailPlaceholder from "../../components/ThumbnailPlaceholder.tsx";
|
||||
import RichContentCard from "../../components/RichContentCard.tsx";
|
||||
import { DumpCard } from "../../components/DumpCard.tsx";
|
||||
import { hueFromString } from "../../utils/thumbnailTint.ts";
|
||||
import { markDumpVisited } from "../../utils/visited.ts";
|
||||
import { makeCategory, makeDump, makeSession } from "../fixtures.ts";
|
||||
import { currentPath, renderWithContexts } from "../render.tsx";
|
||||
|
||||
describe("Markdown", () => {
|
||||
const md = (text: string, inline = false) =>
|
||||
renderWithContexts(<Markdown inline={inline}>{text}</Markdown>);
|
||||
|
||||
test("renders GFM", () => {
|
||||
md("**bold** ~~gone~~\n\n| a | b |\n|---|---|\n| 1 | 2 |\n\n- [x] done");
|
||||
expect(screen.getByText("bold").tagName).toBe("STRONG");
|
||||
expect(screen.getByText("gone").tagName).toBe("DEL");
|
||||
expect(screen.getByRole("table").parentElement).toHaveClass(
|
||||
"md-table-wrap",
|
||||
);
|
||||
expect(screen.getByRole("checkbox")).toBeChecked();
|
||||
});
|
||||
|
||||
test("@mentions link to the profile, in-app", async () => {
|
||||
md("hi @ann and bob@mail.com");
|
||||
const link = screen.getByRole("link", { name: "@ann" });
|
||||
expect(link).toHaveAttribute("href", "/users/ann");
|
||||
expect(link).not.toHaveAttribute("target");
|
||||
// GFM autolinks the address itself; it isn't a mention of "mail".
|
||||
expect(screen.getByRole("link", { name: "bob@mail.com" })).toHaveAttribute(
|
||||
"href",
|
||||
"mailto:bob@mail.com",
|
||||
);
|
||||
expect(document.querySelector('a[href="/users/mail"]')).toBeNull();
|
||||
await userEvent.click(link);
|
||||
expect(currentPath()).toBe("/users/ann");
|
||||
});
|
||||
|
||||
test("mentions in code and inside URLs are left alone", () => {
|
||||
md("`@ann` and https://mastodon.social/@bob");
|
||||
expect(screen.getByText("@ann").tagName).toBe("CODE");
|
||||
expect(screen.getByRole("link")).toHaveAttribute(
|
||||
"href",
|
||||
"https://mastodon.social/@bob",
|
||||
);
|
||||
});
|
||||
|
||||
test("external links open in a new tab; internal and same-origin ones don't", () => {
|
||||
md(
|
||||
`[ext](https://elsewhere.test) [in](/dumps/x) [same](${location.origin}/playlists/p?x=1#y) [file](/api/files/1)`,
|
||||
);
|
||||
expect(screen.getByRole("link", { name: "ext" })).toHaveAttribute(
|
||||
"target",
|
||||
"_blank",
|
||||
);
|
||||
expect(screen.getByRole("link", { name: "ext" })).toHaveAttribute(
|
||||
"rel",
|
||||
"noopener noreferrer",
|
||||
);
|
||||
expect(screen.getByRole("link", { name: "in" })).not.toHaveAttribute(
|
||||
"target",
|
||||
);
|
||||
expect(screen.getByRole("link", { name: "same" })).toHaveAttribute(
|
||||
"href",
|
||||
"/playlists/p?x=1#y",
|
||||
);
|
||||
expect(screen.getByRole("link", { name: "file" })).toHaveAttribute(
|
||||
"target",
|
||||
"_blank",
|
||||
);
|
||||
});
|
||||
|
||||
test("a single newline is a line break", () => {
|
||||
const { container } = md("one\ntwo\nthree");
|
||||
expect(container.querySelectorAll("br")).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("code blocks keep their newlines", () => {
|
||||
const { container } = md("```\na\nb\n```");
|
||||
expect(container.querySelector("pre")!.textContent).toBe("a\nb\n");
|
||||
expect(container.querySelectorAll("br")).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("images load lazily; inline teasers drop them", () => {
|
||||
const full = md("");
|
||||
expect(screen.getByRole("img", { name: "cat" })).toHaveAttribute(
|
||||
"loading",
|
||||
"lazy",
|
||||
);
|
||||
full.unmount();
|
||||
md(" text", true);
|
||||
expect(screen.queryByRole("img")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("text")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("raw HTML is not rendered", () => {
|
||||
const { container } = md("<script>alert(1)</script><b>x</b>");
|
||||
expect(container.querySelector("script")).toBeNull();
|
||||
expect(container.querySelector("b")).toBeNull();
|
||||
});
|
||||
|
||||
test("javascript: links are neutralised", () => {
|
||||
md("[click](javascript:alert(1))");
|
||||
const link = screen.getByText("click").closest("a");
|
||||
expect(link?.getAttribute("href") ?? "").not.toMatch(/^javascript:/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Thumbnail", () => {
|
||||
const placeholder = { url: "https://site.test/page", siteName: "Site" };
|
||||
|
||||
test("direct, then proxied, then the placeholder", () => {
|
||||
renderWithContexts(
|
||||
<Thumbnail
|
||||
src="https://img.test/a.png"
|
||||
alt="art"
|
||||
placeholder={placeholder}
|
||||
/>,
|
||||
);
|
||||
const img = screen.getByRole("img", { name: "art" });
|
||||
expect(img).toHaveAttribute("src", "https://img.test/a.png");
|
||||
expect(img).toHaveAttribute("referrerpolicy", "no-referrer");
|
||||
fireEvent.error(img);
|
||||
expect(screen.getByRole("img", { name: "art" }).getAttribute("src"))
|
||||
.toContain(
|
||||
"/api/proxy-image?url=",
|
||||
);
|
||||
fireEvent.error(screen.getByRole("img", { name: "art" }));
|
||||
expect(screen.queryByRole("img", { name: "art" })).not.toBeInTheDocument();
|
||||
expect(screen.getByText("S")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("our own images skip the proxy retry", () => {
|
||||
renderWithContexts(
|
||||
<Thumbnail
|
||||
src="http://localhost:8000/api/thumbnails/d"
|
||||
alt="t"
|
||||
placeholder={placeholder}
|
||||
/>,
|
||||
);
|
||||
fireEvent.error(screen.getByRole("img", { name: "t" }));
|
||||
expect(screen.queryByRole("img", { name: "t" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("no src: straight to the placeholder; a new src tries again", () => {
|
||||
const { rerender } = renderWithContexts(
|
||||
<Thumbnail placeholder={placeholder} alt="t" />,
|
||||
);
|
||||
expect(screen.queryByRole("img", { name: "t" })).not.toBeInTheDocument();
|
||||
rerender(
|
||||
<Thumbnail
|
||||
src="https://img.test/b.png"
|
||||
placeholder={placeholder}
|
||||
alt="t"
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole("img", { name: "t" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ThumbnailPlaceholder", () => {
|
||||
test("tints by the page colour, or by hostname", () => {
|
||||
const { container, rerender } = renderWithContexts(
|
||||
<ThumbnailPlaceholder
|
||||
url="https://www.site.test/x"
|
||||
accentColor="#123456"
|
||||
/>,
|
||||
);
|
||||
const box = () =>
|
||||
container.querySelector<HTMLElement>(".thumb-placeholder")!;
|
||||
expect(box().style.getPropertyValue("--thumb-tint")).toBe("#123456");
|
||||
rerender(
|
||||
<ThumbnailPlaceholder
|
||||
url="https://www.site.test/x"
|
||||
accentColor="url(evil)"
|
||||
/>,
|
||||
);
|
||||
expect(box().style.getPropertyValue("--thumb-tint")).toBe(
|
||||
hueFromString("site.test"),
|
||||
);
|
||||
});
|
||||
|
||||
test("favicon, then the initial when it fails; a glyph wins over the initial", () => {
|
||||
const { container, rerender } = renderWithContexts(
|
||||
<ThumbnailPlaceholder
|
||||
url="https://site.test"
|
||||
faviconUrl="https://site.test/f.ico"
|
||||
/>,
|
||||
);
|
||||
const icon = container.querySelector("img")!;
|
||||
fireEvent.error(icon);
|
||||
expect(container.querySelector("img")).toBeNull();
|
||||
expect(screen.getByText("S")).toBeInTheDocument();
|
||||
rerender(<ThumbnailPlaceholder seed="clip.mp4" glyph="🎬" />);
|
||||
expect(screen.getByText("🎬")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("RichContentCard", () => {
|
||||
const yt = {
|
||||
type: "youtube",
|
||||
url: "https://youtube.com/watch?v=x",
|
||||
title: "A video",
|
||||
siteName: "YouTube",
|
||||
description: "About it",
|
||||
thumbnailUrl: "https://i.ytimg.com/x.jpg",
|
||||
embedUrl: "https://www.youtube.com/embed/x",
|
||||
};
|
||||
|
||||
test("full card: play button, source link and details", async () => {
|
||||
const play = vi.fn();
|
||||
renderWithContexts(
|
||||
<RichContentCard richContent={yt} dumpHref="/dumps/d" />,
|
||||
{
|
||||
player: { play },
|
||||
},
|
||||
);
|
||||
expect(screen.getByRole("link", { name: /A video/ })).toHaveAttribute(
|
||||
"href",
|
||||
yt.url,
|
||||
);
|
||||
expect(screen.getByText("About it")).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole("button", { name: "Play" }));
|
||||
expect(play).toHaveBeenCalledWith(expect.objectContaining({
|
||||
kind: "embed",
|
||||
embedUrl: yt.embedUrl,
|
||||
dumpHref: "/dumps/d",
|
||||
}));
|
||||
});
|
||||
|
||||
test("compact: shows Pause while this card plays", () => {
|
||||
renderWithContexts(<RichContentCard richContent={yt} compact />, {
|
||||
player: {
|
||||
playing: true,
|
||||
current: { kind: "embed", embedUrl: yt.embedUrl, type: "youtube" },
|
||||
},
|
||||
});
|
||||
expect(screen.getByRole("button", { name: "Pause" })).toHaveClass(
|
||||
"is-playing",
|
||||
);
|
||||
});
|
||||
|
||||
test("compact and unplayable: a link to the source", () => {
|
||||
renderWithContexts(
|
||||
<RichContentCard
|
||||
richContent={{
|
||||
type: "link",
|
||||
url: "https://blog.test/p",
|
||||
siteName: "Blog",
|
||||
}}
|
||||
compact
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole("link", { name: "Open on Blog" })).toHaveAttribute(
|
||||
"href",
|
||||
"https://blog.test/p",
|
||||
);
|
||||
});
|
||||
|
||||
test("a dump thumbnail overrides the provider's", () => {
|
||||
renderWithContexts(
|
||||
<RichContentCard
|
||||
richContent={yt}
|
||||
thumbnailOverrideUrl="http://localhost:8000/api/thumbnails/d"
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole("img", { name: "A video" })).toHaveAttribute(
|
||||
"src",
|
||||
"http://localhost:8000/api/thumbnails/d",
|
||||
);
|
||||
});
|
||||
|
||||
test("a native stream that is the active item toggles instead of restarting", async () => {
|
||||
const togglePlay = vi.fn();
|
||||
const play = vi.fn();
|
||||
const bc = {
|
||||
type: "bandcamp",
|
||||
url: "https://a.bandcamp.com/album/x",
|
||||
embedUrl: "https://bandcamp.com/EmbeddedPlayer/1",
|
||||
};
|
||||
renderWithContexts(<RichContentCard richContent={bc} compact />, {
|
||||
player: {
|
||||
togglePlay,
|
||||
play,
|
||||
playing: true,
|
||||
current: {
|
||||
kind: "stream",
|
||||
type: "bandcamp",
|
||||
streamUrl: "s",
|
||||
resolveUrl: bc.url,
|
||||
resolveIndex: 0,
|
||||
resolvedAt: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
await userEvent.click(screen.getByRole("button", { name: "Pause" }));
|
||||
expect(togglePlay).toHaveBeenCalled();
|
||||
expect(play).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("DumpCard", () => {
|
||||
const props = {
|
||||
voteCount: 3,
|
||||
voted: false,
|
||||
canVote: true,
|
||||
castVote: vi.fn(),
|
||||
removeVote: vi.fn(),
|
||||
};
|
||||
|
||||
test("title, teaser, date, comment count and categories", () => {
|
||||
const dump = makeDump({
|
||||
id: "d1",
|
||||
slug: "nice",
|
||||
title: "Nice find",
|
||||
comment: "Look **here**",
|
||||
commentCount: 2,
|
||||
categoryIds: ["c1", "missing"],
|
||||
});
|
||||
renderWithContexts(
|
||||
<ul>
|
||||
<DumpCard dump={dump} {...props} />
|
||||
</ul>,
|
||||
{
|
||||
categories: [makeCategory({ id: "c1", slug: "music", name: "Music" })],
|
||||
},
|
||||
);
|
||||
expect(screen.getByRole("link", { name: /Nice find/ })).toHaveAttribute(
|
||||
"href",
|
||||
"/dumps/nice",
|
||||
);
|
||||
expect(screen.getByText("here").tagName).toBe("STRONG");
|
||||
expect(screen.getByText("2 comments")).toBeInTheDocument();
|
||||
expect(screen.getByText("1 minute ago")).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: "Music" })).toHaveAttribute(
|
||||
"href",
|
||||
"/music",
|
||||
);
|
||||
});
|
||||
|
||||
test("an unread dot until visited, never on your own", () => {
|
||||
const dump = makeDump({ id: "d2" });
|
||||
const first = renderWithContexts(
|
||||
<ul>
|
||||
<DumpCard dump={dump} {...props} />
|
||||
</ul>,
|
||||
);
|
||||
expect(first.container.querySelector(".unread-dot")).not.toBeNull();
|
||||
first.unmount();
|
||||
const own = renderWithContexts(
|
||||
<ul>
|
||||
<DumpCard dump={dump} {...props} isOwner />
|
||||
</ul>,
|
||||
);
|
||||
expect(own.container.querySelector(".unread-dot")).toBeNull();
|
||||
own.unmount();
|
||||
markDumpVisited("d2");
|
||||
const seen = renderWithContexts(
|
||||
<ul>
|
||||
<DumpCard dump={dump} {...props} />
|
||||
</ul>,
|
||||
);
|
||||
expect(seen.container.querySelector(".unread-dot")).toBeNull();
|
||||
});
|
||||
|
||||
test("the private badge shows to the owner", () => {
|
||||
renderWithContexts(
|
||||
<ul>
|
||||
<DumpCard dump={makeDump({ isPrivate: true })} {...props} isOwner />
|
||||
</ul>,
|
||||
);
|
||||
expect(screen.getByText("private")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("clicking the card opens the dump; voting doesn't", async () => {
|
||||
const castVote = vi.fn();
|
||||
const dump = makeDump({ id: "d3" });
|
||||
renderWithContexts(
|
||||
<ul>
|
||||
<DumpCard dump={dump} {...props} castVote={castVote} />
|
||||
</ul>,
|
||||
{ session: makeSession() },
|
||||
);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Upvote" }));
|
||||
expect(castVote).toHaveBeenCalledWith("d3", 3);
|
||||
expect(currentPath()).toBe("/");
|
||||
await userEvent.click(screen.getByText("1 minute ago"));
|
||||
expect(currentPath()).toBe("/dumps/d3");
|
||||
});
|
||||
|
||||
test("file dumps get a file preview; bare URLs a placeholder", () => {
|
||||
const { container } = renderWithContexts(
|
||||
<ul>
|
||||
<DumpCard
|
||||
dump={makeDump({
|
||||
kind: "file",
|
||||
fileName: "a.pdf",
|
||||
fileMime: "application/pdf",
|
||||
fileSize: 10,
|
||||
url: undefined,
|
||||
})}
|
||||
{...props}
|
||||
/>
|
||||
<DumpCard dump={makeDump({ url: "https://plain.test" })} {...props} />
|
||||
</ul>,
|
||||
);
|
||||
expect(container.querySelectorAll(".thumb-placeholder").length)
|
||||
.toBeGreaterThan(0);
|
||||
const cards = container.querySelectorAll(".dump-card");
|
||||
expect(within(cards[1] as HTMLElement).getByText("P")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
119
src/test/components/dump/DumpCard.test.tsx
Normal file
119
src/test/components/dump/DumpCard.test.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
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 { makeCategory, makeDump, makeSession } from "../../fixtures.ts";
|
||||
import { currentPath, renderWithContexts } from "../../render.tsx";
|
||||
|
||||
describe("DumpCard", () => {
|
||||
const props = {
|
||||
voteCount: 3,
|
||||
voted: false,
|
||||
canVote: true,
|
||||
castVote: vi.fn(),
|
||||
removeVote: vi.fn(),
|
||||
};
|
||||
|
||||
test("title, teaser, date, comment count and categories", () => {
|
||||
const dump = makeDump({
|
||||
id: "d1",
|
||||
slug: "nice",
|
||||
title: "Nice find",
|
||||
comment: "Look **here**",
|
||||
commentCount: 2,
|
||||
categoryIds: ["c1", "missing"],
|
||||
});
|
||||
renderWithContexts(
|
||||
<ul>
|
||||
<DumpCard dump={dump} {...props} />
|
||||
</ul>,
|
||||
{
|
||||
categories: [makeCategory({ id: "c1", slug: "music", name: "Music" })],
|
||||
},
|
||||
);
|
||||
expect(screen.getByRole("link", { name: /Nice find/ })).toHaveAttribute(
|
||||
"href",
|
||||
"/dumps/nice",
|
||||
);
|
||||
expect(screen.getByText("here").tagName).toBe("STRONG");
|
||||
expect(screen.getByText("2 comments")).toBeInTheDocument();
|
||||
expect(screen.getByText("1 minute ago")).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: "Music" })).toHaveAttribute(
|
||||
"href",
|
||||
"/music",
|
||||
);
|
||||
});
|
||||
|
||||
test("an unread dot until visited, never on your own", () => {
|
||||
const dump = makeDump({ id: "d2" });
|
||||
const first = renderWithContexts(
|
||||
<ul>
|
||||
<DumpCard dump={dump} {...props} />
|
||||
</ul>,
|
||||
);
|
||||
expect(first.container.querySelector(".unread-dot")).not.toBeNull();
|
||||
first.unmount();
|
||||
const own = renderWithContexts(
|
||||
<ul>
|
||||
<DumpCard dump={dump} {...props} isOwner />
|
||||
</ul>,
|
||||
);
|
||||
expect(own.container.querySelector(".unread-dot")).toBeNull();
|
||||
own.unmount();
|
||||
markDumpVisited("d2");
|
||||
const seen = renderWithContexts(
|
||||
<ul>
|
||||
<DumpCard dump={dump} {...props} />
|
||||
</ul>,
|
||||
);
|
||||
expect(seen.container.querySelector(".unread-dot")).toBeNull();
|
||||
});
|
||||
|
||||
test("the private badge shows to the owner", () => {
|
||||
renderWithContexts(
|
||||
<ul>
|
||||
<DumpCard dump={makeDump({ isPrivate: true })} {...props} isOwner />
|
||||
</ul>,
|
||||
);
|
||||
expect(screen.getByText("private")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("clicking the card opens the dump; voting doesn't", async () => {
|
||||
const castVote = vi.fn();
|
||||
const dump = makeDump({ id: "d3" });
|
||||
renderWithContexts(
|
||||
<ul>
|
||||
<DumpCard dump={dump} {...props} castVote={castVote} />
|
||||
</ul>,
|
||||
{ session: makeSession() },
|
||||
);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Upvote" }));
|
||||
expect(castVote).toHaveBeenCalledWith("d3", 3);
|
||||
expect(currentPath()).toBe("/");
|
||||
await userEvent.click(screen.getByText("1 minute ago"));
|
||||
expect(currentPath()).toBe("/dumps/d3");
|
||||
});
|
||||
|
||||
test("file dumps get a file preview; bare URLs a placeholder", () => {
|
||||
const { container } = renderWithContexts(
|
||||
<ul>
|
||||
<DumpCard
|
||||
dump={makeDump({
|
||||
kind: "file",
|
||||
fileName: "a.pdf",
|
||||
fileMime: "application/pdf",
|
||||
fileSize: 10,
|
||||
url: undefined,
|
||||
})}
|
||||
{...props}
|
||||
/>
|
||||
<DumpCard dump={makeDump({ url: "https://plain.test" })} {...props} />
|
||||
</ul>,
|
||||
);
|
||||
expect(container.querySelectorAll(".thumb-placeholder").length)
|
||||
.toBeGreaterThan(0);
|
||||
const cards = container.querySelectorAll(".dump-card");
|
||||
expect(within(cards[1] as HTMLElement).getByText("P")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
119
src/test/components/dump/FilePreview.test.tsx
Normal file
119
src/test/components/dump/FilePreview.test.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { FilePreview } from "../../../components/dump/FilePreview.tsx";
|
||||
import { makeDump, makeSession } from "../../fixtures.ts";
|
||||
import { renderWithContexts } from "../../render.tsx";
|
||||
import {
|
||||
dumpFileUrl,
|
||||
dumpFileVersion,
|
||||
dumpThumbnailUrl,
|
||||
} from "../../../utils/urls.ts";
|
||||
|
||||
describe("FilePreview", () => {
|
||||
const session = makeSession();
|
||||
const file = (mime: string, over = {}) =>
|
||||
makeDump({
|
||||
id: "f1",
|
||||
kind: "file",
|
||||
fileName: "thing",
|
||||
fileMime: mime,
|
||||
fileSize: 2048,
|
||||
url: undefined,
|
||||
...over,
|
||||
});
|
||||
|
||||
test("images show inline, with the media token", () => {
|
||||
const d = file("image/png");
|
||||
renderWithContexts(<FilePreview dump={d} />, { session });
|
||||
expect(screen.getByRole("img", { name: "thing" })).toHaveAttribute(
|
||||
"src",
|
||||
`http://localhost:8000/api/files/f1?v=${
|
||||
dumpFileVersion(d)
|
||||
}&token=${session.mediaToken}`,
|
||||
);
|
||||
});
|
||||
|
||||
test("a compact image shows the server's preview, not the upload", () => {
|
||||
const d = file("image/png");
|
||||
renderWithContexts(<FilePreview dump={d} compact />, { session });
|
||||
const img = screen.getByRole("img", { name: "thing" });
|
||||
expect(img).toHaveAttribute("src", dumpThumbnailUrl(d, session.mediaToken));
|
||||
expect(img).toHaveAttribute("loading", "lazy");
|
||||
});
|
||||
|
||||
test("other files are a download link", () => {
|
||||
renderWithContexts(<FilePreview dump={file("application/zip")} />);
|
||||
const link = screen.getByRole("link", { name: /Download thing/ });
|
||||
expect(link).toHaveAttribute("download", "thing");
|
||||
expect(link).toHaveTextContent("(2.0 KB)");
|
||||
});
|
||||
|
||||
test("compact audio plays in the global player", async () => {
|
||||
const play = vi.fn();
|
||||
renderWithContexts(<FilePreview dump={file("audio/mpeg")} compact />, {
|
||||
player: { play },
|
||||
});
|
||||
await userEvent.click(screen.getByRole("button", { name: "Play" }));
|
||||
expect(play).toHaveBeenCalledWith(expect.objectContaining({
|
||||
kind: "file",
|
||||
mimeType: "audio/mpeg",
|
||||
title: "Dump f1",
|
||||
subtitle: "thing",
|
||||
dumpHref: "/dumps/f1",
|
||||
artworkUrl: undefined,
|
||||
}));
|
||||
});
|
||||
|
||||
test("compact video claims its generated still as artwork", async () => {
|
||||
const play = vi.fn();
|
||||
const d = file("video/mp4");
|
||||
renderWithContexts(<FilePreview dump={d} compact />, {
|
||||
player: { play },
|
||||
});
|
||||
await userEvent.click(screen.getByRole("button", { name: "Play" }));
|
||||
expect(play.mock.calls[0][0].artworkUrl).toBe(dumpThumbnailUrl(d));
|
||||
expect(play.mock.calls[0][0].artworkUrl).toMatch(
|
||||
/^http:\/\/localhost:8000\/api\/thumbnails\/f1\?v=2048-\d+$/,
|
||||
);
|
||||
});
|
||||
|
||||
test("the global video button toggles when it's the one playing", async () => {
|
||||
const togglePlay = vi.fn();
|
||||
const d = file("video/mp4");
|
||||
renderWithContexts(<FilePreview dump={d} global />, {
|
||||
player: {
|
||||
togglePlay,
|
||||
playing: true,
|
||||
current: {
|
||||
kind: "file",
|
||||
fileUrl: dumpFileUrl(d),
|
||||
mimeType: "video/mp4",
|
||||
},
|
||||
},
|
||||
});
|
||||
await userEvent.click(screen.getByRole("button", { name: "Pause" }));
|
||||
expect(togglePlay).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("the global audio waveform starts playback", async () => {
|
||||
const play = vi.fn();
|
||||
route("GET /api/files/:id", () => new TypeError("no audio in jsdom"));
|
||||
renderWithContexts(<FilePreview dump={file("audio/ogg")} global />, {
|
||||
player: { play },
|
||||
});
|
||||
await userEvent.click(screen.getByRole("button", { name: "Play" }));
|
||||
expect(play).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ kind: "file", mimeType: "audio/ogg" }),
|
||||
);
|
||||
});
|
||||
|
||||
test("compact PDFs and text get an icon", () => {
|
||||
const { container } = renderWithContexts(
|
||||
<FilePreview dump={file("application/pdf")} compact />,
|
||||
);
|
||||
expect(container).toHaveTextContent("📄");
|
||||
});
|
||||
});
|
||||
|
||||
import { route } from "../../fetch.ts";
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user