Compare commits
2 Commits
49b67a0354
...
277954c47f
| Author | SHA1 | Date | |
|---|---|---|---|
| 277954c47f | |||
| 55ed87fb11 |
@@ -111,7 +111,15 @@ func (svc *Service) Submit(ctx context.Context, accountID uuid.UUID, body string
|
|||||||
} else {
|
} else {
|
||||||
attachmentName = "" // a name without bytes carries no attachment
|
attachmentName = "" // a name without bytes carries no attachment
|
||||||
}
|
}
|
||||||
_, err = svc.store.Insert(ctx, accountID, body, attachment, attachmentName, normalizeChannel(channel), parseIP(senderIP))
|
ch := normalizeChannel(channel)
|
||||||
|
// Snapshot the languages at submit time (acc is already loaded for the guest check):
|
||||||
|
// the sender's interface language, and the connector bot language when the message
|
||||||
|
// came through an external connector (currently Telegram).
|
||||||
|
var channelLang string
|
||||||
|
if ch == "telegram" {
|
||||||
|
channelLang = acc.ServiceLanguage
|
||||||
|
}
|
||||||
|
_, err = svc.store.Insert(ctx, accountID, body, attachment, attachmentName, ch, acc.PreferredLanguage, channelLang, parseIP(senderIP))
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,8 +34,11 @@ func NewStore(db *sql.DB) *Store {
|
|||||||
|
|
||||||
// Insert stores one feedback message from accountID and returns its id. attachment
|
// Insert stores one feedback message from accountID and returns its id. attachment
|
||||||
// is the raw file bytes (nil for none); attachmentName, ip and a non-default
|
// is the raw file bytes (nil for none); attachmentName, ip and a non-default
|
||||||
// channel are stored as given. created_at defaults to now() in the database.
|
// channel are stored as given. lang (the sender's interface language) and channelLang
|
||||||
func (s *Store) Insert(ctx context.Context, accountID uuid.UUID, body string, attachment []byte, attachmentName, channel string, ip *string) (uuid.UUID, error) {
|
// (the connector bot language, empty for a non-connector channel) are snapshots taken
|
||||||
|
// now, so the operator later sees the state at submit time. created_at defaults to
|
||||||
|
// now() in the database.
|
||||||
|
func (s *Store) Insert(ctx context.Context, accountID uuid.UUID, body string, attachment []byte, attachmentName, channel, lang, channelLang string, ip *string) (uuid.UUID, error) {
|
||||||
id, err := uuid.NewV7()
|
id, err := uuid.NewV7()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return uuid.Nil, fmt.Errorf("feedback: new message id: %w", err)
|
return uuid.Nil, fmt.Errorf("feedback: new message id: %w", err)
|
||||||
@@ -44,20 +47,24 @@ func (s *Store) Insert(ctx context.Context, accountID uuid.UUID, body string, at
|
|||||||
if len(attachment) > 0 {
|
if len(attachment) > 0 {
|
||||||
att = attachment
|
att = attachment
|
||||||
}
|
}
|
||||||
var name *string
|
|
||||||
if attachmentName != "" {
|
|
||||||
name = &attachmentName
|
|
||||||
}
|
|
||||||
if _, err := s.db.ExecContext(ctx,
|
if _, err := s.db.ExecContext(ctx,
|
||||||
`INSERT INTO backend.feedback_messages
|
`INSERT INTO backend.feedback_messages
|
||||||
(message_id, account_id, body, attachment, attachment_name, channel, sender_ip)
|
(message_id, account_id, body, attachment, attachment_name, channel, lang, channel_lang, sender_ip)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
||||||
id, accountID, body, att, name, channel, ip); err != nil {
|
id, accountID, body, att, nullStr(attachmentName), channel, nullStr(lang), nullStr(channelLang), ip); err != nil {
|
||||||
return uuid.Nil, fmt.Errorf("feedback: insert: %w", err)
|
return uuid.Nil, fmt.Errorf("feedback: insert: %w", err)
|
||||||
}
|
}
|
||||||
return id, nil
|
return id, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// nullStr maps an empty string to a NULL text bind, else the value.
|
||||||
|
func nullStr(s string) *string {
|
||||||
|
if s == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &s
|
||||||
|
}
|
||||||
|
|
||||||
// HasUnread reports whether the account has any message the operator has not yet
|
// HasUnread reports whether the account has any message the operator has not yet
|
||||||
// dealt with (read_at IS NULL) — the anti-spam gate's condition.
|
// dealt with (read_at IS NULL) — the anti-spam gate's condition.
|
||||||
func (s *Store) HasUnread(ctx context.Context, accountID uuid.UUID) (bool, error) {
|
func (s *Store) HasUnread(ctx context.Context, accountID uuid.UUID) (bool, error) {
|
||||||
@@ -215,12 +222,16 @@ func (s *Store) Attachment(ctx context.Context, id uuid.UUID) (string, []byte, b
|
|||||||
// message with its sender's resolved display name and source. The attachment bytes
|
// message with its sender's resolved display name and source. The attachment bytes
|
||||||
// are not loaded here (served separately); only their presence and name are.
|
// are not loaded here (served separately); only their presence and name are.
|
||||||
type AdminMessage struct {
|
type AdminMessage struct {
|
||||||
ID uuid.UUID
|
ID uuid.UUID
|
||||||
AccountID uuid.UUID
|
AccountID uuid.UUID
|
||||||
SenderName string
|
SenderName string
|
||||||
Source string
|
Source string
|
||||||
Body string
|
Body string
|
||||||
Channel string
|
Channel string
|
||||||
|
// Lang is the sender's interface language and ChannelLang the connector bot language
|
||||||
|
// (en/ru, empty for a non-connector channel) — both snapshotted at submit time.
|
||||||
|
Lang string
|
||||||
|
ChannelLang string
|
||||||
SenderIP string
|
SenderIP string
|
||||||
HasAttachment bool
|
HasAttachment bool
|
||||||
AttachmentName string
|
AttachmentName string
|
||||||
@@ -335,6 +346,7 @@ func (s *Store) AdminGet(ctx context.Context, id uuid.UUID) (AdminMessage, error
|
|||||||
var m AdminMessage
|
var m AdminMessage
|
||||||
var repliedAt sql.NullTime
|
var repliedAt sql.NullTime
|
||||||
q := `SELECT m.message_id, m.account_id, a.display_name, ` + feedbackSource + ` AS source, m.body, m.channel,
|
q := `SELECT m.message_id, m.account_id, a.display_name, ` + feedbackSource + ` AS source, m.body, m.channel,
|
||||||
|
COALESCE(m.lang, ''), COALESCE(m.channel_lang, ''),
|
||||||
COALESCE(m.sender_ip, ''), (m.attachment IS NOT NULL), COALESCE(m.attachment_name, ''),
|
COALESCE(m.sender_ip, ''), (m.attachment IS NOT NULL), COALESCE(m.attachment_name, ''),
|
||||||
(m.read_at IS NOT NULL), (m.archived_at IS NOT NULL), (m.reply_body IS NOT NULL),
|
(m.read_at IS NOT NULL), (m.archived_at IS NOT NULL), (m.reply_body IS NOT NULL),
|
||||||
COALESCE(m.reply_body, ''), m.replied_at, m.created_at
|
COALESCE(m.reply_body, ''), m.replied_at, m.created_at
|
||||||
@@ -343,6 +355,7 @@ func (s *Store) AdminGet(ctx context.Context, id uuid.UUID) (AdminMessage, error
|
|||||||
WHERE m.message_id = $1`
|
WHERE m.message_id = $1`
|
||||||
err := s.db.QueryRowContext(ctx, q, id).Scan(
|
err := s.db.QueryRowContext(ctx, q, id).Scan(
|
||||||
&m.ID, &m.AccountID, &m.SenderName, &m.Source, &m.Body, &m.Channel,
|
&m.ID, &m.AccountID, &m.SenderName, &m.Source, &m.Body, &m.Channel,
|
||||||
|
&m.Lang, &m.ChannelLang,
|
||||||
&m.SenderIP, &m.HasAttachment, &m.AttachmentName,
|
&m.SenderIP, &m.HasAttachment, &m.AttachmentName,
|
||||||
&m.Read, &m.Archived, &m.Replied, &m.ReplyBody, &repliedAt, &m.CreatedAt)
|
&m.Read, &m.Archived, &m.Replied, &m.ReplyBody, &repliedAt, &m.CreatedAt)
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
|||||||
@@ -145,6 +145,51 @@ func TestFeedbackReplyHiddenAfterNewMessage(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFeedbackSnapshotsLanguages(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
svc := newFeedbackService()
|
||||||
|
acc := provisionAccount(t)
|
||||||
|
if _, err := testDB.ExecContext(ctx,
|
||||||
|
`UPDATE backend.accounts SET preferred_language = 'en', service_language = 'ru' WHERE account_id = $1`, acc); err != nil {
|
||||||
|
t.Fatalf("set languages: %v", err)
|
||||||
|
}
|
||||||
|
// A Telegram (connector) message snapshots both the interface language and the bot.
|
||||||
|
if err := svc.Submit(ctx, acc, "from telegram", nil, "", "telegram", ""); err != nil {
|
||||||
|
t.Fatalf("submit: %v", err)
|
||||||
|
}
|
||||||
|
id := latestFeedbackID(t, svc, acc)
|
||||||
|
if m, err := svc.AdminGet(ctx, id); err != nil {
|
||||||
|
t.Fatalf("admin get: %v", err)
|
||||||
|
} else if m.Lang != "en" || m.ChannelLang != "ru" {
|
||||||
|
t.Fatalf("snapshot = lang %q / channel_lang %q, want en / ru", m.Lang, m.ChannelLang)
|
||||||
|
}
|
||||||
|
// Changing the account afterwards must not change the stored snapshot.
|
||||||
|
if _, err := testDB.ExecContext(ctx,
|
||||||
|
`UPDATE backend.accounts SET preferred_language = 'ru', service_language = 'en' WHERE account_id = $1`, acc); err != nil {
|
||||||
|
t.Fatalf("change languages: %v", err)
|
||||||
|
}
|
||||||
|
if m, err := svc.AdminGet(ctx, id); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
} else if m.Lang != "en" || m.ChannelLang != "ru" {
|
||||||
|
t.Fatalf("snapshot drifted after account change = lang %q / channel_lang %q", m.Lang, m.ChannelLang)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A non-connector channel records no bot language even when the account has one.
|
||||||
|
acc2 := provisionAccount(t)
|
||||||
|
if _, err := testDB.ExecContext(ctx,
|
||||||
|
`UPDATE backend.accounts SET preferred_language = 'en', service_language = 'ru' WHERE account_id = $1`, acc2); err != nil {
|
||||||
|
t.Fatalf("set languages 2: %v", err)
|
||||||
|
}
|
||||||
|
if err := svc.Submit(ctx, acc2, "from web", nil, "", "web", ""); err != nil {
|
||||||
|
t.Fatalf("submit web: %v", err)
|
||||||
|
}
|
||||||
|
if m, err := svc.AdminGet(ctx, latestFeedbackID(t, svc, acc2)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
} else if m.Lang != "en" || m.ChannelLang != "" {
|
||||||
|
t.Fatalf("web snapshot = lang %q / channel_lang %q, want en / empty", m.Lang, m.ChannelLang)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestFeedbackBanRole(t *testing.T) {
|
func TestFeedbackBanRole(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
svc := newFeedbackService()
|
svc := newFeedbackService()
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ type FeedbackMessages struct {
|
|||||||
AttachmentName *string
|
AttachmentName *string
|
||||||
SenderIP *string
|
SenderIP *string
|
||||||
Channel string
|
Channel string
|
||||||
|
Lang *string
|
||||||
|
ChannelLang *string
|
||||||
ReadAt *time.Time
|
ReadAt *time.Time
|
||||||
ArchivedAt *time.Time
|
ArchivedAt *time.Time
|
||||||
ReplyBody *string
|
ReplyBody *string
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ type feedbackMessagesTable struct {
|
|||||||
AttachmentName postgres.ColumnString
|
AttachmentName postgres.ColumnString
|
||||||
SenderIP postgres.ColumnString
|
SenderIP postgres.ColumnString
|
||||||
Channel postgres.ColumnString
|
Channel postgres.ColumnString
|
||||||
|
Lang postgres.ColumnString
|
||||||
|
ChannelLang postgres.ColumnString
|
||||||
ReadAt postgres.ColumnTimestampz
|
ReadAt postgres.ColumnTimestampz
|
||||||
ArchivedAt postgres.ColumnTimestampz
|
ArchivedAt postgres.ColumnTimestampz
|
||||||
ReplyBody postgres.ColumnString
|
ReplyBody postgres.ColumnString
|
||||||
@@ -78,14 +80,16 @@ func newFeedbackMessagesTableImpl(schemaName, tableName, alias string) feedbackM
|
|||||||
AttachmentNameColumn = postgres.StringColumn("attachment_name")
|
AttachmentNameColumn = postgres.StringColumn("attachment_name")
|
||||||
SenderIPColumn = postgres.StringColumn("sender_ip")
|
SenderIPColumn = postgres.StringColumn("sender_ip")
|
||||||
ChannelColumn = postgres.StringColumn("channel")
|
ChannelColumn = postgres.StringColumn("channel")
|
||||||
|
LangColumn = postgres.StringColumn("lang")
|
||||||
|
ChannelLangColumn = postgres.StringColumn("channel_lang")
|
||||||
ReadAtColumn = postgres.TimestampzColumn("read_at")
|
ReadAtColumn = postgres.TimestampzColumn("read_at")
|
||||||
ArchivedAtColumn = postgres.TimestampzColumn("archived_at")
|
ArchivedAtColumn = postgres.TimestampzColumn("archived_at")
|
||||||
ReplyBodyColumn = postgres.StringColumn("reply_body")
|
ReplyBodyColumn = postgres.StringColumn("reply_body")
|
||||||
RepliedAtColumn = postgres.TimestampzColumn("replied_at")
|
RepliedAtColumn = postgres.TimestampzColumn("replied_at")
|
||||||
ReplyReadAtColumn = postgres.TimestampzColumn("reply_read_at")
|
ReplyReadAtColumn = postgres.TimestampzColumn("reply_read_at")
|
||||||
CreatedAtColumn = postgres.TimestampzColumn("created_at")
|
CreatedAtColumn = postgres.TimestampzColumn("created_at")
|
||||||
allColumns = postgres.ColumnList{MessageIDColumn, AccountIDColumn, BodyColumn, AttachmentColumn, AttachmentNameColumn, SenderIPColumn, ChannelColumn, ReadAtColumn, ArchivedAtColumn, ReplyBodyColumn, RepliedAtColumn, ReplyReadAtColumn, CreatedAtColumn}
|
allColumns = postgres.ColumnList{MessageIDColumn, AccountIDColumn, BodyColumn, AttachmentColumn, AttachmentNameColumn, SenderIPColumn, ChannelColumn, LangColumn, ChannelLangColumn, ReadAtColumn, ArchivedAtColumn, ReplyBodyColumn, RepliedAtColumn, ReplyReadAtColumn, CreatedAtColumn}
|
||||||
mutableColumns = postgres.ColumnList{AccountIDColumn, BodyColumn, AttachmentColumn, AttachmentNameColumn, SenderIPColumn, ChannelColumn, ReadAtColumn, ArchivedAtColumn, ReplyBodyColumn, RepliedAtColumn, ReplyReadAtColumn, CreatedAtColumn}
|
mutableColumns = postgres.ColumnList{AccountIDColumn, BodyColumn, AttachmentColumn, AttachmentNameColumn, SenderIPColumn, ChannelColumn, LangColumn, ChannelLangColumn, ReadAtColumn, ArchivedAtColumn, ReplyBodyColumn, RepliedAtColumn, ReplyReadAtColumn, CreatedAtColumn}
|
||||||
defaultColumns = postgres.ColumnList{CreatedAtColumn}
|
defaultColumns = postgres.ColumnList{CreatedAtColumn}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -100,6 +104,8 @@ func newFeedbackMessagesTableImpl(schemaName, tableName, alias string) feedbackM
|
|||||||
AttachmentName: AttachmentNameColumn,
|
AttachmentName: AttachmentNameColumn,
|
||||||
SenderIP: SenderIPColumn,
|
SenderIP: SenderIPColumn,
|
||||||
Channel: ChannelColumn,
|
Channel: ChannelColumn,
|
||||||
|
Lang: LangColumn,
|
||||||
|
ChannelLang: ChannelLangColumn,
|
||||||
ReadAt: ReadAtColumn,
|
ReadAt: ReadAtColumn,
|
||||||
ArchivedAt: ArchivedAtColumn,
|
ArchivedAt: ArchivedAtColumn,
|
||||||
ReplyBody: ReplyBodyColumn,
|
ReplyBody: ReplyBodyColumn,
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ SET search_path = backend, pg_catalog;
|
|||||||
-- attachment is the raw bytes (capped at 1,000,000 in Go); attachment_name carries the original
|
-- attachment is the raw bytes (capped at 1,000,000 in Go); attachment_name carries the original
|
||||||
-- file name (its extension drives the safe content-type at serve time). sender_ip is the
|
-- file name (its extension drives the safe content-type at serve time). sender_ip is the
|
||||||
-- gateway-forwarded client IP (validated, like chat); channel is the submitting platform
|
-- gateway-forwarded client IP (validated, like chat); channel is the submitting platform
|
||||||
-- (telegram/ios/android/web, validated in Go).
|
-- (telegram/ios/android/web, validated in Go). The sender's interface/bot language snapshots
|
||||||
|
-- (lang, channel_lang) are added additively in 00005.
|
||||||
CREATE TABLE feedback_messages (
|
CREATE TABLE feedback_messages (
|
||||||
message_id uuid PRIMARY KEY,
|
message_id uuid PRIMARY KEY,
|
||||||
account_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
|
account_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
-- +goose Up
|
||||||
|
-- Snapshot the sender's languages on each feedback message, taken at submit time so the
|
||||||
|
-- operator console shows the state as it was, not the account's current settings (the same
|
||||||
|
-- snapshot discipline as a suspension's reason). lang is the sender's interface language;
|
||||||
|
-- channel_lang is the connector bot language (en/ru) when the message arrived through an
|
||||||
|
-- external connector (Telegram), else NULL. Additive over 00004 so it applies forward-safe.
|
||||||
|
SET search_path = backend, pg_catalog;
|
||||||
|
|
||||||
|
ALTER TABLE feedback_messages ADD COLUMN lang text;
|
||||||
|
ALTER TABLE feedback_messages ADD COLUMN channel_lang text;
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
SET search_path = backend, pg_catalog;
|
||||||
|
ALTER TABLE feedback_messages DROP COLUMN IF EXISTS channel_lang;
|
||||||
|
ALTER TABLE feedback_messages DROP COLUMN IF EXISTS lang;
|
||||||
@@ -79,7 +79,8 @@ func (s *Server) consoleFeedbackDetail(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
view := adminconsole.FeedbackDetailView{
|
view := adminconsole.FeedbackDetailView{
|
||||||
ID: m.ID.String(), AccountID: m.AccountID.String(), SenderName: m.SenderName,
|
ID: m.ID.String(), AccountID: m.AccountID.String(), SenderName: m.SenderName,
|
||||||
Source: m.Source, Channel: m.Channel, IP: m.SenderIP, Body: m.Body,
|
Source: m.Source, Channel: m.Channel, InterfaceLanguage: m.Lang, BotLanguage: m.ChannelLang,
|
||||||
|
IP: m.SenderIP, Body: m.Body,
|
||||||
HasAttachment: m.HasAttachment, AttachmentName: m.AttachmentName, IsImage: feedback.IsImage(m.AttachmentName),
|
HasAttachment: m.HasAttachment, AttachmentName: m.AttachmentName, IsImage: feedback.IsImage(m.AttachmentName),
|
||||||
Read: m.Read, Archived: m.Archived, Replied: m.Replied, ReplyBody: m.ReplyBody,
|
Read: m.Read, Archived: m.Archived, Replied: m.Replied, ReplyBody: m.ReplyBody,
|
||||||
RepliedAt: fmtTime(m.RepliedAt), CreatedAt: fmtTime(m.CreatedAt),
|
RepliedAt: fmtTime(m.RepliedAt), CreatedAt: fmtTime(m.CreatedAt),
|
||||||
@@ -87,14 +88,6 @@ func (s *Server) consoleFeedbackDetail(c *gin.Context) {
|
|||||||
if banned, err := s.accounts.HasRole(ctx, m.AccountID, account.RoleFeedbackBanned); err == nil {
|
if banned, err := s.accounts.HasRole(ctx, m.AccountID, account.RoleFeedbackBanned); err == nil {
|
||||||
view.Banned = banned
|
view.Banned = banned
|
||||||
}
|
}
|
||||||
if acc, err := s.accounts.GetByID(ctx, m.AccountID); err == nil {
|
|
||||||
view.InterfaceLanguage = acc.PreferredLanguage
|
|
||||||
// The connector bot (the account's last service-language bot) is meaningful only
|
|
||||||
// for a message that arrived through an external connector — currently Telegram.
|
|
||||||
if m.Channel == "telegram" && acc.ServiceLanguage != "" {
|
|
||||||
view.BotLanguage = acc.ServiceLanguage
|
|
||||||
}
|
|
||||||
}
|
|
||||||
s.renderConsole(c, "feedback_detail", "feedback", "Feedback", view)
|
s.renderConsole(c, "feedback_detail", "feedback", "Feedback", view)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -179,8 +179,8 @@ PDF, text/log, office documents, RTF or archives; an unsupported file is refused
|
|||||||
without naming the allowed types). After sending, the form clears and confirms "Ваше сообщение
|
without naming the allowed types). After sending, the form clears and confirms "Ваше сообщение
|
||||||
отправлено", and sending is blocked until the operator has dealt with that message — on re-entry
|
отправлено", and sending is blocked until the operator has dealt with that message — on re-entry
|
||||||
the screen reads "Ожидаем рассмотрения вашего последнего обращения". The operator's reply appears
|
the screen reads "Ожидаем рассмотрения вашего последнего обращения". The operator's reply appears
|
||||||
below the form as "Ответ на ваше последнее сообщение"; it is marked read once the screen shows it
|
below the form as "Ответ на ваше последнее сообщение" (any link in it opens in a new tab); it is
|
||||||
and disappears a week later. A badge on the Settings tab (and on Info inside it) flags an
|
marked read once the screen shows it and disappears a week later. A badge on the Settings tab (and on Info inside it) flags an
|
||||||
unanswered reply. Guests cannot send feedback (the entry is hidden). A player the operator has
|
unanswered reply. Guests cannot send feedback (the entry is hidden). A player the operator has
|
||||||
barred from feedback (a role, not a full account block) sees the send control disabled.
|
barred from feedback (a role, not a full account block) sees the send control disabled.
|
||||||
|
|
||||||
|
|||||||
@@ -184,8 +184,8 @@ UTC), суточного окна отсутствия (away; сетка по 10
|
|||||||
допустимых типов). После отправки форма очищается и подтверждает «Ваше сообщение отправлено», а
|
допустимых типов). После отправки форма очищается и подтверждает «Ваше сообщение отправлено», а
|
||||||
повторная отправка блокируется, пока оператор не разобрал обращение — при повторном входе на экране
|
повторная отправка блокируется, пока оператор не разобрал обращение — при повторном входе на экране
|
||||||
«Ожидаем рассмотрения вашего последнего обращения». Ответ оператора показывается под формой как
|
«Ожидаем рассмотрения вашего последнего обращения». Ответ оператора показывается под формой как
|
||||||
«Ответ на ваше последнее сообщение»; он помечается прочитанным, как только экран его показал, и
|
«Ответ на ваше последнее сообщение» (ссылки в нём открываются в новой вкладке); он помечается
|
||||||
исчезает через неделю. Бейдж на вкладке Settings (и на Info внутри неё) сигналит о непрочитанном
|
прочитанным, как только экран его показал, и исчезает через неделю. Бейдж на вкладке Settings (и на Info внутри неё) сигналит о непрочитанном
|
||||||
ответе. Гость отправлять обратную связь не может (пункт скрыт). Игрок, которому оператор запретил
|
ответе. Гость отправлять обратную связь не может (пункт скрыт). Игрок, которому оператор запретил
|
||||||
обратную связь (роль, а не полная блокировка аккаунта), видит кнопку отправки недоступной.
|
обратную связь (роль, а не полная блокировка аккаунта), видит кнопку отправки недоступной.
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,11 @@ test('feedback: an operator reply raises the badge and shows on the screen', asy
|
|||||||
|
|
||||||
await openFeedback(page);
|
await openFeedback(page);
|
||||||
await expect(page.getByText('Reply to your last message')).toBeVisible();
|
await expect(page.getByText('Reply to your last message')).toBeVisible();
|
||||||
await expect(page.getByText(/looking into it/)).toBeVisible();
|
// A URL in the reply renders as a link that opens in a new tab.
|
||||||
|
const link = page.getByRole('link', { name: 'https://example.com/help' });
|
||||||
|
await expect(link).toBeVisible();
|
||||||
|
await expect(link).toHaveAttribute('target', '_blank');
|
||||||
|
await expect(link).toHaveAttribute('rel', /noopener/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('feedback: sending a new message clears the previous reply', async ({ page }) => {
|
test('feedback: sending a new message clears the previous reply', async ({ page }) => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { attachmentError, MAX_ATTACHMENT_BYTES } from './feedback';
|
import { attachmentError, linkify, MAX_ATTACHMENT_BYTES } from './feedback';
|
||||||
|
|
||||||
describe('attachmentError', () => {
|
describe('attachmentError', () => {
|
||||||
it('accepts allowed extensions within the size cap', () => {
|
it('accepts allowed extensions within the size cap', () => {
|
||||||
@@ -22,3 +22,56 @@ describe('attachmentError', () => {
|
|||||||
expect(attachmentError('shot.png', MAX_ATTACHMENT_BYTES + 1)).toBe('size');
|
expect(attachmentError('shot.png', MAX_ATTACHMENT_BYTES + 1)).toBe('size');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('linkify', () => {
|
||||||
|
it('returns a single text segment for plain text', () => {
|
||||||
|
expect(linkify('just text')).toEqual([{ text: 'just text' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks an http(s) URL as a link', () => {
|
||||||
|
expect(linkify('see https://example.com now')).toEqual([
|
||||||
|
{ text: 'see ' },
|
||||||
|
{ text: 'https://example.com', href: 'https://example.com' },
|
||||||
|
{ text: ' now' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps trailing punctuation out of the link', () => {
|
||||||
|
expect(linkify('go to https://example.com.')).toEqual([
|
||||||
|
{ text: 'go to ' },
|
||||||
|
{ text: 'https://example.com', href: 'https://example.com' },
|
||||||
|
{ text: '.' },
|
||||||
|
]);
|
||||||
|
expect(linkify('(see https://example.com/path)')).toEqual([
|
||||||
|
{ text: '(see ' },
|
||||||
|
{ text: 'https://example.com/path', href: 'https://example.com/path' },
|
||||||
|
{ text: ')' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles multiple links', () => {
|
||||||
|
const segs = linkify('a http://x.io b https://y.io');
|
||||||
|
expect(segs.filter((s) => s.href).map((s) => s.href)).toEqual(['http://x.io', 'https://y.io']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('linkifies whitelisted non-http schemes (ftp, mailto, tel)', () => {
|
||||||
|
expect(linkify('ftp://files.example.com/x')).toContainEqual({
|
||||||
|
text: 'ftp://files.example.com/x',
|
||||||
|
href: 'ftp://files.example.com/x',
|
||||||
|
});
|
||||||
|
expect(linkify('write to mailto:help@example.com please')).toContainEqual({
|
||||||
|
text: 'mailto:help@example.com',
|
||||||
|
href: 'mailto:help@example.com',
|
||||||
|
});
|
||||||
|
expect(linkify('call tel:+1234')).toContainEqual({ text: 'tel:+1234', href: 'tel:+1234' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never linkifies dangerous schemes', () => {
|
||||||
|
const segs = linkify('javascript:alert(1) data:text/html,x vbscript:msgbox');
|
||||||
|
expect(segs.some((s) => s.href)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not match a scheme inside a word (hotel:, email:)', () => {
|
||||||
|
expect(linkify('the hotel:foo and email:bar').some((s) => s.href)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -27,3 +27,42 @@ export function attachmentError(name: string, size: number): '' | 'type' | 'size
|
|||||||
if (size > MAX_ATTACHMENT_BYTES) return 'size';
|
if (size > MAX_ATTACHMENT_BYTES) return 'size';
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** ReplySegment is one run of the operator reply: plain text, or a link when href is set. */
|
||||||
|
export interface ReplySegment {
|
||||||
|
text: string;
|
||||||
|
href?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Links are recognised only for an explicit, whitelisted scheme, so dangerous ones
|
||||||
|
// (javascript:, data:, vbscript:) are never turned into an href. http/https/ftp use
|
||||||
|
// "scheme://host…"; mailto/tel use "scheme:rest". Matching stops at whitespace or '<'.
|
||||||
|
// The reply is operator-authored, so explicit schemes (no bare-domain guessing) are
|
||||||
|
// enough — which keeps this a few lines with no dependency.
|
||||||
|
const URL_RE = /\b(?:(?:https?|ftp):\/\/|(?:mailto|tel):)[^\s<]+/gi;
|
||||||
|
// Trailing punctuation that should not be part of the URL (kept as following text).
|
||||||
|
const TRAILING_RE = /[.,!?;:)\]}'"»]+$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* linkify splits the operator reply into segments, marking http(s) URLs as links so
|
||||||
|
* the UI renders them as anchors (opened in a new tab) while everything else stays
|
||||||
|
* plain, escaped text. It never emits markup — the caller renders each segment with
|
||||||
|
* the framework's escaping — so it cannot introduce XSS.
|
||||||
|
*/
|
||||||
|
export function linkify(text: string): ReplySegment[] {
|
||||||
|
const out: ReplySegment[] = [];
|
||||||
|
let last = 0;
|
||||||
|
for (const m of text.matchAll(URL_RE)) {
|
||||||
|
const start = m.index;
|
||||||
|
let url = m[0];
|
||||||
|
const trail = TRAILING_RE.exec(url)?.[0] ?? '';
|
||||||
|
if (trail) url = url.slice(0, url.length - trail.length);
|
||||||
|
if (start > last) out.push({ text: text.slice(last, start) });
|
||||||
|
out.push({ text: url, href: url });
|
||||||
|
if (trail) out.push({ text: trail });
|
||||||
|
last = start + m[0].length;
|
||||||
|
}
|
||||||
|
if (last < text.length) out.push({ text: text.slice(last) });
|
||||||
|
if (out.length === 0) out.push({ text });
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|||||||
@@ -435,7 +435,7 @@ export class MockGateway implements GatewayClient {
|
|||||||
// mockAdminReply simulates an operator reply: it clears the pending message, sets the
|
// mockAdminReply simulates an operator reply: it clears the pending message, sets the
|
||||||
// reply and raises the badge via a live admin_reply notification. e2e hook:
|
// reply and raises the badge via a live admin_reply notification. e2e hook:
|
||||||
// window.__mock.adminReply.
|
// window.__mock.adminReply.
|
||||||
mockAdminReply(body = 'Thanks — we are looking into it.'): void {
|
mockAdminReply(body = 'Thanks — see https://example.com/help for details.'): void {
|
||||||
this.feedbackPending = false;
|
this.feedbackPending = false;
|
||||||
this.feedbackReply = { body, repliedAtUnix: Math.floor(Date.now() / 1000) };
|
this.feedbackReply = { body, repliedAtUnix: Math.floor(Date.now() / 1000) };
|
||||||
this.feedbackReplyUnread = true;
|
this.feedbackReplyUnread = true;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
import { gateway } from '../lib/gateway';
|
import { gateway } from '../lib/gateway';
|
||||||
import { connection } from '../lib/connection.svelte';
|
import { connection } from '../lib/connection.svelte';
|
||||||
import { clientChannel } from '../lib/channel';
|
import { clientChannel } from '../lib/channel';
|
||||||
import { attachmentError, MAX_BODY } from '../lib/feedback';
|
import { attachmentError, linkify, MAX_BODY } from '../lib/feedback';
|
||||||
import type { FeedbackState } from '../lib/model';
|
import type { FeedbackState } from '../lib/model';
|
||||||
|
|
||||||
// The feedback screen: a message (+ optional single attachment) the player sends to
|
// The feedback screen: a message (+ optional single attachment) the player sends to
|
||||||
@@ -23,6 +23,8 @@
|
|||||||
const enabled = $derived(view?.canSend ?? false);
|
const enabled = $derived(view?.canSend ?? false);
|
||||||
const reason = $derived(view?.blockedReason ?? '');
|
const reason = $derived(view?.blockedReason ?? '');
|
||||||
const canSend = $derived(enabled && body.trim().length > 0 && !sending && connection.online);
|
const canSend = $derived(enabled && body.trim().length > 0 && !sending && connection.online);
|
||||||
|
// The operator reply, split into text + http(s) links for rendering.
|
||||||
|
const replySegments = $derived(view?.reply ? linkify(view.reply.body) : []);
|
||||||
|
|
||||||
onMount(() => void load());
|
onMount(() => void load());
|
||||||
|
|
||||||
@@ -121,7 +123,7 @@
|
|||||||
{#if view?.reply}
|
{#if view?.reply}
|
||||||
<section class="reply">
|
<section class="reply">
|
||||||
<h2>{t('feedback.replyTitle')}</h2>
|
<h2>{t('feedback.replyTitle')}</h2>
|
||||||
<p class="replybody">{view.reply.body}</p>
|
<p class="replybody">{#each replySegments as seg, i (i)}{#if seg.href}<a href={seg.href} target="_blank" rel="noopener noreferrer">{seg.text}</a>{:else}{seg.text}{/if}{/each}</p>
|
||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -213,4 +215,7 @@
|
|||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
.replybody a {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user