Compare commits

...

2 Commits

Author SHA1 Message Date
khannurien
bf28e49c34 v3: left and right step through emoji results too — with a :query picker open, only up and down went to the grid; left and right moved the caret in the textarea, so the first press of either did nothing visible. Every arrow now steps into the grid, landing one along from the automatic first match; Escape gives the caret back.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 43s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 11:29:44 +00:00
khannurien
85d0703077 v3: the editors stop fighting the person typing — a review of every edit box turned up an Enter that sent a chat message mid-IME, an emoji picker that grabbed focus on ":D" and never went away, markdown that folded every line break, and limits that were counted but not kept. TextEditor: an Enter confirming an IME candidate no longer also sends, saves or picks. The : trigger wants two letters (Unicode ones too), not one, so emoticons, "note:" and "12:30" type through, and it keeps focus in the textarea — the picker's search follows what's typed, Enter or Tab takes the best match (a name equal to the query, then one starting with it, rather than frimousse's first in emoji order, which made ":rocket" an astronaut), the arrows step into the grid and typing there carries on in the textarea, and Escape closes just the picker, not the chat around it. It closes on a press outside, which it never did, and neither autocomplete fires inside code or a ``` fence. A ☺ button in each editor's corner opens the picker for mouse users, with a skin-tone selector that's remembered, and its search takes focus only once the picker is placed (a hidden field can't). Emoji, mentions and pasted images go in through execCommand, so Ctrl+Z takes back the insertion instead of wrecking the undo stack. Uploading an image made the textarea disabled, which dropped focus for good; it's read-only now, says "Uploading image…" under the box rather than only in an empty one's placeholder, reports a failed upload instead of swallowing it, and holds Enter until it's done. Any dropped file is caught — a non-image used to be opened by the browser in place of the page, draft and all — and a paste carrying text (a spreadsheet's cells come with a picture of themselves) pastes the text. The mention list follows the caret (a stale lookup can't close a newer one, lookups abort), wraps, scrolls its selection into view, closes on blur, and is a real listbox with aria-activedescendant. The counter shows from 80% of the limit by default rather than a standing "0 / 5000". Limits: chat Send and edit Save refused nothing over 2000 and let the server bounce it; they're disabled past it now. RichTextField's bare maxLength rule failed without a message, so an over-long "Why?" made Save do nothing and say nothing; it says so now. Inline editors answer Escape through a new onCancel (comment reply and edit, profile description, playlist description and title), the playlist editor takes Ctrl+Enter and can't be saved without a title, and saving a comment unchanged no longer stamps it edited. Markdown: a single newline is a line break, so a message written over three lines stops reading as one; links into the app route in place instead of opening a tab; headings are sized for prose instead of 2em page titles, and dropped from card teasers; GFM tables scroll in their own box and task lists lose the bullet; images load lazily. A chat message of only an emoji or three is shown large. Themes: brutalist and geocities give the new buttons no chrome, and geocities' ghost-button list, split in two by the placeholder-thumbnail rules in 1cb904d, is whole again, so its modal ✕, emoji cells and the picker's ✕ are flat as intended.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 10:24:30 +00:00
22 changed files with 1324 additions and 520 deletions

View File

@@ -65,6 +65,62 @@
font-weight: 700;
line-height: 1.25;
}
/* Headings inside a comment or a chat message are emphasis within prose, not
page titles: the browser's 2em h1 dwarfed everything around it. */
.md h1 {
font-size: 1.3em;
}
.md h2 {
font-size: 1.18em;
}
.md h3 {
font-size: 1.06em;
}
.md h4,
.md h5,
.md h6 {
font-size: 1em;
}
.md > :first-child {
margin-top: 0;
}
.md hr {
border: none;
border-top: 1px solid var(--color-border);
margin: 0.85em 0;
}
.md del {
opacity: 0.7;
}
/* GFM task lists: the checkbox stands in for the bullet. */
.md .contains-task-list {
list-style: none;
padding-left: 0.2em;
}
.md .task-list-item input {
margin: 0 0.4em 0 0;
vertical-align: -0.1em;
}
.md-table-wrap {
max-width: 100%;
overflow-x: auto;
scrollbar-width: thin;
margin: 0.6em 0;
}
.md table {
border-collapse: collapse;
font-size: 0.95em;
}
.md th,
.md td {
border: 1px solid var(--color-border);
padding: 0.3em 0.6em;
text-align: left;
}
.md th {
font-weight: 700;
background: var(--color-bg);
}
.md img {
max-width: 100%;
@@ -83,6 +139,19 @@
.md--inline li {
margin: 0;
}
.md--inline h1,
.md--inline h2,
.md--inline h3,
.md--inline h4,
.md--inline h5,
.md--inline h6 {
margin: 0;
font-size: inherit;
}
.md--inline hr,
.md--inline .md-table-wrap {
display: none;
}
.md--inline ul,
.md--inline ol {
padding-left: 1.2em;
@@ -3155,6 +3224,11 @@ body.has-player .chat-fab {
display: flex;
flex-direction: column;
}
.dump-edit-panel--details .text-editor-field {
flex: 1 1 auto;
display: flex;
flex-direction: column;
}
.dump-edit-panel--details .mention-textarea-wrap textarea {
flex: 1 1 auto;
}
@@ -4522,6 +4596,15 @@ body.has-player .chat-fab {
line-height: 1.45;
}
/* A message that's only an emoji or three, shown large as chats do. */
.chat-message-text--jumbo {
font-size: 2.1em;
line-height: 1.2;
}
.chat-message-text--jumbo.md {
line-height: 1.2;
}
.chat-message-edited {
font-size: var(--text-2xs);
color: var(--color-text-muted);
@@ -4617,16 +4700,21 @@ body.has-player .chat-fab {
scrollbar-width: thin;
}
/* The counter (only shown near the limit) sits just above the input's right
edge, in the gap over the composer: inside the textarea, the text ran under
it, and in a row below it would knock the Send button out of alignment. */
.chat-composer .text-editor-count {
/* The counter (only shown near the limit) and the upload notice sit just
above the input, in the gap over the composer: inside the textarea, the text
ran under them, and in a row below they would knock the Send button out of
alignment. */
.chat-composer .text-editor-footer {
position: absolute;
left: 0.25rem;
right: 0.25rem;
bottom: calc(100% + 1px);
margin: 0;
line-height: 1;
}
.chat-composer .text-editor-footer > * {
margin: 0;
}
.chat-send-btn {
flex-shrink: 0;
@@ -6440,6 +6528,78 @@ body.has-player .chat-fab {
box-sizing: border-box;
}
/* The textarea and the emoji button in its corner. */
.text-editor-field {
position: relative;
}
/* Room for the button, so text never runs under it. Qualified to outrank the
per-editor and per-theme padding shorthands. */
.mention-textarea-wrap .text-editor-field > textarea {
padding-right: 2.25rem;
}
/* Top corner, not bottom: the bottom one is a resizable textarea's grip. */
.text-editor-emoji-btn {
position: absolute;
right: 0.3rem;
top: 0.3rem;
width: 1.75rem;
height: 1.75rem;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
border: none;
border-radius: var(--radius-sm);
background: none;
color: var(--color-text-muted);
opacity: 0.6;
cursor: pointer;
transition: opacity var(--dur-fast), color var(--dur-fast),
background var(--dur-fast);
}
.text-editor-emoji-btn svg {
width: 1.15rem;
height: 1.15rem;
fill: none;
stroke: currentColor;
stroke-width: 1.6;
stroke-linecap: round;
}
.text-editor-emoji-btn svg circle[r=".6"] {
fill: currentColor;
}
.text-editor-emoji-btn:hover:not(:disabled),
.text-editor-emoji-btn:focus-visible,
.text-editor-emoji-btn--open {
opacity: 1;
color: var(--color-accent);
}
.text-editor-emoji-btn:disabled {
opacity: 0.25;
cursor: default;
}
/* Clear of the scrollbar a long chat message grows. */
.chat-input + .text-editor-emoji-btn {
right: 0.6rem;
}
.text-editor-footer {
display: flex;
align-items: baseline;
gap: 0.75rem;
margin-top: 2px;
}
.text-editor-footer .text-editor-count {
margin: 0 0 0 auto;
}
.text-editor-notice {
font-size: var(--text-2xs);
color: var(--color-text-muted);
}
.text-editor-notice--error {
color: var(--color-danger, #c0392b);
}
.mention-textarea-wrap--dragover textarea {
outline: 2px dashed var(--color-accent, #6c8ebf);
outline-offset: -2px;
@@ -6536,10 +6696,29 @@ body.has-player .chat-fab {
font-size: var(--text-md);
outline: none;
}
/* The row has its own ✕; the browser's clear button would be a second one. */
.emoji-picker-float input::-webkit-search-cancel-button {
display: none;
}
.emoji-picker-float input::placeholder {
color: var(--color-text-muted, #888);
}
.emoji-picker-float [frimousse-skin-tone-selector] {
flex-shrink: 0;
padding: 0 6px;
height: 100%;
background: none;
border: none;
font-size: var(--text-md);
line-height: 1;
cursor: pointer;
border-radius: var(--radius-sm);
}
.emoji-picker-float [frimousse-skin-tone-selector]:hover {
background: var(--color-surface);
}
.emoji-picker-close-btn {
flex-shrink: 0;
padding: 0 10px;

View File

@@ -22,6 +22,7 @@ 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 type {
@@ -77,6 +78,19 @@ 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`);
}
@@ -184,9 +198,8 @@ function ChatEditBox({ initial, onSave, onCancel }: ChatEditBoxProps) {
}, []);
const save = () => {
const body = draft.trim();
if (!body) return;
onSave(body);
if (!sendable(draft)) return;
onSave(draft.trim());
};
return (
@@ -201,13 +214,11 @@ function ChatEditBox({ initial, onSave, onCancel }: ChatEditBoxProps) {
countFrom={COUNT_FROM}
className="comment-reply-textarea chat-input"
onSubmit={save}
onCancel={onCancel}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
save();
} else if (e.key === "Escape") {
e.preventDefault();
onCancel();
}
}}
/>
@@ -216,7 +227,7 @@ function ChatEditBox({ initial, onSave, onCancel }: ChatEditBoxProps) {
type="button"
className="btn-primary"
onClick={save}
disabled={!draft.trim()}
disabled={!sendable(draft)}
>
<Trans>Save</Trans>
</button>
@@ -419,7 +430,11 @@ function ChatMessageItem(
onCancel={onEndEdit}
/>
)
: <Markdown className="chat-message-text">{message.body}</Markdown>}
: (
<Markdown className={messageTextClass(message.body)}>
{message.body}
</Markdown>
)}
{grouped && !editing && edited}
</div>
@@ -485,7 +500,9 @@ function PendingItem(
<span className="chat-message-author">{user.username}</span>
</div>
)}
<Markdown className="chat-message-text">{pending.body}</Markdown>
<Markdown className={messageTextClass(pending.body)}>
{pending.body}
</Markdown>
<div
className="chat-pending-status"
role={failed ? "alert" : undefined}
@@ -956,9 +973,8 @@ export function ChatModal({ onClose }: { onClose: () => void }) {
}, []);
const handleSend = useCallback(() => {
const body = draft.trim();
if (!body) return;
sendChatMessage(body, replyTarget ?? undefined);
if (!sendable(draft)) return;
sendChatMessage(draft.trim(), replyTarget ?? undefined);
setDraft("");
setReplyTarget(null);
stopTyping();
@@ -1275,7 +1291,7 @@ export function ChatModal({ onClose }: { onClose: () => void }) {
type="button"
className="btn-primary chat-send-btn"
onClick={handleSend}
disabled={!draft.trim()}
disabled={!sendable(draft)}
>
<Trans>Send</Trans>
</button>

View File

@@ -93,6 +93,14 @@ function CommentForm({
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}
@@ -228,6 +236,11 @@ function CommentNode({
}
async function handleEditSave(body: string) {
// Saving it unchanged would only stamp it "edited".
if (body === comment.body) {
setEditOpen(false);
return;
}
const res = await authFetch(`${API_URL}/api/comments/${comment.id}`, {
method: "PATCH",
body: JSON.stringify({ body } satisfies UpdateCommentRequest),

View File

@@ -912,7 +912,6 @@ export function DumpCreateModal(
placeholder={t`What makes it worth it?`}
rows={3}
maxLength={VALIDATION.DUMP_COMMENT_MAX}
rules={{ maxLength: VALIDATION.DUMP_COMMENT_MAX }}
disabled={submitting}
editorRef={commentEditorRef}
/>

View File

@@ -57,22 +57,74 @@ function remarkMentions() {
return (tree: MdNode) => walk(tree);
}
const REMARK_PLUGINS = [remarkGfm, remarkMentions];
/**
* A single newline is a line break, as it is in chat everywhere: markdown's
* own rule folds it into a space, so a message typed on three lines with
* Shift+Enter came out as one. (Code keeps its newlines; it has no text
* nodes.)
*/
function remarkBreaks() {
const walk = (node: MdNode) => {
if (!node.children) return;
node.children = node.children.flatMap((child) => {
if (child.type !== "text" || !child.value?.includes("\n")) {
walk(child);
return [child];
}
return child.value.split("\n").flatMap((part, i) =>
i === 0
? [{ type: "text", value: part }]
: [{ type: "break" }, { type: "text", value: part }]
);
});
};
return (tree: MdNode) => walk(tree);
}
const REMARK_PLUGINS = [remarkGfm, remarkMentions, remarkBreaks];
/** The path of a link into this app — relative, or absolute to this origin —
* or null for anywhere else. */
function internalPath(href: string | undefined): string | null {
if (!href) return null;
if (href.startsWith("/") && !href.startsWith("//")) {
// Files and attachments are the API's, not routes: a new tab, as before.
return href.startsWith("/api/") ? null : href;
}
try {
const url = new URL(href);
if (url.origin !== globalThis.location.origin) return null;
if (url.pathname.startsWith("/api/")) return null;
return url.pathname + url.search + url.hash;
} catch {
return null;
}
}
type Components = React.ComponentProps<typeof ReactMarkdown>["components"];
// Static components objects — defined once at module scope to avoid recreation on every render
const MARKDOWN_COMPONENTS: Components = {
// Mentions and links to other pages here stay in the app; the rest open
// in a new tab.
a: ({ href, children: linkChildren }) => {
if (href?.startsWith("/users/")) {
return <Link to={href}>{linkChildren}</Link>;
}
const path = internalPath(href);
if (path) return <Link to={path}>{linkChildren}</Link>;
return (
<a href={href} target="_blank" rel="noopener noreferrer">
{linkChildren}
</a>
);
},
img: ({ src, alt }) => (
<img src={src} alt={alt ?? ""} loading="lazy" decoding="async" />
),
// A wide GFM table scrolls inside its own box instead of the page.
table: ({ children: tableChildren }) => (
<div className="md-table-wrap">
<table>{tableChildren}</table>
</div>
),
};
// Inline renderings are line-clamped teasers inside cards, where an embedded

View File

@@ -1,8 +1,10 @@
import type { CSSProperties } from "react";
import { type CSSProperties, useLayoutEffect, useRef } from "react";
import { Avatar } from "./Avatar.tsx";
import type { UserResult } from "../hooks/useMentionAutocomplete.ts";
interface MentionDropdownProps {
/** For the textarea's `aria-controls` / `aria-activedescendant`. */
id: string;
results: UserResult[];
selectedIndex: number;
onSelect: (username: string) => void;
@@ -11,14 +13,33 @@ interface MentionDropdownProps {
}
export function MentionDropdown(
{ results, selectedIndex, onSelect, style }: MentionDropdownProps,
{ id, results, selectedIndex, onSelect, style }: MentionDropdownProps,
) {
const listRef = useRef<HTMLUListElement>(null);
// Arrowing past the visible rows keeps the selection in view.
useLayoutEffect(() => {
listRef.current
?.querySelector<HTMLElement>("[aria-selected='true']")
?.scrollIntoView({ block: "nearest" });
}, [selectedIndex, results]);
if (results.length === 0) return null;
return (
<ul className="mention-dropdown" style={style}>
<ul
ref={listRef}
id={id}
role="listbox"
className="mention-dropdown"
style={style}
>
{results.map((user, i) => (
<li
key={user.id}
// TextEditor points aria-activedescendant at `${id}-${index}`.
id={`${id}-${i}`}
role="option"
aria-selected={i === selectedIndex}
className={`mention-dropdown-item${
i === selectedIndex ? " mention-dropdown-item--selected" : ""
}`}

View File

@@ -82,7 +82,6 @@ export function PlaylistCreateForm(
placeholder={t`Description (optional)`}
rows={3}
maxLength={VALIDATION.PLAYLIST_DESCRIPTION_MAX}
rules={{ maxLength: VALIDATION.PLAYLIST_DESCRIPTION_MAX }}
/>
<VisibilityToggle<Values> name="isPublic" />
<FormError title={t`Failed to create playlist`} />

View File

@@ -2,13 +2,20 @@ import {
forwardRef,
useCallback,
useEffect,
useId,
useImperativeHandle,
useLayoutEffect,
useRef,
useState,
} from "react";
import { createPortal } from "react-dom";
import { EmojiPicker, type Locale as EmojiLocale } from "frimousse";
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";
@@ -34,12 +41,56 @@ interface TextEditorProps {
id?: string;
className?: string;
autoResize?: boolean;
/** Ctrl/Cmd+Enter. Not called while an image is still uploading. */
onSubmit?: () => void;
/** Escape, once no popup of the editor's own is left to close it: for the
* inline editors (an edit, a reply), where it means "never mind". */
onCancel?: () => void;
/** Runs after the editor's own keys (autocompletes, Ctrl+Enter) and never
* while an IME is composing, so an Enter that confirms a candidate isn't
* taken for "send". */
onKeyDown?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
maxLength?: number;
/** Hide the character counter until the text is at least this long, for
* places where a standing "0 / 2000" is just noise. */
/** Show the character counter only from this length. Defaults to 80% of
* `maxLength`: a standing "0 / 5000" is just noise, but the counter turns
* up well before the warning colour does. */
countFrom?: number;
"aria-invalid"?: boolean;
"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");
}
interface PendingInsert {
start: number;
end: number;
text: string;
focus: boolean;
}
export const TextEditor = forwardRef<TextEditorHandle, TextEditorProps>(
@@ -54,24 +105,36 @@ export const TextEditor = forwardRef<TextEditorHandle, TextEditorProps>(
className,
autoResize = false,
onSubmit,
onCancel,
onKeyDown,
maxLength,
countFrom = 0,
countFrom,
"aria-invalid": ariaInvalid,
"aria-describedby": ariaDescribedBy,
},
ref,
) {
const { i18n } = useLingui();
const textareaRef = useRef<HTMLTextAreaElement>(null);
const floatRef = useRef<HTMLDivElement>(null);
const emojiButtonRef = useRef<HTMLButtonElement>(null);
const emojiViewportRef = useRef<HTMLDivElement>(null);
const emojiSearchRef = useRef<HTMLInputElement>(null);
const valueRef = useRef(value);
const onChangeRef = useRef(onChange);
const [uploading, setUploading] = useState(false);
const [notice, setNotice] = useState<string | null>(null);
const [dragOver, setDragOver] = useState(false);
const pendingInsertRef = useRef<PendingInsert | null>(null);
// Whether the highlighted emoji was chosen with the arrows, rather than
// being frimousse's automatic first result for the query.
const arrowedRef = useRef(false);
const { authFetch } = useAuth();
const mentionListId = useId();
const noticeId = useId();
useEffect(() => {
valueRef.current = value;
}, [value]);
useLayoutEffect(() => {
onChangeRef.current = onChange;
});
useImperativeHandle(ref, () => ({
focus: (options) => {
@@ -85,28 +148,86 @@ export const TextEditor = forwardRef<TextEditorHandle, TextEditorProps>(
},
}), []);
/**
* Puts `text` over `[start, end)` of the textarea, then a space when
* `word` is set and none follows already, and leaves the caret after it.
* Goes through execCommand where it can, so the insertion joins the
* browser's undo stack — Ctrl+Z takes back the emoji or mention itself,
* not everything typed before it — and fires the input event onChange
* listens for.
*/
const replace = useCallback(
(
start: number,
end: number,
text: string,
word: boolean,
focus = true,
) => {
const el = textareaRef.current;
if (!el) return;
const current = el.value;
const spaced = word && /^\s/.test(current.slice(end));
const insert = word && !spaced ? `${text} ` : text;
const caret = start + insert.length + (spaced ? 1 : 0);
if (focus) {
el.focus({ preventScroll: true });
el.setSelectionRange(start, end);
if (document.execCommand("insertText", false, insert)) {
el.setSelectionRange(caret, caret);
return;
}
}
onChangeRef.current(
current.slice(0, start) + insert + current.slice(end),
);
if (!focus) return;
requestAnimationFrame(() => {
textareaRef.current?.setSelectionRange(caret, caret);
});
},
[],
);
const {
mentionOpen,
mentionResults,
mentionSelectedIndex,
handleMentionChange,
syncMention,
closeMention,
handleMentionKeyDown,
handleMentionSelect,
} = useMentionAutocomplete(value, onChange, textareaRef);
} = useMentionAutocomplete(replace);
const {
emojiOpen,
emojiMode,
emojiQuery,
detectEmojiTrigger,
handleEmojiSelect,
syncEmojiTrigger,
openEmojiBrowser,
setEmojiQuery,
selectEmoji,
closeEmoji,
} = useEmojiTrigger(value, onChange, textareaRef);
} = useEmojiTrigger(replace);
const emojiOpen = emojiMode !== "closed";
// A press anywhere outside the picker puts it away — except, for an
// inline `:query`, a press in the textarea: that's a caret move, which
// keeps or closes it depending on where the caret lands.
useEffect(() => {
if (emojiOpen) {
emojiViewportRef.current?.focus({ preventScroll: true });
}
}, [emojiOpen]);
if (emojiMode === "closed") return;
const onPointerDown = (e: PointerEvent) => {
const target = e.target as Node;
if (
floatRef.current?.contains(target) ||
emojiButtonRef.current?.contains(target) ||
(emojiMode === "inline" && textareaRef.current?.contains(target))
) return;
closeEmoji();
};
document.addEventListener("pointerdown", onPointerDown, true);
return () =>
document.removeEventListener("pointerdown", onPointerDown, true);
}, [emojiMode, closeEmoji]);
// Both popups are portaled to <body> and anchored to the textarea: the
// editor sits inside cards, modal bodies and scrollers that clip anything
@@ -118,6 +239,20 @@ export const TextEditor = forwardRef<TextEditorHandle, TextEditorProps>(
maxContentHeight: 220,
});
// Browsing starts in the picker's search; an inline `:query` keeps typing
// in the textarea and only lends the picker its query. Only once the
// picker is placed: until then it's hidden, and a hidden field can't take
// focus.
const emojiPlaced = emojiStyle != null;
useEffect(() => {
arrowedRef.current = false;
}, [emojiQuery, emojiMode]);
useEffect(() => {
if (emojiMode === "browse" && emojiPlaced) {
emojiSearchRef.current?.focus({ preventScroll: true });
}
}, [emojiMode, emojiPlaced]);
const mentionStyle = useAnchoredFloat(textareaRef, mentionOpen, {
width: "anchor", // it used to be stretched edge to edge by left/right: 0
chromeHeight: 10, // list padding + borders
@@ -135,128 +270,305 @@ export const TextEditor = forwardRef<TextEditorHandle, TextEditorProps>(
el.style.height = `${el.scrollHeight}px`;
}, [value, autoResize]);
const insertAtCursor = useCallback((text: string) => {
const el = textareaRef.current;
if (!el) return;
const start = el.selectionStart ?? valueRef.current.length;
const end = el.selectionEnd ?? valueRef.current.length;
const newValue = valueRef.current.slice(0, start) +
text +
valueRef.current.slice(end);
onChange(newValue);
requestAnimationFrame(() => {
if (!textareaRef.current) return;
const pos = start + text.length;
textareaRef.current.selectionStart = pos;
textareaRef.current.selectionEnd = pos;
});
}, [onChange]);
// The textarea is read-only while images upload (so the spot they're
// going to can't move); once it isn't, the images go in.
useLayoutEffect(() => {
if (uploading) return;
const pending = pendingInsertRef.current;
if (!pending) return;
pendingInsertRef.current = null;
replace(pending.start, pending.end, pending.text, false, pending.focus);
}, [uploading, replace]);
const uploadImage = useCallback(async (file: File) => {
const formData = new FormData();
formData.append("file", file);
const uploadImages = useCallback(async (files: File[]) => {
const el = textareaRef.current;
if (!el || files.length === 0) return;
const focus = document.activeElement === el;
const start = el.selectionStart;
const end = el.selectionEnd;
setNotice(null);
setUploading(true);
try {
const res = await authFetch(`${API_URL}/api/attachments`, {
method: "POST",
body: formData,
});
const json = await res.json();
if (json.success) {
insertAtCursor(`![](${API_URL}/api/attachments/${json.data.id})`);
const images: string[] = [];
for (const file of files) {
try {
const formData = new FormData();
formData.append("file", file);
const res = await authFetch(`${API_URL}/api/attachments`, {
method: "POST",
body: formData,
});
const json = await res.json();
if (!json.success) throw new Error(json.error?.message);
images.push(`![](${API_URL}/api/attachments/${json.data.id})`);
} catch {
setNotice(
files.length > 1
? t`Some images couldn't be uploaded.`
: t`The image couldn't be uploaded.`,
);
}
} catch {
// silently ignore — user can retry
} finally {
setUploading(false);
textareaRef.current?.focus();
}
}, [authFetch, insertAtCursor]);
if (images.length > 0) {
pendingInsertRef.current = {
start,
end,
text: images.join("\n"),
focus,
};
}
setUploading(false);
}, [authFetch]);
const handlePaste = useCallback(
async (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
const imageItem = Array.from(e.clipboardData.items).find(
(item) => item.kind === "file" && item.type.startsWith("image/"),
(e: React.ClipboardEvent<HTMLTextAreaElement>) => {
// Copying from a spreadsheet or a document puts a picture of the
// selection on the clipboard next to its text: the text is what was
// meant.
if (e.clipboardData.types.includes("text/plain")) return;
const images = Array.from(e.clipboardData.files).filter((f) =>
f.type.startsWith("image/")
);
if (!imageItem) return;
if (images.length === 0) return;
e.preventDefault();
const file = imageItem.getAsFile();
if (file) await uploadImage(file);
void uploadImages(images);
},
[uploadImage],
[uploadImages],
);
const handleDrop = useCallback(
async (e: React.DragEvent<HTMLTextAreaElement>) => {
e.preventDefault();
(e: React.DragEvent<HTMLTextAreaElement>) => {
setDragOver(false);
const files = Array.from(e.dataTransfer.files).filter((f) =>
f.type.startsWith("image/")
);
for (const file of files) {
await uploadImage(file);
if (!hasFiles(e.dataTransfer)) return; // dragged text: the default
e.preventDefault();
if (disabled || uploading) return;
const files = Array.from(e.dataTransfer.files);
const images = files.filter((f) => f.type.startsWith("image/"));
if (images.length < files.length) {
setNotice(t`Only images can be dropped here.`);
}
void uploadImages(images);
},
[uploadImage],
[uploadImages, disabled, uploading],
);
// Any file is caught, not just images: a file dropped on a textarea that
// doesn't take it is opened by the browser in place of the page, and a
// half-written message goes with it.
const handleDragOver = useCallback(
(e: React.DragEvent<HTMLTextAreaElement>) => {
const hasImage = Array.from(e.dataTransfer.items).some(
(item) => item.kind === "file" && item.type.startsWith("image/"),
);
if (!hasImage) return;
if (!hasFiles(e.dataTransfer)) return;
e.preventDefault();
setDragOver(true);
const accepts = !disabled && !uploading;
e.dataTransfer.dropEffect = accepts ? "copy" : "none";
setDragOver(accepts);
},
[],
[disabled, uploading],
);
const handleDragLeave = useCallback(() => setDragOver(false), []);
// The best match for the query among the results on screen. frimousse
// lists matches in emoji order, so ":rocket" would lead with 🧑‍🚀; a name
// that is the query, then one that starts with it, comes first. (It also
// renders a hidden row of unlabelled stand-ins to measure rows with;
// those aren't picks.)
const firstEmoji = () => {
const buttons = Array.from(
floatRef.current?.querySelectorAll<HTMLButtonElement>(
"[frimousse-list] [frimousse-emoji]:not([aria-label=''])",
) ?? [],
);
const query = emojiQuery.trim().toLowerCase();
const name = (b: HTMLButtonElement) =>
(b.getAttribute("aria-label") ?? "").toLowerCase();
return buttons.find((b) => name(b) === query) ??
buttons.find((b) => name(b).startsWith(query)) ??
buttons[0];
};
// Moves the keyboard into the picker's grid: frimousse only listens for
// arrows while focus is inside it, and the arrow that got us there is
// replayed so the first press already lands on an emoji.
const enterPicker = (key: string) => {
const viewport = emojiViewportRef.current;
if (!viewport) return;
viewport.focus({ preventScroll: true });
setTimeout(() => {
viewport.dispatchEvent(
new KeyboardEvent("keydown", { key, bubbles: true }),
);
}, 0);
};
/** Keys for an inline `:query` picker, pressed in the textarea. */
const handleInlineEmojiKey = (
e: React.KeyboardEvent<HTMLTextAreaElement>,
): boolean => {
const plain = !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey;
if ((e.key === "Enter" || e.key === "Tab") && plain) {
const first = firstEmoji();
if (!first) {
// Nothing to pick (no match, or the data is still loading): the
// key does what it would have.
closeEmoji();
return false;
}
e.preventDefault();
first.click();
return true;
}
// Every arrow steps into the grid, left and right included: while a
// `:query` is open they pick among its results, not move the caret
// (Escape first to get the caret back).
if (e.key.startsWith("Arrow") && plain) {
if (!firstEmoji()) return false;
e.preventDefault();
arrowedRef.current = true;
enterPicker(e.key);
return true;
}
if (e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
closeEmoji();
return true;
}
return false;
};
const openBrowser = () => {
const el = textareaRef.current;
if (!el) return;
if (emojiMode === "browse") {
closeEmoji();
el.focus();
return;
}
closeMention();
openEmojiBrowser(el.selectionStart, el.selectionEnd);
};
const showCount = maxLength != null &&
value.length >= (countFrom ?? Math.floor(maxLength * 0.8));
const describedBy = [ariaDescribedBy, notice ? noticeId : null]
.filter(Boolean).join(" ") || undefined;
return (
<div
className={`mention-textarea-wrap${
dragOver ? " mention-textarea-wrap--dragover" : ""
}`}
>
<textarea
ref={textareaRef}
value={value}
onChange={(e) => {
handleMentionChange(e);
detectEmojiTrigger(e.target.value, e.target.selectionStart ?? 0);
}}
onKeyDown={(e) => {
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
onSubmit?.();
return;
}
handleMentionKeyDown(e);
if (!e.defaultPrevented) onKeyDown?.(e);
}}
onPaste={handlePaste}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
placeholder={uploading ? "Uploading image…" : placeholder}
disabled={disabled || uploading}
rows={rows}
id={id}
className={className}
/>
{maxLength != null && value.length >= countFrom && (
<span
className={`text-editor-count${
charCountClass(value.length, maxLength)
<div className="text-editor-field">
<textarea
ref={textareaRef}
value={value}
onChange={(e) => {
const next = e.target.value;
const cursor = e.target.selectionStart ?? next.length;
onChange(next);
if (notice) setNotice(null);
syncMention(next, cursor, true);
syncEmojiTrigger(next, cursor, true);
}}
onSelect={(e) => {
const el = e.currentTarget;
syncMention(el.value, el.selectionStart, false);
syncEmojiTrigger(el.value, el.selectionStart, false);
}}
onBlur={() => closeMention()}
onKeyDown={(e) => {
// Enter while an IME is composing confirms the candidate; it
// mustn't also pick, send or save.
if (e.nativeEvent.isComposing || e.keyCode === 229) return;
// Nothing is sent or saved with an image still on its way.
if (uploading && e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
return;
}
if (emojiMode === "inline" && handleInlineEmojiKey(e)) return;
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
onSubmit?.();
return;
}
if (handleMentionKeyDown(e)) return;
if (e.key === "Escape" && onCancel) {
e.preventDefault();
onCancel();
return;
}
onKeyDown?.(e);
}}
onPaste={handlePaste}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
placeholder={placeholder}
disabled={disabled}
readOnly={uploading}
aria-busy={uploading || undefined}
aria-invalid={ariaInvalid ||
(maxLength != null && value.length > maxLength) || undefined}
aria-describedby={describedBy}
aria-autocomplete="list"
aria-controls={mentionOpen ? mentionListId : undefined}
aria-activedescendant={mentionOpen
? `${mentionListId}-${mentionSelectedIndex}`
: undefined}
rows={rows}
id={id}
className={className}
/>
<button
ref={emojiButtonRef}
type="button"
className={`text-editor-emoji-btn${
emojiMode === "browse" ? " text-editor-emoji-btn--open" : ""
}`}
// Keeps the textarea's caret (and focus) where it was.
onMouseDown={(e) => e.preventDefault()}
onClick={openBrowser}
disabled={disabled || uploading}
aria-label={t`Insert emoji`}
aria-expanded={emojiMode === "browse"}
title={t`Insert emoji`}
>
{value.length} / {maxLength}
</span>
<svg viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="9" />
<path d="M8.5 14.5c.9 1.2 2.1 1.8 3.5 1.8s2.6-.6 3.5-1.8" />
<circle cx="9" cy="10" r=".6" />
<circle cx="15" cy="10" r=".6" />
</svg>
</button>
</div>
{(uploading || notice || showCount) && (
<div className="text-editor-footer">
{(uploading || notice) && (
<span
id={noticeId}
className={`text-editor-notice${
notice ? " text-editor-notice--error" : ""
}`}
role={notice ? "alert" : "status"}
>
{uploading ? <Trans>Uploading image…</Trans> : notice}
</span>
)}
{showCount && (
<span
className={`text-editor-count${
charCountClass(value.length, maxLength)
}`}
>
{value.length} / {maxLength}
</span>
)}
</div>
)}
{mentionOpen && createPortal(
<MentionDropdown
id={mentionListId}
results={mentionResults}
selectedIndex={mentionSelectedIndex}
onSelect={handleMentionSelect}
@@ -266,6 +578,7 @@ export const TextEditor = forwardRef<TextEditorHandle, TextEditorProps>(
)}
{emojiOpen && createPortal(
<div
ref={floatRef}
className="emoji-picker-float"
style={emojiStyle ?? { visibility: "hidden" }}
onKeyDownCapture={(e) => {
@@ -273,47 +586,74 @@ export const TextEditor = forwardRef<TextEditorHandle, TextEditorProps>(
e.preventDefault();
e.stopPropagation();
closeEmoji();
textareaRef.current?.focus();
return;
}
}}
onKeyDown={(e) => {
// Redirect printable characters to the search input when it
// doesn't already have focus (e.g. while navigating with arrows)
const search = emojiSearchRef.current;
if (e.key.startsWith("Arrow")) arrowedRef.current = true;
// Enter takes the emoji arrowed to, or else the best match —
// not the first result frimousse highlights on its own.
if (e.key === "Enter" && !arrowedRef.current) {
e.preventDefault();
e.stopPropagation();
firstEmoji()?.click();
return;
}
const printable = e.key.length === 1 && !e.ctrlKey &&
!e.metaKey && !e.altKey;
if (
e.key.length === 1 &&
!e.ctrlKey && !e.metaKey && !e.altKey &&
document.activeElement !== emojiSearchRef.current
) {
const search = emojiSearchRef.current;
if (search) {
e.preventDefault();
search.focus({ preventScroll: true });
// Inject the character via the native setter so React's
// synthetic onChange fires and frimousse updates its search state
const setter = Object.getOwnPropertyDescriptor(
globalThis.HTMLInputElement.prototype,
"value",
)?.set;
setter?.call(search, search.value + e.key);
search.dispatchEvent(new Event("input", { bubbles: true }));
}
(!printable && e.key !== "Backspace") ||
document.activeElement === search
) return;
e.preventDefault();
if (emojiMode === "inline") {
// Typing while arrowing through the grid goes on with the
// `:query` in the textarea, which the search follows.
const el = textareaRef.current;
if (!el) return;
el.focus({ preventScroll: true });
document.execCommand(
printable ? "insertText" : "delete",
false,
printable ? e.key : undefined,
);
} else if (search) {
search.focus({ preventScroll: true });
setEmojiQuery(
printable ? search.value + e.key : search.value.slice(0, -1),
);
}
}}
>
<EmojiPicker.Root
onEmojiSelect={(e) => handleEmojiSelect(e.emoji)}
onEmojiSelect={(e) => selectEmoji(e.emoji)}
locale={i18n.locale as EmojiLocale}
skinTone={readSkinTone()}
>
<SkinTonePersist />
<div className="emoji-picker-search-row">
<EmojiPicker.Search
ref={emojiSearchRef}
defaultValue={emojiQuery}
placeholder="Search emoji…"
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}
aria-label="Close emoji picker"
onClick={() => {
closeEmoji();
textareaRef.current?.focus();
}}
aria-label={t`Close emoji picker`}
>
✕
</button>

View File

@@ -1,4 +1,5 @@
import { useId } from "react";
import { t } from "@lingui/core/macro";
import {
Controller,
type FieldValues,
@@ -14,6 +15,7 @@ interface RichTextFieldProps<T extends FieldValues> {
label?: string;
placeholder?: string;
rows?: number;
/** Counted in the editor and enforced on submit, with a message saying so. */
maxLength?: number;
autoResize?: boolean;
disabled?: boolean;
@@ -42,8 +44,18 @@ export function RichTextField<T extends FieldValues>({
onSubmit,
}: RichTextFieldProps<T>) {
const id = useId();
const hintId = useId();
const { control, formState: { errors } } = useFormContext<T>();
const error = errors[name];
// A bare numeric rule fails without a message, which left a submit that did
// nothing and said nothing.
const allRules = maxLength == null ? rules : {
...rules,
maxLength: {
value: maxLength,
message: t`At most ${maxLength} characters.`,
},
};
return (
<div className="form-field">
@@ -55,7 +67,7 @@ export function RichTextField<T extends FieldValues>({
<Controller
name={name}
control={control}
rules={rules}
rules={allRules}
render={({ field }) => (
<TextEditor
ref={editorRef}
@@ -68,11 +80,13 @@ export function RichTextField<T extends FieldValues>({
autoResize={autoResize}
disabled={disabled}
onSubmit={onSubmit}
aria-invalid={error ? true : undefined}
aria-describedby={error?.message ? hintId : undefined}
/>
)}
/>
{error?.message && (
<span className="form-hint form-hint--error">
<span id={hintId} className="form-hint form-hint--error">
{String(error.message)}
</span>
)}

View File

@@ -1,81 +1,97 @@
import { type RefObject, useCallback, useRef, useState } from "react";
import { useCallback, useRef, useState } from "react";
import { insideCode } from "../utils/editorText.ts";
// Trigger: ':' not preceded by a word character, followed by a letter then
// optional word chars / '+' / '-'. Requiring a letter first prevents ASCII
// emoticons like ':-' or ':(' from opening the picker.
const TRIGGER_RE = /(?<![A-Za-z0-9_]):([A-Za-z][A-Za-z0-9_+-]*)$/;
// Trigger: ':' not preceded by a letter, digit or another ':', followed by a
// letter then at least one more word char / '+' / '-'. Two characters, not
// one, so emoticons like ':D' or ':P' — and "note:" or "12:30" — don't open
// the picker; Unicode letters so French names ("coeur", "cœur") search too.
const TRIGGER_RE = /(?<![\p{L}\p{N}_:]):(\p{L}[\p{L}\p{N}_+-]+)$/u;
interface EmojiTriggerState {
open: boolean;
/**
* `inline`: opened by typing `:query` — focus stays in the textarea, the
* picker's search follows what's typed, and a pick replaces the `:query`.
* `browse`: opened from the button — focus goes to the picker's own search,
* and a pick lands at the caret.
*/
export type EmojiMode = "closed" | "inline" | "browse";
interface EmojiState {
mode: EmojiMode;
query: string;
/** The range of the textarea's value a pick replaces. */
start: number;
end: number;
}
const CLOSED: EmojiState = { mode: "closed", query: "", start: 0, end: 0 };
export function useEmojiTrigger(
value: string,
onChange: (value: string) => void,
textareaRef: RefObject<HTMLTextAreaElement | null>,
/** Replaces `[start, end)` of the textarea's value, with a space after
* when `word` is set; see TextEditor. */
replace: (start: number, end: number, text: string, word: boolean) => void,
) {
const [state, setState] = useState<EmojiTriggerState>({
open: false,
query: "",
});
// Saved positions at the moment the trigger was detected
const triggerStartRef = useRef(0);
const triggerCursorRef = useRef(0);
const [state, setStateRaw] = useState<EmojiState>(CLOSED);
// Mirrored synchronously, so a change and a caret move in the same event
// each see what the other just decided.
const stateRef = useRef(state);
const setState = useCallback((next: EmojiState) => {
stateRef.current = next;
setStateRaw(next);
}, []);
/**
* Call this from the textarea's onChange (alongside the mention handler).
* It receives the new value and cursor position already read from the event.
* Call on every change (with `open`) and caret move (without): follows an
* inline `:query` as it's typed, and closes once the caret leaves it.
* Leaves a browse-mode picker alone.
*/
const detectEmojiTrigger = useCallback(
(newValue: string, cursor: number) => {
const textBefore = newValue.slice(0, cursor);
const match = TRIGGER_RE.exec(textBefore);
const syncEmojiTrigger = useCallback(
(value: string, cursor: number, open: boolean) => {
const s = stateRef.current;
if (s.mode === "browse") return;
if (!open && s.mode === "closed") return;
const before = value.slice(0, cursor);
const match = insideCode(before) ? null : TRIGGER_RE.exec(before);
if (match) {
triggerStartRef.current = match.index;
triggerCursorRef.current = cursor;
setState({ open: true, query: match[1] });
} else {
setState((s) => s.open ? { open: false, query: "" } : s);
setState({
mode: "inline",
query: match[1],
start: match.index,
end: cursor,
});
} else if (s.mode !== "closed") {
setState(CLOSED);
}
},
[],
[setState],
);
/**
* Called when frimousse fires onEmojiSelect.
* Replaces the ':query' text with the selected emoji.
*/
const handleEmojiSelect = useCallback(
(native: string) => {
const start = triggerStartRef.current;
const cursor = triggerCursorRef.current;
const newValue = value.slice(0, start) + native + " " +
value.slice(cursor);
onChange(newValue);
setState({ open: false, query: "" });
const newPos = start + native.length + 1; // +1 for trailing space
requestAnimationFrame(() => {
const el = textareaRef.current;
if (el) {
el.focus();
el.setSelectionRange(newPos, newPos);
}
});
},
[value, onChange, textareaRef],
);
const openEmojiBrowser = useCallback((start: number, end: number) => {
setState({ mode: "browse", query: "", start, end });
}, [setState]);
const closeEmoji = useCallback(() => {
setState({ open: false, query: "" });
requestAnimationFrame(() => textareaRef.current?.focus());
}, [textareaRef]);
const setEmojiQuery = useCallback((query: string) => {
const s = stateRef.current;
if (s.mode !== "closed") setState({ ...s, query });
}, [setState]);
const selectEmoji = useCallback((native: string) => {
const { mode, start, end } = stateRef.current;
if (mode === "closed") return;
setState(CLOSED);
// An inline pick finishes a word, so it takes a space after it the way a
// mention does; a browse pick goes in exactly where the caret was.
replace(start, end, native, mode === "inline");
}, [replace, setState]);
const closeEmoji = useCallback(() => setState(CLOSED), [setState]);
return {
emojiOpen: state.open,
emojiMode: state.mode,
emojiQuery: state.query,
detectEmojiTrigger,
handleEmojiSelect,
syncEmojiTrigger,
openEmojiBrowser,
setEmojiQuery,
selectEmoji,
closeEmoji,
};
}

View File

@@ -1,11 +1,6 @@
import {
type RefObject,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { API_URL } from "../config/api.ts";
import { insideCode } from "../utils/editorText.ts";
export interface UserResult {
id: string;
@@ -16,7 +11,10 @@ export interface UserResult {
interface MentionState {
open: boolean;
query: string;
/** Where the `@` sits, and the caret after the query: the range a pick
* replaces. */
start: number;
end: number;
results: UserResult[];
selectedIndex: number;
}
@@ -25,146 +23,170 @@ const CLOSED: MentionState = {
open: false,
query: "",
start: 0,
end: 0,
results: [],
selectedIndex: 0,
};
// The server's mention rule (api/services/notification-service.ts): an `@` not
// preceded by `[`, `(` or a word character, so what's offered is exactly what
// would notify.
const TRIGGER_RE = /(?<![[(\w])@(\w+)$/;
function getMentionQuery(
value: string,
pos: number,
): { query: string; start: number } | null {
const textBefore = value.slice(0, pos);
// Match @word at end of text before cursor, not preceded by [ ( or word char
const match = textBefore.match(/(?<![[(A-Za-z0-9_])@([A-Za-z0-9_]*)$/);
if (!match || match[1].length === 0) return null;
const start = pos - match[0].length;
return { query: match[1], start };
if (insideCode(textBefore)) return null;
const match = TRIGGER_RE.exec(textBefore);
if (!match) return null;
return { query: match[1], start: match.index };
}
export function useMentionAutocomplete(
value: string,
onChange: (v: string) => void,
textareaRef: RefObject<HTMLTextAreaElement | null>,
/** Replaces `[start, end)` of the textarea's value, with a space after
* when `word` is set; see TextEditor. */
replace: (start: number, end: number, text: string, word: boolean) => void,
) {
const [state, setState] = useState<MentionState>(CLOSED);
const [state, setStateRaw] = useState<MentionState>(CLOSED);
// Mirrored synchronously so the key handler and a fetch landing late both
// read what the last keystroke decided, not the last render.
const stateRef = useRef(state);
const setState = useCallback((next: MentionState) => {
stateRef.current = next;
setStateRaw(next);
}, []);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const cursorRef = useRef<number>(0);
const abortRef = useRef<AbortController | null>(null);
const handleMentionChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newValue = e.target.value;
const pos = e.target.selectionStart ?? newValue.length;
cursorRef.current = pos;
onChange(newValue);
const cancelLookup = useCallback(() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = null;
abortRef.current?.abort();
abortRef.current = null;
}, []);
const mention = getMentionQuery(newValue, pos);
const close = useCallback(() => {
cancelLookup();
if (stateRef.current !== CLOSED) setState(CLOSED);
}, [cancelLookup, setState]);
/**
* Call on every change (with `lookup`) and caret move (without): tracks the
* `@query` under the caret, searching for it after a short pause, and closes
* once the caret leaves it.
*/
const syncMention = useCallback(
(value: string, pos: number, lookup: boolean) => {
const mention = getMentionQuery(value, pos);
const s = stateRef.current;
if (!mention) {
setState(CLOSED);
if (debounceRef.current) clearTimeout(debounceRef.current);
close();
return;
}
if (!lookup) {
// A caret move within the same @query keeps the list; one that lands
// on some other @word doesn't open anything.
if (mention.start !== s.start || mention.query !== s.query) close();
return;
}
if (mention.query === s.query && mention.start === s.start) return;
setState((s) => ({
setState({
...s,
open: false,
query: mention.query,
start: mention.start,
end: pos,
selectedIndex: 0,
}));
});
if (debounceRef.current) clearTimeout(debounceRef.current);
cancelLookup();
debounceRef.current = setTimeout(async () => {
const controller = new AbortController();
abortRef.current = controller;
try {
const res = await fetch(
`${API_URL}/api/users/search?q=${
encodeURIComponent(mention.query)
}`,
{ signal: controller.signal },
);
const body = await res.json();
const current = stateRef.current;
// Only the lookup for what's typed now may open or close the list.
if (current.query !== mention.query) return;
if (body.success && body.data.length > 0) {
setState((s) =>
s.query === mention.query
? { ...s, open: true, results: body.data, selectedIndex: 0 }
: s
);
setState({
...current,
open: true,
results: body.data,
selectedIndex: 0,
});
} else {
setState(CLOSED);
setState({ ...current, open: false, results: [] });
}
} catch {
setState(CLOSED);
// Aborted by a newer keystroke, or offline: nothing to offer.
}
}, 150);
},
[onChange],
[cancelLookup, close, setState],
);
const doSelect = useCallback(
(username: string, start: number, cursorPos: number) => {
const before = value.slice(0, start);
const after = value.slice(cursorPos);
onChange(`${before}@${username} ${after}`);
setState(CLOSED);
if (debounceRef.current) clearTimeout(debounceRef.current);
setTimeout(() => {
const el = textareaRef.current;
if (el) {
const newPos = start + username.length + 2; // @ + username + space
el.focus();
el.setSelectionRange(newPos, newPos);
}
}, 0);
const select = useCallback(
(username: string) => {
const { start, end } = stateRef.current;
close();
replace(start, end, `@${username}`, true);
},
[value, onChange, textareaRef],
[close, replace],
);
/** Returns whether it handled the key. */
const handleMentionKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (!state.open || state.results.length === 0) return;
if (e.key === "ArrowDown") {
(e: React.KeyboardEvent<HTMLTextAreaElement>): boolean => {
const s = stateRef.current;
if (!s.open || s.results.length === 0) return false;
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
setState((s) => ({
...s,
selectedIndex: Math.min(s.selectedIndex + 1, s.results.length - 1),
}));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setState((s) => ({
...s,
selectedIndex: Math.max(s.selectedIndex - 1, 0),
}));
} else if (
(e.key === "Enter" || e.key === "Tab") && !e.ctrlKey && !e.metaKey
const n = s.results.length;
const step = e.key === "ArrowDown" ? 1 : -1;
// Wraps, like any listbox.
setState({ ...s, selectedIndex: (s.selectedIndex + step + n) % n });
return true;
}
if (
(e.key === "Enter" || e.key === "Tab") &&
!e.ctrlKey && !e.metaKey && !e.shiftKey && !e.altKey
) {
e.preventDefault();
const user = state.results[state.selectedIndex];
if (user) doSelect(user.username, state.start, cursorRef.current);
} else if (e.key === "Escape") {
e.preventDefault();
setState(CLOSED);
const user = s.results[s.selectedIndex];
if (user) select(user.username);
return true;
}
if (e.key === "Escape") {
// Just the list: whatever Escape means to the editor's owner (cancel
// an edit, close the chat) waits for a second press.
e.preventDefault();
e.stopPropagation();
close();
return true;
}
return false;
},
[state, doSelect],
[close, select, setState],
);
const handleMentionSelect = useCallback(
(username: string) => {
doSelect(username, state.start, cursorRef.current);
},
[doSelect, state.start],
);
useEffect(() => {
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, []);
useEffect(() => cancelLookup, [cancelLookup]);
return {
mentionOpen: state.open,
mentionOpen: state.open && state.results.length > 0,
mentionResults: state.results,
mentionSelectedIndex: state.selectedIndex,
handleMentionChange,
syncMention,
closeMention: close,
handleMentionKeyDown,
handleMentionSelect,
handleMentionSelect: select,
};
}

File diff suppressed because one or more lines are too long

View File

@@ -13,7 +13,7 @@ msgstr ""
"Language-Team: \n"
"Plural-Forms: \n"
#: src/components/CommentThread.tsx:257
#: src/components/CommentThread.tsx:270
msgid "[deleted]"
msgstr "[deleted]"
@@ -35,17 +35,17 @@ msgstr "{0, plural, one {and # earlier dump} other {and # earlier dumps}}"
#. placeholder {0}: typingNames[0]
#. placeholder {1}: typingNames[1]
#: src/components/ChatModal.tsx:1063
#: src/components/ChatModal.tsx:1079
msgid "{0} and {1} are typing…"
msgstr "{0} and {1} are typing…"
#. placeholder {0}: typingNames[0]
#: src/components/ChatModal.tsx:1061
#: src/components/ChatModal.tsx:1077
msgid "{0} is typing…"
msgstr "{0} is typing…"
#. placeholder {0}: rest.length
#: src/components/PresenceRow.tsx:44
#: src/components/PresenceRow.tsx:66
msgid "{0} more online: {restNames}"
msgstr "{0} more online: {restNames}"
@@ -59,15 +59,15 @@ msgstr "{0}–{1} characters: letters, numbers, or underscores"
msgid "{label} ({count})"
msgstr "{label} ({count})"
#: src/components/ChatModal.tsx:1192
#: src/components/ChatModal.tsx:1208
msgid "{unseenCount} new messages"
msgstr "{unseenCount} new messages"
#: src/components/CommentThread.tsx:462
#: src/components/CommentThread.tsx:475
msgid "{visibleCount, plural, one {# comment} other {# comments}}"
msgstr "{visibleCount, plural, one {# comment} other {# comments}}"
#: src/components/DumpCreateModal.tsx:943
#: src/components/DumpCreateModal.tsx:942
#: src/pages/PlaylistDetail.tsx:572
#: src/pages/UserPublicProfile.tsx:790
msgid "← Back"
@@ -143,7 +143,7 @@ msgstr "<0>{0}</0> upvoted <1>{1}</1>"
msgid "<0>{0}</0> was added to <1>{1}</1>"
msgstr "<0>{0}</0> was added to <1>{1}</1>"
#: src/components/ChatModal.tsx:1191
#: src/components/ChatModal.tsx:1207
msgid "1 new message"
msgstr "1 new message"
@@ -159,7 +159,7 @@ msgstr "a post"
msgid "Account"
msgstr "Account"
#: src/components/CommentThread.tsx:482
#: src/components/CommentThread.tsx:495
msgid "Add a comment…"
msgstr "Add a comment…"
@@ -207,6 +207,10 @@ msgstr "Appearance"
msgid "At least {0} characters"
msgstr "At least {0} characters"
#: src/components/form/RichTextField.tsx:56
msgid "At most {maxLength} characters."
msgstr "At most {maxLength} characters."
#: src/pages/UserPublicProfile.tsx:1252
msgid "Auto"
msgstr "Auto"
@@ -223,16 +227,16 @@ msgstr "Can't connect to the live updates server. Upvotes and notifications may
#: src/components/CategoryManager.tsx:210
#: src/components/CategoryManager.tsx:211
#: src/components/ChatModal.tsx:224
#: src/components/CommentThread.tsx:121
#: src/components/ChatModal.tsx:235
#: src/components/CommentThread.tsx:129
#: src/components/ConfirmModal.tsx:34
#: src/components/DumpCreateModal.tsx:953
#: src/components/DumpCreateModal.tsx:952
#: src/components/form/FormActions.tsx:32
#: src/pages/Dump.tsx:402
#: src/pages/DumpEdit.tsx:484
#: src/pages/PlaylistDetail.tsx:923
#: src/pages/DumpEdit.tsx:483
#: src/pages/PlaylistDetail.tsx:929
#: src/pages/UserPublicProfile.tsx:1764
#: src/pages/UserPublicProfile.tsx:1837
#: src/pages/UserPublicProfile.tsx:1838
msgid "Cancel"
msgstr "Cancel"
@@ -240,12 +244,12 @@ msgstr "Cancel"
msgid "Cancel removal"
msgstr "Cancel removal"
#: src/components/ChatModal.tsx:1252
#: src/components/ChatModal.tsx:1253
#: src/components/ChatModal.tsx:1268
#: src/components/ChatModal.tsx:1269
msgid "Cancel reply"
msgstr "Cancel reply"
#: src/components/AppHeader.tsx:139
#: src/components/AppHeader.tsx:127
msgid "Cancel search"
msgstr "Cancel search"
@@ -271,10 +275,14 @@ msgstr "Change password"
msgid "Change password…"
msgstr "Change password…"
#: src/components/TextEditor.tsx:619
msgid "Change skin tone"
msgstr "Change skin tone"
#: src/components/ChatButton.tsx:17
#: src/components/ChatFab.tsx:49
#: src/components/ChatFab.tsx:50
#: src/components/ChatModal.tsx:1152
#: src/components/ChatModal.tsx:1168
msgid "Chat"
msgstr "Chat"
@@ -296,6 +304,10 @@ msgstr "Close"
msgid "Close anyway"
msgstr "Close anyway"
#: src/components/TextEditor.tsx:628
msgid "Close emoji picker"
msgstr "Close emoji picker"
#: src/components/GlobalPlayer.tsx:242
msgid "Close player"
msgstr "Close player"
@@ -345,15 +357,15 @@ msgstr "Could not save"
msgid "Couldn't load a preview for this link — check it, or post it as-is and refresh the preview later."
msgstr "Couldn't load a preview for this link — check it, or post it as-is and refresh the preview later."
#: src/components/ChatModal.tsx:1078
#: src/components/ChatModal.tsx:1094
msgid "Couldn't load the chat."
msgstr "Couldn't load the chat."
#: src/components/PlaylistCreateForm.tsx:91
#: src/components/PlaylistCreateForm.tsx:90
msgid "Create"
msgstr "Create"
#: src/components/PlaylistCreateForm.tsx:91
#: src/components/PlaylistCreateForm.tsx:90
msgid "Create & Add"
msgstr "Create & Add"
@@ -363,7 +375,7 @@ msgstr "Create & Add"
msgid "Created ({0}{1})"
msgstr "Created ({0}{1})"
#: src/components/PlaylistCreateForm.tsx:90
#: src/components/PlaylistCreateForm.tsx:89
msgid "Creating…"
msgstr "Creating…"
@@ -381,13 +393,13 @@ msgstr "Default tab"
#: src/components/CategoryManager.tsx:221
#: src/components/CategoryManager.tsx:222
#: src/components/ChatModal.tsx:336
#: src/components/ChatModal.tsx:337
#: src/components/ChatModal.tsx:431
#: src/components/CommentThread.tsx:382
#: src/components/CommentThread.tsx:388
#: src/components/ChatModal.tsx:347
#: src/components/ChatModal.tsx:348
#: src/components/ChatModal.tsx:446
#: src/components/CommentThread.tsx:395
#: src/components/CommentThread.tsx:401
#: src/components/ConfirmModal.tsx:17
#: src/pages/PlaylistDetail.tsx:919
#: src/pages/PlaylistDetail.tsx:925
msgid "Delete"
msgstr "Delete"
@@ -401,7 +413,7 @@ msgid "Delete category \"{0}\"? This cannot be undone."
msgstr "Delete category \"{0}\"? This cannot be undone."
#: src/pages/DumpEdit.tsx:265
#: src/pages/DumpEdit.tsx:480
#: src/pages/DumpEdit.tsx:479
msgid "Delete dump"
msgstr "Delete dump"
@@ -411,7 +423,7 @@ msgstr "Delete dump"
msgid "Delete playlist"
msgstr "Delete playlist"
#: src/components/CommentThread.tsx:387
#: src/components/CommentThread.tsx:400
msgid "Delete this comment?"
msgstr "Delete this comment?"
@@ -419,7 +431,7 @@ msgstr "Delete this comment?"
msgid "Delete this dump? This cannot be undone."
msgstr "Delete this dump? This cannot be undone."
#: src/components/ChatModal.tsx:430
#: src/components/ChatModal.tsx:445
msgid "Delete this message?"
msgstr "Delete this message?"
@@ -428,25 +440,25 @@ msgstr "Delete this message?"
msgid "Delete this playlist? This cannot be undone."
msgstr "Delete this playlist? This cannot be undone."
#: src/components/ChatModal.tsx:258
#: src/components/ChatModal.tsx:269
msgid "deleted message"
msgstr "deleted message"
#: src/components/PlaylistCreateForm.tsx:82
#: src/pages/PlaylistDetail.tsx:880
#: src/pages/PlaylistDetail.tsx:886
msgid "Description (optional)"
msgstr "Description (optional)"
#: src/components/ChatModal.tsx:516
#: src/components/ChatModal.tsx:533
msgid "Discard"
msgstr "Discard"
#: src/components/ChatModal.tsx:1156
#: src/components/ChatModal.tsx:1172
msgid "Disconnected — reconnecting…"
msgstr "Disconnected — reconnecting…"
#: src/components/ChatModal.tsx:1228
#: src/components/ChatModal.tsx:1229
#: src/components/ChatModal.tsx:1244
#: src/components/ChatModal.tsx:1245
msgid "Dismiss"
msgstr "Dismiss"
@@ -466,11 +478,11 @@ msgstr "Drop a file here, or paste one"
msgid "Drop a replacement here"
msgstr "Drop a replacement here"
#: src/components/AppHeader.tsx:179
#: src/components/AppHeader.tsx:167
msgid "Dump"
msgstr "Dump"
#: src/components/DumpCreateModal.tsx:966
#: src/components/DumpCreateModal.tsx:965
msgid "Dump it"
msgstr "Dump it"
@@ -494,10 +506,10 @@ msgstr "Dumps ({0}{1})"
msgid "Earlier"
msgstr "Earlier"
#: src/components/ChatModal.tsx:325
#: src/components/ChatModal.tsx:326
#: src/components/ChatModal.tsx:509
#: src/components/CommentThread.tsx:373
#: src/components/ChatModal.tsx:336
#: src/components/ChatModal.tsx:337
#: src/components/ChatModal.tsx:526
#: src/components/CommentThread.tsx:386
#: src/pages/Dump.tsx:516
#: src/pages/PlaylistDetail.tsx:627
msgid "Edit"
@@ -512,15 +524,15 @@ msgstr "Edit {0}"
msgid "Edit title"
msgstr "Edit title"
#: src/components/ChatModal.tsx:349
#: src/components/ChatModal.tsx:360
msgid "edited"
msgstr "edited"
#. placeholder {0}: relativeTime(comment.updatedAt)
#. placeholder {0}: relativeTime(dump.updatedAt)
#. placeholder {0}: relativeTime(message.updatedAt)
#: src/components/ChatModal.tsx:350
#: src/components/CommentThread.tsx:322
#: src/components/ChatModal.tsx:361
#: src/components/CommentThread.tsx:335
#: src/pages/Dump.tsx:455
#: src/pages/PlaylistDetail.tsx:666
msgid "edited {0}"
@@ -529,8 +541,8 @@ msgstr "edited {0}"
#. placeholder {0}: comment.updatedAt.toLocaleString()
#. placeholder {0}: dump.updatedAt.toLocaleString()
#. placeholder {0}: message.updatedAt.toLocaleString()
#: src/components/ChatModal.tsx:346
#: src/components/CommentThread.tsx:320
#: src/components/ChatModal.tsx:357
#: src/components/CommentThread.tsx:333
#: src/pages/Dump.tsx:453
#: src/pages/PlaylistDetail.tsx:663
msgid "Edited {0}"
@@ -561,7 +573,7 @@ msgstr "Expand player"
msgid "Failed to create category"
msgstr "Failed to create category"
#: src/components/PlaylistCreateForm.tsx:88
#: src/components/PlaylistCreateForm.tsx:87
msgid "Failed to create playlist"
msgstr "Failed to create playlist"
@@ -590,17 +602,17 @@ msgstr "Failed to load"
msgid "Failed to post"
msgstr "Failed to post"
#: src/components/CommentThread.tsx:481
#: src/components/CommentThread.tsx:494
msgid "Failed to post comment"
msgstr "Failed to post comment"
#: src/components/CommentThread.tsx:403
#: src/components/CommentThread.tsx:416
msgid "Failed to post reply"
msgstr "Failed to post reply"
#: src/pages/PlaylistDetail.tsx:911
#: src/pages/PlaylistDetail.tsx:917
#: src/pages/UserPublicProfile.tsx:1767
#: src/pages/UserPublicProfile.tsx:1839
#: src/pages/UserPublicProfile.tsx:1840
msgid "Failed to save"
msgstr "Failed to save"
@@ -608,7 +620,7 @@ msgstr "Failed to save"
msgid "Failed to save category"
msgstr "Failed to save category"
#: src/components/CommentThread.tsx:335
#: src/components/CommentThread.tsx:348
msgid "Failed to save edit"
msgstr "Failed to save edit"
@@ -704,7 +716,7 @@ msgstr "Hot"
msgid "If that address is registered you'll receive a reset link shortly."
msgstr "If that address is registered you'll receive a reset link shortly."
#: src/components/ChatModal.tsx:81
#: src/components/ChatModal.tsx:95
msgid "image"
msgstr "image"
@@ -712,6 +724,11 @@ msgstr "image"
msgid "In collections"
msgstr "In collections"
#: src/components/TextEditor.tsx:504
#: src/components/TextEditor.tsx:506
msgid "Insert emoji"
msgstr "Insert emoji"
#: src/pages/UserRegister.tsx:96
msgid "Invalid invite"
msgstr "Invalid invite"
@@ -763,7 +780,7 @@ msgstr "Link or file"
msgid "Live updates are temporarily disconnected. Trying to reconnect…"
msgstr "Live updates are temporarily disconnected. Trying to reconnect…"
#: src/components/AppHeader.tsx:212
#: src/components/AppHeader.tsx:200
msgid "Live updates unavailable."
msgstr "Live updates unavailable."
@@ -772,7 +789,7 @@ msgstr "Live updates unavailable."
msgid "Load more"
msgstr "Load more"
#: src/components/ChatModal.tsx:1177
#: src/components/ChatModal.tsx:1193
msgid "Load older messages"
msgstr "Load older messages"
@@ -781,7 +798,7 @@ msgstr "Load older messages"
msgid "Loading dump…"
msgstr "Loading dump…"
#: src/components/ChatModal.tsx:1070
#: src/components/ChatModal.tsx:1086
msgid "Loading messages…"
msgstr "Loading messages…"
@@ -806,9 +823,9 @@ msgid "Loading profile…"
msgstr "Loading profile…"
#: src/components/CategoryManager.tsx:52
#: src/components/ChatModal.tsx:1176
#: src/components/ChatModal.tsx:1192
#: src/components/PlaylistMembershipPanel.tsx:28
#: src/components/TextEditor.tsx:328
#: src/components/TextEditor.tsx:640
#: src/components/UserListPopover.tsx:192
#: src/components/UserListPopover.tsx:230
#: src/pages/index/FollowedFeed.tsx:78
@@ -826,7 +843,7 @@ msgstr "Loading profile…"
msgid "Loading…"
msgstr "Loading…"
#: src/components/AppHeader.tsx:187
#: src/components/AppHeader.tsx:175
#: src/pages/UserLogin.tsx:35
#: src/pages/UserLogin.tsx:61
#: src/pages/UserLogin.tsx:80
@@ -875,7 +892,7 @@ msgstr "Name"
msgid "new"
msgstr "new"
#: src/components/ChatModal.tsx:1105
#: src/components/ChatModal.tsx:1121
#: src/components/FeedTabBar.tsx:31
#: src/pages/UserPublicProfile.tsx:1294
msgid "New"
@@ -899,7 +916,7 @@ msgstr "New password"
msgid "New playlist"
msgstr "New playlist"
#: src/components/DumpCreateModal.tsx:965
#: src/components/DumpCreateModal.tsx:964
msgid "Next →"
msgstr "Next →"
@@ -921,7 +938,7 @@ msgstr "No dumps match \"{q}\"."
msgid "No dumps yet. Be the first!"
msgstr "No dumps yet. Be the first!"
#: src/components/TextEditor.tsx:331
#: src/components/TextEditor.tsx:643
msgid "No emoji found."
msgstr "No emoji found."
@@ -934,7 +951,7 @@ msgstr "No followed playlists yet."
msgid "No invitees yet."
msgstr "No invitees yet."
#: src/components/ChatModal.tsx:1089
#: src/components/ChatModal.tsx:1105
msgid "No messages yet. Say hello!"
msgstr "No messages yet. Say hello!"
@@ -991,6 +1008,10 @@ msgstr "Off"
msgid "On"
msgstr "On"
#: src/components/TextEditor.tsx:338
msgid "Only images can be dropped here."
msgstr "Only images can be dropped here."
#: src/components/FileDropZone.tsx:134
msgid "or <0>browse files</0>"
msgstr "or <0>browse files</0>"
@@ -1030,11 +1051,11 @@ msgstr "Paste a link…"
msgid "Picked up where you left off."
msgstr "Picked up where you left off."
#: src/pages/PlaylistDetail.tsx:866
#: src/pages/PlaylistDetail.tsx:870
msgid "Playlist title"
msgstr "Playlist title"
#: src/components/AppHeader.tsx:160
#: src/components/AppHeader.tsx:148
#: src/components/DumpCreateModal.tsx:68
#: src/components/UserMenu.tsx:62
#: src/pages/Search.tsx:177
@@ -1049,17 +1070,17 @@ msgstr "Playlists"
msgid "Playlists ({0}{1})"
msgstr "Playlists ({0}{1})"
#: src/components/CommentThread.tsx:479
#: src/components/CommentThread.tsx:492
msgid "Post comment"
msgstr "Post comment"
#: src/components/CommentThread.tsx:401
#: src/components/CommentThread.tsx:414
msgid "Post reply"
msgstr "Post reply"
#: src/components/CommentThread.tsx:402
#: src/components/CommentThread.tsx:480
#: src/components/DumpCreateModal.tsx:959
#: src/components/CommentThread.tsx:415
#: src/components/CommentThread.tsx:493
#: src/components/DumpCreateModal.tsx:958
msgid "Posting…"
msgstr "Posting…"
@@ -1077,7 +1098,7 @@ msgid "private"
msgstr "private"
#: src/components/form/SegmentedField.tsx:81
#: src/pages/PlaylistDetail.tsx:905
#: src/pages/PlaylistDetail.tsx:911
msgid "Private"
msgstr "Private"
@@ -1087,7 +1108,7 @@ msgid "public"
msgstr "public"
#: src/components/form/SegmentedField.tsx:80
#: src/pages/PlaylistDetail.tsx:898
#: src/pages/PlaylistDetail.tsx:904
msgid "Public"
msgstr "Public"
@@ -1141,13 +1162,13 @@ msgstr "Remove vote"
msgid "Replace file"
msgstr "Replace file"
#: src/components/ChatModal.tsx:315
#: src/components/ChatModal.tsx:316
#: src/components/CommentThread.tsx:361
#: src/components/ChatModal.tsx:326
#: src/components/ChatModal.tsx:327
#: src/components/CommentThread.tsx:374
msgid "Reply"
msgstr "Reply"
#: src/components/ChatModal.tsx:1240
#: src/components/ChatModal.tsx:1256
msgid "Replying to"
msgstr "Replying to"
@@ -1173,7 +1194,7 @@ msgstr "Reset to default"
msgid "Reset to the suggested title"
msgstr "Reset to the suggested title"
#: src/components/ChatModal.tsx:502
#: src/components/ChatModal.tsx:519
#: src/pages/Dump.tsx:283
#: src/pages/DumpEdit.tsx:180
msgid "Retry"
@@ -1183,27 +1204,27 @@ msgstr "Retry"
msgid "Role"
msgstr "Role"
#: src/components/ChatModal.tsx:221
#: src/components/CommentThread.tsx:333
#: src/components/ChatModal.tsx:232
#: src/components/CommentThread.tsx:346
#: src/pages/Dump.tsx:394
#: src/pages/DumpEdit.tsx:487
#: src/pages/PlaylistDetail.tsx:930
#: src/pages/DumpEdit.tsx:486
#: src/pages/PlaylistDetail.tsx:936
#: src/pages/UserPublicProfile.tsx:1756
#: src/pages/UserPublicProfile.tsx:1829
#: src/pages/UserPublicProfile.tsx:1830
msgid "Save"
msgstr "Save"
#: src/components/ChangePasswordModal.tsx:103
#: src/components/CommentThread.tsx:334
#: src/components/CommentThread.tsx:347
#: src/pages/Dump.tsx:393
#: src/pages/PlaylistDetail.tsx:926
#: src/pages/PlaylistDetail.tsx:932
#: src/pages/ResetPassword.tsx:126
#: src/pages/UserPublicProfile.tsx:1753
#: src/pages/UserPublicProfile.tsx:1826
#: src/pages/UserPublicProfile.tsx:1827
msgid "Saving…"
msgstr "Saving…"
#: src/components/AppHeader.tsx:139
#: src/components/AppHeader.tsx:127
#: src/components/SearchBar.tsx:73
#: src/pages/Search.tsx:50
msgid "Search"
@@ -1213,6 +1234,14 @@ msgstr "Search"
msgid "Search dumps, users, playlists…"
msgstr "Search dumps, users, playlists…"
#: src/components/TextEditor.tsx:612
msgid "Search emoji"
msgstr "Search emoji"
#: src/components/TextEditor.tsx:611
msgid "Search emoji…"
msgstr "Search emoji…"
#: src/pages/Search.tsx:218
msgid "Search failed"
msgstr "Search failed"
@@ -1221,7 +1250,7 @@ msgstr "Search failed"
msgid "Searching…"
msgstr "Searching…"
#: src/components/ChatModal.tsx:1280
#: src/components/ChatModal.tsx:1296
msgid "Send"
msgstr "Send"
@@ -1229,12 +1258,12 @@ msgstr "Send"
msgid "Send reset link"
msgstr "Send reset link"
#: src/components/ChatModal.tsx:520
#: src/components/ChatModal.tsx:537
#: src/pages/UserLogin.tsx:142
msgid "Sending…"
msgstr "Sending…"
#: src/components/AppHeader.tsx:176
#: src/components/AppHeader.tsx:164
msgid "Server unreachable"
msgstr "Server unreachable"
@@ -1247,7 +1276,7 @@ msgstr "Set new password"
msgid "Settings"
msgstr "Settings"
#: src/components/ChatModal.tsx:1064
#: src/components/ChatModal.tsx:1080
msgid "Several people are typing…"
msgstr "Several people are typing…"
@@ -1259,6 +1288,10 @@ msgstr "Single use, valid for 7 days."
msgid "slug"
msgstr "slug"
#: src/components/TextEditor.tsx:297
msgid "Some images couldn't be uploaded."
msgstr "Some images couldn't be uploaded."
#: src/components/form/FormError.tsx:15
#: src/components/PageError.tsx:13
#: src/contexts/WSProvider.tsx:663
@@ -1285,6 +1318,10 @@ msgstr "Submit search"
msgid "the chat"
msgstr "the chat"
#: src/components/TextEditor.tsx:298
msgid "The image couldn't be uploaded."
msgstr "The image couldn't be uploaded."
#: src/pages/UserRegister.tsx:97
msgid "This invite link is missing, expired, or already used."
msgstr "This invite link is missing, expired, or already used."
@@ -1315,7 +1352,7 @@ msgstr "Title"
msgid "Title is required."
msgstr "Title is required."
#: src/components/ChatModal.tsx:133
#: src/components/ChatModal.tsx:147
#: src/pages/Notifications.tsx:398
msgid "Today"
msgstr "Today"
@@ -1325,11 +1362,11 @@ msgstr "Today"
msgid "Track {0}"
msgstr "Track {0}"
#: src/components/ChatModal.tsx:1081
#: src/components/ChatModal.tsx:1097
msgid "Try again"
msgstr "Try again"
#: src/components/ChatModal.tsx:1265
#: src/components/ChatModal.tsx:1281
msgid "Type a message…"
msgstr "Type a message…"
@@ -1354,7 +1391,11 @@ msgstr "Upload failed"
msgid "Uploading {0}%"
msgstr "Uploading {0}%"
#: src/components/DumpCreateModal.tsx:962
#: src/components/TextEditor.tsx:526
msgid "Uploading image…"
msgstr "Uploading image…"
#: src/components/DumpCreateModal.tsx:961
msgid "Uploading…"
msgstr "Uploading…"
@@ -1423,11 +1464,11 @@ msgid "What makes it worth it?"
msgstr "What makes it worth it?"
#: src/pages/UserPublicProfile.tsx:925
#: src/pages/UserPublicProfile.tsx:1818
#: src/pages/UserPublicProfile.tsx:1819
msgid "Who am I?"
msgstr "Who am I?"
#: src/components/DumpCreateModal.tsx:928
#: src/components/DumpCreateModal.tsx:927
msgid "Who can see it"
msgstr "Who can see it"
@@ -1440,11 +1481,11 @@ msgstr "Why & where"
msgid "Why?"
msgstr "Why?"
#: src/components/CommentThread.tsx:405
#: src/components/CommentThread.tsx:418
msgid "Write a reply…"
msgstr "Write a reply…"
#: src/components/ChatModal.tsx:136
#: src/components/ChatModal.tsx:150
#: src/pages/Notifications.tsx:400
msgid "Yesterday"
msgstr "Yesterday"

File diff suppressed because one or more lines are too long

View File

@@ -13,7 +13,7 @@ msgstr ""
"Last-Translator: \n"
"Language-Team: \n"
#: src/components/CommentThread.tsx:257
#: src/components/CommentThread.tsx:270
msgid "[deleted]"
msgstr "[supprimé]"
@@ -35,17 +35,17 @@ msgstr "{0, plural, one {et # reco plus ancienne} other {et # recos plus ancienn
#. placeholder {0}: typingNames[0]
#. placeholder {1}: typingNames[1]
#: src/components/ChatModal.tsx:1063
#: src/components/ChatModal.tsx:1079
msgid "{0} and {1} are typing…"
msgstr "{0} et {1} sont en train d'écrire…"
#. placeholder {0}: typingNames[0]
#: src/components/ChatModal.tsx:1061
#: src/components/ChatModal.tsx:1077
msgid "{0} is typing…"
msgstr "{0} est en train d'écrire…"
#. placeholder {0}: rest.length
#: src/components/PresenceRow.tsx:44
#: src/components/PresenceRow.tsx:66
msgid "{0} more online: {restNames}"
msgstr "{0} de plus en ligne : {restNames}"
@@ -59,15 +59,15 @@ msgstr "{0}–{1} caractères : lettres, chiffres ou tirets bas"
msgid "{label} ({count})"
msgstr "{label} ({count})"
#: src/components/ChatModal.tsx:1192
#: src/components/ChatModal.tsx:1208
msgid "{unseenCount} new messages"
msgstr "{unseenCount} nouveaux messages"
#: src/components/CommentThread.tsx:462
#: src/components/CommentThread.tsx:475
msgid "{visibleCount, plural, one {# comment} other {# comments}}"
msgstr "{visibleCount, plural, one {# commentaire} other {# commentaires}}"
#: src/components/DumpCreateModal.tsx:943
#: src/components/DumpCreateModal.tsx:942
#: src/pages/PlaylistDetail.tsx:572
#: src/pages/UserPublicProfile.tsx:790
msgid "← Back"
@@ -143,7 +143,7 @@ msgstr "<0>{0}</0> a voté pour <1>{1}</1>"
msgid "<0>{0}</0> was added to <1>{1}</1>"
msgstr "<0>{0}</0> a été ajouté à <1>{1}</1>"
#: src/components/ChatModal.tsx:1191
#: src/components/ChatModal.tsx:1207
msgid "1 new message"
msgstr "1 nouveau message"
@@ -159,7 +159,7 @@ msgstr "une publication"
msgid "Account"
msgstr "Compte"
#: src/components/CommentThread.tsx:482
#: src/components/CommentThread.tsx:495
msgid "Add a comment…"
msgstr "Ajouter un commentaire…"
@@ -207,6 +207,10 @@ msgstr "Apparence"
msgid "At least {0} characters"
msgstr "Au moins {0} caractères"
#: src/components/form/RichTextField.tsx:56
msgid "At most {maxLength} characters."
msgstr "{maxLength} caractères au maximum."
#: src/pages/UserPublicProfile.tsx:1252
msgid "Auto"
msgstr "Auto"
@@ -223,16 +227,16 @@ msgstr "Impossible de se connecter au serveur de mises à jour en direct. Les vo
#: src/components/CategoryManager.tsx:210
#: src/components/CategoryManager.tsx:211
#: src/components/ChatModal.tsx:224
#: src/components/CommentThread.tsx:121
#: src/components/ChatModal.tsx:235
#: src/components/CommentThread.tsx:129
#: src/components/ConfirmModal.tsx:34
#: src/components/DumpCreateModal.tsx:953
#: src/components/DumpCreateModal.tsx:952
#: src/components/form/FormActions.tsx:32
#: src/pages/Dump.tsx:402
#: src/pages/DumpEdit.tsx:484
#: src/pages/PlaylistDetail.tsx:923
#: src/pages/DumpEdit.tsx:483
#: src/pages/PlaylistDetail.tsx:929
#: src/pages/UserPublicProfile.tsx:1764
#: src/pages/UserPublicProfile.tsx:1837
#: src/pages/UserPublicProfile.tsx:1838
msgid "Cancel"
msgstr "Annuler"
@@ -240,12 +244,12 @@ msgstr "Annuler"
msgid "Cancel removal"
msgstr "Annuler la suppression"
#: src/components/ChatModal.tsx:1252
#: src/components/ChatModal.tsx:1253
#: src/components/ChatModal.tsx:1268
#: src/components/ChatModal.tsx:1269
msgid "Cancel reply"
msgstr "Annuler la réponse"
#: src/components/AppHeader.tsx:139
#: src/components/AppHeader.tsx:127
msgid "Cancel search"
msgstr "Annuler la recherche"
@@ -271,10 +275,14 @@ msgstr "Changer le mot de passe"
msgid "Change password…"
msgstr "Changer le mot de passe…"
#: src/components/TextEditor.tsx:619
msgid "Change skin tone"
msgstr "Changer la couleur de peau"
#: src/components/ChatButton.tsx:17
#: src/components/ChatFab.tsx:49
#: src/components/ChatFab.tsx:50
#: src/components/ChatModal.tsx:1152
#: src/components/ChatModal.tsx:1168
msgid "Chat"
msgstr "Tribune"
@@ -296,6 +304,10 @@ msgstr "Fermer"
msgid "Close anyway"
msgstr "Fermer quand même"
#: src/components/TextEditor.tsx:628
msgid "Close emoji picker"
msgstr "Fermer le sélecteur d'emoji"
#: src/components/GlobalPlayer.tsx:242
msgid "Close player"
msgstr "Fermer le lecteur"
@@ -345,15 +357,15 @@ msgstr "Sauvegarde impossible"
msgid "Couldn't load a preview for this link — check it, or post it as-is and refresh the preview later."
msgstr "Impossible de charger un aperçu pour ce lien — vérifiez-le, ou publiez-le tel quel et actualisez l'aperçu plus tard."
#: src/components/ChatModal.tsx:1078
#: src/components/ChatModal.tsx:1094
msgid "Couldn't load the chat."
msgstr "Impossible de charger le chat."
#: src/components/PlaylistCreateForm.tsx:91
#: src/components/PlaylistCreateForm.tsx:90
msgid "Create"
msgstr "Créer"
#: src/components/PlaylistCreateForm.tsx:91
#: src/components/PlaylistCreateForm.tsx:90
msgid "Create & Add"
msgstr "Créer et ajouter"
@@ -363,7 +375,7 @@ msgstr "Créer et ajouter"
msgid "Created ({0}{1})"
msgstr "Créées ({0}{1})"
#: src/components/PlaylistCreateForm.tsx:90
#: src/components/PlaylistCreateForm.tsx:89
msgid "Creating…"
msgstr "Création…"
@@ -381,13 +393,13 @@ msgstr "Onglet par défaut"
#: src/components/CategoryManager.tsx:221
#: src/components/CategoryManager.tsx:222
#: src/components/ChatModal.tsx:336
#: src/components/ChatModal.tsx:337
#: src/components/ChatModal.tsx:431
#: src/components/CommentThread.tsx:382
#: src/components/CommentThread.tsx:388
#: src/components/ChatModal.tsx:347
#: src/components/ChatModal.tsx:348
#: src/components/ChatModal.tsx:446
#: src/components/CommentThread.tsx:395
#: src/components/CommentThread.tsx:401
#: src/components/ConfirmModal.tsx:17
#: src/pages/PlaylistDetail.tsx:919
#: src/pages/PlaylistDetail.tsx:925
msgid "Delete"
msgstr "Supprimer"
@@ -401,7 +413,7 @@ msgid "Delete category \"{0}\"? This cannot be undone."
msgstr "Supprimer la catégorie \"{0}\" ? Cette action est irréversible."
#: src/pages/DumpEdit.tsx:265
#: src/pages/DumpEdit.tsx:480
#: src/pages/DumpEdit.tsx:479
msgid "Delete dump"
msgstr "Supprimer la reco"
@@ -411,7 +423,7 @@ msgstr "Supprimer la reco"
msgid "Delete playlist"
msgstr "Supprimer la collection"
#: src/components/CommentThread.tsx:387
#: src/components/CommentThread.tsx:400
msgid "Delete this comment?"
msgstr "Supprimer ce commentaire ?"
@@ -419,7 +431,7 @@ msgstr "Supprimer ce commentaire ?"
msgid "Delete this dump? This cannot be undone."
msgstr "Supprimer cette reco ? Cette action est irréversible."
#: src/components/ChatModal.tsx:430
#: src/components/ChatModal.tsx:445
msgid "Delete this message?"
msgstr "Supprimer ce message ?"
@@ -428,25 +440,25 @@ msgstr "Supprimer ce message ?"
msgid "Delete this playlist? This cannot be undone."
msgstr "Supprimer cette collection ? Cette action est irréversible."
#: src/components/ChatModal.tsx:258
#: src/components/ChatModal.tsx:269
msgid "deleted message"
msgstr "message supprimé"
#: src/components/PlaylistCreateForm.tsx:82
#: src/pages/PlaylistDetail.tsx:880
#: src/pages/PlaylistDetail.tsx:886
msgid "Description (optional)"
msgstr "Description (facultatif)"
#: src/components/ChatModal.tsx:516
#: src/components/ChatModal.tsx:533
msgid "Discard"
msgstr "Abandonner"
#: src/components/ChatModal.tsx:1156
#: src/components/ChatModal.tsx:1172
msgid "Disconnected — reconnecting…"
msgstr "Déconnecté — reconnexion…"
#: src/components/ChatModal.tsx:1228
#: src/components/ChatModal.tsx:1229
#: src/components/ChatModal.tsx:1244
#: src/components/ChatModal.tsx:1245
msgid "Dismiss"
msgstr "Fermer"
@@ -466,11 +478,11 @@ msgstr "Déposez un fichier ici, ou collez-en un"
msgid "Drop a replacement here"
msgstr "Déposez un fichier de remplacement ici"
#: src/components/AppHeader.tsx:179
#: src/components/AppHeader.tsx:167
msgid "Dump"
msgstr "Reco"
#: src/components/DumpCreateModal.tsx:966
#: src/components/DumpCreateModal.tsx:965
msgid "Dump it"
msgstr "Recommander"
@@ -494,10 +506,10 @@ msgstr "Recos ({0}{1})"
msgid "Earlier"
msgstr "Plus tôt"
#: src/components/ChatModal.tsx:325
#: src/components/ChatModal.tsx:326
#: src/components/ChatModal.tsx:509
#: src/components/CommentThread.tsx:373
#: src/components/ChatModal.tsx:336
#: src/components/ChatModal.tsx:337
#: src/components/ChatModal.tsx:526
#: src/components/CommentThread.tsx:386
#: src/pages/Dump.tsx:516
#: src/pages/PlaylistDetail.tsx:627
msgid "Edit"
@@ -512,15 +524,15 @@ msgstr "Modifier {0}"
msgid "Edit title"
msgstr "Modifier le titre"
#: src/components/ChatModal.tsx:349
#: src/components/ChatModal.tsx:360
msgid "edited"
msgstr "modifié"
#. placeholder {0}: relativeTime(comment.updatedAt)
#. placeholder {0}: relativeTime(dump.updatedAt)
#. placeholder {0}: relativeTime(message.updatedAt)
#: src/components/ChatModal.tsx:350
#: src/components/CommentThread.tsx:322
#: src/components/ChatModal.tsx:361
#: src/components/CommentThread.tsx:335
#: src/pages/Dump.tsx:455
#: src/pages/PlaylistDetail.tsx:666
msgid "edited {0}"
@@ -529,8 +541,8 @@ msgstr "modifié {0}"
#. placeholder {0}: comment.updatedAt.toLocaleString()
#. placeholder {0}: dump.updatedAt.toLocaleString()
#. placeholder {0}: message.updatedAt.toLocaleString()
#: src/components/ChatModal.tsx:346
#: src/components/CommentThread.tsx:320
#: src/components/ChatModal.tsx:357
#: src/components/CommentThread.tsx:333
#: src/pages/Dump.tsx:453
#: src/pages/PlaylistDetail.tsx:663
msgid "Edited {0}"
@@ -561,7 +573,7 @@ msgstr "Agrandir le lecteur"
msgid "Failed to create category"
msgstr "Échec de la création de la catégorie"
#: src/components/PlaylistCreateForm.tsx:88
#: src/components/PlaylistCreateForm.tsx:87
msgid "Failed to create playlist"
msgstr "Impossible de créer la collection"
@@ -590,17 +602,17 @@ msgstr "Chargement échoué"
msgid "Failed to post"
msgstr "Publication échouée"
#: src/components/CommentThread.tsx:481
#: src/components/CommentThread.tsx:494
msgid "Failed to post comment"
msgstr "Impossible de publier le commentaire"
#: src/components/CommentThread.tsx:403
#: src/components/CommentThread.tsx:416
msgid "Failed to post reply"
msgstr "Impossible de publier la réponse"
#: src/pages/PlaylistDetail.tsx:911
#: src/pages/PlaylistDetail.tsx:917
#: src/pages/UserPublicProfile.tsx:1767
#: src/pages/UserPublicProfile.tsx:1839
#: src/pages/UserPublicProfile.tsx:1840
msgid "Failed to save"
msgstr "Enregistrement échoué"
@@ -608,7 +620,7 @@ msgstr "Enregistrement échoué"
msgid "Failed to save category"
msgstr "Échec de l’enregistrement de la catégorie"
#: src/components/CommentThread.tsx:335
#: src/components/CommentThread.tsx:348
msgid "Failed to save edit"
msgstr "Impossible d'enregistrer la modification"
@@ -704,7 +716,7 @@ msgstr "Tendances"
msgid "If that address is registered you'll receive a reset link shortly."
msgstr "Si cette adresse est enregistrée, vous recevrez un lien de réinitialisation sous peu."
#: src/components/ChatModal.tsx:81
#: src/components/ChatModal.tsx:95
msgid "image"
msgstr "image"
@@ -712,6 +724,11 @@ msgstr "image"
msgid "In collections"
msgstr "Dans les collections"
#: src/components/TextEditor.tsx:504
#: src/components/TextEditor.tsx:506
msgid "Insert emoji"
msgstr "Insérer un emoji"
#: src/pages/UserRegister.tsx:96
msgid "Invalid invite"
msgstr "Invitation invalide"
@@ -763,7 +780,7 @@ msgstr "Lien ou fichier"
msgid "Live updates are temporarily disconnected. Trying to reconnect…"
msgstr "Les mises à jour en direct sont temporairement interrompues. Tentative de reconnexion…"
#: src/components/AppHeader.tsx:212
#: src/components/AppHeader.tsx:200
msgid "Live updates unavailable."
msgstr "Mises à jour en direct indisponibles."
@@ -772,7 +789,7 @@ msgstr "Mises à jour en direct indisponibles."
msgid "Load more"
msgstr "Charger plus"
#: src/components/ChatModal.tsx:1177
#: src/components/ChatModal.tsx:1193
msgid "Load older messages"
msgstr "Charger les messages plus anciens"
@@ -781,7 +798,7 @@ msgstr "Charger les messages plus anciens"
msgid "Loading dump…"
msgstr "Chargement de la reco…"
#: src/components/ChatModal.tsx:1070
#: src/components/ChatModal.tsx:1086
msgid "Loading messages…"
msgstr "Chargement des messages…"
@@ -806,9 +823,9 @@ msgid "Loading profile…"
msgstr "Chargement du profil…"
#: src/components/CategoryManager.tsx:52
#: src/components/ChatModal.tsx:1176
#: src/components/ChatModal.tsx:1192
#: src/components/PlaylistMembershipPanel.tsx:28
#: src/components/TextEditor.tsx:328
#: src/components/TextEditor.tsx:640
#: src/components/UserListPopover.tsx:192
#: src/components/UserListPopover.tsx:230
#: src/pages/index/FollowedFeed.tsx:78
@@ -826,7 +843,7 @@ msgstr "Chargement du profil…"
msgid "Loading…"
msgstr "Chargement…"
#: src/components/AppHeader.tsx:187
#: src/components/AppHeader.tsx:175
#: src/pages/UserLogin.tsx:35
#: src/pages/UserLogin.tsx:61
#: src/pages/UserLogin.tsx:80
@@ -875,7 +892,7 @@ msgstr "Nom"
msgid "new"
msgstr "nouveau"
#: src/components/ChatModal.tsx:1105
#: src/components/ChatModal.tsx:1121
#: src/components/FeedTabBar.tsx:31
#: src/pages/UserPublicProfile.tsx:1294
msgid "New"
@@ -899,7 +916,7 @@ msgstr "Nouveau mot de passe"
msgid "New playlist"
msgstr "Nouvelle collection"
#: src/components/DumpCreateModal.tsx:965
#: src/components/DumpCreateModal.tsx:964
msgid "Next →"
msgstr "Suivant →"
@@ -921,7 +938,7 @@ msgstr "Aucune reco ne correspond à « {q} »."
msgid "No dumps yet. Be the first!"
msgstr "Pas encore de recos. Soyez le premier !"
#: src/components/TextEditor.tsx:331
#: src/components/TextEditor.tsx:643
msgid "No emoji found."
msgstr "Aucun emoji trouvé."
@@ -934,7 +951,7 @@ msgstr "Pas encore de collections suivies."
msgid "No invitees yet."
msgstr "Aucun invité pour le moment."
#: src/components/ChatModal.tsx:1089
#: src/components/ChatModal.tsx:1105
msgid "No messages yet. Say hello!"
msgstr "Aucun message pour l'instant. Dites bonjour !"
@@ -991,6 +1008,10 @@ msgstr "Désactivés"
msgid "On"
msgstr "Activés"
#: src/components/TextEditor.tsx:338
msgid "Only images can be dropped here."
msgstr "Seules les images peuvent être déposées ici."
#: src/components/FileDropZone.tsx:134
msgid "or <0>browse files</0>"
msgstr "ou <0>parcourir les fichiers</0>"
@@ -1030,11 +1051,11 @@ msgstr "Collez un lien…"
msgid "Picked up where you left off."
msgstr "Reprise de votre brouillon."
#: src/pages/PlaylistDetail.tsx:866
#: src/pages/PlaylistDetail.tsx:870
msgid "Playlist title"
msgstr "Titre de la collection"
#: src/components/AppHeader.tsx:160
#: src/components/AppHeader.tsx:148
#: src/components/DumpCreateModal.tsx:68
#: src/components/UserMenu.tsx:62
#: src/pages/Search.tsx:177
@@ -1049,17 +1070,17 @@ msgstr "Collections"
msgid "Playlists ({0}{1})"
msgstr "Collections ({0}{1})"
#: src/components/CommentThread.tsx:479
#: src/components/CommentThread.tsx:492
msgid "Post comment"
msgstr "Publier le commentaire"
#: src/components/CommentThread.tsx:401
#: src/components/CommentThread.tsx:414
msgid "Post reply"
msgstr "Publier la réponse"
#: src/components/CommentThread.tsx:402
#: src/components/CommentThread.tsx:480
#: src/components/DumpCreateModal.tsx:959
#: src/components/CommentThread.tsx:415
#: src/components/CommentThread.tsx:493
#: src/components/DumpCreateModal.tsx:958
msgid "Posting…"
msgstr "Publication…"
@@ -1077,7 +1098,7 @@ msgid "private"
msgstr "privé"
#: src/components/form/SegmentedField.tsx:81
#: src/pages/PlaylistDetail.tsx:905
#: src/pages/PlaylistDetail.tsx:911
msgid "Private"
msgstr "Privé"
@@ -1087,7 +1108,7 @@ msgid "public"
msgstr "public"
#: src/components/form/SegmentedField.tsx:80
#: src/pages/PlaylistDetail.tsx:898
#: src/pages/PlaylistDetail.tsx:904
msgid "Public"
msgstr "Public"
@@ -1141,13 +1162,13 @@ msgstr "Retirer le vote"
msgid "Replace file"
msgstr "Remplacer le fichier"
#: src/components/ChatModal.tsx:315
#: src/components/ChatModal.tsx:316
#: src/components/CommentThread.tsx:361
#: src/components/ChatModal.tsx:326
#: src/components/ChatModal.tsx:327
#: src/components/CommentThread.tsx:374
msgid "Reply"
msgstr "Répondre"
#: src/components/ChatModal.tsx:1240
#: src/components/ChatModal.tsx:1256
msgid "Replying to"
msgstr "En réponse à"
@@ -1173,7 +1194,7 @@ msgstr "Réinitialiser par défaut"
msgid "Reset to the suggested title"
msgstr "Rétablir le titre suggéré"
#: src/components/ChatModal.tsx:502
#: src/components/ChatModal.tsx:519
#: src/pages/Dump.tsx:283
#: src/pages/DumpEdit.tsx:180
msgid "Retry"
@@ -1183,27 +1204,27 @@ msgstr "Réessayer"
msgid "Role"
msgstr "Rôle"
#: src/components/ChatModal.tsx:221
#: src/components/CommentThread.tsx:333
#: src/components/ChatModal.tsx:232
#: src/components/CommentThread.tsx:346
#: src/pages/Dump.tsx:394
#: src/pages/DumpEdit.tsx:487
#: src/pages/PlaylistDetail.tsx:930
#: src/pages/DumpEdit.tsx:486
#: src/pages/PlaylistDetail.tsx:936
#: src/pages/UserPublicProfile.tsx:1756
#: src/pages/UserPublicProfile.tsx:1829
#: src/pages/UserPublicProfile.tsx:1830
msgid "Save"
msgstr "Enregistrer"
#: src/components/ChangePasswordModal.tsx:103
#: src/components/CommentThread.tsx:334
#: src/components/CommentThread.tsx:347
#: src/pages/Dump.tsx:393
#: src/pages/PlaylistDetail.tsx:926
#: src/pages/PlaylistDetail.tsx:932
#: src/pages/ResetPassword.tsx:126
#: src/pages/UserPublicProfile.tsx:1753
#: src/pages/UserPublicProfile.tsx:1826
#: src/pages/UserPublicProfile.tsx:1827
msgid "Saving…"
msgstr "Enregistrement…"
#: src/components/AppHeader.tsx:139
#: src/components/AppHeader.tsx:127
#: src/components/SearchBar.tsx:73
#: src/pages/Search.tsx:50
msgid "Search"
@@ -1213,6 +1234,14 @@ msgstr "Rechercher"
msgid "Search dumps, users, playlists…"
msgstr "Rechercher des recos, utilisateurs, collections…"
#: src/components/TextEditor.tsx:612
msgid "Search emoji"
msgstr "Rechercher un emoji"
#: src/components/TextEditor.tsx:611
msgid "Search emoji…"
msgstr "Rechercher un emoji…"
#: src/pages/Search.tsx:218
msgid "Search failed"
msgstr "Recherche échouée"
@@ -1221,7 +1250,7 @@ msgstr "Recherche échouée"
msgid "Searching…"
msgstr "Recherche…"
#: src/components/ChatModal.tsx:1280
#: src/components/ChatModal.tsx:1296
msgid "Send"
msgstr "Envoyer"
@@ -1229,12 +1258,12 @@ msgstr "Envoyer"
msgid "Send reset link"
msgstr "Envoyer le lien de réinitialisation"
#: src/components/ChatModal.tsx:520
#: src/components/ChatModal.tsx:537
#: src/pages/UserLogin.tsx:142
msgid "Sending…"
msgstr "Envoi…"
#: src/components/AppHeader.tsx:176
#: src/components/AppHeader.tsx:164
msgid "Server unreachable"
msgstr "Serveur inaccessible"
@@ -1247,7 +1276,7 @@ msgstr "Définir un nouveau mot de passe"
msgid "Settings"
msgstr "Paramètres"
#: src/components/ChatModal.tsx:1064
#: src/components/ChatModal.tsx:1080
msgid "Several people are typing…"
msgstr "Plusieurs personnes écrivent…"
@@ -1259,6 +1288,10 @@ msgstr "Usage unique, valable 7 jours."
msgid "slug"
msgstr "identifiant"
#: src/components/TextEditor.tsx:297
msgid "Some images couldn't be uploaded."
msgstr "Certaines images n'ont pas pu être envoyées."
#: src/components/form/FormError.tsx:15
#: src/components/PageError.tsx:13
#: src/contexts/WSProvider.tsx:663
@@ -1285,6 +1318,10 @@ msgstr "Lancer la recherche"
msgid "the chat"
msgstr "la tribune"
#: src/components/TextEditor.tsx:298
msgid "The image couldn't be uploaded."
msgstr "L'image n'a pas pu être envoyée."
#: src/pages/UserRegister.tsx:97
msgid "This invite link is missing, expired, or already used."
msgstr "Ce lien d'invitation est manquant, expiré ou déjà utilisé."
@@ -1315,7 +1352,7 @@ msgstr "Titre"
msgid "Title is required."
msgstr "Un titre est requis."
#: src/components/ChatModal.tsx:133
#: src/components/ChatModal.tsx:147
#: src/pages/Notifications.tsx:398
msgid "Today"
msgstr "Aujourd'hui"
@@ -1325,11 +1362,11 @@ msgstr "Aujourd'hui"
msgid "Track {0}"
msgstr "Piste {0}"
#: src/components/ChatModal.tsx:1081
#: src/components/ChatModal.tsx:1097
msgid "Try again"
msgstr "Réessayer"
#: src/components/ChatModal.tsx:1265
#: src/components/ChatModal.tsx:1281
msgid "Type a message…"
msgstr "Écrivez un message…"
@@ -1354,7 +1391,11 @@ msgstr "Envoi échoué"
msgid "Uploading {0}%"
msgstr "Envoi {0} %"
#: src/components/DumpCreateModal.tsx:962
#: src/components/TextEditor.tsx:526
msgid "Uploading image…"
msgstr "Envoi de l'image…"
#: src/components/DumpCreateModal.tsx:961
msgid "Uploading…"
msgstr "Envoi…"
@@ -1423,11 +1464,11 @@ msgid "What makes it worth it?"
msgstr "Pourquoi on en voudrait ?"
#: src/pages/UserPublicProfile.tsx:925
#: src/pages/UserPublicProfile.tsx:1818
#: src/pages/UserPublicProfile.tsx:1819
msgid "Who am I?"
msgstr "Qui suis-je ?"
#: src/components/DumpCreateModal.tsx:928
#: src/components/DumpCreateModal.tsx:927
msgid "Who can see it"
msgstr "Qui peut la voir"
@@ -1440,11 +1481,11 @@ msgstr "Pourquoi & où"
msgid "Why?"
msgstr "Pourquoi ?"
#: src/components/CommentThread.tsx:405
#: src/components/CommentThread.tsx:418
msgid "Write a reply…"
msgstr "Écrire une réponse…"
#: src/components/ChatModal.tsx:136
#: src/components/ChatModal.tsx:150
#: src/pages/Notifications.tsx:400
msgid "Yesterday"
msgstr "Hier"

View File

@@ -459,7 +459,6 @@ function DumpEditForm({
placeholder={t`What makes it worth it?`}
rows={3}
maxLength={VALIDATION.DUMP_COMMENT_MAX}
rules={{ maxLength: VALIDATION.DUMP_COMMENT_MAX }}
/>
<CategorySelect

View File

@@ -794,6 +794,7 @@ function PlaylistEditForm(
},
});
const description = form.watch("description");
const title = form.watch("title");
const onSubmit = form.submit(
async ({ title, description, isPublic, image }) => {
@@ -862,6 +863,9 @@ function PlaylistEditForm(
className="playlist-edit-input"
value={field.value}
onChange={field.onChange}
onKeyDown={(e) => {
if (e.key === "Escape") onCancel();
}}
autoFocus
placeholder={t`Playlist title`}
maxLength={VALIDATION.PLAYLIST_TITLE_MAX}
@@ -877,6 +881,8 @@ function PlaylistEditForm(
className="playlist-edit-textarea"
value={field.value}
onChange={field.onChange}
onSubmit={() => void onSubmit()}
onCancel={onCancel}
placeholder={t`Description (optional)`}
autoResize
rows={2}
@@ -924,8 +930,8 @@ function PlaylistEditForm(
</button>
<SubmitButton
pendingLabel={<Trans>Saving…</Trans>}
disabled={description.length >
VALIDATION.PLAYLIST_DESCRIPTION_MAX}
disabled={!title.trim() ||
description.length > VALIDATION.PLAYLIST_DESCRIPTION_MAX}
>
<Trans>Save</Trans>
</SubmitButton>

View File

@@ -1815,6 +1815,7 @@ function DescriptionEditor(
value={field.value}
onChange={field.onChange}
onSubmit={() => void onSubmit()}
onCancel={submitting ? undefined : onCancel}
placeholder={t`Who am I?`}
autoResize
maxLength={VALIDATION.USER_DESCRIPTION_MAX}

View File

@@ -94,6 +94,8 @@
[data-style="brutalist"] .vote-btn,
[data-style="brutalist"] .emoji-picker-float [frimousse-emoji],
[data-style="brutalist"] .emoji-picker-close-btn,
[data-style="brutalist"] .text-editor-emoji-btn,
[data-style="brutalist"] .emoji-picker-float [frimousse-skin-tone-selector],
[data-style="brutalist"] .new-item-toggle,
[data-style="brutalist"] .btn--ghost,
[data-style="brutalist"] .audio-player-btn,
@@ -128,6 +130,8 @@
[data-style="brutalist"] .vote-btn:hover:not(:disabled),
[data-style="brutalist"] .emoji-picker-float [frimousse-emoji]:hover,
[data-style="brutalist"] .emoji-picker-close-btn:hover,
[data-style="brutalist"] .text-editor-emoji-btn:hover:not(:disabled),
[data-style="brutalist"] .emoji-picker-float [frimousse-skin-tone-selector]:hover,
[data-style="brutalist"] .new-item-toggle:hover:not(:disabled),
[data-style="brutalist"] .btn--ghost:hover:not(:disabled),
[data-style="brutalist"] .audio-player-btn:hover:not(:disabled),

View File

@@ -189,12 +189,6 @@
background: var(--color-surface);
}
/* Ghost / icon-only buttons stay flat */
[data-style="geocities"] .modal-close-btn,
[data-style="geocities"] .playlist-remove-btn,
[data-style="geocities"] .playlist-card-delete-btn,
[data-style="geocities"] .emoji-picker-float [frimousse-emoji],
[data-style="geocities"] .emoji-picker-close-btn,
/* Turn the tint up and give it hard diagonal bands — a flat wash reads as an
unstyled gap in a theme built on saturation. */
[data-style="geocities"] {
@@ -209,6 +203,14 @@
);
}
/* Ghost / icon-only buttons stay flat */
[data-style="geocities"] .modal-close-btn,
[data-style="geocities"] .playlist-remove-btn,
[data-style="geocities"] .playlist-card-delete-btn,
[data-style="geocities"] .emoji-picker-float [frimousse-emoji],
[data-style="geocities"] .emoji-picker-close-btn,
[data-style="geocities"] .text-editor-emoji-btn,
[data-style="geocities"] .emoji-picker-float [frimousse-skin-tone-selector],
[data-style="geocities"] .btn--ghost,
[data-style="geocities"] .new-item-toggle,
[data-style="geocities"] .audio-player-btn,

View File

@@ -3,3 +3,9 @@ export function charCountClass(len: number, max: number): string {
if (len >= max * 0.85) return " text-editor-count--warn";
return "";
}
/** Whether `value` is past `max` — the counter's danger state past its
* edge, and what the server would refuse. */
export function overLimit(value: string, max: number): boolean {
return value.length > max;
}

33
src/utils/editorText.ts Normal file
View File

@@ -0,0 +1,33 @@
// Text helpers shared by the editor's autocompletes (mentions, emoji).
/**
* Whether the caret at the end of `textBefore` sits inside markdown code — an
* open ``` fence, or an unclosed `span` on the current line — where `@bob`
* and `:smile` are literal text, not something to complete.
*/
export function insideCode(textBefore: string): boolean {
const fences = textBefore.match(/^\s{0,3}```/gm)?.length ?? 0;
if (fences % 2 === 1) return true;
const line = textBefore.slice(textBefore.lastIndexOf("\n") + 1);
if (/^\s{0,3}```/.test(line)) return false;
return (line.match(/`/g)?.length ?? 0) % 2 === 1;
}
const EMOJI_ONLY_RE =
/^(?:\p{Extended_Pictographic}|\p{Regional_Indicator}|[\u{1F3FB}-\u{1F3FF}#*0-9]|\u200D|\uFE0F|\u20E3|\s)+$/u;
const PICTOGRAPH_RE = /\p{Extended_Pictographic}|\p{Regional_Indicator}/u;
/** Whether `text` is nothing but a few emoji (at most `max`), for the
* jumbo rendering chats give a message like "🎉" or "👍👍". */
export function isEmojiOnly(text: string, max = 3): boolean {
const trimmed = text.trim();
if (!trimmed || !EMOJI_ONLY_RE.test(trimmed)) return false;
if (!PICTOGRAPH_RE.test(trimmed)) return false; // "123", "#"
const segments = [
...new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment(
trimmed.replace(/\s+/g, ""),
),
];
return segments.length <= max &&
segments.every((s) => PICTOGRAPH_RE.test(s.segment));
}