feat(messages): команды чата, личные сообщения и лимиты

AGENT.md 7.6:

- команды разбираются на сервере: /me (блок действия), /whisper и /wisper
  (шёпот), /scream (громкий блок), «//» экранирует слэш, неизвестная команда
  остаётся обычным текстом, пустой текст у команды — 422;
- /ls <логин> <текст> — личное сообщение: тип private, адресат в mentions,
  получатель проверяется на участие в сервере и доступ к комнате; сообщение не
  отдаётся посторонним ни в истории, ни в поиске (и FTS, и подстрочном) и
  доставляется в Gateway только автору и адресатам;
- лимиты: 10 правок в минуту, 20 уникальных реакций на сообщение, 50
  закреплений на комнату, не более 10 упоминаний, @everyone/@here только с
  MENTION_EVERYONE;
- тесты: команды и экранирование, изоляция личного сообщения (история, поиск,
  третий участник), лимит правок и @everyone.
This commit is contained in:
2026-09-20 11:32:50 +03:00
parent 4c8f888b3e
commit 65d0bafe0e
11 changed files with 1556 additions and 210 deletions
+168 -9
View File
@@ -2,6 +2,7 @@ package server
import (
"context"
"errors"
"log/slog"
"net/http"
"regexp"
@@ -114,6 +115,13 @@ func (s *Server) registerMessageRoutes(api huma.API) {
if err != nil {
return nil, err
}
// Команды чата разбираются на сервере (AGENT.md 7.6): /me, /whisper,
// /scream, /ls. Неизвестная команда остаётся обычным текстом, «//» в
// начале экранирует слэш.
command, err := s.applyCommand(ctx, channelID, user.ID, &content)
if err != nil {
return nil, err
}
if content == "" && len(attachments) == 0 {
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "message must have content or attachments")
}
@@ -125,12 +133,28 @@ func (s *Server) registerMessageRoutes(api huma.API) {
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "message is too long")
}
mentions := s.extractMentions(ctx, channelID, content)
if len(mentions) > maxMentionsPerMessage {
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "too many mentions in one message")
}
messageType := store.MessageDefault
if command != nil {
messageType = command.kind
if command.recipientID != 0 {
mentions = []uint64{command.recipientID}
}
}
if mentionsEveryone(content) && !resolved.Has(permissions.MentionEveryone) {
return nil, humaErrorStatus(http.StatusForbidden, "perm.denied", "MENTION_EVERYONE is required for @everyone")
}
params := store.CreateMessageParams{
ChannelID: channelID,
AuthorID: user.ID,
Content: content,
Type: messageType,
Attachments: attachments,
Mentions: s.extractMentions(ctx, channelID, content),
Mentions: mentions,
}
if input.Body.ReplyToID != "" {
replyTo, err := parseID("reply_to_id", input.Body.ReplyToID)
@@ -164,12 +188,22 @@ func (s *Server) registerMessageRoutes(api huma.API) {
if err := s.store.SetReadState(ctx, user.ID, channelID, message.ID, 0); err != nil {
s.logger.WarnContext(ctx, "failed to update author read state", slog.Any("error", err))
}
s.notifyMentions(ctx, channelID, user.ID, message.Mentions)
if !message.Type.IsPrivate() {
s.notifyMentions(ctx, channelID, user.ID, message.Mentions)
}
payload, err := s.messagePayload(ctx, message, user.ID)
if err != nil {
return nil, err
}
s.dispatchChannelEvent(ctx, channelID, "MESSAGE_CREATE", payload)
if message.Type.IsPrivate() {
// Личное сообщение (/ls) доставляем только автору и адресатам.
for _, target := range message.Mentions {
s.gatewaySendToUser(target, "MESSAGE_CREATE", payload)
}
s.gatewaySendToUser(user.ID, "MESSAGE_CREATE", payload)
} else {
s.dispatchChannelEvent(ctx, channelID, "MESSAGE_CREATE", payload)
}
output := &messageOutput{}
output.Body.Message = payload
return output, nil
@@ -203,7 +237,7 @@ func (s *Server) registerMessageRoutes(api huma.API) {
return nil, err
}
}
messages, err := s.store.ListMessages(ctx, channelID, beforeID, input.Limit)
messages, err := s.store.ListMessages(ctx, channelID, beforeID, user.ID, input.Limit)
if err != nil {
return nil, humaError(err)
}
@@ -240,7 +274,7 @@ func (s *Server) registerMessageRoutes(api huma.API) {
if allowed, retryAfter := s.searchLimiter.Allow("search:" + formatSnowflake(user.ID)); !allowed {
return nil, rateLimitedError(retryAfter)
}
messages, err := s.store.SearchMessages(ctx, []uint64{channelID}, input.Query, input.Limit)
messages, err := s.store.SearchMessages(ctx, []uint64{channelID}, user.ID, input.Query, input.Limit)
if err != nil {
return nil, humaError(err)
}
@@ -287,6 +321,12 @@ func (s *Server) registerMessageRoutes(api huma.API) {
if content == "" && len(message.Attachments) == 0 {
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "message must have content or attachments")
}
// Правки ограничены: 10 в минуту (AGENT.md 7.6).
if !user.IsInstanceAdmin {
if allowed, retryAfter := s.editLimiter.Allow("edit:" + formatSnowflake(user.ID)); !allowed {
return nil, rateLimitedError(retryAfter)
}
}
updated, err := s.store.UpdateMessageContent(ctx, message.ID, content)
if err != nil {
return nil, humaError(err)
@@ -506,6 +546,94 @@ func (s *Server) registerMessageRoutes(api huma.API) {
})
}
// Лимиты сообщений (AGENT.md 7.6).
const (
maxMentionsPerMessage = 10
maxReactionsPerMessage = 20
maxPinsPerChannel = 50
)
// chatCommand — разобранная команда чата.
type chatCommand struct {
kind store.MessageType
recipientID uint64
}
// applyCommand разбирает команду в начале сообщения (AGENT.md 7.6):
// /me, /whisper (/wisper), /scream и /ls (личное сообщение).
func (s *Server) applyCommand(ctx context.Context, channelID, authorID uint64, content *string) (*chatCommand, error) {
text := strings.TrimSpace(*content)
if strings.HasPrefix(text, "//") {
// Экранирование: «//текст» превращается в «/текст».
*content = strings.TrimPrefix(text, "/")
return nil, nil
}
if !strings.HasPrefix(text, "/") {
return nil, nil
}
name, rest, _ := strings.Cut(strings.TrimPrefix(text, "/"), " ")
rest = strings.TrimSpace(rest)
switch strings.ToLower(name) {
case "me":
if rest == "" {
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "command /me requires text")
}
*content = rest
return &chatCommand{kind: store.MessageAction}, nil
case "whisper", "wisper":
if rest == "" {
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "command /whisper requires text")
}
*content = rest
return &chatCommand{kind: store.MessageWhisper}, nil
case "scream":
if rest == "" {
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "command /scream requires text")
}
*content = rest
return &chatCommand{kind: store.MessageScream}, nil
case "ls":
login, body, _ := strings.Cut(rest, " ")
login = strings.TrimPrefix(strings.TrimSpace(login), "@")
body = strings.TrimSpace(body)
if login == "" || body == "" {
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "command /ls requires a username and text")
}
recipient, err := s.store.GetUserByUsername(ctx, login)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
return nil, humaErrorStatus(http.StatusNotFound, "user.not_found", "получатель не найден")
}
return nil, humaError(err)
}
if recipient.ID == authorID {
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "cannot send a private message to yourself")
}
// Получатель должен быть участником сервера и видеть комнату.
if _, _, _, err := s.requireChannelPermission(ctx, formatSnowflake(channelID), recipient, permissions.ViewChannel); err != nil {
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "получатель не имеет доступа к комнате")
}
*content = body
return &chatCommand{kind: store.MessagePrivate, recipientID: recipient.ID}, nil
default:
// Неизвестная команда — обычный текст.
return nil, nil
}
}
// mentionsEveryone ищет @everyone/@here в тексте.
func mentionsEveryone(content string) bool {
lowered := strings.ToLower(content)
return strings.Contains(lowered, "@everyone") || strings.Contains(lowered, "@here")
}
// gatewaySendToUser отправляет событие пользователю, если Gateway доступен.
func (s *Server) gatewaySendToUser(userID uint64, event string, payload any) {
if s.gateway != nil {
s.gateway.SendToUser(userID, event, payload)
}
}
// notifyMentions увеличивает счётчики упоминаний и уведомляет устройства
// упомянутых пользователей (AGENT.md 7.16).
func (s *Server) notifyMentions(ctx context.Context, channelID, authorID uint64, mentions []uint64) {
@@ -626,13 +754,34 @@ func (s *Server) changeReaction(ctx context.Context, rawChannelID, rawMessageID,
if err != nil {
return nil, err
}
var reactionErr error
if add {
err = s.store.AddReaction(ctx, message.ID, user.ID, emoji)
// Не больше 20 уникальных реакций на сообщение (AGENT.md 7.6).
unique, err := s.store.CountReactions(ctx, message.ID)
if err != nil {
return nil, humaError(err)
}
if unique >= maxReactionsPerMessage {
existing, err := s.store.ListReactions(ctx, message.ID, user.ID)
if err != nil {
return nil, humaError(err)
}
known := false
for _, reaction := range existing {
if reaction.Emoji == emoji {
known = true
}
}
if !known {
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "reaction limit reached for this message")
}
}
reactionErr = s.store.AddReaction(ctx, message.ID, user.ID, emoji)
} else {
err = s.store.RemoveReaction(ctx, message.ID, user.ID, emoji)
reactionErr = s.store.RemoveReaction(ctx, message.ID, user.ID, emoji)
}
if err != nil {
return nil, humaError(err)
if reactionErr != nil {
return nil, humaError(reactionErr)
}
event := "MESSAGE_REACTION_REMOVE"
if add {
@@ -661,6 +810,16 @@ func (s *Server) changePin(ctx context.Context, rawChannelID, rawMessageID strin
if err != nil {
return nil, err
}
if pinned {
// Не больше 50 закреплений на комнату (AGENT.md 7.6).
pins, err := s.store.CountPinnedMessages(ctx, channelID)
if err != nil {
return nil, humaError(err)
}
if pins >= maxPinsPerChannel {
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "pin limit reached for this channel")
}
}
if err := s.store.SetMessagePinned(ctx, message.ID, pinned); err != nil {
return nil, humaError(err)
}
+160
View File
@@ -8,6 +8,7 @@ import (
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"testing"
"glchat/internal/permissions"
@@ -622,3 +623,162 @@ func TestFuzzySearch(t *testing.T) {
t.Fatalf("поиск «другое» вернул %d результатов: %v", len(results), results)
}
}
// TestChatCommands проверяет команды /me, /whisper, /scream и экранирование.
func TestChatCommands(t *testing.T) {
f := newMessagingFixture(t)
cases := []struct {
input string
want string
kind string
}{
{`{"content":"/me танцует"}`, "танцует", "action"},
{`{"content":"/whisper тихо"}`, "тихо", "whisper"},
{`{"content":"/scream ГРОМКО"}`, "ГРОМКО", "scream"},
{`{"content":"/wisper тихо"}`, "тихо", "whisper"},
{`{"content":"/unknown команда"}`, "/unknown команда", "default"},
{`{"content":"//не команда"}`, "/не команда", "default"},
}
for _, tc := range cases {
rec := doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+f.openChannel+"/messages",
tc.input, f.ownerCookie)
if rec.Code != http.StatusOK {
t.Fatalf("%s → %d, body = %s", tc.input, rec.Code, rec.Body.String())
}
message := decodeResponse[struct {
Message struct {
Content string `json:"content"`
Type string `json:"type"`
} `json:"message"`
}](t, rec)
if message.Message.Content != tc.want || message.Message.Type != tc.kind {
t.Fatalf("%s → content=%q type=%q, ожидалось %q/%q",
tc.input, message.Message.Content, message.Message.Type, tc.want, tc.kind)
}
}
// Команда без текста — ошибка.
empty := doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+f.openChannel+"/messages",
`{"content":"/me"}`, f.ownerCookie)
if empty.Code != http.StatusUnprocessableEntity {
t.Fatalf("/me без текста = %d, want 422", empty.Code)
}
}
// TestPrivateMessageVisibility проверяет /ls: сообщение видят только автор и адресат.
func TestPrivateMessageVisibility(t *testing.T) {
f := newMessagingFixture(t)
memberLogin := "msg_member"
sent := doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+f.openChannel+"/messages",
`{"content":"/ls `+memberLogin+` секретное сообщение"}`, f.ownerCookie)
if sent.Code != http.StatusOK {
t.Fatalf("private message = %d, body = %s", sent.Code, sent.Body.String())
}
message := decodeResponse[struct {
Message struct {
ID string `json:"id"`
Content string `json:"content"`
Type string `json:"type"`
Mentions []string `json:"mentions"`
} `json:"message"`
}](t, sent)
if message.Message.Type != "private" || message.Message.Content != "секретное сообщение" {
t.Fatalf("private message payload = %+v", message.Message)
}
if len(message.Message.Mentions) != 1 || message.Message.Mentions[0] != f.memberID {
t.Fatalf("получатель = %+v", message.Message.Mentions)
}
// Автор и адресат видят сообщение в истории.
ownerHistory := doJSON(t, f.srv, http.MethodGet, "/api/v1/channels/"+f.openChannel+"/messages", "", f.ownerCookie)
memberHistory := doJSON(t, f.srv, http.MethodGet, "/api/v1/channels/"+f.openChannel+"/messages", "", f.memberCookie)
for name, rec := range map[string]*httptest.ResponseRecorder{"автор": ownerHistory, "адресат": memberHistory} {
list := decodeResponse[struct {
Messages []struct {
ID string `json:"id"`
} `json:"messages"`
}](t, rec)
found := false
for _, item := range list.Messages {
if item.ID == message.Message.ID {
found = true
}
}
if !found {
t.Fatalf("%s не видит личное сообщение", name)
}
}
// Третий участник не видит ни в истории, ни в поиске.
thirdCookie := registerAndLogin(t, f.srv, "msg_third", "msg-third@example.com")
doJSON(t, f.srv, http.MethodPost, "/api/v1/guilds/"+f.guildID+"/join", "", thirdCookie)
thirdHistory := doJSON(t, f.srv, http.MethodGet, "/api/v1/channels/"+f.openChannel+"/messages", "", thirdCookie)
list := decodeResponse[struct {
Messages []struct {
ID string `json:"id"`
} `json:"messages"`
}](t, thirdHistory)
for _, item := range list.Messages {
if item.ID == message.Message.ID {
t.Fatal("посторонний увидел личное сообщение в истории")
}
}
thirdSearch := doJSON(t, f.srv, http.MethodGet,
"/api/v1/channels/"+f.openChannel+"/messages/search?q="+url.QueryEscape("секретное"), "", thirdCookie)
results := decodeResponse[struct {
Messages []struct {
ID string `json:"id"`
} `json:"messages"`
}](t, thirdSearch)
if len(results.Messages) != 0 {
leaked, _ := f.srv.store.GetMessage(t.Context(), guildIDOf(t, results.Messages[0].ID))
t.Fatalf("поиск отдал личное сообщение постороннему: id=%s type=%s author=%v mentions=%v (ожидалось private id=%s)",
results.Messages[0].ID, leaked.Type, leaked.AuthorID, leaked.Mentions, message.Message.ID)
}
// Несуществующий получатель — понятная ошибка.
bad := doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+f.openChannel+"/messages",
`{"content":"/ls nobody_here текст"}`, f.ownerCookie)
if bad.Code != http.StatusNotFound {
t.Fatalf("/ls с неизвестным логином = %d, want 404", bad.Code)
}
}
// TestMessageLimits проверяет лимиты пинов и правок (AGENT.md 7.6).
func TestMessageLimits(t *testing.T) {
f := newMessagingFixture(t)
created := doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+f.openChannel+"/messages",
`{"content":"для правок"}`, f.memberCookie)
message := decodeResponse[struct {
Message struct {
ID string `json:"id"`
} `json:"message"`
}](t, created)
// 10 правок в минуту: одиннадцатая — 429.
var last *httptest.ResponseRecorder
for i := range 11 {
last = doJSON(t, f.srv, http.MethodPatch,
"/api/v1/channels/"+f.openChannel+"/messages/"+message.Message.ID,
`{"content":"правка `+strconv.Itoa(i)+`"}`, f.memberCookie)
}
if last.Code != http.StatusTooManyRequests {
t.Fatalf("одиннадцатая правка = %d, want 429", last.Code)
}
// @everyone без MENTION_EVERYONE запрещён у участника с ролью по умолчанию.
everyone := doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+f.openChannel+"/messages",
`{"content":"@everyone привет"}`, f.memberCookie)
if everyone.Code != http.StatusForbidden {
t.Fatalf("@everyone без права = %d, want 403", everyone.Code)
}
// Владелец (ADMINISTRATOR) может.
ownerEveryone := doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+f.openChannel+"/messages",
`{"content":"@everyone привет"}`, f.ownerCookie)
if ownerEveryone.Code != http.StatusOK {
t.Fatalf("@everyone владельцем = %d, body = %s", ownerEveryone.Code, ownerEveryone.Body.String())
}
}
+3
View File
@@ -51,6 +51,8 @@ type Server struct {
messageLimiter *httpx.RateLimiter
typingLimiter *httpx.RateLimiter
searchLimiter *httpx.RateLimiter
// editLimiter — 10 правок сообщений в минуту (AGENT.md 7.6).
editLimiter *httpx.RateLimiter
// inviteLimiter — 10 приглашений в сутки на пользователя (AGENT.md 8.6).
inviteLimiter *httpx.RateLimiter
// slowmode — время последней отправки в комнату для режима медленной
@@ -83,6 +85,7 @@ func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Se
messageLimiter: httpx.NewRateLimiter(60, 10),
typingLimiter: httpx.NewRateLimiter(20, 1),
searchLimiter: httpx.NewRateLimiter(10, 10),
editLimiter: httpx.NewRateLimiter(10, 10),
inviteLimiter: httpx.NewRateLimiterWindow(10, 24*time.Hour, 10),
slowmode: map[string]time.Time{},
presence: map[uint64]time.Time{},
+54 -11
View File
@@ -15,8 +15,17 @@ const (
MessageDefault MessageType = "default"
MessageSystem MessageType = "system"
MessageWhisper MessageType = "whisper"
// MessageAction — блок действия (/me), MessageScream — громкий блок,
// MessagePrivate — личное сообщение (/ls): видно только автору и адресатам
// (AGENT.md 7.6).
MessageAction MessageType = "action"
MessageScream MessageType = "scream"
MessagePrivate MessageType = "private"
)
// IsPrivate сообщает, что сообщение видно только автору и адресатам.
func (m MessageType) IsPrivate() bool { return m == MessagePrivate }
// Message — сообщение комнаты.
type Message struct {
ID uint64
@@ -110,8 +119,9 @@ func (s *Store) GetMessage(ctx context.Context, id uint64) (*Message, error) {
}
// ListMessages возвращает сообщения комнаты от новых к старым. beforeID
// используется для подгрузки истории вверх (AGENT.md 7.6).
func (s *Store) ListMessages(ctx context.Context, channelID, beforeID uint64, limit int) ([]Message, error) {
// используется для подгрузки истории вверх, viewerID — чтобы не отдавать
// личные сообщения (/ls) посторонним (AGENT.md 7.6).
func (s *Store) ListMessages(ctx context.Context, channelID, beforeID, viewerID uint64, limit int) ([]Message, error) {
if limit <= 0 || limit > 100 {
limit = 50
}
@@ -121,6 +131,8 @@ func (s *Store) ListMessages(ctx context.Context, channelID, beforeID uint64, li
query += ` AND id < ?`
args = append(args, int64(beforeID))
}
query += ` AND (` + privateVisibilityClause + `)`
args = append(args, int64(viewerID), int64(viewerID))
query += ` ORDER BY id DESC LIMIT ?`
args = append(args, limit)
@@ -141,6 +153,12 @@ func (s *Store) ListMessages(ctx context.Context, channelID, beforeID uint64, li
return messages, rows.Err()
}
// privateVisibilityClause пропускает обычные сообщения и личные, если зритель
// автор или адресат (адресаты хранятся в mentions_json).
const privateVisibilityClause = `type <> 'private'
OR author_id = ?
OR EXISTS (SELECT 1 FROM json_each(messages.mentions_json) WHERE json_each.value = ?)`
// ListPinnedMessages возвращает закреплённые сообщения комнаты.
func (s *Store) ListPinnedMessages(ctx context.Context, channelID uint64, limit int) ([]Message, error) {
if limit <= 0 || limit > 100 {
@@ -323,7 +341,7 @@ func (s *Store) ListReadStates(ctx context.Context, userID uint64) ([]ReadState,
// (AGENT.md 7.15: права проверяет вызывающий код, хранилище ограничивает выборку).
// Поиск нечёткий: FTS5 находит слова по началу («пр» → «Привет»), а подстрочные
// совпадения добираются по нормализованной копии текста без учёта регистра.
func (s *Store) SearchMessages(ctx context.Context, channelIDs []uint64, query string, limit int) ([]Message, error) {
func (s *Store) SearchMessages(ctx context.Context, channelIDs []uint64, viewerID uint64, query string, limit int) ([]Message, error) {
query = strings.TrimSpace(query)
if len(channelIDs) == 0 || query == "" {
return []Message{}, nil
@@ -336,7 +354,7 @@ func (s *Store) SearchMessages(ctx context.Context, channelIDs []uint64, query s
seen := map[uint64]bool{}
if ftsQuery := toFTSPrefixQuery(query); ftsQuery != "" {
prefixMatches, err := s.searchMessagesFTS(ctx, channelIDs, ftsQuery, limit)
prefixMatches, err := s.searchMessagesFTS(ctx, channelIDs, viewerID, ftsQuery, limit)
if err != nil {
return nil, err
}
@@ -349,7 +367,7 @@ func (s *Store) SearchMessages(ctx context.Context, channelIDs []uint64, query s
}
if len(found) < limit {
substringMatches, err := s.searchMessagesSubstring(ctx, channelIDs, strings.ToLower(query), limit-len(found))
substringMatches, err := s.searchMessagesSubstring(ctx, channelIDs, viewerID, strings.ToLower(query), limit-len(found))
if err != nil {
return nil, err
}
@@ -393,20 +411,21 @@ func toFTSPrefixQuery(input string) string {
}
// searchMessagesFTS ищет по индексу FTS5 (слова и их начала).
func (s *Store) searchMessagesFTS(ctx context.Context, channelIDs []uint64, match string, limit int) ([]Message, error) {
func (s *Store) searchMessagesFTS(ctx context.Context, channelIDs []uint64, viewerID uint64, match string, limit int) ([]Message, error) {
placeholders := strings.TrimSuffix(strings.Repeat("?,", len(channelIDs)), ",")
args := make([]any, 0, len(channelIDs)+2)
args := make([]any, 0, len(channelIDs)+4)
args = append(args, match)
for _, id := range channelIDs {
args = append(args, int64(id))
}
args = append(args, limit)
args = append(args, int64(viewerID), int64(viewerID), limit)
rows, err := s.reader.QueryContext(ctx, `
SELECT `+prefixedMessageColumns("m")+`
FROM messages_fts f
JOIN messages m ON m.id = f.rowid
WHERE messages_fts MATCH ? AND m.channel_id IN (`+placeholders+`)
AND (`+privateVisibilityClauseFor("m")+`)
ORDER BY m.id DESC LIMIT ?`, args...)
if err != nil {
return nil, err
@@ -415,18 +434,19 @@ func (s *Store) searchMessagesFTS(ctx context.Context, channelIDs []uint64, matc
}
// searchMessagesSubstring ищет вхождения подстроки без учёта регистра.
func (s *Store) searchMessagesSubstring(ctx context.Context, channelIDs []uint64, query string, limit int) ([]Message, error) {
func (s *Store) searchMessagesSubstring(ctx context.Context, channelIDs []uint64, viewerID uint64, query string, limit int) ([]Message, error) {
placeholders := strings.TrimSuffix(strings.Repeat("?,", len(channelIDs)), ",")
args := make([]any, 0, len(channelIDs)+2)
args := make([]any, 0, len(channelIDs)+4)
args = append(args, "%"+escapeLike(query)+"%")
for _, id := range channelIDs {
args = append(args, int64(id))
}
args = append(args, limit)
args = append(args, int64(viewerID), int64(viewerID), limit)
rows, err := s.reader.QueryContext(ctx, `
SELECT `+messageColumns+` FROM messages
WHERE content_lower LIKE ? ESCAPE '\' AND channel_id IN (`+placeholders+`)
AND (`+privateVisibilityClause+`)
ORDER BY id DESC LIMIT ?`, args...)
if err != nil {
return nil, err
@@ -448,6 +468,29 @@ func scanMessages(rows *sql.Rows, limit int) ([]Message, error) {
return messages, rows.Err()
}
// privateVisibilityClauseFor — то же правило видимости для таблицы с алиасом.
func privateVisibilityClauseFor(alias string) string {
return alias + `.type <> 'private'
OR ` + alias + `.author_id = ?
OR EXISTS (SELECT 1 FROM json_each(` + alias + `.mentions_json) WHERE json_each.value = ?)`
}
// CountPinnedMessages считает закрепления комнаты (лимит 50, AGENT.md 7.6).
func (s *Store) CountPinnedMessages(ctx context.Context, channelID uint64) (int, error) {
var count int
err := s.reader.QueryRowContext(ctx,
`SELECT COUNT(*) FROM messages WHERE channel_id = ? AND pinned = 1`, int64(channelID)).Scan(&count)
return count, err
}
// CountReactions считает уникальные эмодзи сообщения (лимит 20, AGENT.md 7.6).
func (s *Store) CountReactions(ctx context.Context, messageID uint64) (int, error) {
var count int
err := s.reader.QueryRowContext(ctx,
`SELECT COUNT(DISTINCT emoji) FROM message_reactions WHERE message_id = ?`, int64(messageID)).Scan(&count)
return count, err
}
// prefixedMessageColumns добавляет префикс таблицы к списку колонок.
func prefixedMessageColumns(alias string) string {
columns := strings.Split(strings.ReplaceAll(messageColumns, "\n", " "), ",")
+91 -28
View File
@@ -6,6 +6,11 @@ import { fileContentUrl, uploadChannelFile, type UploadedFile } from '@/api/file
import { fetchMeta, metaQueryKey } from '@/api/meta';
import { MESSAGE_MAX_LENGTH, retryAfterMs, type Message } from '@/api/messages';
import type { Channel } from '@/api/types';
import {
COMPOSER_TEXT_CLASS,
ComposerMirror,
type ComposerSelection,
} from '@/components/chat/ComposerMirror';
import type { AuthorDirectory } from '@/components/chat/useAuthorDirectory';
import { ErrorNotice } from '@/components/ui/ErrorNotice';
import { formatBytes, errorCode } from '@/lib/format';
@@ -32,6 +37,11 @@ const LINE_HEIGHT_PX = 22;
* Композер сообщения: автовысота, Enter/Shift+Enter, счётчик символов,
* вложения (скрепка и drag&drop), плашка ответа и обработка ошибок отправки
* (лимит частоты 429 с таймером, отсутствие прав).
*
* Над полем лежит зеркало (`ComposerMirror`): оно рисует разметку, поэтому
* форматирование видно ещё до отправки, а текст самой textarea прозрачный.
* Позиция каретки живёт в состоянии и обновляется на ввод, выделение, клик и
* отпускание клавиши.
*/
export function Composer({
channel,
@@ -55,8 +65,11 @@ export function Composer({
/** Тикающее «сейчас»: по нему считается остаток лимита частоты. */
const [now, setNow] = useState(() => Date.now());
const [dragging, setDragging] = useState(false);
/** Позиция каретки: по ней зеркало решает, показывать ли маркеры. */
const [selection, setSelection] = useState<ComposerSelection>({ start: 0, end: 0 });
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const mirrorRef = useRef<HTMLDivElement | null>(null);
const fileInputRef = useRef<HTMLInputElement | null>(null);
// Предельный размер файла знает сервер (`GET /meta`); если он неизвестен,
@@ -69,6 +82,26 @@ export function Composer({
});
const maxUploadSize = meta.data?.max_upload_size;
/** Прокрутка зеркала повторяет прокрутку поля (текст длиннее восьми строк). */
const syncScroll = useCallback(() => {
const element = textareaRef.current;
const mirror = mirrorRef.current;
if (element === null || mirror === null) {
return;
}
mirror.scrollTop = element.scrollTop;
mirror.scrollLeft = element.scrollLeft;
}, []);
/** Обновление каретки: лишних перерисовок не делаем. */
const syncSelection = useCallback((element: HTMLTextAreaElement) => {
const start = element.selectionStart;
const end = element.selectionEnd;
setSelection((current) =>
current.start === start && current.end === end ? current : { start, end },
);
}, []);
// Автовысота: от одной строки до ~8.
useEffect(() => {
const element = textareaRef.current;
@@ -78,7 +111,8 @@ export function Composer({
element.style.height = 'auto';
const max = Math.min(MAX_TEXTAREA_HEIGHT_PX, LINE_HEIGHT_PX * 8);
element.style.height = `${String(Math.min(element.scrollHeight, max))}px`;
}, [value]);
syncScroll();
}, [value, syncScroll]);
// Таймер лимита частоты: пока он идёт, отправка заблокирована.
// Когда пауза истекла, снимаем `retryAt` — иначе интервал тикал бы вечно.
@@ -160,6 +194,8 @@ export function Composer({
...(files.length === 0 ? {} : { files }),
});
setValue('');
// Поле пустое — сбрасываем и каретку, чтобы зеркало не ждало маркеров.
setSelection({ start: 0, end: 0 });
setFiles([]);
setNotice(null);
// Отправка прошла — лимит частоты больше не держим.
@@ -292,33 +328,60 @@ export function Composer({
event.target.value = '';
}}
/>
<textarea
ref={textareaRef}
aria-label={t(direct ? 'chat.composer.directLabel' : 'chat.composer.label', {
name: channel.name,
})}
placeholder={t(direct ? 'chat.composer.directPlaceholder' : 'chat.composer.placeholder', {
name: channel.name,
})}
className="max-h-44 min-h-[1.75rem] w-full resize-none bg-transparent py-1 text-sm text-fg placeholder:text-fg-muted/70 focus:outline-none disabled:opacity-60"
rows={1}
value={value}
maxLength={MESSAGE_MAX_LENGTH}
disabled={blockedReason !== null}
onChange={(event) => {
setValue(event.target.value);
useMessagesStore.getState().notifyTyping(channel.id);
}}
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
void submit();
}
if (event.key === 'Escape' && replyTo !== null) {
onCancelReply();
}
}}
/>
{/* Заблокированное поле приглушаем вместе с зеркалом: плейсхолдер и
подсветка должны выглядеть одинаково неактивными. */}
<div className={`relative w-full ${blockedReason === null ? '' : 'opacity-60'}`}>
<ComposerMirror
value={value}
selection={selection}
mirrorRef={mirrorRef}
resolveMention={authors.resolveMention}
/>
<textarea
ref={textareaRef}
aria-label={t(direct ? 'chat.composer.directLabel' : 'chat.composer.label', {
name: channel.name,
})}
placeholder={t(
direct ? 'chat.composer.directPlaceholder' : 'chat.composer.placeholder',
{ name: channel.name },
)}
// Текст прозрачный: видно зеркало с разметкой. Каретка и выделение
// остаются родными для поля ввода.
className={`gl-composer-scroll relative z-10 block max-h-44 min-h-[1.75rem] w-full resize-none bg-transparent text-transparent caret-fg selection:bg-accent/30 placeholder:text-fg-muted/70 focus:outline-none ${COMPOSER_TEXT_CLASS}`}
rows={1}
value={value}
maxLength={MESSAGE_MAX_LENGTH}
disabled={blockedReason !== null}
// Текст невидим, поэтому подчёркивание проверки орфографии рисовалось
// бы поверх подсветки.
spellCheck={false}
onChange={(event) => {
setValue(event.target.value);
syncSelection(event.target);
useMessagesStore.getState().notifyTyping(channel.id);
}}
onSelect={(event) => {
syncSelection(event.currentTarget);
}}
onKeyUp={(event) => {
syncSelection(event.currentTarget);
}}
onClick={(event) => {
syncSelection(event.currentTarget);
}}
onScroll={syncScroll}
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
void submit();
}
if (event.key === 'Escape' && replyTo !== null) {
onCancelReply();
}
}}
/>
</div>
<button
type="button"
aria-label={t('chat.composer.send')}
+247
View File
@@ -0,0 +1,247 @@
import { Fragment, useMemo, type ReactNode, type RefObject } from 'react';
import { useChannelNames } from '@/components/chat/useChannelNames';
import {
hasMarkers,
selectionIntersects,
splitLink,
toMirrorLines,
type InlineToken,
type MirrorLine,
} from '@/lib/markdown';
/**
* Зеркало композера: слой под `textarea`, который рисует тот же текст, но с
* разметкой. Текст в самом поле ввода прозрачный, поэтому пользователь видит
* форматирование ещё до отправки, а каретка и выделение остаются родными для
* textarea.
*
* Маркеры (`**`, `==`, `~~`, `` ` `` и т.д.) видны только у фрагмента, внутри
* которого стоит каретка, и приглушены — так их удобно править. У остальных
* фрагментов маркеры скрыты, а содержимое отформатировано — ровно как в ленте
* (`MessageContent` использует тот же разбор из `@/lib/markdown`).
*
* Оформление здесь намеренно «безметрическое»: ни отступов, ни моноширинного
* шрифта, ни изменения размера. Метрики текста обязаны совпадать с textarea,
* иначе подсветка разъедется с полем ввода.
*/
/** Позиция каретки или выделения в поле ввода. */
export interface ComposerSelection {
start: number;
end: number;
}
/**
* Метрики текста, общие у зеркала и textarea: размер, интерлиньяж и отступы.
* Любое расхождение сдвинет подсветку относительно поля ввода.
*/
export const COMPOSER_TEXT_CLASS = 'py-1 text-sm leading-5';
type MentionResolver = (userId: string) => { name: string; isMe: boolean } | null;
interface ComposerMirrorProps {
value: string;
selection: ComposerSelection;
/** Ссылка на слой: композер синхронизирует по ней прокрутку. */
mirrorRef: RefObject<HTMLDivElement | null>;
/** Как показать упоминание — как в ленте сообщений. */
resolveMention?: MentionResolver;
}
/** Содержимое токена без маркеров. */
function renderFormatted(
token: InlineToken,
content: string,
channels: ReadonlySet<string>,
resolveMention: MentionResolver | undefined,
): ReactNode {
switch (token.kind) {
case 'code':
// Фон вместо `font-mono`: смена шрифта сдвинула бы метрики строки.
return <span className="rounded-[var(--radius-sm)] bg-surface-3">{content}</span>;
case 'bold-italic':
return (
<strong className="font-semibold">
<em>{content}</em>
</strong>
);
case 'bold':
return <strong className="font-semibold">{content}</strong>;
case 'italic':
return <em>{content}</em>;
case 'strike':
return <s className="opacity-70">{content}</s>;
case 'spoiler':
// В отличие от ленты текст спойлера виден: иначе его нельзя было бы
// править. Приглушённая подложка показывает, что фрагмент скрыт.
return <span className="rounded-[var(--radius-sm)] bg-fg-muted/40 text-fg">{content}</span>;
case 'mark':
return <mark className="rounded-[var(--radius-sm)] bg-accent/30 text-fg">{content}</mark>;
case 'mention': {
const mention = resolveMention?.(content) ?? null;
return (
<span
className={`rounded-[var(--radius-sm)] font-medium ${
mention?.isMe === true ? 'bg-accent/30 text-fg' : 'bg-accent/15 text-accent'
}`}
>
@{mention?.name ?? content}
</span>
);
}
case 'link': {
const { href, trailing } = splitLink(token.raw);
return (
<>
<a
href={href}
target="_blank"
rel="noreferrer noopener"
className="text-accent underline decoration-accent/40 underline-offset-2"
>
{href}
</a>
{trailing}
</>
);
}
case 'channel': {
const name = token.raw.slice(1);
return channels.has(name.toLowerCase()) ? (
<span className="text-accent">{token.raw}</span>
) : (
token.raw
);
}
}
}
/** Один токен: маркеры (если каретка внутри) плюс оформленное содержимое. */
function renderToken(
token: InlineToken,
line: MirrorLine,
selection: ComposerSelection,
channels: ReadonlySet<string>,
resolveMention: MentionResolver | undefined,
key: string,
): ReactNode {
const text = line.text;
const toLocal = (absolute: number): number => absolute - line.start;
// Каретка внутри фрагмента — показываем маркеры приглушёнными, чтобы их
// было видно и удобно править. Иначе маркеры скрыты.
const active = hasMarkers(token.kind) && selectionIntersects(selection, token.start, token.end);
return (
<Fragment key={key}>
{active ? (
<span className="text-fg-muted">
{text.slice(toLocal(token.start), toLocal(token.contentStart))}
</span>
) : null}
{renderFormatted(
token,
text.slice(toLocal(token.contentStart), toLocal(token.contentEnd)),
channels,
resolveMention,
)}
{active ? (
<span className="text-fg-muted">
{text.slice(toLocal(token.contentEnd), toLocal(token.end))}
</span>
) : null}
</Fragment>
);
}
/** Строка с разметкой: текст между токенами остаётся обычным текстом. */
function renderTokens(
line: MirrorLine,
selection: ComposerSelection,
channels: ReadonlySet<string>,
resolveMention: MentionResolver | undefined,
): ReactNode {
const start = line.start + line.prefixLength;
const nodes: ReactNode[] = [];
let cursor = start;
for (const [index, token] of line.tokens.entries()) {
if (token.start > cursor) {
nodes.push(line.text.slice(cursor - line.start, token.start - line.start));
}
nodes.push(
renderToken(
token,
line,
selection,
channels,
resolveMention,
`${String(line.start)}-${String(index)}`,
),
);
cursor = token.end;
}
if (cursor < line.end) {
nodes.push(line.text.slice(cursor - line.start));
}
return nodes;
}
/** Одна строка зеркала. */
function renderLine(
line: MirrorLine,
selection: ComposerSelection,
channels: ReadonlySet<string>,
resolveMention: MentionResolver | undefined,
): ReactNode {
switch (line.kind) {
case 'code':
return line.text === '' ? null : <span className="bg-surface-3/60">{line.text}</span>;
case 'fence': {
// Ограждение ``` показываем, только когда каретка внутри блока кода;
// строка при этом остаётся на месте, поэтому переносы не съезжают.
const range = line.codeRange;
const active = range !== null && selectionIntersects(selection, range.start, range.end);
return active ? <span className="text-fg-muted">{line.text}</span> : null;
}
case 'quote':
return (
<>
<span className="text-fg-muted">{line.text.slice(0, line.prefixLength)}</span>
{renderTokens(line, selection, channels, resolveMention)}
</>
);
case 'text':
return renderTokens(line, selection, channels, resolveMention);
}
}
/**
* Слой подсветки. `aria-hidden`: скринридер читает исходный текст из textarea,
* а не оформленную копию. `pointer-events-none`: весь ввод остаётся у поля.
*/
export function ComposerMirror({
value,
selection,
mirrorRef,
resolveMention,
}: ComposerMirrorProps) {
const lines = useMemo(() => toMirrorLines(value), [value]);
const channels = useChannelNames();
return (
<div
ref={mirrorRef}
aria-hidden="true"
data-testid="composer-mirror"
className={`gl-composer-scroll pointer-events-none absolute inset-0 overflow-hidden whitespace-pre-wrap break-words text-fg ${COMPOSER_TEXT_CLASS}`}
>
{lines.map((line, index) => (
<Fragment key={`${String(line.start)}-${String(index)}`}>
{renderLine(line, selection, channels, resolveMention)}
{index < lines.length - 1 ? '\n' : null}
</Fragment>
))}
</div>
);
}
+107 -162
View File
@@ -1,16 +1,22 @@
import { Fragment, useMemo, useState, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { useSessionStore } from '@/stores/session';
import { useChannelNames } from '@/components/chat/useChannelNames';
import {
parseBlocks,
parseInline,
splitLink,
QUOTE_PREFIX,
type InlineToken,
} from '@/lib/markdown';
/**
* Лёгкий безопасный рендер разметки сообщения.
*
* `dangerouslySetInnerHTML` не используется: текст разбирается в React-узлы,
* поэтому HTML из сообщения остаётся текстом. Поддерживается: `**жирный**`,
* `__жирный__`, `*курсив*`, `_курсив_`, `***жирный курсив***`, `` `код` ``,
* ```блок кода```, `~~зачёркнутый~~`, `==выделение==`, `||спойлер||`,
* `> цитата`, ссылки `http(s)://…`, упоминания `<@id>` и `#канал`.
* поэтому HTML из сообщения остаётся текстом. Сам разбор живёт в
* `@/lib/markdown` — его же использует зеркало композера, чтобы предпросмотр
* форматирования в поле ввода совпадал с лентой.
*
* Незакрытые маркеры остаются обычным текстом: шаблон требует закрывающую
* пару, поэтому `==фыв` и `**фыв` рендерятся как есть.
@@ -23,27 +29,6 @@ export interface MessageContentProps {
className?: string;
}
/**
* Порядок альтернатив важен: сначала более длинные маркеры (`***` раньше `**`
* и `*`), ссылки — раньше `#канала`, чтобы `https://x/#якорь` целиком остался
* ссылкой. Одиночная `*` не считается маркером внутри ряда звёздочек
* (`**фыв` не превращается в курсив со «звёздочкой» в тексте), а `_` — внутри
* слова (`foo_bar_baz`).
*/
const INLINE_PATTERN =
/(`[^`\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 } {
let href = raw;
let trailing = '';
while (href.length > 0 && '.,!?;:)]}»"'.includes(href[href.length - 1] ?? '')) {
trailing = (href[href.length - 1] ?? '') + trailing;
href = href.slice(0, -1);
}
return { href, trailing };
}
type MentionResolver = MessageContentProps['resolveMention'];
/**
@@ -69,86 +54,68 @@ function Spoiler({ children }: { children: ReactNode }) {
);
}
function renderInline(
text: string,
/** Оформление одного токена: содержимое уже без маркеров. */
function renderToken(
token: InlineToken,
content: string,
key: string,
resolveMention: MentionResolver,
channels: ReadonlySet<string>,
keyPrefix: string,
): ReactNode[] {
const nodes: ReactNode[] = [];
let lastIndex = 0;
let index = 0;
for (const match of text.matchAll(INLINE_PATTERN)) {
const start = match.index ?? 0;
if (start > lastIndex) {
nodes.push(text.slice(lastIndex, start));
}
const token = match[0];
const key = `${keyPrefix}-${String(index)}`;
index += 1;
if (token.startsWith('`')) {
nodes.push(
): ReactNode {
switch (token.kind) {
case 'code':
return (
<code
key={key}
className="rounded-[var(--radius-sm)] bg-surface-3 px-1 py-0.5 font-mono text-[0.85em]"
>
{token.slice(1, -1)}
</code>,
{content}
</code>
);
} else if (token.startsWith('***')) {
nodes.push(
case 'bold-italic':
return (
<strong key={key} className="font-semibold">
<em>{token.slice(3, -3)}</em>
</strong>,
<em>{content}</em>
</strong>
);
} else if (token.startsWith('**')) {
nodes.push(
case 'bold':
return (
<strong key={key} className="font-semibold">
{token.slice(2, -2)}
</strong>,
{content}
</strong>
);
} else if (token.startsWith('__')) {
nodes.push(
<strong key={key} className="font-semibold">
{token.slice(2, -2)}
</strong>,
);
} else if (token.startsWith('~~')) {
nodes.push(
case 'italic':
return <em key={key}>{content}</em>;
case 'strike':
return (
<s key={key} className="opacity-70">
{token.slice(2, -2)}
</s>,
{content}
</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(
case 'spoiler':
return <Spoiler key={key}>{content}</Spoiler>;
case 'mark':
return (
<mark key={key} className="rounded-[var(--radius-sm)] bg-accent/30 px-0.5 text-fg">
{token.slice(2, -2)}
</mark>,
{content}
</mark>
);
} else if (token.startsWith('<@')) {
const userId = token.slice(2, -1);
const mention = resolveMention?.(userId) ?? null;
nodes.push(
case 'mention': {
const mention = resolveMention?.(content) ?? null;
return (
<span
key={key}
className={`rounded-[var(--radius-sm)] px-1 font-medium ${
mention?.isMe === true ? 'bg-accent/30 text-fg' : 'bg-accent/15 text-accent'
}`}
>
@{mention?.name ?? userId}
</span>,
@{mention?.name ?? content}
</span>
);
} else if (token.startsWith('http')) {
const { href, trailing } = splitLink(token);
nodes.push(
}
case 'link': {
const { href, trailing } = splitLink(token.raw);
return (
<Fragment key={key}>
<a
href={href}
@@ -159,23 +126,49 @@ function renderInline(
{href}
</a>
{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
),
</Fragment>
);
}
lastIndex = start + token.length;
case 'channel': {
// `#канал`: подсвечиваем только известный канал, иначе оставляем текстом.
const name = token.raw.slice(1);
return channels.has(name.toLowerCase()) ? (
<span key={key} className="rounded-[var(--radius-sm)] bg-accent/15 px-1 text-accent">
{token.raw}
</span>
) : (
token.raw
);
}
}
}
function renderInline(
text: string,
resolveMention: MentionResolver,
channels: ReadonlySet<string>,
keyPrefix: string,
): ReactNode[] {
const nodes: ReactNode[] = [];
let lastIndex = 0;
let index = 0;
for (const token of parseInline(text)) {
if (token.start > lastIndex) {
nodes.push(text.slice(lastIndex, token.start));
}
const key = `${keyPrefix}-${String(index)}`;
index += 1;
nodes.push(
renderToken(
token,
text.slice(token.contentStart, token.contentEnd),
key,
resolveMention,
channels,
),
);
lastIndex = token.end;
}
if (lastIndex < text.length) {
@@ -184,68 +177,10 @@ function renderInline(
return nodes;
}
interface Block {
kind: 'text' | 'quote' | 'code';
lines: string[];
}
/** Разбиение на блоки: цитаты, огороженный код и обычный текст. */
function toBlocks(content: string): Block[] {
const lines = content.split('\n');
const blocks: Block[] = [];
let fence: Block | null = null;
for (const line of lines) {
const fenceMatch = /^```(\w*)\s*$/u.exec(line.trim());
if (fence !== null) {
if (fenceMatch !== null) {
blocks.push(fence);
fence = null;
} else {
fence.lines.push(line);
}
continue;
}
if (fenceMatch !== null) {
fence = { kind: 'code', lines: [] };
continue;
}
if (line.startsWith('> ')) {
const previous = blocks[blocks.length - 1];
if (previous?.kind === 'quote') {
previous.lines.push(line.slice(2));
} else {
blocks.push({ kind: 'quote', lines: [line.slice(2)] });
}
continue;
}
const previous = blocks[blocks.length - 1];
if (previous !== undefined && previous.kind === 'text') {
previous.lines.push(line);
} else {
blocks.push({ kind: 'text', lines: [line] });
}
}
if (fence !== null) {
// Незакрытый блок кода — показываем как код, а не теряем текст.
blocks.push(fence);
}
return blocks;
}
export function MessageContent({ content, resolveMention, className = '' }: MessageContentProps) {
const blocks = toBlocks(content);
const blocks = useMemo(() => parseBlocks(content), [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]);
const channels = useChannelNames();
return (
<div className={`whitespace-pre-wrap break-words ${className}`}>
@@ -257,20 +192,30 @@ export function MessageContent({ content, resolveMention, className = '' }: Mess
key={key}
className="my-1 overflow-x-auto rounded-[var(--radius-md)] border border-border/60 bg-surface-2 p-2 font-mono text-xs"
>
<code>{block.lines.join('\n')}</code>
<code>{block.lines.map((line) => line.text).join('\n')}</code>
</pre>
);
}
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, channels, key)}
{renderInline(
block.lines.map((line) => line.text.slice(QUOTE_PREFIX.length)).join('\n'),
resolveMention,
channels,
key,
)}
</blockquote>
);
}
return (
<p key={key} className="min-w-0">
{renderInline(block.lines.join('\n'), resolveMention, channels, key)}
{renderInline(
block.lines.map((line) => line.text).join('\n'),
resolveMention,
channels,
key,
)}
</p>
);
})}
@@ -0,0 +1,20 @@
import { useMemo } from 'react';
import { useSessionStore } from '@/stores/session';
/**
* Имена каналов из снапшота: `#канал` подсвечивается только для известных
* комнат. Общий хук для ленты сообщений и зеркала композера.
*/
export function useChannelNames(): ReadonlySet<string> {
const guilds = useSessionStore((state) => state.guilds);
return 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]);
}
+9
View File
@@ -370,6 +370,15 @@ body {
transition: transform var(--duration-fast) var(--ease-out);
}
/*
* Композер: поле ввода и зеркало подсветки должны переносить строки одинаково.
* Полосу прокрутки резервируем всегда — иначе при переполнении textarea её
* ширина сузила бы текстовую область только у поля, и подсветка разъехалась бы.
*/
.gl-composer-scroll {
scrollbar-gutter: stable;
}
/*
* Доступность: при `prefers-reduced-motion: reduce` анимации становятся
* мгновенными. Задержки каскада тоже обнуляем — иначе элементы списка
+357
View File
@@ -0,0 +1,357 @@
/**
* Общий разбор разметки сообщения: блоки (текст, цитата, код) и строчные
* токены с диапазонами.
*
* Модуль намеренно не рисует ничего сам: им пользуются оба потребителя —
* `MessageContent` (лента сообщений) и зеркало композера (подсветка под
* textarea). Разбор один, поэтому предпросмотр в поле ввода показывает ровно
* то же форматирование, что увидит получатель.
*
* Диапазоны нужны зеркалу: по ним видно, где у токена маркеры
* (`start`…`contentStart` и `contentEnd`…`end`), а где содержимое. Это
* позволяет спрятать маркеры, пока каретка вне фрагмента, и показать их
* приглушёнными, когда пользователь его правит.
*
* `dangerouslySetInnerHTML` не используется: текст разбирается в React-узлы,
* поэтому HTML из сообщения остаётся текстом. Поддерживается: `**жирный**`,
* `__жирный__`, `*курсив*`, `_курсив_`, `***жирный курсив***`, `` `код` ``,
* ```блок кода```, `~~зачёркнутый~~`, `==выделение==`, `||спойлер||`,
* `> цитата`, ссылки `http(s)://…`, упоминания `<@id>` и `#канал`.
*
* Незакрытые маркеры остаются обычным текстом: шаблон требует закрывающую
* пару, поэтому `==фыв` и `**фыв` рендерятся как есть.
*/
/** Вид строчного токена. */
export type InlineKind =
| 'code'
| 'bold-italic'
| 'bold'
| 'italic'
| 'strike'
| 'spoiler'
| 'mark'
| 'mention'
| 'link'
| 'channel';
/** Токен разметки с диапазонами в исходном тексте. */
export interface InlineToken {
kind: InlineKind;
/** Исходный текст токена вместе с маркерами. */
raw: string;
/** Смещение первого символа токена (включая открывающий маркер). */
start: number;
/** Смещение за последним символом токена. */
end: number;
/** Начало содержимого: сразу после открывающего маркера. */
contentStart: number;
/** Конец содержимого: перед закрывающим маркером. */
contentEnd: number;
}
/**
* Порядок альтернатив важен: сначала более длинные маркеры (`***` раньше `**`
* и `*`), ссылки — раньше `#канала`, чтобы `https://x/#якорь` целиком остался
* ссылкой. Одиночная `*` не считается маркером внутри ряда звёздочек
* (`**фыв` не превращается в курсив со «звёздочкой» в тексте), а `_` — внутри
* слова (`foo_bar_baz`).
*/
const INLINE_PATTERN =
/(`[^`\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;
/** Вид токена по номеру группы шаблона (порядок альтернатив выше). */
const GROUP_KINDS: readonly InlineKind[] = [
'code',
'bold-italic',
'bold',
'bold',
'strike',
'spoiler',
'italic',
'italic',
'mark',
'mention',
'link',
'channel',
];
/** Длина парных маркеров вокруг содержимого. */
const MARKER_LENGTH: Readonly<Record<InlineKind, number>> = {
code: 1,
'bold-italic': 3,
bold: 2,
italic: 1,
strike: 2,
spoiler: 2,
mark: 2,
// У упоминаний, ссылок и каналов маркеров нет: содержимое — весь токен.
mention: 0,
link: 0,
channel: 0,
};
/** Вид токена по совпавшей группе шаблона. */
function kindOf(match: RegExpMatchArray): InlineKind | null {
for (let group = 1; group < match.length; group += 1) {
if (match[group] !== undefined) {
return GROUP_KINDS[group - 1] ?? null;
}
}
return null;
}
/** Границы содержимого токена в абсолютных смещениях. */
function contentBounds(
kind: InlineKind,
raw: string,
start: number,
): { contentStart: number; contentEnd: number } {
if (kind === 'mention') {
// `<@123>`: содержимое — идентификатор без `<@` и `>`.
return { contentStart: start + 2, contentEnd: start + raw.length - 1 };
}
const marker = MARKER_LENGTH[kind];
return { contentStart: start + marker, contentEnd: start + raw.length - marker };
}
/**
* Разбор строчной разметки. `offset` — смещение `text` в исходном тексте:
* все диапазоны токенов возвращаются в абсолютных координатах, что нужно
* зеркалу для сопоставления с позицией каретки.
*/
export function parseInline(text: string, offset = 0): InlineToken[] {
const tokens: InlineToken[] = [];
for (const match of text.matchAll(INLINE_PATTERN)) {
const kind = kindOf(match);
if (kind === null) {
continue;
}
const raw = match[0];
const start = offset + (match.index ?? 0);
tokens.push({
kind,
raw,
start,
end: start + raw.length,
...contentBounds(kind, raw, start),
});
}
return tokens;
}
/**
* Есть ли у токена парные маркеры. У упоминаний, ссылок и каналов их нет:
* содержимое — весь токен, прятать нечего.
*/
export function hasMarkers(kind: InlineKind): boolean {
return kind !== 'mention' && kind !== 'link' && kind !== 'channel';
}
/** Ссылка: обрезаем хвостовую пунктуацию, которая не часть адреса. */
export function splitLink(raw: string): { href: string; trailing: string } {
let href = raw;
let trailing = '';
while (href.length > 0 && '.,!?;:)]}»"'.includes(href[href.length - 1] ?? '')) {
trailing = (href[href.length - 1] ?? '') + trailing;
href = href.slice(0, -1);
}
return { href, trailing };
}
/** Строка блока с абсолютными смещениями в исходном тексте. */
export interface BlockLine {
text: string;
/** Смещение первого символа строки. */
start: number;
/** Смещение за последним символом строки (без завершающего `\n`). */
end: number;
}
/** Вид блока: обычный текст, цитата или блок кода. */
export type BlockKind = 'text' | 'quote' | 'code';
/** Блок разметки: строки и границы в исходном тексте. */
export interface MessageBlock {
kind: BlockKind;
start: number;
end: number;
lines: BlockLine[];
/** Ограждение ``` в начале блока кода; у остальных блоков — null. */
fenceStart: BlockLine | null;
/** Закрывающее ограждение; у незакрытого блока кода — null. */
fenceEnd: BlockLine | null;
}
/** Префикс цитаты: с него начинается каждая строка блока-цитаты. */
export const QUOTE_PREFIX = '> ';
/** Шаблон строки-ограждения блока кода. */
const CODE_FENCE_PATTERN = /^```(\w*)\s*$/u;
function fence(text: string): boolean {
return CODE_FENCE_PATTERN.exec(text.trim()) !== null;
}
/**
* Разбиение на блоки: цитаты, огороженный код и обычный текст. Строки блока
* кода хранятся без ограждений, сами ограждения — в `fenceStart`/`fenceEnd`
* (зеркало рисует их отдельно, а `MessageContent` не рисует вовсе).
*/
export function parseBlocks(content: string): MessageBlock[] {
const blocks: MessageBlock[] = [];
let open: MessageBlock | null = null;
let cursor = 0;
for (const text of content.split('\n')) {
const line: BlockLine = { text, start: cursor, end: cursor + text.length };
cursor = line.end + 1;
if (open !== null) {
if (fence(text)) {
open.fenceEnd = line;
open.end = line.end;
blocks.push(open);
open = null;
} else {
open.lines.push(line);
open.end = line.end;
}
continue;
}
if (fence(text)) {
open = {
kind: 'code',
start: line.start,
end: line.end,
lines: [],
fenceStart: line,
fenceEnd: null,
};
continue;
}
if (text.startsWith(QUOTE_PREFIX)) {
const previous = blocks[blocks.length - 1];
if (previous?.kind === 'quote') {
previous.lines.push(line);
previous.end = line.end;
} else {
blocks.push({
kind: 'quote',
start: line.start,
end: line.end,
lines: [line],
fenceStart: null,
fenceEnd: null,
});
}
continue;
}
const previous = blocks[blocks.length - 1];
if (previous?.kind === 'text') {
previous.lines.push(line);
previous.end = line.end;
} else {
blocks.push({
kind: 'text',
start: line.start,
end: line.end,
lines: [line],
fenceStart: null,
fenceEnd: null,
});
}
}
if (open !== null) {
// Незакрытый блок кода — показываем как код, а не теряем текст.
blocks.push(open);
}
return blocks;
}
/** Как рисовать строку в зеркале композера. */
export type MirrorLineKind = 'text' | 'quote' | 'code' | 'fence';
/** Строка зеркала: исходный текст строки и разобранные токены. */
export interface MirrorLine {
kind: MirrorLineKind;
/** Исходный текст строки (у цитаты — вместе с префиксом `> `). */
text: string;
start: number;
end: number;
/** Токены разметки; у кода и ограждений пусто (внутри кода не форматируем). */
tokens: InlineToken[];
/** Длина префикса цитаты, который рисуется отдельно; иначе 0. */
prefixLength: number;
/** Блок кода, к которому относится строка (для `code` и `fence`); иначе null. */
codeRange: { start: number; end: number } | null;
}
/**
* Строки зеркала композера: та же последовательность строк, что и в исходном
* тексте (`lines.map(text).join('\n') === content`), но с готовым разбором.
* Инвариант важен: переносы строк в зеркале обязаны совпадать с textarea,
* иначе подсветка разъедется с полем ввода.
*/
export function toMirrorLines(content: string): MirrorLine[] {
const lines: MirrorLine[] = [];
for (const block of parseBlocks(content)) {
const codeRange = block.kind === 'code' ? { start: block.start, end: block.end } : null;
const fenceStart = block.fenceStart;
if (block.kind === 'code' && fenceStart !== null) {
lines.push({
kind: 'fence',
text: fenceStart.text,
start: fenceStart.start,
end: fenceStart.end,
tokens: [],
prefixLength: 0,
codeRange,
});
}
for (const line of block.lines) {
const isQuote = block.kind === 'quote';
lines.push({
kind: isQuote ? 'quote' : block.kind === 'code' ? 'code' : 'text',
text: line.text,
start: line.start,
end: line.end,
tokens:
block.kind === 'code'
? []
: parseInline(
isQuote ? line.text.slice(QUOTE_PREFIX.length) : line.text,
isQuote ? line.start + QUOTE_PREFIX.length : line.start,
),
prefixLength: isQuote ? QUOTE_PREFIX.length : 0,
codeRange,
});
}
const fenceEnd = block.fenceEnd;
if (block.kind === 'code' && fenceEnd !== null) {
lines.push({
kind: 'fence',
text: fenceEnd.text,
start: fenceEnd.start,
end: fenceEnd.end,
tokens: [],
prefixLength: 0,
codeRange,
});
}
}
return lines;
}
/**
* Пересекается ли выделение с диапазоном. Для каретки без выделения это
* «строго внутри»: позиции сразу за открывающим и перед закрывающим маркером
* попадают внутрь, а позиции на самих маркерах снаружи — там маркеры скрыты.
*/
export function selectionIntersects(
selection: { start: number; end: number },
start: number,
end: number,
): boolean {
return selection.start < end && selection.end > start;
}
+340
View File
@@ -0,0 +1,340 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { QueryClientProvider } from '@tanstack/react-query';
import type { Channel } from '@/api/types';
import { Composer } from '@/components/chat/Composer';
import { MessageContent } from '@/components/chat/MessageContent';
import type { AuthorDirectory } from '@/components/chat/useAuthorDirectory';
import { hasMarkers, parseInline, toMirrorLines } from '@/lib/markdown';
import {
createTestQueryClient,
findRequest,
installFetch,
json,
makeMessage,
resetStores,
} from './helpers';
/**
* Форматирование в поле ввода: зеркало под textarea показывает разметку, а
* маркеры видны только у фрагмента, внутри которого стоит каретка.
*/
const channel: Channel = { id: 'c-1', name: 'общий', type: 'text', position: 0 };
const authors: AuthorDirectory = {
resolve: () => ({
id: 'user-2',
name: 'Bob',
avatarFileId: undefined,
colorHex: null,
isInstanceAdmin: false,
isMember: true,
}),
resolveMention: (userId) => ({ name: userId === 'user-2' ? 'Bob' : userId, isMe: false }),
loaded: true,
};
function renderComposer() {
const fetchMock = installFetch([
{ match: '/api/v1/meta', response: () => json({ max_upload_size: 26_214_400 }) },
{
match: '/api/v1/channels/c-1/messages',
method: 'POST',
response: () =>
json({ message: makeMessage({ id: 'm-1', channel_id: 'c-1', author_id: 'user-1' }) }),
},
{ match: '/api/v1/channels/c-1/typing', method: 'POST', response: () => json({ ok: true }) },
]);
const view = render(
<QueryClientProvider client={createTestQueryClient()}>
<Composer
channel={channel}
authors={authors}
replyTo={null}
onCancelReply={() => undefined}
disabledReason={null}
canAttach
/>
</QueryClientProvider>,
);
return { ...view, fetchMock };
}
/** Поле ввода (настоящий textbox с меткой). */
function field(): HTMLTextAreaElement {
return screen.getByLabelText('Сообщение в комнату общий');
}
/** Слой подсветки под полем ввода. */
function layer(): HTMLElement {
return screen.getByTestId('composer-mirror');
}
/**
* Ставит каретку и сообщает об этом React. Нативный `select` React
* синтезирует из `keyup`/`mouseup`/`selectionchange`, поэтому в тесте
* отправляем и `select`, и `keyup` — как это делает браузер.
*/
function placeCaret(input: HTMLTextAreaElement, start: number, end = start): void {
input.setSelectionRange(start, end);
fireEvent.select(input);
fireEvent.keyUp(input);
}
/** Тексты приглушённых маркеров в зеркале. */
function markers(): string[] {
return Array.from(layer().querySelectorAll('.text-fg-muted')).map(
(node) => node.textContent ?? '',
);
}
beforeEach(() => {
resetStores();
});
describe('композер: форматирование в поле ввода', () => {
it('показывает выделение как mark, а маркеры прячет', async () => {
const visitor = userEvent.setup();
renderComposer();
const input = field();
await visitor.type(input, 'важно: ==выделение== да');
// В поле ввода остаётся исходный текст с маркерами.
expect(input).toHaveValue('важно: ==выделение== да');
// А в зеркале — уже отформатированный, без `==`.
const mark = within(layer()).getByText('выделение');
expect(mark.tagName).toBe('MARK');
expect(layer().textContent).toBe('важно: выделение да');
expect(markers()).toEqual([]);
});
it('показывает приглушённые маркеры, когда каретка внутри фрагмента', async () => {
const visitor = userEvent.setup();
renderComposer();
const input = field();
await visitor.type(input, '==выделение==');
// Каретка внутри содержимого.
placeCaret(input, 5);
expect(layer().textContent).toBe('==выделение==');
expect(markers()).toEqual(['==', '==']);
// Позиция сразу после открывающего маркера и перед закрывающим — тоже внутри.
placeCaret(input, 2);
expect(layer().textContent).toBe('==выделение==');
placeCaret(input, 11);
expect(layer().textContent).toBe('==выделение==');
// На самих маркерах (и за фрагментом) маркеры снова скрыты.
placeCaret(input, 0);
expect(layer().textContent).toBe('выделение');
expect(markers()).toEqual([]);
placeCaret(input, 13);
expect(layer().textContent).toBe('выделение');
});
it('форматирует жирный, курсив и зачёркнутый текст', async () => {
const visitor = userEvent.setup();
renderComposer();
const input = field();
await visitor.type(input, '**жирный** *курсив* ~~зачёркнутый~~');
expect(within(layer()).getByText('жирный').tagName).toBe('STRONG');
expect(within(layer()).getByText('курсив').tagName).toBe('EM');
expect(within(layer()).getByText('зачёркнутый').tagName).toBe('S');
expect(layer().textContent).toBe('жирный курсив зачёркнутый');
// Каретка внутри `**жирный**` — маркеры на месте.
placeCaret(input, 4);
expect(layer().textContent).toContain('**жирный**');
expect(markers()).toEqual(['**', '**']);
// То же для курсива и зачёркнутого.
placeCaret(input, 15);
expect(layer().textContent).toContain('*курсив*');
expect(markers()).toEqual(['*', '*']);
placeCaret(input, 25);
expect(layer().textContent).toContain('~~зачёркнутый~~');
expect(markers()).toEqual(['~~', '~~']);
});
it('оставляет незакрытые маркеры обычным текстом', async () => {
const visitor = userEvent.setup();
renderComposer();
const input = field();
await visitor.type(input, '==фыв');
expect(layer().querySelector('mark')).toBeNull();
expect(layer().textContent).toBe('==фыв');
});
it('не разбирает разметку внутри кода, а ограждения показывает по каретке', () => {
renderComposer();
const input = field();
fireEvent.change(input, { target: { value: 'до\n```\n==не mark==\n```\nпосле' } });
// Каретка снаружи блока кода: ограждения скрыты, но строки остаются на месте.
placeCaret(input, 0);
expect(layer().querySelector('mark')).toBeNull();
expect(layer().textContent).toBe('до\n\n==не mark==\n\nпосле');
// Каретка внутри блока — ограждения видны.
placeCaret(input, 10);
expect(layer().querySelector('mark')).toBeNull();
expect(layer().textContent).toBe('до\n```\n==не mark==\n```\nпосле');
});
it('рендерит кодовый фрагмент без моноширинного шрифта — метрики важнее', async () => {
const visitor = userEvent.setup();
renderComposer();
const input = field();
await visitor.type(input, '`код`');
expect(layer().textContent).toBe('код');
// Смена шрифта в зеркале сдвинула бы подсветку относительно поля ввода.
expect(layer().querySelector('.font-mono')).toBeNull();
});
it('отправляет исходный текст с маркерами, а не то, что видно в зеркале', async () => {
const visitor = userEvent.setup();
const { fetchMock } = renderComposer();
const input = field();
await visitor.type(input, '==выделение== и **жирный**');
await visitor.keyboard('{Enter}');
await waitFor(() => {
const request = findRequest(fetchMock, { url: '/channels/c-1/messages', method: 'POST' });
expect(request?.body).toMatchObject({ content: '==выделение== и **жирный**' });
});
await waitFor(() => {
expect(input).toHaveValue('');
});
});
it('не ломает счётчик и лимит 4000 символов', () => {
renderComposer();
const input = field();
const long = 'а'.repeat(4000);
fireEvent.change(input, { target: { value: long } });
expect(input).toHaveAttribute('maxlength', '4000');
expect(input).toHaveValue(long);
expect(screen.getByText('4000/4000')).toHaveClass('text-warning');
// Зеркало рисует весь текст: разбор мемоизирован по значению.
expect(layer().textContent).toBe(long);
});
it('зеркало скрыто от скринридера, а поле остаётся настоящим textbox', async () => {
const visitor = userEvent.setup();
renderComposer();
const input = field();
await visitor.type(input, '==выделение==');
expect(layer()).toHaveAttribute('aria-hidden', 'true');
expect(layer()).toHaveClass('pointer-events-none');
expect(screen.getAllByRole('textbox')).toHaveLength(1);
expect(screen.getByRole('textbox')).toBe(input);
});
it('синхронизирует прокрутку зеркала с полем ввода', () => {
renderComposer();
const input = field();
const mirror = layer();
Object.defineProperty(input, 'scrollTop', { value: 40, writable: true, configurable: true });
Object.defineProperty(input, 'scrollLeft', { value: 5, writable: true, configurable: true });
let top = 0;
let left = 0;
Object.defineProperty(mirror, 'scrollTop', {
get: () => top,
set: (value: number) => {
top = value;
},
configurable: true,
});
Object.defineProperty(mirror, 'scrollLeft', {
get: () => left,
set: (value: number) => {
left = value;
},
configurable: true,
});
fireEvent.scroll(input);
expect(top).toBe(40);
expect(left).toBe(5);
});
});
describe('композер: разбор совпадает с лентой сообщений', () => {
it('зеркало и MessageContent показывают один и тот же текст', async () => {
const content =
'==выделение== **жирный** *курсив* ~~зачёркнутый~~ ||спойлер|| `код` https://example.com/a. <@user-2> конец';
const visitor = userEvent.setup();
renderComposer();
const input = field();
await visitor.type(input, content);
const feed = render(
<MessageContent content={content} resolveMention={authors.resolveMention} />,
);
expect(layer().textContent).toBe(feed.container.textContent);
});
it('диапазоны токенов указывают на маркеры и содержимое', () => {
const [token] = parseInline('==выделение==', 0);
expect(token).toBeDefined();
expect(token?.kind).toBe('mark');
expect(token?.start).toBe(0);
expect(token?.end).toBe(13);
expect(token?.contentStart).toBe(2);
expect(token?.contentEnd).toBe(11);
expect(hasMarkers('mark')).toBe(true);
expect(hasMarkers('mention')).toBe(false);
// Смещение базы: диапазоны остаются абсолютными.
const [shifted] = parseInline('==a==', 10);
expect(shifted?.start).toBe(10);
expect(shifted?.end).toBe(15);
expect(shifted?.contentStart).toBe(12);
expect(shifted?.contentEnd).toBe(13);
// Незакрытый маркер токеном не считается.
expect(parseInline('==фыв', 0)).toEqual([]);
});
it('строки зеркала повторяют исходные переносы', () => {
const samples = [
'',
'одна строка',
'первая\nвторая\n',
'```\nкод\n```',
'до\n```\nкод\n```\nпосле',
'незакрытый ```\nкод',
'> цитата\n> ещё\nобычный',
'```',
];
for (const sample of samples) {
expect(
toMirrorLines(sample)
.map((line) => line.text)
.join('\n'),
).toBe(sample);
}
});
});