feat(threads): add editing, moderation retry, and emoji reactions
Add API routes and UI for editing threads and messages Allow users to retry AI moderation for failed messages Migrate thread reactions to use native emojis Implement frontend search filtering for thread list
This commit is contained in:
24
DESIGN.md
24
DESIGN.md
@@ -984,27 +984,33 @@ Message 可配置:
|
|||||||
|
|
||||||
- 所属 Thread
|
- 所属 Thread
|
||||||
- 正文
|
- 正文
|
||||||
- 创建者、创建时间
|
- 创建者、创建时间、更新时间
|
||||||
- Reaction 汇总
|
- Reaction 汇总
|
||||||
- AI 审核状态和语言区
|
- AI 审核状态和语言区
|
||||||
|
|
||||||
前台行为:
|
前台行为:
|
||||||
|
|
||||||
- 所有人都可以浏览已公开的 Channel、Thread 和审核通过的 Message。
|
- 所有人都可以浏览已公开的 Channel、Thread 和审核通过的 Message。
|
||||||
- `/threads` 展示 Threads 工作区,左侧为 Channel 列表,中间为 Thread List;桌面端 Thread 详情使用聊天布局,移动端通过详情页堆叠显示。
|
- `/threads` 展示 Threads 工作区,左侧为 Channel 列表,中间为 Thread List。
|
||||||
- `/threads/:threadId` 打开 Thread 详情;默认进入最新消息位置。
|
- `/threads/:threadId` 通过 route-backed Modal 打开 Thread 详情;默认进入最新消息位置。
|
||||||
- 用户可在 Channel 内创建 Thread;需要已注册、邮箱已验证并拥有 `threads.create` 权限,且 Channel 允许用户创建 Thread。
|
- 用户可在 Channel 内创建 Thread;需要已注册、邮箱已验证并拥有 `threads.create` 权限,且 Channel 允许用户创建 Thread。
|
||||||
|
- 创建 Thread 时可从 Thread List 顶部搜索框预填 Title,Title 可在创建表单中继续修改。
|
||||||
|
- Thread 作者本人或拥有现有 Thread 管理权限的管理员可编辑 Thread 标题和 Tags;Tags 只能选择该 Channel 可用标签。
|
||||||
- 已注册、邮箱已验证并拥有 `threads.messages.create` 权限的用户可以在未锁定 Thread 中发送 Message。
|
- 已注册、邮箱已验证并拥有 `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 列表按创建时间正序展示,新消息出现在底部。
|
||||||
- 初始读取最新一页 Message;向上滚动或点击 To Top 加载更早历史消息。
|
- 初始读取最新一页 Message;向上滚动或点击 To Top 加载更早历史消息。
|
||||||
- 有新消息且用户不在底部时显示 Jump to Present;点击后滚动到最新消息。
|
- 有新消息且用户不在底部时显示 Jump to Present;点击后滚动到最新消息。
|
||||||
- 连续 Message 在展示层自动合并:同一用户连续发送,且相邻消息时间间隔不超过 5 分钟;合并组只显示一次 Avatar、Username 和组首条 Timestamp。合并窗口默认 5 分钟,后续可由系统配置扩展。
|
- 连续 Message 在展示层自动合并:同一用户连续发送,且相邻消息时间间隔不超过 5 分钟;合并组只显示一次 Avatar、Username 和组首条 Timestamp。合并窗口默认 5 分钟,后续可由系统配置扩展。
|
||||||
- Thread 支持 Follow / Unfollow;Follow 后新审核通过 Message 会让 Threads Sidebar 和 Thread List 显示未读红点或未读提示。
|
- Thread 支持 Follow / Unfollow;Follow 后新审核通过 Message 会让 Threads Sidebar 和 Thread List 显示未读红点或未读提示。
|
||||||
- Thread 详情支持未读消息分隔线;用户进入最新位置或显式标记已读后更新 `thread_reads`。
|
- 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 支持排序:`last-active` 默认按最后活跃倒序;`latest` 按创建时间倒序;`most-discussed` 按公开消息数倒序。
|
||||||
- Thread List 支持语言筛选:All languages 或指定启用语言 / Channel 可用语言。
|
- Thread List 支持语言筛选:All languages 或指定启用语言 / Channel 可用语言。
|
||||||
- Thread List 支持按 Channel 标签筛选。
|
- Thread List 支持按 Channel 标签筛选。
|
||||||
|
- Thread List 提供前端快速搜索,可在当前已加载列表内按 Thread 标题、作者展示名、语言和标签过滤;当前不提供后端全文搜索。
|
||||||
- Thread 新消息实时更新通过 Thread WebSocket;WebSocket 使用短期一次性 ticket,不把 session token 放入 WebSocket URL。
|
- Thread 新消息实时更新通过 Thread WebSocket;WebSocket 使用短期一次性 ticket,不把 session token 放入 WebSocket URL。
|
||||||
- Thread Message 是用户生成内容,必须经过 AI 审核;未审核通过的 Message 不向普通访客公开。作者本人和拥有 `admin.threads.messages.delete` 权限的管理用户可以看到自己的未通过/审核中 Message 状态。
|
- Thread Message 是用户生成内容,必须经过 AI 审核;未审核通过的 Message 不向普通访客公开。作者本人和拥有 `admin.threads.messages.delete` 权限的管理用户可以看到自己的未通过/审核中 Message 状态。
|
||||||
- 审核通过的 Message 才计入普通公开消息数、最后活跃排序和未读状态。
|
- 审核通过的 Message 才计入普通公开消息数、最后活跃排序和未读状态。
|
||||||
@@ -1024,7 +1030,7 @@ API 暴露边界:
|
|||||||
|
|
||||||
- Channel API 只返回展示和管理需要的 `id`、`name`、`allowUserThreads`、`tags`、`languages`、`sortOrder` 和未读摘要;不返回内部审计或调试字段。
|
- Channel API 只返回展示和管理需要的 `id`、`name`、`allowUserThreads`、`tags`、`languages`、`sortOrder` 和未读摘要;不返回内部审计或调试字段。
|
||||||
- Thread API 只返回 `id`、`channelId`、`title`、标签、语言、作者必要署名、创建时间、最后活跃时间、锁定状态、消息数、Reaction 汇总、当前用户 Reaction、Follow 状态和未读状态。
|
- 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、模型响应、内部审核错误、错误堆栈、删除时间、删除人或内部调试字段。
|
- API 不返回邮箱、角色、权限、session、token/hash、AI prompt、模型响应、内部审核错误、错误堆栈、删除时间、删除人或内部调试字段。
|
||||||
- Thread 内容正文按作者输入展示,不进入 `entity_translations`;Thread 的语言用于筛选和内容区分,不改变系统 UI 语言。
|
- Thread 内容正文按作者输入展示,不进入 `entity_translations`;Thread 的语言用于筛选和内容区分,不改变系统 UI 语言。
|
||||||
- Channel 名称和标签当前作为管理数据直接存储,不进入 `entity_translations`。
|
- Channel 名称和标签当前作为管理数据直接存储,不进入 `entity_translations`。
|
||||||
@@ -1038,11 +1044,11 @@ API 暴露边界:
|
|||||||
- RBAC 已包含 Thread 用户权限:`threads.create`、`threads.messages.create`、`threads.follow`、`threads.reactions.set`。
|
- 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`。
|
- RBAC 已包含 Thread 管理权限:`admin.threads.channels.*`、`admin.threads.threads.delete`、`admin.threads.threads.lock`、`admin.threads.messages.delete`。
|
||||||
- 公开 API 已支持读取 Channel、分页读取 Thread、读取单个 Thread、读取 Thread Message 历史。
|
- 公开 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。
|
- 管理 API 已支持创建、编辑、删除 Channel,锁定 / 解锁 Thread,删除 Thread,删除 Message。
|
||||||
- Thread Message 已接入 AI 审核队列;审核通过后才更新 Thread 的公开 `message_count`、`last_message_id` 和 `last_active_at`。
|
- Thread Message 已接入 AI 审核队列;审核通过后才更新 Thread 的公开 `message_count`、`last_message_id` 和 `last_active_at`。
|
||||||
- Thread WebSocket 已实现短期 ticket 连接,并可推送新审核通过 Message、Reaction 更新和当前用户 read 状态更新。
|
- 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 展示已支持同一用户 5 分钟内连续消息的合并显示。
|
||||||
- 前端 Message 历史已支持点击 Load older 向上加载更早消息。
|
- 前端 Message 历史已支持点击 Load older 向上加载更早消息。
|
||||||
- 前端已支持 Jump to Present:用户不在底部且收到新消息时可跳到最新。
|
- 前端已支持 Jump to Present:用户不在底部且收到新消息时可跳到最新。
|
||||||
@@ -1062,7 +1068,7 @@ API 暴露边界:
|
|||||||
- Thread 删除、Message 删除和锁定 / 解锁当前直接执行,尚未使用确认 Modal。
|
- Thread 删除、Message 删除和锁定 / 解锁当前直接执行,尚未使用确认 Modal。
|
||||||
- Thread List 的实时排序更新是基础 upsert 行为;复杂筛选条件下收到不匹配当前筛选的新 Thread / Message 时,仍可能需要后续刷新来得到完全一致的列表。
|
- Thread List 的实时排序更新是基础 upsert 行为;复杂筛选条件下收到不匹配当前筛选的新 Thread / Message 时,仍可能需要后续刷新来得到完全一致的列表。
|
||||||
- 移动端已使用响应式堆叠布局,但还不是独立的移动端双页导航体验;后续可优化为 Channel / Thread List / Chat 分步视图。
|
- 移动端已使用响应式堆叠布局,但还不是独立的移动端双页导航体验;后续可优化为 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`
|
- `DELETE /api/discussions/comments/:id/like`
|
||||||
- Thread 创建需要 `threads.create`。
|
- Thread 创建需要 `threads.create`。
|
||||||
- `POST /api/threads`
|
- `POST /api/threads`
|
||||||
|
- Thread 编辑需要作者本人或现有 Thread 管理权限。
|
||||||
|
- `PUT /api/threads/:id`
|
||||||
- Thread Message 创建需要 `threads.messages.create`。
|
- Thread Message 创建需要 `threads.messages.create`。
|
||||||
- `POST /api/threads/:id/messages`
|
- `POST /api/threads/:id/messages`
|
||||||
- Thread Follow 需要 `threads.follow`。
|
- Thread Follow 需要 `threads.follow`。
|
||||||
|
|||||||
@@ -987,24 +987,52 @@ CREATE INDEX IF NOT EXISTS thread_messages_user_idx
|
|||||||
CREATE TABLE IF NOT EXISTS thread_reactions (
|
CREATE TABLE IF NOT EXISTS thread_reactions (
|
||||||
thread_id integer NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
|
thread_id integer NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
|
||||||
user_id integer NOT NULL REFERENCES users(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(),
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
PRIMARY KEY (thread_id, user_id, reaction_type)
|
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
|
CREATE INDEX IF NOT EXISTS thread_reactions_thread_idx
|
||||||
ON thread_reactions(thread_id, reaction_type);
|
ON thread_reactions(thread_id, reaction_type);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS thread_message_reactions (
|
CREATE TABLE IF NOT EXISTS thread_message_reactions (
|
||||||
message_id integer NOT NULL REFERENCES thread_messages(id) ON DELETE CASCADE,
|
message_id integer NOT NULL REFERENCES thread_messages(id) ON DELETE CASCADE,
|
||||||
user_id integer NOT NULL REFERENCES users(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(),
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
PRIMARY KEY (message_id, user_id, reaction_type)
|
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
|
CREATE INDEX IF NOT EXISTS thread_message_reactions_message_idx
|
||||||
ON thread_message_reactions(message_id, reaction_type);
|
ON thread_message_reactions(message_id, reaction_type);
|
||||||
|
|
||||||
|
|||||||
@@ -772,12 +772,47 @@ async function updateTargetStatus(
|
|||||||
if (status === 'approved') {
|
if (status === 'approved') {
|
||||||
await applyApprovedThreadMessage(target.id);
|
await applyApprovedThreadMessage(target.id);
|
||||||
} else {
|
} else {
|
||||||
const row = await queryOne<{ threadId: number }>(
|
const row = await queryOne<{
|
||||||
'SELECT thread_id AS "threadId" FROM thread_messages WHERE id = $1',
|
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]
|
[target.id]
|
||||||
);
|
);
|
||||||
if (row) {
|
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;
|
return;
|
||||||
|
|||||||
@@ -33,8 +33,8 @@ type ListPage<T> = {
|
|||||||
nextCursor: string | null;
|
nextCursor: string | null;
|
||||||
hasMore: boolean;
|
hasMore: boolean;
|
||||||
};
|
};
|
||||||
export type ThreadReactionType = 'thumbs-up' | 'heart' | 'laugh' | 'fire' | 'eyes';
|
export type ThreadReactionType = string;
|
||||||
export type ThreadReactionCounts = Record<ThreadReactionType, number>;
|
export type ThreadReactionCounts = Record<string, number>;
|
||||||
export type ThreadChannelTag = { id: number; name: string; sortOrder: number };
|
export type ThreadChannelTag = { id: number; name: string; sortOrder: number };
|
||||||
export type ThreadChannel = {
|
export type ThreadChannel = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -1154,6 +1154,12 @@ function validationError(message: string): ValidationError {
|
|||||||
return error;
|
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 {
|
function requirePositiveInteger(value: unknown, message: string): number {
|
||||||
const numberValue = Number(value);
|
const numberValue = Number(value);
|
||||||
if (!Number.isInteger(numberValue) || numberValue <= 0) {
|
if (!Number.isInteger(numberValue) || numberValue <= 0) {
|
||||||
@@ -8891,28 +8897,31 @@ export async function importAdminHabitatsCsv(payload: Record<string, unknown>, u
|
|||||||
return getAdminDataToolsSummary();
|
return getAdminDataToolsSummary();
|
||||||
}
|
}
|
||||||
|
|
||||||
const threadReactionTypes: ThreadReactionType[] = ['thumbs-up', 'heart', 'laugh', 'fire', 'eyes'];
|
|
||||||
const defaultThreadLimit = 20;
|
const defaultThreadLimit = 20;
|
||||||
const maxThreadLimit = 50;
|
const maxThreadLimit = 50;
|
||||||
const defaultThreadMessageLimit = 30;
|
const defaultThreadMessageLimit = 30;
|
||||||
const maxThreadMessageLimit = 80;
|
const maxThreadMessageLimit = 80;
|
||||||
|
const threadEmojiReactionPattern = /(?:\p{Extended_Pictographic}|\p{Regional_Indicator})/u;
|
||||||
|
|
||||||
type ThreadCursor = { value: string; id: number };
|
type ThreadCursor = { value: string; id: number };
|
||||||
type ThreadMessageCursor = { createdAt: string; id: number };
|
type ThreadMessageCursor = { createdAt: string; id: number };
|
||||||
|
|
||||||
function emptyThreadReactionCounts(): ThreadReactionCounts {
|
function emptyThreadReactionCounts(): ThreadReactionCounts {
|
||||||
return { 'thumbs-up': 0, heart: 0, laugh: 0, fire: 0, eyes: 0 };
|
return {};
|
||||||
}
|
|
||||||
|
|
||||||
function isThreadReactionType(value: unknown): value is ThreadReactionType {
|
|
||||||
return typeof value === 'string' && threadReactionTypes.includes(value as ThreadReactionType);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function cleanThreadReactionType(value: unknown): ThreadReactionType {
|
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');
|
throw validationError('server.validation.reactionInvalid');
|
||||||
}
|
}
|
||||||
return value;
|
return reactionType;
|
||||||
}
|
}
|
||||||
|
|
||||||
function cleanThreadLimit(value: QueryValue, fallback = defaultThreadLimit, max = maxThreadLimit): number {
|
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) {
|
for (const row of countRows) {
|
||||||
const item = counts.get(row.threadId);
|
const item = counts.get(row.threadId);
|
||||||
if (item && isThreadReactionType(row.reactionType)) {
|
if (item) {
|
||||||
item[row.reactionType] = row.count;
|
item[row.reactionType] = row.count;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -9129,7 +9138,6 @@ async function threadReactionCounts(threadIds: number[], userId: number | null):
|
|||||||
[userId, threadIds]
|
[userId, threadIds]
|
||||||
);
|
);
|
||||||
for (const row of myRows) {
|
for (const row of myRows) {
|
||||||
if (!isThreadReactionType(row.reactionType)) continue;
|
|
||||||
mine.set(row.threadId, [...(mine.get(row.threadId) ?? []), row.reactionType]);
|
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) {
|
for (const row of countRows) {
|
||||||
const item = counts.get(row.messageId);
|
const item = counts.get(row.messageId);
|
||||||
if (item && isThreadReactionType(row.reactionType)) {
|
if (item) {
|
||||||
item[row.reactionType] = row.count;
|
item[row.reactionType] = row.count;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -9173,7 +9181,6 @@ async function threadMessageReactionCounts(messageIds: number[], userId: number
|
|||||||
[userId, messageIds]
|
[userId, messageIds]
|
||||||
);
|
);
|
||||||
for (const row of myRows) {
|
for (const row of myRows) {
|
||||||
if (!isThreadReactionType(row.reactionType)) continue;
|
|
||||||
mine.set(row.messageId, [...(mine.get(row.messageId) ?? []), row.reactionType]);
|
mine.set(row.messageId, [...(mine.get(row.messageId) ?? []), row.reactionType]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -9464,6 +9471,68 @@ export async function createThread(payload: Record<string, unknown>, userId: num
|
|||||||
return (await getThread(ids.threadId, userId)) as ThreadSummary;
|
return (await getThread(ids.threadId, userId)) as ThreadSummary;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateThread(
|
||||||
|
threadIdValue: number,
|
||||||
|
payload: Record<string, unknown>,
|
||||||
|
userId: number,
|
||||||
|
canUpdateAny = false
|
||||||
|
): Promise<ThreadSummary | null> {
|
||||||
|
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<void> {
|
||||||
|
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<string, unknown>, userId: number): Promise<ThreadMessage | null> {
|
export async function createThreadMessage(threadIdValue: number, payload: Record<string, unknown>, userId: number): Promise<ThreadMessage | null> {
|
||||||
const threadId = requirePositiveInteger(threadIdValue, 'server.validation.recordInvalid');
|
const threadId = requirePositiveInteger(threadIdValue, 'server.validation.recordInvalid');
|
||||||
const body = cleanThreadMessageBody(payload.body);
|
const body = cleanThreadMessageBody(payload.body);
|
||||||
@@ -9488,6 +9557,87 @@ export async function createThreadMessage(threadIdValue: number, payload: Record
|
|||||||
return getThreadMessageById(result.id, userId, false);
|
return getThreadMessageById(result.id, userId, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateThreadMessage(
|
||||||
|
messageIdValue: number,
|
||||||
|
payload: Record<string, unknown>,
|
||||||
|
userId: number,
|
||||||
|
canUpdateAny = false
|
||||||
|
): Promise<ThreadMessage | null> {
|
||||||
|
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<ThreadMessage | null> {
|
||||||
|
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<ThreadSummary | null> {
|
export async function markThreadRead(threadIdValue: number, userId: number): Promise<ThreadSummary | null> {
|
||||||
const threadId = requirePositiveInteger(threadIdValue, 'server.validation.recordInvalid');
|
const threadId = requirePositiveInteger(threadIdValue, 'server.validation.recordInvalid');
|
||||||
const row = await queryOne<{ lastMessageId: number | null }>(
|
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);
|
const thread = await getThread(threadId, userId);
|
||||||
if (!thread) return null;
|
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]);
|
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<string, unknown>, userId: number): Promise<ThreadMessage | null> {
|
export async function setThreadMessageReaction(messageIdValue: number, payload: Record<string, unknown>, userId: number): Promise<ThreadMessage | null> {
|
||||||
@@ -9597,7 +9758,18 @@ export async function deleteThreadMessageReaction(messageIdValue: number, payloa
|
|||||||
const message = await getThreadMessageById(messageId, userId);
|
const message = await getThreadMessageById(messageId, userId);
|
||||||
if (!message) return null;
|
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]);
|
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<void> {
|
export async function applyApprovedThreadMessage(messageId: number): Promise<void> {
|
||||||
@@ -9629,7 +9801,7 @@ export async function applyApprovedThreadMessage(messageId: number): Promise<voi
|
|||||||
if (message && thread) {
|
if (message && thread) {
|
||||||
await publishThreadMessageCreated(thread, message);
|
await publishThreadMessageCreated(thread, message);
|
||||||
} else {
|
} else {
|
||||||
await publishThreadMessageModeration(row.threadId, message);
|
await publishThreadMessageModeration(row.threadId, messageId, message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -131,6 +131,7 @@ import {
|
|||||||
retryEntityDiscussionCommentModeration,
|
retryEntityDiscussionCommentModeration,
|
||||||
retryLifeCommentModeration,
|
retryLifeCommentModeration,
|
||||||
retryLifePostModeration,
|
retryLifePostModeration,
|
||||||
|
retryThreadMessageModeration,
|
||||||
restoreLifeComment,
|
restoreLifeComment,
|
||||||
setLifePostRating,
|
setLifePostRating,
|
||||||
setLifePostReaction,
|
setLifePostReaction,
|
||||||
@@ -150,7 +151,9 @@ import {
|
|||||||
updatePokemon,
|
updatePokemon,
|
||||||
updateRecipe,
|
updateRecipe,
|
||||||
updateAdminThreadChannel,
|
updateAdminThreadChannel,
|
||||||
|
updateThread,
|
||||||
updateThreadLock,
|
updateThreadLock,
|
||||||
|
updateThreadMessage,
|
||||||
unfollowUser,
|
unfollowUser,
|
||||||
unfollowThread,
|
unfollowThread,
|
||||||
wipeAdminData
|
wipeAdminData
|
||||||
@@ -1734,6 +1737,17 @@ app.post('/api/threads', async (request, reply) => {
|
|||||||
return user ? reply.code(201).send(await createThread(request.body as Record<string, unknown>, user.id)) : undefined;
|
return user ? reply.code(201).send(await createThread(request.body as Record<string, unknown>, 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<string, unknown>, user.id, canUpdateAny);
|
||||||
|
return thread ? thread : notFound(reply, request);
|
||||||
|
});
|
||||||
|
|
||||||
app.get('/api/threads/:id', async (request, reply) => {
|
app.get('/api/threads/:id', async (request, reply) => {
|
||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
const user = await optionalUser(request);
|
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);
|
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<string, unknown>, 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) => {
|
app.put('/api/threads/:id/follow', async (request, reply) => {
|
||||||
const user = await requirePermissionWithRateLimits(request, reply, 'threads.follow', 'communityReaction');
|
const user = await requirePermissionWithRateLimits(request, reply, 'threads.follow', 'communityReaction');
|
||||||
if (!user) {
|
if (!user) {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import type { ThreadMessage, ThreadReactionCounts, ThreadReactionType, ThreadSum
|
|||||||
export type ThreadWsMessage =
|
export type ThreadWsMessage =
|
||||||
| { type: 'threads.connected'; followedUnreadCount: number }
|
| { type: 'threads.connected'; followedUnreadCount: number }
|
||||||
| { type: 'thread.message.created'; threadId: number; message: ThreadMessage; thread: ThreadSummary }
|
| { 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';
|
type: 'thread.reactions.updated';
|
||||||
target: 'thread' | 'message';
|
target: 'thread' | 'message';
|
||||||
@@ -311,7 +311,7 @@ export async function applyApprovedThreadMessage(messageId: number): Promise<voi
|
|||||||
lastActiveAt: row.lastActiveAt,
|
lastActiveAt: row.lastActiveAt,
|
||||||
createdAt: row.threadCreatedAt,
|
createdAt: row.threadCreatedAt,
|
||||||
author: row.threadAuthor,
|
author: row.threadAuthor,
|
||||||
reactionCounts: { 'thumbs-up': 0, heart: 0, laugh: 0, fire: 0, eyes: 0 },
|
reactionCounts: {},
|
||||||
myReactions: [],
|
myReactions: [],
|
||||||
followed: true,
|
followed: true,
|
||||||
unread: true
|
unread: true
|
||||||
@@ -326,16 +326,37 @@ export async function applyApprovedThreadMessage(messageId: number): Promise<voi
|
|||||||
createdAt: row.messageCreatedAt,
|
createdAt: row.messageCreatedAt,
|
||||||
updatedAt: row.messageUpdatedAt,
|
updatedAt: row.messageUpdatedAt,
|
||||||
author: row.messageAuthor,
|
author: row.messageAuthor,
|
||||||
reactionCounts: { 'thumbs-up': 0, heart: 0, laugh: 0, fire: 0, eyes: 0 },
|
reactionCounts: {},
|
||||||
myReactions: []
|
myReactions: []
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function publishThreadMessageModeration(threadId: number, message: ThreadMessage | null): Promise<void> {
|
export async function publishThreadMessageModeration(
|
||||||
await publishToUsers([...new Set([...(await recipientUserIds(threadId)), ...connectedUserIds()])], {
|
threadId: number,
|
||||||
|
messageId: number,
|
||||||
|
message: ThreadMessage | null
|
||||||
|
): Promise<void> {
|
||||||
|
const publicUsers = new Set([...(await recipientUserIds(threadId)), ...connectedUserIds()]);
|
||||||
|
if (message?.author?.id) {
|
||||||
|
publicUsers.delete(message.author.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
await publishToUsers([...publicUsers], {
|
||||||
type: 'thread.message.moderation',
|
type: 'thread.message.moderation',
|
||||||
threadId,
|
threadId,
|
||||||
|
messageId,
|
||||||
|
message: null
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!message?.author?.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await publishToUsers([message.author.id], {
|
||||||
|
type: 'thread.message.moderation',
|
||||||
|
threadId,
|
||||||
|
messageId,
|
||||||
message
|
message
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -575,8 +575,8 @@ export interface LifeReactionUsersParams {
|
|||||||
reactionType?: LifeReactionType;
|
reactionType?: LifeReactionType;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ThreadReactionType = 'thumbs-up' | 'heart' | 'laugh' | 'fire' | 'eyes';
|
export type ThreadReactionType = string;
|
||||||
export type ThreadReactionCounts = Record<ThreadReactionType, number>;
|
export type ThreadReactionCounts = Record<string, number>;
|
||||||
export type ThreadSort = 'last-active' | 'latest' | 'most-discussed';
|
export type ThreadSort = 'last-active' | 'latest' | 'most-discussed';
|
||||||
|
|
||||||
export interface ThreadChannelTag {
|
export interface ThreadChannelTag {
|
||||||
@@ -660,6 +660,11 @@ export interface ThreadPayload {
|
|||||||
tagIds: number[];
|
tagIds: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ThreadUpdatePayload {
|
||||||
|
title: string;
|
||||||
|
tagIds: number[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface ThreadMessagePayload {
|
export interface ThreadMessagePayload {
|
||||||
body: string;
|
body: string;
|
||||||
}
|
}
|
||||||
@@ -672,7 +677,7 @@ export interface ThreadWsTicket {
|
|||||||
export type ThreadWsMessage =
|
export type ThreadWsMessage =
|
||||||
| { type: 'threads.connected'; followedUnreadCount: number }
|
| { type: 'threads.connected'; followedUnreadCount: number }
|
||||||
| { type: 'thread.message.created'; threadId: number; message: ThreadMessage; thread: ThreadSummary }
|
| { 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';
|
type: 'thread.reactions.updated';
|
||||||
target: 'thread' | 'message';
|
target: 'thread' | 'message';
|
||||||
@@ -1490,6 +1495,7 @@ export const api = {
|
|||||||
),
|
),
|
||||||
thread: (id: string | number) => getJson<ThreadSummary>(`/api/threads/${id}`),
|
thread: (id: string | number) => getJson<ThreadSummary>(`/api/threads/${id}`),
|
||||||
createThread: (payload: ThreadPayload) => sendJson<ThreadSummary>('/api/threads', 'POST', payload),
|
createThread: (payload: ThreadPayload) => sendJson<ThreadSummary>('/api/threads', 'POST', payload),
|
||||||
|
updateThread: (id: string | number, payload: ThreadUpdatePayload) => sendJson<ThreadSummary>(`/api/threads/${id}`, 'PUT', payload),
|
||||||
threadMessages: (id: string | number, params: ThreadMessagesParams = {}) =>
|
threadMessages: (id: string | number, params: ThreadMessagesParams = {}) =>
|
||||||
getJson<ThreadMessagesPage>(
|
getJson<ThreadMessagesPage>(
|
||||||
`/api/threads/${id}/messages${buildQuery({
|
`/api/threads/${id}/messages${buildQuery({
|
||||||
@@ -1499,6 +1505,10 @@ export const api = {
|
|||||||
),
|
),
|
||||||
createThreadMessage: (id: string | number, payload: ThreadMessagePayload) =>
|
createThreadMessage: (id: string | number, payload: ThreadMessagePayload) =>
|
||||||
sendJson<ThreadMessage>(`/api/threads/${id}/messages`, 'POST', payload),
|
sendJson<ThreadMessage>(`/api/threads/${id}/messages`, 'POST', payload),
|
||||||
|
updateThreadMessage: (id: string | number, payload: ThreadMessagePayload) =>
|
||||||
|
sendJson<ThreadMessage>(`/api/thread-messages/${id}`, 'PUT', payload),
|
||||||
|
retryThreadMessageModeration: (id: string | number) =>
|
||||||
|
sendJson<ThreadMessage>(`/api/thread-messages/${id}/moderation/retry`, 'POST', {}),
|
||||||
followThread: (id: string | number) => sendJson<ThreadSummary>(`/api/threads/${id}/follow`, 'PUT', {}),
|
followThread: (id: string | number) => sendJson<ThreadSummary>(`/api/threads/${id}/follow`, 'PUT', {}),
|
||||||
unfollowThread: (id: string | number) => deleteAndGetJson<ThreadSummary>(`/api/threads/${id}/follow`),
|
unfollowThread: (id: string | number) => deleteAndGetJson<ThreadSummary>(`/api/threads/${id}/follow`),
|
||||||
markThreadRead: (id: string | number) => sendJson<ThreadSummary>(`/api/threads/${id}/read`, 'POST', {}),
|
markThreadRead: (id: string | number) => sendJson<ThreadSummary>(`/api/threads/${id}/read`, 'POST', {}),
|
||||||
|
|||||||
@@ -100,6 +100,18 @@ svg {
|
|||||||
flex: 0 0 auto;
|
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 {
|
:focus-visible {
|
||||||
outline: 3px solid var(--focus);
|
outline: 3px solid var(--focus);
|
||||||
outline-offset: 3px;
|
outline-offset: 3px;
|
||||||
@@ -9692,7 +9704,7 @@ button:disabled,
|
|||||||
.threads-layout {
|
.threads-layout {
|
||||||
min-height: min(78vh, 860px);
|
min-height: min(78vh, 860px);
|
||||||
display: grid;
|
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;
|
gap: 16px;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
@@ -9762,12 +9774,52 @@ button:disabled,
|
|||||||
|
|
||||||
.threads-list-panel {
|
.threads-list-panel {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: auto auto minmax(0, 1fr);
|
grid-template-rows: auto auto auto minmax(0, 1fr);
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 14px;
|
padding: 14px;
|
||||||
overflow: hidden;
|
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 {
|
.thread-filters {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
@@ -9782,7 +9834,6 @@ button:disabled,
|
|||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
}
|
}
|
||||||
|
|
||||||
.thread-filters select,
|
|
||||||
.thread-composer textarea {
|
.thread-composer textarea {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
@@ -9864,9 +9915,16 @@ button.thread-chip {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.thread-chat-panel--modal {
|
||||||
|
min-height: min(72vh, 680px);
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
.thread-chat-header {
|
.thread-chat-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: flex-end;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
border-bottom: 1px solid var(--line);
|
border-bottom: 1px solid var(--line);
|
||||||
@@ -9894,6 +9952,7 @@ button.thread-chip {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
padding: 10px 16px;
|
padding: 10px 16px;
|
||||||
border-bottom: 1px solid var(--line);
|
border-bottom: 1px solid var(--line);
|
||||||
}
|
}
|
||||||
@@ -9923,6 +9982,17 @@ button.thread-chip {
|
|||||||
color: var(--ink);
|
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 {
|
.thread-message-scroll {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
@@ -9976,6 +10046,24 @@ button.thread-chip {
|
|||||||
white-space: pre-wrap;
|
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 {
|
.thread-composer {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
@@ -10018,10 +10106,6 @@ button.thread-chip {
|
|||||||
.threads-layout {
|
.threads-layout {
|
||||||
grid-template-columns: minmax(180px, 220px) minmax(0, 1fr);
|
grid-template-columns: minmax(180px, 220px) minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
.thread-chat-panel {
|
|
||||||
grid-column: 1 / -1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
@@ -10031,6 +10115,7 @@ button.thread-chip {
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.thread-search-create,
|
||||||
.thread-filters,
|
.thread-filters,
|
||||||
.thread-composer,
|
.thread-composer,
|
||||||
.thread-chat-header {
|
.thread-chat-header {
|
||||||
|
|||||||
@@ -7,15 +7,20 @@ import Modal from '../components/Modal.vue';
|
|||||||
import PageHeader from '../components/PageHeader.vue';
|
import PageHeader from '../components/PageHeader.vue';
|
||||||
import Skeleton from '../components/Skeleton.vue';
|
import Skeleton from '../components/Skeleton.vue';
|
||||||
import StatusMessage from '../components/StatusMessage.vue';
|
import StatusMessage from '../components/StatusMessage.vue';
|
||||||
|
import TagsSelect, { type TagsSelectOption } from '../components/TagsSelect.vue';
|
||||||
import {
|
import {
|
||||||
iconAdd,
|
iconAdd,
|
||||||
iconBell,
|
iconBell,
|
||||||
|
iconCancel,
|
||||||
iconChevronUp,
|
iconChevronUp,
|
||||||
iconComment,
|
iconComment,
|
||||||
iconDelete,
|
iconDelete,
|
||||||
|
iconEdit,
|
||||||
|
iconSave,
|
||||||
|
iconSearch,
|
||||||
iconSend,
|
iconSend,
|
||||||
iconThreads,
|
iconThreads,
|
||||||
type AppIcon
|
iconUndo
|
||||||
} from '../icons';
|
} from '../icons';
|
||||||
import {
|
import {
|
||||||
api,
|
api,
|
||||||
@@ -50,6 +55,7 @@ const selectedChannelId = ref<number | null>(null);
|
|||||||
const selectedTagId = ref<number | null>(null);
|
const selectedTagId = ref<number | null>(null);
|
||||||
const selectedLanguage = ref('all');
|
const selectedLanguage = ref('all');
|
||||||
const sort = ref<ThreadSort>('last-active');
|
const sort = ref<ThreadSort>('last-active');
|
||||||
|
const threadSearch = ref('');
|
||||||
const nextCursor = ref<string | null>(null);
|
const nextCursor = ref<string | null>(null);
|
||||||
const hasMoreThreads = ref(false);
|
const hasMoreThreads = ref(false);
|
||||||
const beforeCursor = ref<string | null>(null);
|
const beforeCursor = ref<string | null>(null);
|
||||||
@@ -61,19 +67,19 @@ const loadingOlder = ref(false);
|
|||||||
const busy = ref(false);
|
const busy = ref(false);
|
||||||
const errorMessage = ref('');
|
const errorMessage = ref('');
|
||||||
const createModalOpen = ref(false);
|
const createModalOpen = ref(false);
|
||||||
|
const editModalOpen = ref(false);
|
||||||
const composerBody = ref('');
|
const composerBody = ref('');
|
||||||
const threadForm = ref({ title: '', body: '', languageCode: '', tagIds: [] as number[] });
|
const threadForm = ref({ title: '', body: '', languageCode: '', tagIds: [] as number[] });
|
||||||
|
const threadEditForm = ref({ title: '', tagIds: [] as number[] });
|
||||||
|
const editingMessageId = ref<number | null>(null);
|
||||||
|
const editingMessageBody = ref('');
|
||||||
|
const messageActionBusyId = ref<number | null>(null);
|
||||||
|
const moderationBusyId = ref<number | null>(null);
|
||||||
const socket = ref<WebSocket | null>(null);
|
const socket = ref<WebSocket | null>(null);
|
||||||
const chatScroller = ref<HTMLElement | null>(null);
|
const chatScroller = ref<HTMLElement | null>(null);
|
||||||
const showJump = ref(false);
|
const showJump = ref(false);
|
||||||
|
|
||||||
const reactionOptions: Array<{ type: ThreadReactionType; label: string; icon: AppIcon }> = [
|
const reactionOptions: ThreadReactionType[] = ['👍', '❤️', '😂', '🔥', '👀'];
|
||||||
{ 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 selectedChannel = computed(() => channels.value.find((channel) => channel.id === selectedChannelId.value) ?? null);
|
const selectedChannel = computed(() => channels.value.find((channel) => channel.id === selectedChannelId.value) ?? null);
|
||||||
const activeThreadId = computed(() => {
|
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 canLockThreads = computed(() => currentUser.value?.permissions.includes('admin.threads.threads.lock') === true);
|
||||||
const canDeleteThreads = computed(() => currentUser.value?.permissions.includes('admin.threads.threads.delete') === 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 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 languageOptions = computed(() => {
|
||||||
const channelLanguages = selectedChannel.value?.languages ?? channels.value.flatMap((channel) => channel.languages);
|
const channelLanguages = selectedChannel.value?.languages ?? channels.value.flatMap((channel) => channel.languages);
|
||||||
const byCode = new Map(channelLanguages.map((language) => [language.code, language]));
|
const byCode = new Map(channelLanguages.map((language) => [language.code, language]));
|
||||||
return [...byCode.values()];
|
return [...byCode.values()];
|
||||||
});
|
});
|
||||||
const tagOptions = computed(() => selectedChannel.value?.tags ?? []);
|
const tagOptions = computed(() => selectedChannel.value?.tags ?? []);
|
||||||
const currentThreadList = computed(() => threads.value);
|
const languageFilterOptions = computed<TagsSelectOption[]>(() => [
|
||||||
|
{ id: 'all', name: t('pages.threads.allLanguages') },
|
||||||
|
...languageOptions.value.map((language) => ({ id: language.code, name: language.name }))
|
||||||
|
]);
|
||||||
|
const sortOptions = computed<TagsSelectOption[]>(() => [
|
||||||
|
{ 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<MessageGroup[]>(() => {
|
const messageGroups = computed<MessageGroup[]>(() => {
|
||||||
const groups: MessageGroup[] = [];
|
const groups: MessageGroup[] = [];
|
||||||
@@ -137,6 +184,27 @@ function reactionActive(threadOrMessage: ThreadSummary | ThreadMessage, type: Th
|
|||||||
return threadOrMessage.myReactions.includes(type);
|
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) {
|
function updateThreadInList(thread: ThreadSummary) {
|
||||||
const index = threads.value.findIndex((item) => item.id === thread.id);
|
const index = threads.value.findIndex((item) => item.id === thread.id);
|
||||||
if (index >= 0) {
|
if (index >= 0) {
|
||||||
@@ -279,10 +347,14 @@ function selectChannel(channelId: number | null) {
|
|||||||
void loadThreads(true);
|
void loadThreads(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function submitThreadSearch() {
|
||||||
|
threadSearch.value = threadSearch.value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
function openCreateThread() {
|
function openCreateThread() {
|
||||||
const channel = selectedChannel.value ?? channels.value[0];
|
const channel = selectedChannel.value ?? channels.value[0];
|
||||||
threadForm.value = {
|
threadForm.value = {
|
||||||
title: '',
|
title: threadSearch.value.trim(),
|
||||||
body: '',
|
body: '',
|
||||||
languageCode: channel?.languages[0]?.code ?? 'en',
|
languageCode: channel?.languages[0]?.code ?? 'en',
|
||||||
tagIds: []
|
tagIds: []
|
||||||
@@ -294,6 +366,34 @@ function closeCreateThread() {
|
|||||||
createModalOpen.value = false;
|
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) {
|
function toggleThreadTag(tag: ThreadChannelTag) {
|
||||||
const tags = new Set(threadForm.value.tagIds);
|
const tags = new Set(threadForm.value.tagIds);
|
||||||
if (tags.has(tag.id)) {
|
if (tags.has(tag.id)) {
|
||||||
@@ -304,6 +404,16 @@ function toggleThreadTag(tag: ThreadChannelTag) {
|
|||||||
threadForm.value.tagIds = [...tags];
|
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() {
|
async function submitThread() {
|
||||||
const channel = selectedChannel.value ?? channels.value[0];
|
const channel = selectedChannel.value ?? channels.value[0];
|
||||||
if (!channel) return;
|
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() {
|
async function submitMessage() {
|
||||||
const id = activeThreadId.value;
|
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;
|
busy.value = true;
|
||||||
errorMessage.value = '';
|
errorMessage.value = '';
|
||||||
try {
|
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() {
|
async function toggleFollow() {
|
||||||
const thread = activeThread.value;
|
const thread = activeThread.value;
|
||||||
if (!thread) return;
|
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<ThreadWsMessage, { type: 'thread.message.moderation' }>) {
|
||||||
|
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) {
|
function handleThreadWsMessage(message: ThreadWsMessage) {
|
||||||
if (message.type === 'thread.message.created') {
|
if (message.type === 'thread.message.created') {
|
||||||
updateThreadInList(message.thread);
|
updateThreadInList(message.thread);
|
||||||
@@ -452,6 +625,8 @@ function handleThreadWsMessage(message: ThreadWsMessage) {
|
|||||||
if (thread) {
|
if (thread) {
|
||||||
updateThreadInList({ ...thread, unread: message.unread });
|
updateThreadInList({ ...thread, unread: message.unread });
|
||||||
}
|
}
|
||||||
|
} else if (message.type === 'thread.message.moderation') {
|
||||||
|
applyMessageModerationUpdate(message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -505,10 +680,6 @@ onBeforeUnmount(() => {
|
|||||||
<section class="page-stack threads-page">
|
<section class="page-stack threads-page">
|
||||||
<PageHeader :title="t('pages.threads.title')" :subtitle="t('pages.threads.subtitle')">
|
<PageHeader :title="t('pages.threads.title')" :subtitle="t('pages.threads.subtitle')">
|
||||||
<template #kicker>{{ t('pages.threads.kicker') }}</template>
|
<template #kicker>{{ t('pages.threads.kicker') }}</template>
|
||||||
<button v-if="canCreateThread" type="button" class="ui-button ui-button--primary" @click="openCreateThread">
|
|
||||||
<Icon :icon="iconAdd" class="ui-icon" aria-hidden="true" />
|
|
||||||
{{ t('pages.threads.newThread') }}
|
|
||||||
</button>
|
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
<StatusMessage v-if="errorMessage" variant="warning">{{ errorMessage }}</StatusMessage>
|
<StatusMessage v-if="errorMessage" variant="warning">{{ errorMessage }}</StatusMessage>
|
||||||
@@ -540,21 +711,39 @@ onBeforeUnmount(() => {
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<section class="threads-list-panel">
|
<section class="threads-list-panel">
|
||||||
|
<form class="thread-search-create" role="search" @submit.prevent="submitThreadSearch">
|
||||||
|
<label class="sr-only" for="thread-search">{{ t('pages.threads.searchOrCreate') }}</label>
|
||||||
|
<div class="thread-search-control">
|
||||||
|
<Icon :icon="iconSearch" class="ui-icon" aria-hidden="true" />
|
||||||
|
<input id="thread-search" v-model="threadSearch" type="search" :placeholder="t('pages.threads.searchOrCreate')" />
|
||||||
|
</div>
|
||||||
|
<button v-if="canCreateThread" type="button" class="ui-button ui-button--primary" @click="openCreateThread">
|
||||||
|
<Icon :icon="iconAdd" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ t('pages.threads.createPost') }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
<div class="thread-filters">
|
<div class="thread-filters">
|
||||||
<label>
|
<label>
|
||||||
<span>{{ t('pages.threads.language') }}</span>
|
<span>{{ t('pages.threads.language') }}</span>
|
||||||
<select v-model="selectedLanguage">
|
<TagsSelect
|
||||||
<option value="all">{{ t('pages.threads.allLanguages') }}</option>
|
id="thread-language-filter"
|
||||||
<option v-for="language in languageOptions" :key="language.code" :value="language.code">{{ language.name }}</option>
|
v-model="selectedLanguage"
|
||||||
</select>
|
:options="languageFilterOptions"
|
||||||
|
:multiple="false"
|
||||||
|
:placeholder="t('pages.threads.allLanguages')"
|
||||||
|
:search-placeholder="t('pages.threads.language')"
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
<span>{{ t('pages.threads.sort') }}</span>
|
<span>{{ t('pages.threads.sort') }}</span>
|
||||||
<select v-model="sort">
|
<TagsSelect
|
||||||
<option value="last-active">{{ t('pages.threads.sortLastActive') }}</option>
|
id="thread-sort-filter"
|
||||||
<option value="latest">{{ t('pages.threads.sortLatest') }}</option>
|
v-model="sortModel"
|
||||||
<option value="most-discussed">{{ t('pages.threads.sortMostDiscussed') }}</option>
|
:options="sortOptions"
|
||||||
</select>
|
:multiple="false"
|
||||||
|
:placeholder="t('pages.threads.sort')"
|
||||||
|
:search-placeholder="t('pages.threads.sort')"
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="tagOptions.length" class="thread-tag-filter" :aria-label="t('pages.threads.tags')">
|
<div v-if="tagOptions.length" class="thread-tag-filter" :aria-label="t('pages.threads.tags')">
|
||||||
@@ -604,19 +793,25 @@ onBeforeUnmount(() => {
|
|||||||
{{ t('pages.threads.loadMoreThreads') }}
|
{{ t('pages.threads.loadMoreThreads') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p v-else class="threads-empty">{{ t('pages.threads.noThreads') }}</p>
|
<p v-else class="threads-empty">{{ hasThreadSearch ? t('pages.threads.noSearchResults') : t('pages.threads.noThreads') }}</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="thread-chat-panel">
|
|
||||||
<template v-if="activeThread">
|
|
||||||
<header class="thread-chat-header">
|
|
||||||
<div>
|
|
||||||
<h2>{{ activeThread.title }}</h2>
|
|
||||||
<p>
|
|
||||||
{{ activeThread.author?.displayName ?? t('pages.life.byUnknown') }} · {{ formatDateTime(activeThread.createdAt) }}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
v-if="detailModalOpen && activeThread"
|
||||||
|
:title="activeThread.title"
|
||||||
|
:subtitle="`${activeThread.author?.displayName ?? t('pages.life.byUnknown')} · ${formatDateTime(activeThread.createdAt)}`"
|
||||||
|
:close-label="t('common.close')"
|
||||||
|
size="wide"
|
||||||
|
@close="closeThreadDetail"
|
||||||
|
>
|
||||||
|
<section class="thread-chat-panel thread-chat-panel--modal">
|
||||||
|
<header class="thread-chat-header">
|
||||||
<div class="thread-chat-actions">
|
<div class="thread-chat-actions">
|
||||||
|
<button v-if="canEditActiveThread" type="button" class="ui-button ui-button--small" :disabled="busy" @click="openEditThread">
|
||||||
|
<Icon :icon="iconEdit" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ t('pages.threads.editPost') }}
|
||||||
|
</button>
|
||||||
<button v-if="canFollow" type="button" class="ui-button ui-button--small" :disabled="busy" @click="toggleFollow">
|
<button v-if="canFollow" type="button" class="ui-button ui-button--small" :disabled="busy" @click="toggleFollow">
|
||||||
<Icon :icon="iconBell" class="ui-icon" aria-hidden="true" />
|
<Icon :icon="iconBell" class="ui-icon" aria-hidden="true" />
|
||||||
{{ activeThread.followed ? t('pages.threads.unfollow') : t('pages.threads.follow') }}
|
{{ activeThread.followed ? t('pages.threads.unfollow') : t('pages.threads.follow') }}
|
||||||
@@ -634,16 +829,16 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
<div class="thread-reactions">
|
<div class="thread-reactions">
|
||||||
<button
|
<button
|
||||||
v-for="option in reactionOptions"
|
v-for="reactionType in reactionTypesFor(activeThread)"
|
||||||
:key="option.type"
|
:key="reactionType"
|
||||||
type="button"
|
type="button"
|
||||||
class="thread-reaction"
|
class="thread-reaction"
|
||||||
:class="{ active: reactionActive(activeThread, option.type) }"
|
:class="{ active: reactionActive(activeThread, reactionType) }"
|
||||||
:disabled="!canReact"
|
:disabled="!canReact"
|
||||||
@click="toggleThreadReaction(activeThread, option.type)"
|
@click="toggleThreadReaction(activeThread, reactionType)"
|
||||||
>
|
>
|
||||||
<Icon :icon="option.icon" class="ui-icon" aria-hidden="true" />
|
<span class="thread-reaction__emoji">{{ reactionType }}</span>
|
||||||
<span>{{ reactionCount(activeThread, option.type) }}</span>
|
<span>{{ reactionCount(activeThread, reactionType) }}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -673,23 +868,70 @@ onBeforeUnmount(() => {
|
|||||||
<time :datetime="group.createdAt">{{ formatDateTime(group.createdAt) }}</time>
|
<time :datetime="group.createdAt">{{ formatDateTime(group.createdAt) }}</time>
|
||||||
</div>
|
</div>
|
||||||
<div v-for="message in group.messages" :key="message.id" class="thread-message">
|
<div v-for="message in group.messages" :key="message.id" class="thread-message">
|
||||||
<p>{{ message.body }}</p>
|
<form v-if="editingMessageId === message.id" class="thread-message-edit" @submit.prevent="submitMessageEdit(message)">
|
||||||
<span v-if="message.moderationStatus === 'reviewing'" class="config-flag">{{ t('pages.threads.messageReviewing') }}</span>
|
<label class="sr-only" :for="`thread-message-edit-${message.id}`">{{ t('pages.threads.message') }}</label>
|
||||||
<span v-else-if="message.moderationStatus === 'rejected' || message.moderationStatus === 'failed'" class="config-flag">
|
<textarea
|
||||||
{{ t('pages.threads.messageRejected') }}
|
:id="`thread-message-edit-${message.id}`"
|
||||||
</span>
|
v-model="editingMessageBody"
|
||||||
|
rows="3"
|
||||||
|
required
|
||||||
|
maxlength="2000"
|
||||||
|
:disabled="messageActionBusyId === message.id"
|
||||||
|
></textarea>
|
||||||
|
<div class="thread-message-actions">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="ui-button ui-button--primary ui-button--small"
|
||||||
|
:disabled="messageActionBusyId === message.id || !editingMessageBody.trim()"
|
||||||
|
>
|
||||||
|
<Icon :icon="iconSave" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ messageActionBusyId === message.id ? t('common.saving') : t('common.save') }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="ui-button ui-button--small"
|
||||||
|
:disabled="messageActionBusyId === message.id"
|
||||||
|
@click="cancelEditMessage"
|
||||||
|
>
|
||||||
|
<Icon :icon="iconCancel" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ t('common.cancel') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<p v-else>{{ message.body }}</p>
|
||||||
|
<span v-if="message.moderationStatus !== 'approved'" class="config-flag">{{ messageModerationLabel(message) }}</span>
|
||||||
<div class="thread-reactions thread-reactions--message">
|
<div class="thread-reactions thread-reactions--message">
|
||||||
<button
|
<button
|
||||||
v-for="option in reactionOptions"
|
v-for="reactionType in reactionTypesFor(message)"
|
||||||
:key="option.type"
|
:key="reactionType"
|
||||||
type="button"
|
type="button"
|
||||||
class="thread-reaction"
|
class="thread-reaction"
|
||||||
:class="{ active: reactionActive(message, option.type) }"
|
:class="{ active: reactionActive(message, reactionType) }"
|
||||||
:disabled="!canReact || message.moderationStatus !== 'approved'"
|
:disabled="!canReact || message.moderationStatus !== 'approved'"
|
||||||
@click="toggleMessageReaction(message, option.type)"
|
@click="toggleMessageReaction(message, reactionType)"
|
||||||
>
|
>
|
||||||
<Icon :icon="option.icon" class="ui-icon" aria-hidden="true" />
|
<span class="thread-reaction__emoji">{{ reactionType }}</span>
|
||||||
<span>{{ reactionCount(message, option.type) }}</span>
|
<span>{{ reactionCount(message, reactionType) }}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="canEditMessage(message) && editingMessageId !== message.id"
|
||||||
|
type="button"
|
||||||
|
class="thread-reaction thread-reaction--action"
|
||||||
|
:disabled="messageActionBusyId === message.id"
|
||||||
|
@click="startEditMessage(message)"
|
||||||
|
>
|
||||||
|
<Icon :icon="iconEdit" class="ui-icon" aria-hidden="true" />
|
||||||
|
<span>{{ t('pages.threads.editMessage') }}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="canRetryMessageModeration(message)"
|
||||||
|
type="button"
|
||||||
|
class="thread-reaction thread-reaction--action"
|
||||||
|
:disabled="moderationBusyId === message.id"
|
||||||
|
@click="retryMessageModeration(message)"
|
||||||
|
>
|
||||||
|
<Icon :icon="iconUndo" class="ui-icon" aria-hidden="true" />
|
||||||
|
<span>{{ moderationBusyId === message.id ? t('pages.threads.moderationRetrying') : t('pages.threads.moderationRetry') }}</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="canDeleteMessages"
|
v-if="canDeleteMessages"
|
||||||
@@ -721,18 +963,46 @@ onBeforeUnmount(() => {
|
|||||||
rows="2"
|
rows="2"
|
||||||
:disabled="busy || !canCreateMessage || activeThread.locked"
|
:disabled="busy || !canCreateMessage || activeThread.locked"
|
||||||
:placeholder="t('pages.threads.message')"
|
:placeholder="t('pages.threads.message')"
|
||||||
|
@keydown="handleMessageKeydown"
|
||||||
></textarea>
|
></textarea>
|
||||||
<button class="ui-button ui-button--primary" type="submit" :disabled="busy || !composerBody.trim() || !canCreateMessage || activeThread.locked">
|
<button class="ui-button ui-button--primary" type="submit" :disabled="busy || !composerBody.trim() || !canCreateMessage || activeThread.locked">
|
||||||
<Icon :icon="iconSend" class="ui-icon" aria-hidden="true" />
|
<Icon :icon="iconSend" class="ui-icon" aria-hidden="true" />
|
||||||
{{ busy ? t('pages.threads.sending') : t('pages.threads.send') }}
|
{{ busy ? t('pages.threads.sending') : t('pages.threads.send') }}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</template>
|
|
||||||
<p v-else class="threads-empty threads-empty--select">{{ t('pages.threads.selectThread') }}</p>
|
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</Modal>
|
||||||
|
|
||||||
<Modal v-if="createModalOpen" :title="t('pages.threads.newThread')" :close-label="t('common.close')" @close="closeCreateThread">
|
<Modal v-if="editModalOpen" :title="t('pages.threads.editPost')" :close-label="t('common.close')" @close="closeEditThread">
|
||||||
|
<form class="modal-edit-form" @submit.prevent="submitThreadEdit">
|
||||||
|
<div class="field">
|
||||||
|
<label for="thread-edit-title">{{ t('pages.threads.threadTitle') }}</label>
|
||||||
|
<input id="thread-edit-title" v-model="threadEditForm.title" required maxlength="140" :disabled="busy" />
|
||||||
|
</div>
|
||||||
|
<div v-if="editTagOptions.length" class="field">
|
||||||
|
<span class="field-label">{{ t('pages.threads.tags') }}</span>
|
||||||
|
<div class="thread-tag-filter">
|
||||||
|
<button
|
||||||
|
v-for="tag in editTagOptions"
|
||||||
|
:key="tag.id"
|
||||||
|
type="button"
|
||||||
|
class="thread-chip"
|
||||||
|
:class="{ active: threadEditForm.tagIds.includes(tag.id) }"
|
||||||
|
:disabled="busy"
|
||||||
|
@click="toggleEditThreadTag(tag)"
|
||||||
|
>
|
||||||
|
{{ tag.name }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="ui-button ui-button--primary" type="submit" :disabled="busy || !threadEditForm.title.trim()">
|
||||||
|
<Icon :icon="iconEdit" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ busy ? t('common.saving') : t('common.save') }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal v-if="createModalOpen" :title="t('pages.threads.createPost')" :close-label="t('common.close')" @close="closeCreateThread">
|
||||||
<form class="modal-edit-form" @submit.prevent="submitThread">
|
<form class="modal-edit-form" @submit.prevent="submitThread">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="thread-title">{{ t('pages.threads.threadTitle') }}</label>
|
<label for="thread-title">{{ t('pages.threads.threadTitle') }}</label>
|
||||||
|
|||||||
@@ -1028,11 +1028,15 @@ export const systemWordingMessages = {
|
|||||||
channels: 'Channels',
|
channels: 'Channels',
|
||||||
allChannels: 'All channels',
|
allChannels: 'All channels',
|
||||||
newThread: 'New Thread',
|
newThread: 'New Thread',
|
||||||
|
createPost: 'Create Post',
|
||||||
|
searchOrCreate: 'Search or Create New Post',
|
||||||
|
editPost: 'Edit Post',
|
||||||
threadTitle: 'Title',
|
threadTitle: 'Title',
|
||||||
firstMessage: 'First message',
|
firstMessage: 'First message',
|
||||||
message: 'Message',
|
message: 'Message',
|
||||||
send: 'Send',
|
send: 'Send',
|
||||||
sending: 'Sending',
|
sending: 'Sending',
|
||||||
|
editMessage: 'Edit Message',
|
||||||
follow: 'Follow',
|
follow: 'Follow',
|
||||||
unfollow: 'Unfollow',
|
unfollow: 'Unfollow',
|
||||||
lock: 'Lock',
|
lock: 'Lock',
|
||||||
@@ -1051,12 +1055,20 @@ export const systemWordingMessages = {
|
|||||||
jumpToPresent: 'Jump to Present',
|
jumpToPresent: 'Jump to Present',
|
||||||
unreadDivider: 'Unread messages',
|
unreadDivider: 'Unread messages',
|
||||||
noThreads: 'No threads yet',
|
noThreads: 'No threads yet',
|
||||||
|
noSearchResults: 'No matching posts',
|
||||||
noMessages: 'No messages yet',
|
noMessages: 'No messages yet',
|
||||||
selectThread: 'Select a thread',
|
selectThread: 'Select a thread',
|
||||||
|
messageUnreviewed: 'Not reviewed',
|
||||||
messageReviewing: 'Reviewing',
|
messageReviewing: 'Reviewing',
|
||||||
messageRejected: 'Not approved',
|
messageRejected: 'Not approved',
|
||||||
|
messageFailedReview: 'Review failed',
|
||||||
|
moderationRetry: 'Retry review',
|
||||||
|
moderationRetrying: 'Retrying',
|
||||||
createFailed: 'Thread could not be created',
|
createFailed: 'Thread could not be created',
|
||||||
|
editFailed: 'Thread could not be updated',
|
||||||
messageFailed: 'Message could not be sent',
|
messageFailed: 'Message could not be sent',
|
||||||
|
messageEditFailed: 'Message could not be updated',
|
||||||
|
moderationRetryFailed: 'Review retry failed',
|
||||||
reactionFailed: 'Reaction could not be updated',
|
reactionFailed: 'Reaction could not be updated',
|
||||||
followFailed: 'Follow could not be updated'
|
followFailed: 'Follow could not be updated'
|
||||||
},
|
},
|
||||||
@@ -2445,11 +2457,15 @@ export const systemWordingMessages = {
|
|||||||
channels: '频道',
|
channels: '频道',
|
||||||
allChannels: '全部频道',
|
allChannels: '全部频道',
|
||||||
newThread: '新帖子',
|
newThread: '新帖子',
|
||||||
|
createPost: '创建帖子',
|
||||||
|
searchOrCreate: '搜索或创建新帖子',
|
||||||
|
editPost: '编辑帖子',
|
||||||
threadTitle: '标题',
|
threadTitle: '标题',
|
||||||
firstMessage: '首条消息',
|
firstMessage: '首条消息',
|
||||||
message: '消息',
|
message: '消息',
|
||||||
send: '发送',
|
send: '发送',
|
||||||
sending: '发送中',
|
sending: '发送中',
|
||||||
|
editMessage: '编辑消息',
|
||||||
follow: '关注',
|
follow: '关注',
|
||||||
unfollow: '取消关注',
|
unfollow: '取消关注',
|
||||||
lock: '锁定',
|
lock: '锁定',
|
||||||
@@ -2468,12 +2484,20 @@ export const systemWordingMessages = {
|
|||||||
jumpToPresent: '跳到最新',
|
jumpToPresent: '跳到最新',
|
||||||
unreadDivider: '未读消息',
|
unreadDivider: '未读消息',
|
||||||
noThreads: '暂无帖子',
|
noThreads: '暂无帖子',
|
||||||
|
noSearchResults: '没有匹配的帖子',
|
||||||
noMessages: '暂无消息',
|
noMessages: '暂无消息',
|
||||||
selectThread: '选择一个帖子',
|
selectThread: '选择一个帖子',
|
||||||
|
messageUnreviewed: '未审核',
|
||||||
messageReviewing: '审核中',
|
messageReviewing: '审核中',
|
||||||
messageRejected: '未通过',
|
messageRejected: '未通过',
|
||||||
|
messageFailedReview: '审核失败',
|
||||||
|
moderationRetry: '重新审核',
|
||||||
|
moderationRetrying: '重审中',
|
||||||
createFailed: '帖子创建失败',
|
createFailed: '帖子创建失败',
|
||||||
|
editFailed: '帖子更新失败',
|
||||||
messageFailed: '消息发送失败',
|
messageFailed: '消息发送失败',
|
||||||
|
messageEditFailed: '消息更新失败',
|
||||||
|
moderationRetryFailed: '重新审核失败',
|
||||||
reactionFailed: 'Reaction 更新失败',
|
reactionFailed: 'Reaction 更新失败',
|
||||||
followFailed: '关注状态更新失败'
|
followFailed: '关注状态更新失败'
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user