Compare commits
2 Commits
5fd1d364da
...
bf28e49c34
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf28e49c34 | ||
|
|
85d0703077 |
187
src/App.css
187
src/App.css
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" : ""
|
||||
}`}
|
||||
|
||||
@@ -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`} />
|
||||
|
||||
@@ -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(``);
|
||||
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(``);
|
||||
} 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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
33
src/utils/editorText.ts
Normal 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));
|
||||
}
|
||||
Reference in New Issue
Block a user