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:
2026-09-20 01:11:04 +03:00
parent b01eb1f34c
commit 33dc1fec96
19 changed files with 1738 additions and 82 deletions
+89 -7
View File
@@ -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>
);
})}