feat(social): друзья, личные беседы, присутствие, аватары и часовой пояс
По запросу пользователя (вне очереди AGENT.md §13): - главного сервера как точки входа больше нет: новичок начинает с пустым списком серверов, вход — только по приглашению или созданием своего; - друзья: поиск по логину с экранированием LIKE, заявки (POST /users/@me/relationships), принятие, удаление/отклонение, списки friends/ incoming/outgoing/blocked; - личные беседы: POST /users/@me/channels (идемпотентно), GET /users/@me/channels со собеседником, статусом и последним сообщением; сообщения в DM работают через общие ручки комнат, доступ — только участникам (посторонний получает 404, события в Gateway тоже фильтруются); - присутствие: last_seen_at обновляется при активности, «невидимка» и простой дольше двух минут выглядят как офлайн, смена статуса рассылает PRESENCE_UPDATE друзьям; - READY отдаёт dm_channels (собеседник, аватар, статус, последнее сообщение); - профиль: timezone (по умолчанию Europe/Moscow) в PATCH /users/@me, загрузка аватара POST /users/@me/avatar (проверка, что это изображение, в том числе по содержимому) и удаление DELETE /users/@me/avatar; старый файл удаляется с диска; - тесты: заявки в друзья, личные беседы и их изоляция, «невидимка», часовой пояс и аватар, PRESENCE_UPDATE другу, отсутствие событий DM у постороннего.
This commit is contained in:
@@ -1,12 +1,19 @@
|
||||
import { Fragment, type ReactNode } from 'react';
|
||||
import { Fragment, useMemo, useState, type ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
|
||||
/**
|
||||
* Лёгкий безопасный рендер разметки сообщения.
|
||||
*
|
||||
* `dangerouslySetInnerHTML` не используется: текст разбирается в React-узлы,
|
||||
* поэтому HTML из сообщения остаётся текстом. Поддерживается: `**жирный**`,
|
||||
* `*курсив*`, `` `код` ``, ```блок кода```, `~~зачёркнутый~~`, `> цитата`,
|
||||
* ссылки `http(s)://…` и упоминания `<@id>`.
|
||||
* `__жирный__`, `*курсив*`, `_курсив_`, `***жирный курсив***`, `` `код` ``,
|
||||
* ```блок кода```, `~~зачёркнутый~~`, `==выделение==`, `||спойлер||`,
|
||||
* `> цитата`, ссылки `http(s)://…`, упоминания `<@id>` и `#канал`.
|
||||
*
|
||||
* Незакрытые маркеры остаются обычным текстом: шаблон требует закрывающую
|
||||
* пару, поэтому `==фыв` и `**фыв` рендерятся как есть.
|
||||
*/
|
||||
|
||||
export interface MessageContentProps {
|
||||
@@ -16,8 +23,13 @@ export interface MessageContentProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Порядок альтернатив важен: сначала более длинные маркеры (`***` раньше `**`
|
||||
* и `*`), ссылки — раньше `#канала`, чтобы `https://x/#якорь` целиком остался
|
||||
* ссылкой. Подчёркивания внутри слова (`foo_bar_baz`) курсивом не выделяются.
|
||||
*/
|
||||
const INLINE_PATTERN =
|
||||
/(`[^`\n]+`)|(\*\*[^*\n]+\*\*)|(\*[^*\n]+\*)|(~~[^~\n]+~~)|(<@\d+>)|(https?:\/\/[^\s<>()]+)/gu;
|
||||
/(`[^`\n]+`)|(\*\*\*[^*\n]+\*\*\*)|(\*\*[^*\n]+\*\*)|(__[^_\n]+__)|(~~[^~\n]+~~)|(\|\|[^|\n]+\|\|)|(\*[^*\n]+\*)|((?<![\p{L}\p{N}_])_[^_\n]+_(?![\p{L}\p{N}_]))|(==[^=\n]+==)|(<@\d+>)|(https?:\/\/[^\s<>()]+)|(#[\p{L}\p{N}_-]+)/gu;
|
||||
|
||||
/** Ссылка: обрезаем хвостовую пунктуацию, которая не частью адреса. */
|
||||
function splitLink(raw: string): { href: string; trailing: string } {
|
||||
@@ -32,9 +44,33 @@ function splitLink(raw: string): { href: string; trailing: string } {
|
||||
|
||||
type MentionResolver = MessageContentProps['resolveMention'];
|
||||
|
||||
/**
|
||||
* Спойлер: текст скрыт до клика. Это кнопка, поэтому состояние живёт внутри
|
||||
* узла, а сам текст остаётся в DOM (скринридеру он тоже доступен).
|
||||
*/
|
||||
function Spoiler({ children }: { children: ReactNode }) {
|
||||
const { t } = useTranslation();
|
||||
const [revealed, setRevealed] = useState(false);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={revealed}
|
||||
aria-label={t(revealed ? 'chat.spoiler.hide' : 'chat.spoiler.show')}
|
||||
onClick={() => setRevealed((value) => !value)}
|
||||
className={`rounded-[var(--radius-sm)] px-0.5 text-left transition-colors duration-[var(--duration-fast)] ${
|
||||
revealed ? 'bg-surface-3/60' : 'bg-fg-muted/40 text-transparent hover:bg-fg-muted/50'
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function renderInline(
|
||||
text: string,
|
||||
resolveMention: MentionResolver,
|
||||
channels: ReadonlySet<string>,
|
||||
keyPrefix: string,
|
||||
): ReactNode[] {
|
||||
const nodes: ReactNode[] = [];
|
||||
@@ -59,20 +95,42 @@ function renderInline(
|
||||
{token.slice(1, -1)}
|
||||
</code>,
|
||||
);
|
||||
} else if (token.startsWith('***')) {
|
||||
nodes.push(
|
||||
<strong key={key} className="font-semibold">
|
||||
<em>{token.slice(3, -3)}</em>
|
||||
</strong>,
|
||||
);
|
||||
} else if (token.startsWith('**')) {
|
||||
nodes.push(
|
||||
<strong key={key} className="font-semibold">
|
||||
{token.slice(2, -2)}
|
||||
</strong>,
|
||||
);
|
||||
} else if (token.startsWith('__')) {
|
||||
nodes.push(
|
||||
<strong key={key} className="font-semibold">
|
||||
{token.slice(2, -2)}
|
||||
</strong>,
|
||||
);
|
||||
} else if (token.startsWith('~~')) {
|
||||
nodes.push(
|
||||
<s key={key} className="opacity-70">
|
||||
{token.slice(2, -2)}
|
||||
</s>,
|
||||
);
|
||||
} else if (token.startsWith('||')) {
|
||||
nodes.push(<Spoiler key={key}>{token.slice(2, -2)}</Spoiler>);
|
||||
} else if (token.startsWith('*')) {
|
||||
nodes.push(<em key={key}>{token.slice(1, -1)}</em>);
|
||||
} else if (token.startsWith('_')) {
|
||||
nodes.push(<em key={key}>{token.slice(1, -1)}</em>);
|
||||
} else if (token.startsWith('==')) {
|
||||
nodes.push(
|
||||
<mark key={key} className="rounded-[var(--radius-sm)] bg-accent/30 px-0.5 text-fg">
|
||||
{token.slice(2, -2)}
|
||||
</mark>,
|
||||
);
|
||||
} else if (token.startsWith('<@')) {
|
||||
const userId = token.slice(2, -1);
|
||||
const mention = resolveMention?.(userId) ?? null;
|
||||
@@ -86,7 +144,7 @@ function renderInline(
|
||||
@{mention?.name ?? userId}
|
||||
</span>,
|
||||
);
|
||||
} else {
|
||||
} else if (token.startsWith('http')) {
|
||||
const { href, trailing } = splitLink(token);
|
||||
nodes.push(
|
||||
<Fragment key={key}>
|
||||
@@ -101,6 +159,19 @@ function renderInline(
|
||||
{trailing}
|
||||
</Fragment>,
|
||||
);
|
||||
} else {
|
||||
// `#канал`: подсвечиваем только известный канал, иначе оставляем текстом.
|
||||
const name = token.slice(1);
|
||||
const known = channels.has(name.toLowerCase());
|
||||
nodes.push(
|
||||
known ? (
|
||||
<span key={key} className="rounded-[var(--radius-sm)] bg-accent/15 px-1 text-accent">
|
||||
{token}
|
||||
</span>
|
||||
) : (
|
||||
token
|
||||
),
|
||||
);
|
||||
}
|
||||
lastIndex = start + token.length;
|
||||
}
|
||||
@@ -162,6 +233,17 @@ function toBlocks(content: string): Block[] {
|
||||
|
||||
export function MessageContent({ content, resolveMention, className = '' }: MessageContentProps) {
|
||||
const blocks = toBlocks(content);
|
||||
// Имена каналов снапшота: `#канал` подсвечиваем только для них.
|
||||
const guilds = useSessionStore((state) => state.guilds);
|
||||
const channels = useMemo(() => {
|
||||
const names = new Set<string>();
|
||||
for (const guild of guilds) {
|
||||
for (const channel of guild.channels) {
|
||||
names.add(channel.name.toLowerCase());
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}, [guilds]);
|
||||
|
||||
return (
|
||||
<div className={`whitespace-pre-wrap break-words ${className}`}>
|
||||
@@ -180,13 +262,13 @@ export function MessageContent({ content, resolveMention, className = '' }: Mess
|
||||
if (block.kind === 'quote') {
|
||||
return (
|
||||
<blockquote key={key} className="my-1 border-l-2 border-border pl-2 text-fg-muted">
|
||||
{renderInline(block.lines.join('\n'), resolveMention, key)}
|
||||
{renderInline(block.lines.join('\n'), resolveMention, channels, key)}
|
||||
</blockquote>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<p key={key} className="min-w-0">
|
||||
{renderInline(block.lines.join('\n'), resolveMention, key)}
|
||||
{renderInline(block.lines.join('\n'), resolveMention, channels, key)}
|
||||
</p>
|
||||
);
|
||||
})}
|
||||
|
||||
+27
-16
@@ -15,7 +15,11 @@ interface LocationState {
|
||||
from?: string;
|
||||
}
|
||||
|
||||
/** Форма входа: поле кода 2FA появляется при ответе `auth.2fa_required`. */
|
||||
/**
|
||||
* Форма входа. Поле кода 2FA показывается всегда: сервер принимает и код из
|
||||
* приложения (6 цифр), и резервный код вида `a8eh-pshp-t8st`, а при попытке
|
||||
* входа без кода отвечает `auth.2fa_required` — тогда подсказываем про код.
|
||||
*/
|
||||
export default function LoginPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
@@ -30,8 +34,11 @@ export default function LoginPage() {
|
||||
const from = (location.state as LocationState | null)?.from ?? '/app';
|
||||
|
||||
const submit = useMutation({
|
||||
mutationFn: () =>
|
||||
login(needsTotp ? { email, password, totp_code: totpCode } : { email, password }),
|
||||
mutationFn: () => {
|
||||
// Пустое поле — вход без кода: сервер сам скажет, нужен ли он.
|
||||
const code = totpCode.trim();
|
||||
return login(code === '' ? { email, password } : { email, password, totp_code: code });
|
||||
},
|
||||
onSuccess: async () => {
|
||||
// Профиль перечитываем заново: cookie уже выставлена сервером.
|
||||
await getQueryClient().invalidateQueries();
|
||||
@@ -39,6 +46,7 @@ export default function LoginPage() {
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
if (errorCode(error) === 'auth.2fa_required') {
|
||||
// Логин, пароль и уже введённый код остаются в форме.
|
||||
setNeedsTotp(true);
|
||||
}
|
||||
},
|
||||
@@ -46,6 +54,7 @@ export default function LoginPage() {
|
||||
|
||||
const onSubmit = (event: FormEvent<HTMLFormElement>): void => {
|
||||
event.preventDefault();
|
||||
setNeedsTotp(false);
|
||||
submit.mutate();
|
||||
};
|
||||
|
||||
@@ -79,22 +88,24 @@ export default function LoginPage() {
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
required
|
||||
/>
|
||||
<Field
|
||||
label={t('auth.login.totp')}
|
||||
hint={t('auth.login.totpHint')}
|
||||
name="totp_code"
|
||||
inputMode="text"
|
||||
autoComplete="one-time-code"
|
||||
maxLength={32}
|
||||
value={totpCode}
|
||||
onChange={(event) => setTotpCode(event.target.value)}
|
||||
/>
|
||||
{needsTotp ? (
|
||||
<Field
|
||||
label={t('auth.login.totp')}
|
||||
hint={t('auth.login.totpHint')}
|
||||
name="totp_code"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
pattern="[0-9]*"
|
||||
maxLength={8}
|
||||
value={totpCode}
|
||||
onChange={(event) => setTotpCode(event.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<p role="alert" className="text-sm text-danger">
|
||||
{t('auth.login.totpRequired')}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{submit.isError ? <ErrorNotice error={submit.error} /> : null}
|
||||
{/* При `auth.2fa_required` подсказка уже показана у поля — не дублируем. */}
|
||||
{submit.isError && !needsTotp ? <ErrorNotice error={submit.error} /> : null}
|
||||
|
||||
<Button type="submit" disabled={submit.isPending}>
|
||||
{t(submit.isPending ? 'auth.login.submitting' : 'auth.login.submit')}
|
||||
|
||||
@@ -148,8 +148,11 @@ export function TwoFactorSection({
|
||||
className="mt-2 grid grid-cols-2 gap-1 font-mono text-xs"
|
||||
data-testid="recovery-codes"
|
||||
>
|
||||
{/* Коды вида `a8eh-pshp-t8st` не обрезаем: переносим по символам. */}
|
||||
{recoveryCodes.map((recoveryCode) => (
|
||||
<li key={recoveryCode}>{recoveryCode}</li>
|
||||
<li key={recoveryCode} className="min-w-0 break-all">
|
||||
{recoveryCode}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="mt-3 flex items-center gap-3">
|
||||
|
||||
Reference in New Issue
Block a user