diff --git a/DESIGN.md b/DESIGN.md index 20e4baf..c8a5779 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -984,27 +984,33 @@ Message 可配置: - 所属 Thread - 正文 -- 创建者、创建时间 +- 创建者、创建时间、更新时间 - Reaction 汇总 - AI 审核状态和语言区 前台行为: - 所有人都可以浏览已公开的 Channel、Thread 和审核通过的 Message。 -- `/threads` 展示 Threads 工作区,左侧为 Channel 列表,中间为 Thread List;桌面端 Thread 详情使用聊天布局,移动端通过详情页堆叠显示。 -- `/threads/:threadId` 打开 Thread 详情;默认进入最新消息位置。 +- `/threads` 展示 Threads 工作区,左侧为 Channel 列表,中间为 Thread List。 +- `/threads/:threadId` 通过 route-backed Modal 打开 Thread 详情;默认进入最新消息位置。 - 用户可在 Channel 内创建 Thread;需要已注册、邮箱已验证并拥有 `threads.create` 权限,且 Channel 允许用户创建 Thread。 +- 创建 Thread 时可从 Thread List 顶部搜索框预填 Title,Title 可在创建表单中继续修改。 +- Thread 作者本人或拥有现有 Thread 管理权限的管理员可编辑 Thread 标题和 Tags;Tags 只能选择该 Channel 可用标签。 - 已注册、邮箱已验证并拥有 `threads.messages.create` 权限的用户可以在未锁定 Thread 中发送 Message。 +- Thread Message 输入框中 Enter 发送,Ctrl + Enter 输入换行。 +- Message 作者本人或拥有 `admin.threads.messages.delete` 权限的管理用户可编辑 Message 正文;编辑后 Message 重新进入 AI 审核,审核通过前不向普通访客公开。 +- `unreviewed`、`rejected` 和 `failed` 状态的 Message 可由作者本人或拥有 `admin.threads.messages.delete` 权限的管理用户触发重新审核;`reviewing` 和 `approved` 状态不可重新审核。 - Message 列表按创建时间正序展示,新消息出现在底部。 - 初始读取最新一页 Message;向上滚动或点击 To Top 加载更早历史消息。 - 有新消息且用户不在底部时显示 Jump to Present;点击后滚动到最新消息。 - 连续 Message 在展示层自动合并:同一用户连续发送,且相邻消息时间间隔不超过 5 分钟;合并组只显示一次 Avatar、Username 和组首条 Timestamp。合并窗口默认 5 分钟,后续可由系统配置扩展。 - Thread 支持 Follow / Unfollow;Follow 后新审核通过 Message 会让 Threads Sidebar 和 Thread List 显示未读红点或未读提示。 - Thread 详情支持未读消息分隔线;用户进入最新位置或显式标记已读后更新 `thread_reads`。 -- Thread 和 Message 支持 Emoji Reaction,内置类型为 `thumbs-up`、`heart`、`laugh`、`fire`、`eyes`;API 只返回各类型数量和当前用户自己的 Reaction,不内嵌用户列表。 +- Thread 和 Message 支持 Emoji Reaction,当前提供默认快捷 Emoji:`👍`、`❤️`、`😂`、`🔥`、`👀`;API 只返回各类型数量和当前用户自己的 Reaction,不内嵌用户列表。 - Thread List 支持排序:`last-active` 默认按最后活跃倒序;`latest` 按创建时间倒序;`most-discussed` 按公开消息数倒序。 - Thread List 支持语言筛选:All languages 或指定启用语言 / Channel 可用语言。 - Thread List 支持按 Channel 标签筛选。 +- Thread List 提供前端快速搜索,可在当前已加载列表内按 Thread 标题、作者展示名、语言和标签过滤;当前不提供后端全文搜索。 - Thread 新消息实时更新通过 Thread WebSocket;WebSocket 使用短期一次性 ticket,不把 session token 放入 WebSocket URL。 - Thread Message 是用户生成内容,必须经过 AI 审核;未审核通过的 Message 不向普通访客公开。作者本人和拥有 `admin.threads.messages.delete` 权限的管理用户可以看到自己的未通过/审核中 Message 状态。 - 审核通过的 Message 才计入普通公开消息数、最后活跃排序和未读状态。 @@ -1024,7 +1030,7 @@ API 暴露边界: - Channel API 只返回展示和管理需要的 `id`、`name`、`allowUserThreads`、`tags`、`languages`、`sortOrder` 和未读摘要;不返回内部审计或调试字段。 - Thread API 只返回 `id`、`channelId`、`title`、标签、语言、作者必要署名、创建时间、最后活跃时间、锁定状态、消息数、Reaction 汇总、当前用户 Reaction、Follow 状态和未读状态。 -- Message API 只返回 `id`、`threadId`、`body`、作者必要署名、创建时间、审核状态、语言区、必要审核原因、Reaction 汇总和当前用户 Reaction。 +- Message API 只返回 `id`、`threadId`、`body`、作者必要署名、创建时间、更新时间、审核状态、语言区、必要审核原因、Reaction 汇总和当前用户 Reaction。 - API 不返回邮箱、角色、权限、session、token/hash、AI prompt、模型响应、内部审核错误、错误堆栈、删除时间、删除人或内部调试字段。 - Thread 内容正文按作者输入展示,不进入 `entity_translations`;Thread 的语言用于筛选和内容区分,不改变系统 UI 语言。 - Channel 名称和标签当前作为管理数据直接存储,不进入 `entity_translations`。 @@ -1038,11 +1044,11 @@ API 暴露边界: - RBAC 已包含 Thread 用户权限:`threads.create`、`threads.messages.create`、`threads.follow`、`threads.reactions.set`。 - RBAC 已包含 Thread 管理权限:`admin.threads.channels.*`、`admin.threads.threads.delete`、`admin.threads.threads.lock`、`admin.threads.messages.delete`。 - 公开 API 已支持读取 Channel、分页读取 Thread、读取单个 Thread、读取 Thread Message 历史。 -- 写入 API 已支持创建 Thread、发送 Message、Follow / Unfollow、标记已读、设置 / 取消 Thread Reaction、设置 / 取消 Message Reaction。 +- 写入 API 已支持创建 Thread、发送与编辑 Message、Message 重新审核、Follow / Unfollow、标记已读、设置 / 取消 Thread Reaction、设置 / 取消 Message Reaction。 - 管理 API 已支持创建、编辑、删除 Channel,锁定 / 解锁 Thread,删除 Thread,删除 Message。 - Thread Message 已接入 AI 审核队列;审核通过后才更新 Thread 的公开 `message_count`、`last_message_id` 和 `last_active_at`。 - Thread WebSocket 已实现短期 ticket 连接,并可推送新审核通过 Message、Reaction 更新和当前用户 read 状态更新。 -- 前端已新增 `/threads` 和 `/threads/:threadId`,包含 Channel Sidebar、Thread List、Thread 详情聊天布局、创建 Thread、发送 Message、Follow / Unfollow、Reaction、管理员锁定 / 解锁 Thread、管理员删除 Thread 和管理员删除 Message。 +- 前端已新增 `/threads` 和 `/threads/:threadId`,包含 Channel Sidebar、Thread List、Thread 详情 Modal、创建 Thread、编辑 Thread 标题和 Tags、发送与编辑 Message、Message 重新审核、Follow / Unfollow、Reaction、管理员锁定 / 解锁 Thread、管理员删除 Thread 和管理员删除 Message。 - 前端 Message 展示已支持同一用户 5 分钟内连续消息的合并显示。 - 前端 Message 历史已支持点击 Load older 向上加载更早消息。 - 前端已支持 Jump to Present:用户不在底部且收到新消息时可跳到最新。 @@ -1062,7 +1068,7 @@ API 暴露边界: - Thread 删除、Message 删除和锁定 / 解锁当前直接执行,尚未使用确认 Modal。 - Thread List 的实时排序更新是基础 upsert 行为;复杂筛选条件下收到不匹配当前筛选的新 Thread / Message 时,仍可能需要后续刷新来得到完全一致的列表。 - 移动端已使用响应式堆叠布局,但还不是独立的移动端双页导航体验;后续可优化为 Channel / Thread List / Chat 分步视图。 -- 当前没有 Thread 搜索、置顶、收藏、编辑 Thread 标题 / 标签 / 语言、编辑 Message、上传图片、@mention 或通知到全局 NotificationBell。 +- 当前没有 Thread 后端全文搜索、置顶、收藏、编辑 Thread 语言、编辑 Message、上传图片、@mention 或通知到全局 NotificationBell。 ## 开发中入口 @@ -1292,6 +1298,8 @@ API 暴露边界: - `DELETE /api/discussions/comments/:id/like` - Thread 创建需要 `threads.create`。 - `POST /api/threads` +- Thread 编辑需要作者本人或现有 Thread 管理权限。 + - `PUT /api/threads/:id` - Thread Message 创建需要 `threads.messages.create`。 - `POST /api/threads/:id/messages` - Thread Follow 需要 `threads.follow`。 diff --git a/backend/db/schema.sql b/backend/db/schema.sql index 5362daf..35a377b 100644 --- a/backend/db/schema.sql +++ b/backend/db/schema.sql @@ -987,24 +987,52 @@ CREATE INDEX IF NOT EXISTS thread_messages_user_idx CREATE TABLE IF NOT EXISTS thread_reactions ( thread_id integer NOT NULL REFERENCES threads(id) ON DELETE CASCADE, user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE, - reaction_type text NOT NULL CHECK (reaction_type IN ('thumbs-up', 'heart', 'laugh', 'fire', 'eyes')), + reaction_type text NOT NULL CHECK (length(reaction_type) BETWEEN 1 AND 24), created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), PRIMARY KEY (thread_id, user_id, reaction_type) ); +ALTER TABLE thread_reactions + DROP CONSTRAINT IF EXISTS thread_reactions_reaction_type_check, + ADD CONSTRAINT thread_reactions_reaction_type_check CHECK (length(reaction_type) BETWEEN 1 AND 24); +UPDATE thread_reactions +SET reaction_type = CASE reaction_type + WHEN 'thumbs-up' THEN '👍' + WHEN 'heart' THEN '❤️' + WHEN 'laugh' THEN '😂' + WHEN 'fire' THEN '🔥' + WHEN 'eyes' THEN '👀' + ELSE reaction_type +END +WHERE reaction_type IN ('thumbs-up', 'heart', 'laugh', 'fire', 'eyes'); + CREATE INDEX IF NOT EXISTS thread_reactions_thread_idx ON thread_reactions(thread_id, reaction_type); CREATE TABLE IF NOT EXISTS thread_message_reactions ( message_id integer NOT NULL REFERENCES thread_messages(id) ON DELETE CASCADE, user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE, - reaction_type text NOT NULL CHECK (reaction_type IN ('thumbs-up', 'heart', 'laugh', 'fire', 'eyes')), + reaction_type text NOT NULL CHECK (length(reaction_type) BETWEEN 1 AND 24), created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), PRIMARY KEY (message_id, user_id, reaction_type) ); +ALTER TABLE thread_message_reactions + DROP CONSTRAINT IF EXISTS thread_message_reactions_reaction_type_check, + ADD CONSTRAINT thread_message_reactions_reaction_type_check CHECK (length(reaction_type) BETWEEN 1 AND 24); +UPDATE thread_message_reactions +SET reaction_type = CASE reaction_type + WHEN 'thumbs-up' THEN '👍' + WHEN 'heart' THEN '❤️' + WHEN 'laugh' THEN '😂' + WHEN 'fire' THEN '🔥' + WHEN 'eyes' THEN '👀' + ELSE reaction_type +END +WHERE reaction_type IN ('thumbs-up', 'heart', 'laugh', 'fire', 'eyes'); + CREATE INDEX IF NOT EXISTS thread_message_reactions_message_idx ON thread_message_reactions(message_id, reaction_type); diff --git a/backend/src/aiModeration.ts b/backend/src/aiModeration.ts index 58e2e3d..f3dd86f 100644 --- a/backend/src/aiModeration.ts +++ b/backend/src/aiModeration.ts @@ -772,12 +772,47 @@ async function updateTargetStatus( if (status === 'approved') { await applyApprovedThreadMessage(target.id); } else { - const row = await queryOne<{ threadId: number }>( - 'SELECT thread_id AS "threadId" FROM thread_messages WHERE id = $1', + const row = await queryOne<{ + threadId: number; + body: string; + moderationStatus: AiModerationStatus; + moderationLanguageCode: string | null; + moderationReason: string | null; + createdAt: Date; + updatedAt: Date; + author: { id: number; displayName: string } | null; + }>( + ` + SELECT + tm.thread_id AS "threadId", + tm.body, + tm.ai_moderation_status AS "moderationStatus", + tm.ai_moderation_language_code AS "moderationLanguageCode", + tm.ai_moderation_reason AS "moderationReason", + tm.created_at AS "createdAt", + tm.updated_at AS "updatedAt", + CASE WHEN u.id IS NULL THEN NULL ELSE json_build_object('id', u.id, 'displayName', u.display_name) END AS author + FROM thread_messages tm + LEFT JOIN users u ON u.id = tm.created_by_user_id + WHERE tm.id = $1 + AND tm.deleted_at IS NULL + `, [target.id] ); if (row) { - await publishThreadMessageModeration(row.threadId, null); + await publishThreadMessageModeration(row.threadId, target.id, { + id: target.id, + threadId: row.threadId, + body: row.body, + moderationStatus: row.moderationStatus, + moderationLanguageCode: row.moderationLanguageCode, + moderationReason: row.moderationReason, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + author: row.author, + reactionCounts: {}, + myReactions: [] + }); } } return; diff --git a/backend/src/queries.ts b/backend/src/queries.ts index 75878c8..39e940c 100644 --- a/backend/src/queries.ts +++ b/backend/src/queries.ts @@ -33,8 +33,8 @@ type ListPage = { nextCursor: string | null; hasMore: boolean; }; -export type ThreadReactionType = 'thumbs-up' | 'heart' | 'laugh' | 'fire' | 'eyes'; -export type ThreadReactionCounts = Record; +export type ThreadReactionType = string; +export type ThreadReactionCounts = Record; export type ThreadChannelTag = { id: number; name: string; sortOrder: number }; export type ThreadChannel = { id: number; @@ -1154,6 +1154,12 @@ function validationError(message: string): ValidationError { return error; } +function forbiddenError(): ValidationError { + const error = new Error('server.errors.permissionDenied') as ValidationError; + error.statusCode = 403; + return error; +} + function requirePositiveInteger(value: unknown, message: string): number { const numberValue = Number(value); if (!Number.isInteger(numberValue) || numberValue <= 0) { @@ -8891,28 +8897,31 @@ export async function importAdminHabitatsCsv(payload: Record, u return getAdminDataToolsSummary(); } -const threadReactionTypes: ThreadReactionType[] = ['thumbs-up', 'heart', 'laugh', 'fire', 'eyes']; const defaultThreadLimit = 20; const maxThreadLimit = 50; const defaultThreadMessageLimit = 30; const maxThreadMessageLimit = 80; +const threadEmojiReactionPattern = /(?:\p{Extended_Pictographic}|\p{Regional_Indicator})/u; type ThreadCursor = { value: string; id: number }; type ThreadMessageCursor = { createdAt: string; id: number }; function emptyThreadReactionCounts(): ThreadReactionCounts { - return { 'thumbs-up': 0, heart: 0, laugh: 0, fire: 0, eyes: 0 }; -} - -function isThreadReactionType(value: unknown): value is ThreadReactionType { - return typeof value === 'string' && threadReactionTypes.includes(value as ThreadReactionType); + return {}; } function cleanThreadReactionType(value: unknown): ThreadReactionType { - if (!isThreadReactionType(value)) { + const reactionType = typeof value === 'string' ? value.trim() : ''; + if ( + !reactionType || + reactionType.length > 24 || + /\s/.test(reactionType) || + /[\p{Letter}\p{Number}]/u.test(reactionType) || + !threadEmojiReactionPattern.test(reactionType) + ) { throw validationError('server.validation.reactionInvalid'); } - return value; + return reactionType; } function cleanThreadLimit(value: QueryValue, fallback = defaultThreadLimit, max = maxThreadLimit): number { @@ -9113,7 +9122,7 @@ async function threadReactionCounts(threadIds: number[], userId: number | null): ); for (const row of countRows) { const item = counts.get(row.threadId); - if (item && isThreadReactionType(row.reactionType)) { + if (item) { item[row.reactionType] = row.count; } } @@ -9129,7 +9138,6 @@ async function threadReactionCounts(threadIds: number[], userId: number | null): [userId, threadIds] ); for (const row of myRows) { - if (!isThreadReactionType(row.reactionType)) continue; mine.set(row.threadId, [...(mine.get(row.threadId) ?? []), row.reactionType]); } } @@ -9157,7 +9165,7 @@ async function threadMessageReactionCounts(messageIds: number[], userId: number ); for (const row of countRows) { const item = counts.get(row.messageId); - if (item && isThreadReactionType(row.reactionType)) { + if (item) { item[row.reactionType] = row.count; } } @@ -9173,7 +9181,6 @@ async function threadMessageReactionCounts(messageIds: number[], userId: number [userId, messageIds] ); for (const row of myRows) { - if (!isThreadReactionType(row.reactionType)) continue; mine.set(row.messageId, [...(mine.get(row.messageId) ?? []), row.reactionType]); } } @@ -9464,6 +9471,68 @@ export async function createThread(payload: Record, userId: num return (await getThread(ids.threadId, userId)) as ThreadSummary; } +export async function updateThread( + threadIdValue: number, + payload: Record, + userId: number, + canUpdateAny = false +): Promise { + const threadId = requirePositiveInteger(threadIdValue, 'server.validation.recordInvalid'); + const title = cleanThreadTitle(payload.title); + const tagIds = cleanThreadTagIds(payload.tagIds); + const thread = await queryOne<{ id: number; channelId: number; createdByUserId: number }>( + ` + SELECT id, channel_id AS "channelId", created_by_user_id AS "createdByUserId" + FROM threads + WHERE id = $1 + AND deleted_at IS NULL + `, + [threadId] + ); + if (!thread) return null; + if (!canUpdateAny && thread.createdByUserId !== userId) { + throw forbiddenError(); + } + await validateThreadTags(thread.channelId, tagIds); + + await withTransaction(async (client) => { + await client.query('UPDATE threads SET title = $1, updated_by_user_id = $2, updated_at = now() WHERE id = $3', [title, userId, threadId]); + await client.query('DELETE FROM thread_tag_links WHERE thread_id = $1', [threadId]); + for (const tagId of tagIds) { + await client.query('INSERT INTO thread_tag_links (thread_id, tag_id) VALUES ($1, $2) ON CONFLICT DO NOTHING', [threadId, tagId]); + } + }); + + return getThread(threadId, userId); +} + +async function refreshThreadMessageAggregates(threadId: number): Promise { + await pool.query( + ` + UPDATE threads t + SET message_count = ( + SELECT COUNT(*)::integer + FROM thread_messages tm + WHERE tm.thread_id = t.id + AND tm.deleted_at IS NULL + AND tm.ai_moderation_status = 'approved' + ), + last_message_id = ( + SELECT tm.id + FROM thread_messages tm + WHERE tm.thread_id = t.id + AND tm.deleted_at IS NULL + AND tm.ai_moderation_status = 'approved' + ORDER BY tm.created_at DESC, tm.id DESC + LIMIT 1 + ), + updated_at = now() + WHERE t.id = $1 + `, + [threadId] + ); +} + export async function createThreadMessage(threadIdValue: number, payload: Record, userId: number): Promise { const threadId = requirePositiveInteger(threadIdValue, 'server.validation.recordInvalid'); const body = cleanThreadMessageBody(payload.body); @@ -9488,6 +9557,87 @@ export async function createThreadMessage(threadIdValue: number, payload: Record return getThreadMessageById(result.id, userId, false); } +export async function updateThreadMessage( + messageIdValue: number, + payload: Record, + userId: number, + canUpdateAny = false +): Promise { + const messageId = requirePositiveInteger(messageIdValue, 'server.validation.recordInvalid'); + const body = cleanThreadMessageBody(payload.body); + const message = await queryOne<{ id: number; threadId: number; languageCode: string; createdByUserId: number }>( + ` + SELECT + tm.id, + tm.thread_id AS "threadId", + t.language_code AS "languageCode", + tm.created_by_user_id AS "createdByUserId" + FROM thread_messages tm + JOIN threads t ON t.id = tm.thread_id + WHERE tm.id = $1 + AND tm.deleted_at IS NULL + AND t.deleted_at IS NULL + `, + [messageId] + ); + if (!message) return null; + if (!canUpdateAny && message.createdByUserId !== userId) { + throw forbiddenError(); + } + + const result = await queryOne<{ id: number }>( + ` + UPDATE thread_messages + SET body = $1, + ai_moderation_status = 'reviewing', + ai_moderation_language_code = NULL, + ai_moderation_reason = NULL, + ai_moderation_content_hash = NULL, + ai_moderation_checked_at = NULL, + ai_moderation_retry_count = 0, + ai_moderation_updated_at = now(), + updated_at = now() + WHERE id = $2 + AND deleted_at IS NULL + RETURNING id + `, + [body, messageId] + ); + if (!result) return null; + + await refreshThreadMessageAggregates(message.threadId); + await publishThreadMessageModeration(message.threadId, messageId, null); + await requestAiModerationReview({ type: 'thread-message', id: messageId }, { languageCode: message.languageCode, resetRetries: true }); + return getThreadMessageById(messageId, userId, canUpdateAny); +} + +export async function retryThreadMessageModeration( + messageIdValue: number, + userId: number, + canRetryAny = false +): Promise { + const messageId = requirePositiveInteger(messageIdValue, 'server.validation.recordInvalid'); + const message = await queryOne<{ id: number; createdByUserId: number }>( + ` + SELECT tm.id, tm.created_by_user_id AS "createdByUserId" + FROM thread_messages tm + JOIN threads t ON t.id = tm.thread_id + WHERE tm.id = $1 + AND tm.deleted_at IS NULL + AND t.deleted_at IS NULL + AND tm.ai_moderation_status IN ('unreviewed', 'rejected', 'failed') + `, + [messageId] + ); + if (!message) return null; + if (!canRetryAny && message.createdByUserId !== userId) { + throw forbiddenError(); + } + + await requestAiModerationReview({ type: 'thread-message', id: messageId }, { incrementRetries: true }); + return getThreadMessageById(messageId, userId, canRetryAny); +} + export async function markThreadRead(threadIdValue: number, userId: number): Promise { const threadId = requirePositiveInteger(threadIdValue, 'server.validation.recordInvalid'); const row = await queryOne<{ lastMessageId: number | null }>( @@ -9560,7 +9710,18 @@ export async function deleteThreadReaction(threadIdValue: number, payload: Recor const thread = await getThread(threadId, userId); if (!thread) return null; await pool.query('DELETE FROM thread_reactions WHERE thread_id = $1 AND user_id = $2 AND reaction_type = $3', [threadId, userId, reactionType]); - return getThread(threadId, userId); + const updated = await getThread(threadId, userId); + if (updated) { + await publishThreadReactionUpdated(userId, { + type: 'thread.reactions.updated', + target: 'thread', + threadId, + messageId: null, + reactionCounts: updated.reactionCounts, + myReactions: updated.myReactions + }); + } + return updated; } export async function setThreadMessageReaction(messageIdValue: number, payload: Record, userId: number): Promise { @@ -9597,7 +9758,18 @@ export async function deleteThreadMessageReaction(messageIdValue: number, payloa const message = await getThreadMessageById(messageId, userId); if (!message) return null; await pool.query('DELETE FROM thread_message_reactions WHERE message_id = $1 AND user_id = $2 AND reaction_type = $3', [messageId, userId, reactionType]); - return getThreadMessageById(messageId, userId); + const updated = await getThreadMessageById(messageId, userId); + if (updated) { + await publishThreadReactionUpdated(userId, { + type: 'thread.reactions.updated', + target: 'message', + threadId: updated.threadId, + messageId, + reactionCounts: updated.reactionCounts, + myReactions: updated.myReactions + }); + } + return updated; } export async function applyApprovedThreadMessage(messageId: number): Promise { @@ -9629,7 +9801,7 @@ export async function applyApprovedThreadMessage(messageId: number): Promise { return user ? reply.code(201).send(await createThread(request.body as Record, user.id)) : undefined; }); +app.put('/api/threads/:id', async (request, reply) => { + const user = await requireVerifiedUser(request, reply); + if (!user || !(await enforceUserRateLimits(request, reply, user, 'communityWrite'))) { + return; + } + const { id } = request.params as { id: string }; + const canUpdateAny = userHasPermission(user, 'admin.threads.threads.lock') || userHasPermission(user, 'admin.threads.threads.delete'); + const thread = await updateThread(Number(id), request.body as Record, user.id, canUpdateAny); + return thread ? thread : notFound(reply, request); +}); + app.get('/api/threads/:id', async (request, reply) => { const { id } = request.params as { id: string }; const user = await optionalUser(request); @@ -1764,6 +1778,38 @@ app.post('/api/threads/:id/messages', async (request, reply) => { return message ? reply.code(201).send(message) : notFound(reply, request); }); +app.put('/api/thread-messages/:id', async (request, reply) => { + const user = await requireAnyPermissionWithRateLimits( + request, + reply, + ['threads.messages.create', 'admin.threads.messages.delete'], + 'communityWrite' + ); + if (!user) { + return; + } + const { id } = request.params as { id: string }; + const canUpdateAny = userHasPermission(user, 'admin.threads.messages.delete'); + const message = await updateThreadMessage(Number(id), request.body as Record, user.id, canUpdateAny); + return message ? message : notFound(reply, request); +}); + +app.post('/api/thread-messages/:id/moderation/retry', async (request, reply) => { + const user = await requireAnyPermissionWithRateLimits( + request, + reply, + ['threads.messages.create', 'admin.threads.messages.delete'], + 'communityWrite' + ); + if (!user) { + return; + } + const { id } = request.params as { id: string }; + const canRetryAny = userHasPermission(user, 'admin.threads.messages.delete'); + const message = await retryThreadMessageModeration(Number(id), user.id, canRetryAny); + return message ? message : notFound(reply, request); +}); + app.put('/api/threads/:id/follow', async (request, reply) => { const user = await requirePermissionWithRateLimits(request, reply, 'threads.follow', 'communityReaction'); if (!user) { diff --git a/backend/src/threadsRealtime.ts b/backend/src/threadsRealtime.ts index 5404c76..3225438 100644 --- a/backend/src/threadsRealtime.ts +++ b/backend/src/threadsRealtime.ts @@ -9,7 +9,7 @@ import type { ThreadMessage, ThreadReactionCounts, ThreadReactionType, ThreadSum export type ThreadWsMessage = | { type: 'threads.connected'; followedUnreadCount: number } | { type: 'thread.message.created'; threadId: number; message: ThreadMessage; thread: ThreadSummary } - | { type: 'thread.message.moderation'; threadId: number; message: ThreadMessage | null } + | { type: 'thread.message.moderation'; threadId: number; messageId: number; message: ThreadMessage | null } | { type: 'thread.reactions.updated'; target: 'thread' | 'message'; @@ -311,7 +311,7 @@ export async function applyApprovedThreadMessage(messageId: number): Promise { - await publishToUsers([...new Set([...(await recipientUserIds(threadId)), ...connectedUserIds()])], { +export async function publishThreadMessageModeration( + threadId: number, + messageId: number, + message: ThreadMessage | null +): Promise { + const publicUsers = new Set([...(await recipientUserIds(threadId)), ...connectedUserIds()]); + if (message?.author?.id) { + publicUsers.delete(message.author.id); + } + + await publishToUsers([...publicUsers], { type: 'thread.message.moderation', threadId, + messageId, + message: null + }); + + if (!message?.author?.id) { + return; + } + + await publishToUsers([message.author.id], { + type: 'thread.message.moderation', + threadId, + messageId, message }); } diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 5085219..9c716f0 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -575,8 +575,8 @@ export interface LifeReactionUsersParams { reactionType?: LifeReactionType; } -export type ThreadReactionType = 'thumbs-up' | 'heart' | 'laugh' | 'fire' | 'eyes'; -export type ThreadReactionCounts = Record; +export type ThreadReactionType = string; +export type ThreadReactionCounts = Record; export type ThreadSort = 'last-active' | 'latest' | 'most-discussed'; export interface ThreadChannelTag { @@ -660,6 +660,11 @@ export interface ThreadPayload { tagIds: number[]; } +export interface ThreadUpdatePayload { + title: string; + tagIds: number[]; +} + export interface ThreadMessagePayload { body: string; } @@ -672,7 +677,7 @@ export interface ThreadWsTicket { export type ThreadWsMessage = | { type: 'threads.connected'; followedUnreadCount: number } | { type: 'thread.message.created'; threadId: number; message: ThreadMessage; thread: ThreadSummary } - | { type: 'thread.message.moderation'; threadId: number; message: ThreadMessage | null } + | { type: 'thread.message.moderation'; threadId: number; messageId: number; message: ThreadMessage | null } | { type: 'thread.reactions.updated'; target: 'thread' | 'message'; @@ -1490,6 +1495,7 @@ export const api = { ), thread: (id: string | number) => getJson(`/api/threads/${id}`), createThread: (payload: ThreadPayload) => sendJson('/api/threads', 'POST', payload), + updateThread: (id: string | number, payload: ThreadUpdatePayload) => sendJson(`/api/threads/${id}`, 'PUT', payload), threadMessages: (id: string | number, params: ThreadMessagesParams = {}) => getJson( `/api/threads/${id}/messages${buildQuery({ @@ -1499,6 +1505,10 @@ export const api = { ), createThreadMessage: (id: string | number, payload: ThreadMessagePayload) => sendJson(`/api/threads/${id}/messages`, 'POST', payload), + updateThreadMessage: (id: string | number, payload: ThreadMessagePayload) => + sendJson(`/api/thread-messages/${id}`, 'PUT', payload), + retryThreadMessageModeration: (id: string | number) => + sendJson(`/api/thread-messages/${id}/moderation/retry`, 'POST', {}), followThread: (id: string | number) => sendJson(`/api/threads/${id}/follow`, 'PUT', {}), unfollowThread: (id: string | number) => deleteAndGetJson(`/api/threads/${id}/follow`), markThreadRead: (id: string | number) => sendJson(`/api/threads/${id}/read`, 'POST', {}), diff --git a/frontend/src/styles/main.css b/frontend/src/styles/main.css index 86641e3..c64d82f 100644 --- a/frontend/src/styles/main.css +++ b/frontend/src/styles/main.css @@ -100,6 +100,18 @@ svg { flex: 0 0 auto; } +.sr-only { + position: absolute !important; + width: 1px !important; + height: 1px !important; + padding: 0 !important; + margin: -1px !important; + overflow: hidden !important; + clip: rect(0, 0, 0, 0) !important; + white-space: nowrap !important; + border: 0 !important; +} + :focus-visible { outline: 3px solid var(--focus); outline-offset: 3px; @@ -9692,7 +9704,7 @@ button:disabled, .threads-layout { min-height: min(78vh, 860px); display: grid; - grid-template-columns: minmax(180px, 220px) minmax(260px, 360px) minmax(0, 1fr); + grid-template-columns: minmax(180px, 220px) minmax(0, 1fr); gap: 16px; align-items: stretch; } @@ -9762,12 +9774,52 @@ button:disabled, .threads-list-panel { display: grid; - grid-template-rows: auto auto minmax(0, 1fr); + grid-template-rows: auto auto auto minmax(0, 1fr); gap: 12px; padding: 14px; overflow: hidden; } +.thread-search-create { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px; + align-items: end; +} + +.thread-search-control { + min-width: 0; + min-height: 44px; + display: flex; + align-items: center; + gap: 8px; + padding: 0 12px; + border: 1px solid var(--line); + border-radius: var(--radius-control); + background: var(--surface-soft); + color: var(--muted); +} + +.thread-search-control .ui-icon { + width: 20px; + height: 20px; + flex: 0 0 auto; +} + +.thread-search-control input { + width: 100%; + min-width: 0; + border: 0; + background: transparent; + color: var(--ink); + outline: 0; +} + +.thread-search-control:focus-within { + border-color: var(--focus); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--focus) 16%, transparent); +} + .thread-filters { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); @@ -9782,7 +9834,6 @@ button:disabled, font-weight: 900; } -.thread-filters select, .thread-composer textarea { width: 100%; } @@ -9864,9 +9915,16 @@ button.thread-chip { overflow: hidden; } +.thread-chat-panel--modal { + min-height: min(72vh, 680px); + border: 0; + border-radius: 0; + box-shadow: none; +} + .thread-chat-header { display: flex; - justify-content: space-between; + justify-content: flex-end; gap: 14px; padding: 16px; border-bottom: 1px solid var(--line); @@ -9894,6 +9952,7 @@ button.thread-chip { display: flex; flex-wrap: wrap; gap: 6px; + align-items: center; padding: 10px 16px; border-bottom: 1px solid var(--line); } @@ -9923,6 +9982,17 @@ button.thread-chip { color: var(--ink); } +.thread-reaction__emoji { + font-size: 17px; + line-height: 1; +} + +.thread-reaction--action { + padding: 4px 9px; + font-size: 13px; + font-weight: 900; +} + .thread-message-scroll { min-height: 0; overflow: auto; @@ -9976,6 +10046,24 @@ button.thread-chip { white-space: pre-wrap; } +.thread-message-edit { + display: grid; + gap: 8px; +} + +.thread-message-edit textarea { + width: 100%; + min-height: 92px; + resize: vertical; +} + +.thread-message-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; +} + .thread-composer { display: grid; grid-template-columns: minmax(0, 1fr) auto; @@ -10018,10 +10106,6 @@ button.thread-chip { .threads-layout { grid-template-columns: minmax(180px, 220px) minmax(0, 1fr); } - - .thread-chat-panel { - grid-column: 1 / -1; - } } @media (max-width: 640px) { @@ -10031,6 +10115,7 @@ button.thread-chip { grid-template-columns: 1fr; } + .thread-search-create, .thread-filters, .thread-composer, .thread-chat-header { diff --git a/frontend/src/views/ThreadsView.vue b/frontend/src/views/ThreadsView.vue index 3ae62e1..5badd28 100644 --- a/frontend/src/views/ThreadsView.vue +++ b/frontend/src/views/ThreadsView.vue @@ -7,15 +7,20 @@ import Modal from '../components/Modal.vue'; import PageHeader from '../components/PageHeader.vue'; import Skeleton from '../components/Skeleton.vue'; import StatusMessage from '../components/StatusMessage.vue'; +import TagsSelect, { type TagsSelectOption } from '../components/TagsSelect.vue'; import { iconAdd, iconBell, + iconCancel, iconChevronUp, iconComment, iconDelete, + iconEdit, + iconSave, + iconSearch, iconSend, iconThreads, - type AppIcon + iconUndo } from '../icons'; import { api, @@ -50,6 +55,7 @@ const selectedChannelId = ref(null); const selectedTagId = ref(null); const selectedLanguage = ref('all'); const sort = ref('last-active'); +const threadSearch = ref(''); const nextCursor = ref(null); const hasMoreThreads = ref(false); const beforeCursor = ref(null); @@ -61,19 +67,19 @@ const loadingOlder = ref(false); const busy = ref(false); const errorMessage = ref(''); const createModalOpen = ref(false); +const editModalOpen = ref(false); const composerBody = ref(''); const threadForm = ref({ title: '', body: '', languageCode: '', tagIds: [] as number[] }); +const threadEditForm = ref({ title: '', tagIds: [] as number[] }); +const editingMessageId = ref(null); +const editingMessageBody = ref(''); +const messageActionBusyId = ref(null); +const moderationBusyId = ref(null); const socket = ref(null); const chatScroller = ref(null); const showJump = ref(false); -const reactionOptions: Array<{ type: ThreadReactionType; label: string; icon: AppIcon }> = [ - { type: 'thumbs-up', label: '👍', icon: 'mdi:thumb-up-outline' }, - { type: 'heart', label: '❤️', icon: 'mdi:heart-outline' }, - { type: 'laugh', label: '😂', icon: 'mdi:emoticon-lol-outline' }, - { type: 'fire', label: '🔥', icon: 'mdi:fire' }, - { type: 'eyes', label: '👀', icon: 'mdi:eye-outline' } -]; +const reactionOptions: ThreadReactionType[] = ['👍', '❤️', '😂', '🔥', '👀']; const selectedChannel = computed(() => channels.value.find((channel) => channel.id === selectedChannelId.value) ?? null); const activeThreadId = computed(() => { @@ -88,13 +94,54 @@ const canReact = computed(() => currentUser.value?.permissions.includes('threads const canLockThreads = computed(() => currentUser.value?.permissions.includes('admin.threads.threads.lock') === true); const canDeleteThreads = computed(() => currentUser.value?.permissions.includes('admin.threads.threads.delete') === true); const canDeleteMessages = computed(() => currentUser.value?.permissions.includes('admin.threads.messages.delete') === true); +const activeThreadChannel = computed(() => channels.value.find((channel) => channel.id === activeThread.value?.channelId) ?? selectedChannel.value); +const editTagOptions = computed(() => activeThreadChannel.value?.tags ?? []); +const canEditActiveThread = computed(() => { + const thread = activeThread.value; + if (!thread || !currentUser.value) return false; + return thread.author?.id === currentUser.value.id || canLockThreads.value || canDeleteThreads.value; +}); const languageOptions = computed(() => { const channelLanguages = selectedChannel.value?.languages ?? channels.value.flatMap((channel) => channel.languages); const byCode = new Map(channelLanguages.map((language) => [language.code, language])); return [...byCode.values()]; }); const tagOptions = computed(() => selectedChannel.value?.tags ?? []); -const currentThreadList = computed(() => threads.value); +const languageFilterOptions = computed(() => [ + { id: 'all', name: t('pages.threads.allLanguages') }, + ...languageOptions.value.map((language) => ({ id: language.code, name: language.name })) +]); +const sortOptions = computed(() => [ + { id: 'last-active', name: t('pages.threads.sortLastActive') }, + { id: 'latest', name: t('pages.threads.sortLatest') }, + { id: 'most-discussed', name: t('pages.threads.sortMostDiscussed') } +]); +const hasThreadSearch = computed(() => threadSearch.value.trim() !== ''); +const sortModel = computed({ + get: () => sort.value, + set: (value: string) => { + if (value === 'last-active' || value === 'latest' || value === 'most-discussed') { + sort.value = value; + } + } +}); +const currentThreadList = computed(() => { + const keyword = threadSearch.value.trim().toLowerCase(); + if (!keyword) return threads.value; + + return threads.value.filter((thread) => { + const searchable = [ + thread.title, + thread.author?.displayName ?? '', + thread.languageCode, + ...thread.tags.map((tag) => tag.name) + ] + .join(' ') + .toLowerCase(); + return searchable.includes(keyword); + }); +}); +const detailModalOpen = computed(() => activeThread.value !== null); const messageGroups = computed(() => { const groups: MessageGroup[] = []; @@ -137,6 +184,27 @@ function reactionActive(threadOrMessage: ThreadSummary | ThreadMessage, type: Th return threadOrMessage.myReactions.includes(type); } +function reactionTypesFor(threadOrMessage: ThreadSummary | ThreadMessage) { + return [...new Set([...reactionOptions, ...Object.keys(threadOrMessage.reactionCounts), ...threadOrMessage.myReactions])]; +} + +function canEditMessage(message: ThreadMessage) { + if (!currentUser.value) return false; + return message.author?.id === currentUser.value.id || canDeleteMessages.value; +} + +function canRetryMessageModeration(message: ThreadMessage) { + return message.moderationStatus !== 'approved' && message.moderationStatus !== 'reviewing' && canEditMessage(message); +} + +function messageModerationLabel(message: ThreadMessage) { + if (message.moderationStatus === 'unreviewed') return t('pages.threads.messageUnreviewed'); + if (message.moderationStatus === 'reviewing') return t('pages.threads.messageReviewing'); + if (message.moderationStatus === 'failed') return t('pages.threads.messageFailedReview'); + if (message.moderationStatus === 'rejected') return t('pages.threads.messageRejected'); + return ''; +} + function updateThreadInList(thread: ThreadSummary) { const index = threads.value.findIndex((item) => item.id === thread.id); if (index >= 0) { @@ -279,10 +347,14 @@ function selectChannel(channelId: number | null) { void loadThreads(true); } +function submitThreadSearch() { + threadSearch.value = threadSearch.value.trim(); +} + function openCreateThread() { const channel = selectedChannel.value ?? channels.value[0]; threadForm.value = { - title: '', + title: threadSearch.value.trim(), body: '', languageCode: channel?.languages[0]?.code ?? 'en', tagIds: [] @@ -294,6 +366,34 @@ function closeCreateThread() { createModalOpen.value = false; } +function openEditThread() { + const thread = activeThread.value; + if (!thread) return; + threadEditForm.value = { + title: thread.title, + tagIds: thread.tags.map((tag) => tag.id) + }; + editModalOpen.value = true; +} + +function closeEditThread() { + editModalOpen.value = false; +} + +function startEditMessage(message: ThreadMessage) { + editingMessageId.value = message.id; + editingMessageBody.value = message.body; +} + +function cancelEditMessage() { + editingMessageId.value = null; + editingMessageBody.value = ''; +} + +async function closeThreadDetail() { + await router.push('/threads'); +} + function toggleThreadTag(tag: ThreadChannelTag) { const tags = new Set(threadForm.value.tagIds); if (tags.has(tag.id)) { @@ -304,6 +404,16 @@ function toggleThreadTag(tag: ThreadChannelTag) { threadForm.value.tagIds = [...tags]; } +function toggleEditThreadTag(tag: ThreadChannelTag) { + const tags = new Set(threadEditForm.value.tagIds); + if (tags.has(tag.id)) { + tags.delete(tag.id); + } else { + tags.add(tag.id); + } + threadEditForm.value.tagIds = [...tags]; +} + async function submitThread() { const channel = selectedChannel.value ?? channels.value[0]; if (!channel) return; @@ -327,9 +437,28 @@ async function submitThread() { } } +async function submitThreadEdit() { + const thread = activeThread.value; + if (!thread) return; + busy.value = true; + errorMessage.value = ''; + try { + const updated = await api.updateThread(thread.id, { + title: threadEditForm.value.title, + tagIds: threadEditForm.value.tagIds + }); + updateThreadInList(updated); + closeEditThread(); + } catch (error) { + errorMessage.value = error instanceof Error && error.message ? error.message : t('pages.threads.editFailed'); + } finally { + busy.value = false; + } +} + async function submitMessage() { const id = activeThreadId.value; - if (!id || !composerBody.value.trim()) return; + if (!id || !composerBody.value.trim() || busy.value || !canCreateMessage.value || activeThread.value?.locked) return; busy.value = true; errorMessage.value = ''; try { @@ -344,6 +473,27 @@ async function submitMessage() { } } +async function submitMessageEdit(message: ThreadMessage) { + if (!editingMessageBody.value.trim() || messageActionBusyId.value !== null || !canEditMessage(message)) return; + messageActionBusyId.value = message.id; + errorMessage.value = ''; + try { + const updated = await api.updateThreadMessage(message.id, { body: editingMessageBody.value }); + updateMessageInList(updated); + cancelEditMessage(); + } catch (error) { + errorMessage.value = error instanceof Error && error.message ? error.message : t('pages.threads.messageEditFailed'); + } finally { + messageActionBusyId.value = null; + } +} + +function handleMessageKeydown(event: KeyboardEvent) { + if (event.isComposing || event.key !== 'Enter' || event.ctrlKey) return; + event.preventDefault(); + void submitMessage(); +} + async function toggleFollow() { const thread = activeThread.value; if (!thread) return; @@ -423,6 +573,29 @@ async function toggleMessageReaction(message: ThreadMessage, type: ThreadReactio } } +async function retryMessageModeration(message: ThreadMessage) { + if (!canRetryMessageModeration(message) || moderationBusyId.value !== null) return; + moderationBusyId.value = message.id; + errorMessage.value = ''; + try { + const updated = await api.retryThreadMessageModeration(message.id); + updateMessageInList(updated); + } catch (error) { + errorMessage.value = error instanceof Error && error.message ? error.message : t('pages.threads.moderationRetryFailed'); + } finally { + moderationBusyId.value = null; + } +} + +function applyMessageModerationUpdate(message: Extract) { + if (activeThreadId.value !== message.threadId) return; + if (message.message) { + updateMessageInList(message.message); + return; + } + messages.value = messages.value.filter((item) => item.id !== message.messageId); +} + function handleThreadWsMessage(message: ThreadWsMessage) { if (message.type === 'thread.message.created') { updateThreadInList(message.thread); @@ -452,6 +625,8 @@ function handleThreadWsMessage(message: ThreadWsMessage) { if (thread) { updateThreadInList({ ...thread, unread: message.unread }); } + } else if (message.type === 'thread.message.moderation') { + applyMessageModerationUpdate(message); } } @@ -505,10 +680,6 @@ onBeforeUnmount(() => {
- {{ errorMessage }} @@ -540,21 +711,39 @@ onBeforeUnmount(() => {
+
@@ -604,46 +793,52 @@ onBeforeUnmount(() => { {{ t('pages.threads.loadMoreThreads') }}
-

{{ t('pages.threads.noThreads') }}

+

{{ hasThreadSearch ? t('pages.threads.noSearchResults') : t('pages.threads.noThreads') }}

+ -
- -

{{ t('pages.threads.selectThread') }}

+
+ + + +
- + - + + + + +