feat(threads): add real-time forum and chat system
Implement DB schema, API, and WebSocket for channels and messages Add frontend views, AI moderation, and admin management
This commit is contained in:
141
DESIGN.md
141
DESIGN.md
@@ -6,6 +6,7 @@
|
|||||||
- 所有人都可以浏览 Wiki 内容。
|
- 所有人都可以浏览 Wiki 内容。
|
||||||
- 已注册并完成邮箱验证且拥有对应权限的用户可以创建、编辑、删除 Wiki 内容。
|
- 已注册并完成邮箱验证且拥有对应权限的用户可以创建、编辑、删除 Wiki 内容。
|
||||||
- 前台以 Home 首页、Pokedex(Main Game / Event)、Habitat Dex(Main Game / Event)、Collections(Main Game / Event / Ancient Artifacts)、材料单、每日 CheckList、Life、Automation、Dish、Events、Actions、Dream Island、Clothes 为主要浏览入口。
|
- 前台以 Home 首页、Pokedex(Main Game / Event)、Habitat Dex(Main Game / Event)、Collections(Main Game / Event / Ancient Artifacts)、材料单、每日 CheckList、Life、Automation、Dish、Events、Actions、Dream Island、Clothes 为主要浏览入口。
|
||||||
|
- Threads 是社区讨论入口,采用 Discord Forum + 聊天室混合形态;用户按 Channel 浏览 Thread,并在 Thread 内使用聊天室式消息流讨论。
|
||||||
- Home 首页路径为 `/`,用于聚合公开 Wiki 入口;Logo 导航回到 Home,用户可从 Home 进入核心资料、每日 CheckList、Life 和正在准备中的分区。
|
- Home 首页路径为 `/`,用于聚合公开 Wiki 入口;Logo 导航回到 Home,用户可从 Home 进入核心资料、每日 CheckList、Life 和正在准备中的分区。
|
||||||
- 桌面端使用侧边栏导航,侧边栏可折叠为图标栏;移动端继续使用抽屉式侧边栏。
|
- 桌面端使用侧边栏导航,侧边栏可折叠为图标栏;移动端继续使用抽屉式侧边栏。
|
||||||
- 全局顶部导航栏承载语言切换、通知、User Profile 和登录 / 退出等账号操作;除 User Profile 可展示用户名外,顶部操作以图标按钮呈现。
|
- 全局顶部导航栏承载语言切换、通知、User Profile 和登录 / 退出等账号操作;除 User Profile 可展示用户名外,顶部操作以图标按钮呈现。
|
||||||
@@ -302,6 +303,7 @@
|
|||||||
- 通知 API 不返回邮箱、角色、权限、session、token/hash、AI prompt、模型响应、内部审核错误、错误堆栈、调试字段或内部审计 payload。
|
- 通知 API 不返回邮箱、角色、权限、session、token/hash、AI prompt、模型响应、内部审核错误、错误堆栈、调试字段或内部审计 payload。
|
||||||
- 前端在主导航登录区展示通知入口、未读数量和通知列表;点击通知后标记已读并跳转到对应 Life Post 或 Wiki 详情页。
|
- 前端在主导航登录区展示通知入口、未读数量和通知列表;点击通知后标记已读并跳转到对应 Life Post 或 Wiki 详情页。
|
||||||
- Follow 对象发布 Life Post 的动态属于 Following Feed,不进入 Notifications,不产生未读数量,也不需要标记已读。
|
- Follow 对象发布 Life Post 的动态属于 Following Feed,不进入 Notifications,不产生未读数量,也不需要标记已读。
|
||||||
|
- Thread Follow 的未读状态在 Threads 自身侧边栏和 Thread 列表展示,不进入全局 Notifications,不影响 NotificationBell 未读数量。
|
||||||
|
|
||||||
## 滥用防护与限流
|
## 滥用防护与限流
|
||||||
|
|
||||||
@@ -328,8 +330,8 @@
|
|||||||
- Wiki 内容写入(Pokemon、物品、材料单、栖息地、每日 CheckList 和排序)默认按用户 ID 限制为 120 次 / 1 小时,并有 2 秒冷却时间。
|
- Wiki 内容写入(Pokemon、物品、材料单、栖息地、每日 CheckList 和排序)默认按用户 ID 限制为 120 次 / 1 小时,并有 2 秒冷却时间。
|
||||||
- 上传默认按用户 ID 限制为 20 次 / 1 小时,并有 30 秒冷却时间。
|
- 上传默认按用户 ID 限制为 20 次 / 1 小时,并有 30 秒冷却时间。
|
||||||
- Community 写入:
|
- Community 写入:
|
||||||
- Life Post、Life 评论、Wiki 讨论评论和对应删除 / 更新操作默认按用户 ID 限制为 60 次 / 1 小时,并有 5 秒冷却时间。
|
- Life Post、Life 评论、Wiki 讨论评论、Thread / Thread Message 和对应删除 / 更新操作默认按用户 ID 限制为 60 次 / 1 小时,并有 5 秒冷却时间。
|
||||||
- Life reaction 写入默认按用户 ID 限制为 120 次 / 1 小时,并有 1 秒冷却时间。
|
- Life reaction 和 Thread reaction / follow 写入默认按用户 ID 限制为 120 次 / 1 小时,并有 1 秒冷却时间。
|
||||||
- Pokemon Fetch 数据和图片候选查询默认按用户 ID 限制为 60 次 / 10 分钟,并有 1 秒冷却时间。
|
- Pokemon Fetch 数据和图片候选查询默认按用户 ID 限制为 60 次 / 10 分钟,并有 1 秒冷却时间。
|
||||||
|
|
||||||
## Community 编辑与审计
|
## Community 编辑与审计
|
||||||
@@ -954,6 +956,114 @@ API 暴露边界:
|
|||||||
- API 不返回 Life Post 的 `deleted_at`、`deleted_by_user_id` 等内部软删除字段。
|
- API 不返回 Life Post 的 `deleted_at`、`deleted_by_user_id` 等内部软删除字段。
|
||||||
- 非作者只有拥有对应 `*-any` 管理权限时才能编辑或删除其他用户的 Life Post 或 Life Comment。
|
- 非作者只有拥有对应 `*-any` 管理权限时才能编辑或删除其他用户的 Life Post 或 Life Comment。
|
||||||
|
|
||||||
|
## Threads
|
||||||
|
|
||||||
|
Threads 是社区长期讨论区,形态类似 Discord Forum + 聊天室混合系统。
|
||||||
|
|
||||||
|
Channel 可配置:
|
||||||
|
|
||||||
|
- 名称
|
||||||
|
- 是否允许用户创建 Thread
|
||||||
|
- 可用标签:每个 Channel 内唯一,按 `sort_order` 展示
|
||||||
|
- 可用语言:使用 `languages.code`,可配置允许的语言集合;未配置时前台回退到启用语言
|
||||||
|
- `sort_order`
|
||||||
|
|
||||||
|
Thread 可配置:
|
||||||
|
|
||||||
|
- 标题
|
||||||
|
- 所属 Channel
|
||||||
|
- 标签:多选,只能选择该 Channel 可用标签
|
||||||
|
- 语言:只能选择该 Channel 可用语言或启用语言中的一项
|
||||||
|
- 创建者、创建时间、最后活跃时间
|
||||||
|
- 消息数
|
||||||
|
- Follow 状态
|
||||||
|
- Reaction 汇总
|
||||||
|
- 锁定状态
|
||||||
|
|
||||||
|
Message 可配置:
|
||||||
|
|
||||||
|
- 所属 Thread
|
||||||
|
- 正文
|
||||||
|
- 创建者、创建时间
|
||||||
|
- Reaction 汇总
|
||||||
|
- AI 审核状态和语言区
|
||||||
|
|
||||||
|
前台行为:
|
||||||
|
|
||||||
|
- 所有人都可以浏览已公开的 Channel、Thread 和审核通过的 Message。
|
||||||
|
- `/threads` 展示 Threads 工作区,左侧为 Channel 列表,中间为 Thread List;桌面端 Thread 详情使用聊天布局,移动端通过详情页堆叠显示。
|
||||||
|
- `/threads/:threadId` 打开 Thread 详情;默认进入最新消息位置。
|
||||||
|
- 用户可在 Channel 内创建 Thread;需要已注册、邮箱已验证并拥有 `threads.create` 权限,且 Channel 允许用户创建 Thread。
|
||||||
|
- 已注册、邮箱已验证并拥有 `threads.messages.create` 权限的用户可以在未锁定 Thread 中发送 Message。
|
||||||
|
- 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 List 支持排序:`last-active` 默认按最后活跃倒序;`latest` 按创建时间倒序;`most-discussed` 按公开消息数倒序。
|
||||||
|
- Thread List 支持语言筛选:All languages 或指定启用语言 / Channel 可用语言。
|
||||||
|
- Thread List 支持按 Channel 标签筛选。
|
||||||
|
- Thread 新消息实时更新通过 Thread WebSocket;WebSocket 使用短期一次性 ticket,不把 session token 放入 WebSocket URL。
|
||||||
|
- Thread Message 是用户生成内容,必须经过 AI 审核;未审核通过的 Message 不向普通访客公开。作者本人和拥有 `admin.threads.messages.delete` 权限的管理用户可以看到自己的未通过/审核中 Message 状态。
|
||||||
|
- 审核通过的 Message 才计入普通公开消息数、最后活跃排序和未读状态。
|
||||||
|
- Thread 被锁定后不可新增 Message,但仍可浏览和设置 Reaction / Follow。
|
||||||
|
- 删除 Thread 使用软删除;删除后不出现在列表,详情返回未找到。
|
||||||
|
- 删除 Message 使用软删除;普通列表不展示已删除 Message,不暴露删除字段。
|
||||||
|
|
||||||
|
管理员行为:
|
||||||
|
|
||||||
|
- 拥有 `admin.threads.channels.read` 可查看 Channel 管理。
|
||||||
|
- 拥有 `admin.threads.channels.create` / `update` / `delete` 可创建、编辑、删除 Channel,配置标签、语言和是否允许用户创建 Thread。
|
||||||
|
- 拥有 `admin.threads.threads.delete` 可删除任意 Thread。
|
||||||
|
- 拥有 `admin.threads.threads.lock` 可锁定 / 解锁任意 Thread。
|
||||||
|
- 拥有 `admin.threads.messages.delete` 可删除任意 Message。
|
||||||
|
|
||||||
|
API 暴露边界:
|
||||||
|
|
||||||
|
- Channel API 只返回展示和管理需要的 `id`、`name`、`allowUserThreads`、`tags`、`languages`、`sortOrder` 和未读摘要;不返回内部审计或调试字段。
|
||||||
|
- Thread API 只返回 `id`、`channelId`、`title`、标签、语言、作者必要署名、创建时间、最后活跃时间、锁定状态、消息数、Reaction 汇总、当前用户 Reaction、Follow 状态和未读状态。
|
||||||
|
- Message API 只返回 `id`、`threadId`、`body`、作者必要署名、创建时间、审核状态、语言区、必要审核原因、Reaction 汇总和当前用户 Reaction。
|
||||||
|
- API 不返回邮箱、角色、权限、session、token/hash、AI prompt、模型响应、内部审核错误、错误堆栈、删除时间、删除人或内部调试字段。
|
||||||
|
- Thread 内容正文按作者输入展示,不进入 `entity_translations`;Thread 的语言用于筛选和内容区分,不改变系统 UI 语言。
|
||||||
|
- Channel 名称和标签当前作为管理数据直接存储,不进入 `entity_translations`。
|
||||||
|
|
||||||
|
当前实现状态:
|
||||||
|
|
||||||
|
已实现:
|
||||||
|
|
||||||
|
- 数据库已包含 `thread_channels`、`thread_channel_tags`、`thread_channel_languages`、`threads`、`thread_tag_links`、`thread_messages`、`thread_reactions`、`thread_message_reactions`、`thread_follows`、`thread_reads` 和 `thread_ws_tickets`。
|
||||||
|
- 初始化会创建默认 Channel:General、Questions、Showcase。
|
||||||
|
- 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 已支持创建、编辑、删除 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。
|
||||||
|
- 前端 Message 展示已支持同一用户 5 分钟内连续消息的合并显示。
|
||||||
|
- 前端 Message 历史已支持点击 Load older 向上加载更早消息。
|
||||||
|
- 前端已支持 Jump to Present:用户不在底部且收到新消息时可跳到最新。
|
||||||
|
- 前端 Thread List 已支持 Channel、标签、语言和排序筛选。
|
||||||
|
- 前端管理端已新增 Thread Channels 管理入口,可配置 Channel 名称、是否允许用户创建 Thread、标签和语言。
|
||||||
|
|
||||||
|
未实现 / 待完善:
|
||||||
|
|
||||||
|
- Thread 详情中的未读消息分隔线尚未完整实现;当前已记录 read 状态并显示列表未读红点,但没有在消息流中定位并渲染 unread divider。
|
||||||
|
- WebSocket 没有自动重连、退避重试或跨标签页连接复用;连接断开后需页面重新加载或后续操作重新进入。
|
||||||
|
- Reaction 用户列表 Modal 尚未实现;当前只显示 Reaction 类型和数量,以及当前用户自己的 Reaction 状态。
|
||||||
|
- Thread / Message Reaction 取消 API 当前通过 JSON body 传入 `reactionType`,前端可用;若后续需要更标准的 REST 形态,可改为 `DELETE /reaction/:reactionType`。
|
||||||
|
- Channel 排序 UI 尚未实现;数据库已有 `sort_order`,但管理端目前不能拖拽或调整 Channel / Tag / Language 顺序。
|
||||||
|
- Channel 名称和标签尚未进入 `entity_translations`;当前按管理数据原文展示。
|
||||||
|
- Thread 创建后的首条 Message 如果审核失败,Thread 会存在但普通访客看不到公开 Message,前端尚未提供 Message 审核重试入口。
|
||||||
|
- Thread Message 审核失败 / 拒绝后的重试 API 和 UI 尚未实现。
|
||||||
|
- Thread 删除、Message 删除和锁定 / 解锁当前直接执行,尚未使用确认 Modal。
|
||||||
|
- Thread List 的实时排序更新是基础 upsert 行为;复杂筛选条件下收到不匹配当前筛选的新 Thread / Message 时,仍可能需要后续刷新来得到完全一致的列表。
|
||||||
|
- 移动端已使用响应式堆叠布局,但还不是独立的移动端双页导航体验;后续可优化为 Channel / Thread List / Chat 分步视图。
|
||||||
|
- 当前没有 Thread 搜索、置顶、收藏、编辑 Thread 标题 / 标签 / 语言、编辑 Message、上传图片、@mention 或通知到全局 NotificationBell。
|
||||||
|
|
||||||
## 开发中入口
|
## 开发中入口
|
||||||
|
|
||||||
以下前台公开入口当前仅展示“正在开发中”占位页,不提供数据模型、后端 API、编辑表单、管理入口或排序能力:
|
以下前台公开入口当前仅展示“正在开发中”占位页,不提供数据模型、后端 API、编辑表单、管理入口或排序能力:
|
||||||
@@ -1102,6 +1212,12 @@ API 暴露边界:
|
|||||||
- `GET /api/life-posts/:id`:读取单条 Life Post 详情,遵守软删除和审核可见性规则。
|
- `GET /api/life-posts/:id`:读取单条 Life Post 详情,遵守软删除和审核可见性规则。
|
||||||
- `GET /api/life-posts/:id/reactions`:分页读取该 Life Post 的公开 Reaction 用户列表;支持 `cursor` / `limit` 和 `reactionType` 筛选。
|
- `GET /api/life-posts/:id/reactions`:分页读取该 Life Post 的公开 Reaction 用户列表;支持 `cursor` / `limit` 和 `reactionType` 筛选。
|
||||||
- `GET /api/life-posts/:postId/comments`:支持 `cursor` / `limit` 分页读取 Life Post 评论;支持 `language` 按审核语言区筛选;支持 `sort` 为 `oldest`、`latest`、`most-liked` 或 `most-replied`。
|
- `GET /api/life-posts/:postId/comments`:支持 `cursor` / `limit` 分页读取 Life Post 评论;支持 `language` 按审核语言区筛选;支持 `sort` 为 `oldest`、`latest`、`most-liked` 或 `most-replied`。
|
||||||
|
- `GET /api/thread-channels`:读取公开 Channel 列表,登录用户可同时得到 Follow Thread 的未读摘要。
|
||||||
|
- `GET /api/threads`:支持 `cursor` / `limit` 分页读取 Thread;支持 `channelId`、`language`、`tagId` 和 `sort`(`last-active`、`latest`、`most-discussed`)。
|
||||||
|
- `GET /api/threads/:id`:读取单个 Thread 详情。
|
||||||
|
- `GET /api/threads/:id/messages`:读取 Thread 消息;默认返回最新一页,支持 `before` / `limit` 向上加载历史。
|
||||||
|
- `POST /api/threads/ws-ticket`:创建短期一次性 Thread WebSocket ticket;需要登录。
|
||||||
|
- `GET /api/threads/ws?ticket=...`:Thread WebSocket 连接;只接收短期一次性 ticket。
|
||||||
- `GET /api/users/:id/profile`:读取公开用户 Profile 摘要、Wiki 贡献统计、公开社区统计和公开 Follow 统计;登录用户读取时返回自己与目标用户的关系状态。
|
- `GET /api/users/:id/profile`:读取公开用户 Profile 摘要、Wiki 贡献统计、公开社区统计和公开 Follow 统计;登录用户读取时返回自己与目标用户的关系状态。
|
||||||
- `GET /api/users/:id/life-posts`:分页读取该用户发布过且未删除的 Life Post。
|
- `GET /api/users/:id/life-posts`:分页读取该用户发布过且未删除的 Life Post。
|
||||||
- `GET /api/users/:id/reactions`:分页读取该用户设置过 Reaction 且目标未删除的 Life Post。
|
- `GET /api/users/:id/reactions`:分页读取该用户设置过 Reaction 且目标未删除的 Life Post。
|
||||||
@@ -1174,6 +1290,27 @@ API 暴露边界:
|
|||||||
- 实体讨论评论的点赞和取消点赞需要 `discussions.comments.like` 权限。
|
- 实体讨论评论的点赞和取消点赞需要 `discussions.comments.like` 权限。
|
||||||
- `PUT /api/discussions/comments/:id/like`
|
- `PUT /api/discussions/comments/:id/like`
|
||||||
- `DELETE /api/discussions/comments/:id/like`
|
- `DELETE /api/discussions/comments/:id/like`
|
||||||
|
- Thread 创建需要 `threads.create`。
|
||||||
|
- `POST /api/threads`
|
||||||
|
- Thread Message 创建需要 `threads.messages.create`。
|
||||||
|
- `POST /api/threads/:id/messages`
|
||||||
|
- Thread Follow 需要 `threads.follow`。
|
||||||
|
- `PUT /api/threads/:id/follow`
|
||||||
|
- `DELETE /api/threads/:id/follow`
|
||||||
|
- `POST /api/threads/:id/read`
|
||||||
|
- Thread 和 Message Reaction 需要 `threads.reactions.set`。
|
||||||
|
- `PUT /api/threads/:id/reaction`
|
||||||
|
- `DELETE /api/threads/:id/reaction`
|
||||||
|
- `PUT /api/thread-messages/:id/reaction`
|
||||||
|
- `DELETE /api/thread-messages/:id/reaction`
|
||||||
|
- Thread 管理需要 `admin.threads.*` 权限。
|
||||||
|
- `GET /api/admin/thread-channels`
|
||||||
|
- `POST /api/admin/thread-channels`
|
||||||
|
- `PUT /api/admin/thread-channels/:id`
|
||||||
|
- `DELETE /api/admin/thread-channels/:id`
|
||||||
|
- `PUT /api/admin/threads/:id/lock`
|
||||||
|
- `DELETE /api/admin/threads/:id`
|
||||||
|
- `DELETE /api/admin/thread-messages/:id`
|
||||||
- Life Reaction 的设置、替换和取消。
|
- Life Reaction 的设置、替换和取消。
|
||||||
- `PUT /api/life-posts/:id/reaction`
|
- `PUT /api/life-posts/:id/reaction`
|
||||||
- `DELETE /api/life-posts/:id/reaction`
|
- `DELETE /api/life-posts/:id/reaction`
|
||||||
|
|||||||
@@ -267,6 +267,17 @@ VALUES
|
|||||||
('life.comments.like', 'Like Life comments', 'Like and unlike Life comments.', 'Life', true),
|
('life.comments.like', 'Like Life comments', 'Like and unlike Life comments.', 'Life', true),
|
||||||
('life.reactions.set', 'Set Life reactions', 'Set and remove Life reactions.', 'Life', true),
|
('life.reactions.set', 'Set Life reactions', 'Set and remove Life reactions.', 'Life', true),
|
||||||
('life.ratings.set', 'Set Life ratings', 'Set and remove Life star ratings.', 'Life', true),
|
('life.ratings.set', 'Set Life ratings', 'Set and remove Life star ratings.', 'Life', true),
|
||||||
|
('threads.create', 'Create Threads', 'Create forum threads.', 'Threads', true),
|
||||||
|
('threads.messages.create', 'Create Thread messages', 'Create chat messages inside Threads.', 'Threads', true),
|
||||||
|
('threads.follow', 'Follow Threads', 'Follow Threads and manage read state.', 'Threads', true),
|
||||||
|
('threads.reactions.set', 'Set Thread reactions', 'Set and remove Thread and Thread message reactions.', 'Threads', true),
|
||||||
|
('admin.threads.channels.read', 'View Thread channels', 'View Thread channel configuration.', 'Threads', true),
|
||||||
|
('admin.threads.channels.create', 'Create Thread channels', 'Create Thread channels.', 'Threads', true),
|
||||||
|
('admin.threads.channels.update', 'Update Thread channels', 'Edit Thread channel configuration.', 'Threads', true),
|
||||||
|
('admin.threads.channels.delete', 'Delete Thread channels', 'Delete Thread channels.', 'Threads', true),
|
||||||
|
('admin.threads.threads.delete', 'Delete any Thread', 'Delete any Thread.', 'Threads', true),
|
||||||
|
('admin.threads.threads.lock', 'Lock Threads', 'Lock and unlock Threads.', 'Threads', true),
|
||||||
|
('admin.threads.messages.delete', 'Delete any Thread message', 'Delete any Thread message.', 'Threads', true),
|
||||||
('users.follow', 'Follow users', 'Follow and unfollow public user profiles.', 'Users', true),
|
('users.follow', 'Follow users', 'Follow and unfollow public user profiles.', 'Users', true),
|
||||||
('discussions.comments.create', 'Create discussion comments', 'Create entity discussion comments and replies.', 'Discussions', true),
|
('discussions.comments.create', 'Create discussion comments', 'Create entity discussion comments and replies.', 'Discussions', true),
|
||||||
('discussions.comments.delete', 'Delete own discussion comments', 'Delete own entity discussion comments.', 'Discussions', true),
|
('discussions.comments.delete', 'Delete own discussion comments', 'Delete own entity discussion comments.', 'Discussions', true),
|
||||||
@@ -367,6 +378,17 @@ JOIN permissions p ON p.key = ANY (ARRAY[
|
|||||||
'life.comments.like',
|
'life.comments.like',
|
||||||
'life.reactions.set',
|
'life.reactions.set',
|
||||||
'life.ratings.set',
|
'life.ratings.set',
|
||||||
|
'threads.create',
|
||||||
|
'threads.messages.create',
|
||||||
|
'threads.follow',
|
||||||
|
'threads.reactions.set',
|
||||||
|
'admin.threads.channels.read',
|
||||||
|
'admin.threads.channels.create',
|
||||||
|
'admin.threads.channels.update',
|
||||||
|
'admin.threads.channels.delete',
|
||||||
|
'admin.threads.threads.delete',
|
||||||
|
'admin.threads.threads.lock',
|
||||||
|
'admin.threads.messages.delete',
|
||||||
'users.follow',
|
'users.follow',
|
||||||
'discussions.comments.create',
|
'discussions.comments.create',
|
||||||
'discussions.comments.delete',
|
'discussions.comments.delete',
|
||||||
@@ -440,6 +462,10 @@ JOIN permissions p ON p.key = ANY (ARRAY[
|
|||||||
'life.comments.like',
|
'life.comments.like',
|
||||||
'life.reactions.set',
|
'life.reactions.set',
|
||||||
'life.ratings.set',
|
'life.ratings.set',
|
||||||
|
'threads.create',
|
||||||
|
'threads.messages.create',
|
||||||
|
'threads.follow',
|
||||||
|
'threads.reactions.set',
|
||||||
'users.follow',
|
'users.follow',
|
||||||
'discussions.comments.create',
|
'discussions.comments.create',
|
||||||
'discussions.comments.delete',
|
'discussions.comments.delete',
|
||||||
@@ -513,6 +539,10 @@ JOIN permissions p ON p.key = ANY (ARRAY[
|
|||||||
'life.comments.like',
|
'life.comments.like',
|
||||||
'life.reactions.set',
|
'life.reactions.set',
|
||||||
'life.ratings.set',
|
'life.ratings.set',
|
||||||
|
'threads.create',
|
||||||
|
'threads.messages.create',
|
||||||
|
'threads.follow',
|
||||||
|
'threads.reactions.set',
|
||||||
'users.follow',
|
'users.follow',
|
||||||
'discussions.comments.create',
|
'discussions.comments.create',
|
||||||
'discussions.comments.delete',
|
'discussions.comments.delete',
|
||||||
@@ -554,6 +584,33 @@ JOIN permissions p ON p.key = 'users.follow'
|
|||||||
WHERE r.key IN ('admin', 'editor', 'member')
|
WHERE r.key IN ('admin', 'editor', 'member')
|
||||||
ON CONFLICT DO NOTHING;
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO role_permissions (role_id, permission_id)
|
||||||
|
SELECT r.id, p.id
|
||||||
|
FROM roles r
|
||||||
|
JOIN permissions p ON p.key = ANY (ARRAY[
|
||||||
|
'threads.create',
|
||||||
|
'threads.messages.create',
|
||||||
|
'threads.follow',
|
||||||
|
'threads.reactions.set'
|
||||||
|
])
|
||||||
|
WHERE r.key IN ('admin', 'editor', 'member')
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO role_permissions (role_id, permission_id)
|
||||||
|
SELECT r.id, p.id
|
||||||
|
FROM roles r
|
||||||
|
JOIN permissions p ON p.key = ANY (ARRAY[
|
||||||
|
'admin.threads.channels.read',
|
||||||
|
'admin.threads.channels.create',
|
||||||
|
'admin.threads.channels.update',
|
||||||
|
'admin.threads.channels.delete',
|
||||||
|
'admin.threads.threads.delete',
|
||||||
|
'admin.threads.threads.lock',
|
||||||
|
'admin.threads.messages.delete'
|
||||||
|
])
|
||||||
|
WHERE r.key = 'admin'
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
WITH first_owner_user AS (
|
WITH first_owner_user AS (
|
||||||
SELECT u.id
|
SELECT u.id
|
||||||
FROM users u
|
FROM users u
|
||||||
@@ -805,6 +862,184 @@ CREATE INDEX IF NOT EXISTS life_post_ratings_post_idx
|
|||||||
CREATE INDEX IF NOT EXISTS life_post_ratings_user_idx
|
CREATE INDEX IF NOT EXISTS life_post_ratings_user_idx
|
||||||
ON life_post_ratings(user_id, updated_at DESC, post_id DESC);
|
ON life_post_ratings(user_id, updated_at DESC, post_id DESC);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS thread_channels (
|
||||||
|
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
name text NOT NULL UNIQUE,
|
||||||
|
allow_user_threads boolean NOT NULL DEFAULT true,
|
||||||
|
sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0),
|
||||||
|
created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
CHECK (length(name) BETWEEN 1 AND 80)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS thread_channels_sort_order_idx
|
||||||
|
ON thread_channels(sort_order, id);
|
||||||
|
|
||||||
|
INSERT INTO thread_channels (name, allow_user_threads, sort_order)
|
||||||
|
VALUES
|
||||||
|
('General', true, 10),
|
||||||
|
('Questions', true, 20),
|
||||||
|
('Showcase', true, 30)
|
||||||
|
ON CONFLICT (name) DO NOTHING;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS thread_channel_tags (
|
||||||
|
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
channel_id integer NOT NULL REFERENCES thread_channels(id) ON DELETE CASCADE,
|
||||||
|
name text NOT NULL,
|
||||||
|
sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0),
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (channel_id, name),
|
||||||
|
CHECK (length(name) BETWEEN 1 AND 40)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS thread_channel_tags_channel_sort_idx
|
||||||
|
ON thread_channel_tags(channel_id, sort_order, id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS thread_channel_languages (
|
||||||
|
channel_id integer NOT NULL REFERENCES thread_channels(id) ON DELETE CASCADE,
|
||||||
|
language_code text NOT NULL REFERENCES languages(code) ON DELETE CASCADE,
|
||||||
|
sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0),
|
||||||
|
PRIMARY KEY (channel_id, language_code)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS thread_channel_languages_sort_idx
|
||||||
|
ON thread_channel_languages(channel_id, sort_order, language_code);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS threads (
|
||||||
|
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
channel_id integer NOT NULL REFERENCES thread_channels(id) ON DELETE CASCADE,
|
||||||
|
title text NOT NULL,
|
||||||
|
language_code text NOT NULL REFERENCES languages(code) ON DELETE RESTRICT,
|
||||||
|
locked boolean NOT NULL DEFAULT false,
|
||||||
|
message_count integer NOT NULL DEFAULT 0 CHECK (message_count >= 0),
|
||||||
|
last_message_id integer,
|
||||||
|
last_active_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
deleted_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
deleted_at timestamptz,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
CHECK (length(title) BETWEEN 1 AND 140)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS threads_channel_last_active_idx
|
||||||
|
ON threads(channel_id, last_active_at DESC, id DESC)
|
||||||
|
WHERE deleted_at IS NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS threads_created_at_idx
|
||||||
|
ON threads(created_at DESC, id DESC)
|
||||||
|
WHERE deleted_at IS NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS threads_language_idx
|
||||||
|
ON threads(language_code, last_active_at DESC, id DESC)
|
||||||
|
WHERE deleted_at IS NULL;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS thread_tag_links (
|
||||||
|
thread_id integer NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
|
||||||
|
tag_id integer NOT NULL REFERENCES thread_channel_tags(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (thread_id, tag_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS thread_tag_links_tag_idx
|
||||||
|
ON thread_tag_links(tag_id, thread_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS thread_messages (
|
||||||
|
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
thread_id integer NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
|
||||||
|
body text NOT NULL CHECK (length(body) BETWEEN 1 AND 2000),
|
||||||
|
ai_moderation_status text NOT NULL DEFAULT 'unreviewed' CHECK (ai_moderation_status IN ('unreviewed', 'reviewing', 'approved', 'rejected', 'failed')),
|
||||||
|
ai_moderation_language_code text REFERENCES languages(code) ON DELETE SET NULL,
|
||||||
|
ai_moderation_reason text,
|
||||||
|
ai_moderation_content_hash text,
|
||||||
|
ai_moderation_checked_at timestamptz,
|
||||||
|
ai_moderation_retry_count integer NOT NULL DEFAULT 0 CHECK (ai_moderation_retry_count >= 0),
|
||||||
|
ai_moderation_updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
deleted_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
deleted_at timestamptz,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM pg_constraint
|
||||||
|
WHERE conname = 'threads_last_message_fk'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE threads
|
||||||
|
ADD CONSTRAINT threads_last_message_fk
|
||||||
|
FOREIGN KEY (last_message_id) REFERENCES thread_messages(id) ON DELETE SET NULL;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS thread_messages_thread_created_idx
|
||||||
|
ON thread_messages(thread_id, created_at DESC, id DESC);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS thread_messages_user_idx
|
||||||
|
ON thread_messages(created_by_user_id, created_at DESC, id DESC);
|
||||||
|
|
||||||
|
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')),
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (thread_id, user_id, reaction_type)
|
||||||
|
);
|
||||||
|
|
||||||
|
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')),
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (message_id, user_id, reaction_type)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS thread_message_reactions_message_idx
|
||||||
|
ON thread_message_reactions(message_id, reaction_type);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS thread_follows (
|
||||||
|
thread_id integer NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
|
||||||
|
user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (thread_id, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS thread_follows_user_idx
|
||||||
|
ON thread_follows(user_id, created_at DESC, thread_id DESC);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS thread_reads (
|
||||||
|
thread_id integer NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
|
||||||
|
user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
last_read_message_id integer REFERENCES thread_messages(id) ON DELETE SET NULL,
|
||||||
|
last_read_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (thread_id, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS thread_reads_user_idx
|
||||||
|
ON thread_reads(user_id, thread_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS thread_ws_tickets (
|
||||||
|
ticket_hash text PRIMARY KEY,
|
||||||
|
user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
expires_at timestamptz NOT NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
CHECK (length(ticket_hash) BETWEEN 32 AND 128)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS thread_ws_tickets_expires_idx
|
||||||
|
ON thread_ws_tickets(expires_at);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS skills (
|
CREATE TABLE IF NOT EXISTS skills (
|
||||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
name text NOT NULL UNIQUE,
|
name text NOT NULL UNIQUE,
|
||||||
|
|||||||
@@ -5,9 +5,10 @@ import {
|
|||||||
createApprovedCommentNotification,
|
createApprovedCommentNotification,
|
||||||
createModerationResultNotification
|
createModerationResultNotification
|
||||||
} from './notifications.ts';
|
} from './notifications.ts';
|
||||||
|
import { applyApprovedThreadMessage, publishThreadMessageModeration } from './threadsRealtime.ts';
|
||||||
|
|
||||||
export type AiModerationStatus = 'unreviewed' | 'reviewing' | 'approved' | 'rejected' | 'failed';
|
export type AiModerationStatus = 'unreviewed' | 'reviewing' | 'approved' | 'rejected' | 'failed';
|
||||||
export type AiModerationTargetType = 'life-post' | 'life-comment' | 'discussion-comment';
|
export type AiModerationTargetType = 'life-post' | 'life-comment' | 'discussion-comment' | 'thread-message';
|
||||||
export type AiModerationApiFormat = 'gemini-generate-content' | 'openai-chat-completions';
|
export type AiModerationApiFormat = 'gemini-generate-content' | 'openai-chat-completions';
|
||||||
export type AiModerationAuthMode = 'query-key' | 'bearer-token';
|
export type AiModerationAuthMode = 'query-key' | 'bearer-token';
|
||||||
|
|
||||||
@@ -254,6 +255,49 @@ const targetQueries: Record<
|
|||||||
AND deleted_at IS NULL
|
AND deleted_at IS NULL
|
||||||
RETURNING id
|
RETURNING id
|
||||||
`
|
`
|
||||||
|
},
|
||||||
|
'thread-message': {
|
||||||
|
select: `
|
||||||
|
SELECT
|
||||||
|
tm.id,
|
||||||
|
tm.body,
|
||||||
|
tm.ai_moderation_status AS status,
|
||||||
|
tm.ai_moderation_language_code AS "languageCode",
|
||||||
|
tm.ai_moderation_reason AS reason,
|
||||||
|
tm.ai_moderation_content_hash AS "contentHash"
|
||||||
|
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
|
||||||
|
`,
|
||||||
|
updateStatus: `
|
||||||
|
UPDATE thread_messages
|
||||||
|
SET ai_moderation_status = $2,
|
||||||
|
ai_moderation_language_code = $3,
|
||||||
|
ai_moderation_reason = CASE WHEN $2 IN ('rejected', 'failed') THEN $4 ELSE NULL END,
|
||||||
|
ai_moderation_checked_at = now(),
|
||||||
|
ai_moderation_updated_at = now()
|
||||||
|
WHERE id = $1
|
||||||
|
AND deleted_at IS NULL
|
||||||
|
`,
|
||||||
|
updateForReview: `
|
||||||
|
UPDATE thread_messages
|
||||||
|
SET ai_moderation_status = 'reviewing',
|
||||||
|
ai_moderation_language_code = $2,
|
||||||
|
ai_moderation_reason = NULL,
|
||||||
|
ai_moderation_content_hash = $3,
|
||||||
|
ai_moderation_checked_at = NULL,
|
||||||
|
ai_moderation_retry_count = CASE
|
||||||
|
WHEN $4::boolean THEN 0
|
||||||
|
WHEN $5::boolean THEN ai_moderation_retry_count + 1
|
||||||
|
ELSE ai_moderation_retry_count
|
||||||
|
END,
|
||||||
|
ai_moderation_updated_at = now()
|
||||||
|
WHERE id = $1
|
||||||
|
AND deleted_at IS NULL
|
||||||
|
RETURNING id
|
||||||
|
`
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -595,6 +639,15 @@ async function enqueuePendingAiModeration(): Promise<void> {
|
|||||||
WHERE deleted_at IS NULL
|
WHERE deleted_at IS NULL
|
||||||
AND ai_moderation_status IN ('unreviewed', 'reviewing')
|
AND ai_moderation_status IN ('unreviewed', 'reviewing')
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
SELECT 'thread-message'::text AS type, tm.id
|
||||||
|
FROM thread_messages tm
|
||||||
|
JOIN threads t ON t.id = tm.thread_id
|
||||||
|
WHERE tm.deleted_at IS NULL
|
||||||
|
AND t.deleted_at IS NULL
|
||||||
|
AND tm.ai_moderation_status IN ('unreviewed', 'reviewing')
|
||||||
|
|
||||||
LIMIT $1
|
LIMIT $1
|
||||||
`,
|
`,
|
||||||
[retryScanLimit]
|
[retryScanLimit]
|
||||||
@@ -715,9 +768,28 @@ async function updateTargetStatus(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await createModerationResultNotification(target, status);
|
if (target.type === 'thread-message') {
|
||||||
|
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',
|
||||||
|
[target.id]
|
||||||
|
);
|
||||||
|
if (row) {
|
||||||
|
await publishThreadMessageModeration(row.threadId, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const notificationTarget = {
|
||||||
|
type: target.type as Exclude<AiModerationTargetType, 'thread-message'>,
|
||||||
|
id: target.id
|
||||||
|
};
|
||||||
|
await createModerationResultNotification(notificationTarget, status);
|
||||||
if (status === 'approved') {
|
if (status === 'approved') {
|
||||||
await createApprovedCommentNotification(target);
|
await createApprovedCommentNotification(notificationTarget);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger?.warn(
|
logger?.warn(
|
||||||
|
|||||||
@@ -945,7 +945,6 @@ export function setupNotificationWebSocketServer(server: Server, logger: Fastify
|
|||||||
server.on('upgrade', async (request, socket) => {
|
server.on('upgrade', async (request, socket) => {
|
||||||
const url = new URL(request.url ?? '/', 'http://localhost');
|
const url = new URL(request.url ?? '/', 'http://localhost');
|
||||||
if (url.pathname !== '/api/notifications/ws') {
|
if (url.pathname !== '/api/notifications/ws') {
|
||||||
socket.destroy();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,13 @@ import {
|
|||||||
type AiModerationStatus
|
type AiModerationStatus
|
||||||
} from './aiModeration.ts';
|
} from './aiModeration.ts';
|
||||||
import { createLifePostReactionNotification, createUserFollowNotification } from './notifications.ts';
|
import { createLifePostReactionNotification, createUserFollowNotification } from './notifications.ts';
|
||||||
|
import {
|
||||||
|
createThreadWebSocketTicket,
|
||||||
|
publishThreadMessageCreated,
|
||||||
|
publishThreadMessageModeration,
|
||||||
|
publishThreadReactionUpdated,
|
||||||
|
publishThreadReadUpdated
|
||||||
|
} from './threadsRealtime.ts';
|
||||||
|
|
||||||
type QueryValue = string | string[] | undefined;
|
type QueryValue = string | string[] | undefined;
|
||||||
|
|
||||||
@@ -26,6 +33,53 @@ type ListPage<T> = {
|
|||||||
nextCursor: string | null;
|
nextCursor: string | null;
|
||||||
hasMore: boolean;
|
hasMore: boolean;
|
||||||
};
|
};
|
||||||
|
export type ThreadReactionType = 'thumbs-up' | 'heart' | 'laugh' | 'fire' | 'eyes';
|
||||||
|
export type ThreadReactionCounts = Record<ThreadReactionType, number>;
|
||||||
|
export type ThreadChannelTag = { id: number; name: string; sortOrder: number };
|
||||||
|
export type ThreadChannel = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
allowUserThreads: boolean;
|
||||||
|
sortOrder: number;
|
||||||
|
tags: ThreadChannelTag[];
|
||||||
|
languages: Array<{ code: string; name: string }>;
|
||||||
|
unreadCount: number;
|
||||||
|
};
|
||||||
|
export type ThreadSummary = {
|
||||||
|
id: number;
|
||||||
|
channelId: number;
|
||||||
|
title: string;
|
||||||
|
languageCode: string;
|
||||||
|
tags: ThreadChannelTag[];
|
||||||
|
locked: boolean;
|
||||||
|
messageCount: number;
|
||||||
|
lastActiveAt: Date;
|
||||||
|
createdAt: Date;
|
||||||
|
author: { id: number; displayName: string } | null;
|
||||||
|
reactionCounts: ThreadReactionCounts;
|
||||||
|
myReactions: ThreadReactionType[];
|
||||||
|
followed: boolean;
|
||||||
|
unread: boolean;
|
||||||
|
};
|
||||||
|
export type ThreadMessage = {
|
||||||
|
id: number;
|
||||||
|
threadId: number;
|
||||||
|
body: string;
|
||||||
|
moderationStatus: AiModerationStatus;
|
||||||
|
moderationLanguageCode: string | null;
|
||||||
|
moderationReason: string | null;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
author: { id: number; displayName: string } | null;
|
||||||
|
reactionCounts: ThreadReactionCounts;
|
||||||
|
myReactions: ThreadReactionType[];
|
||||||
|
};
|
||||||
|
export type ThreadMessagesPage = {
|
||||||
|
items: ThreadMessage[];
|
||||||
|
beforeCursor: string | null;
|
||||||
|
hasMoreBefore: boolean;
|
||||||
|
};
|
||||||
|
export type ThreadsPage = ListPage<ThreadSummary>;
|
||||||
|
|
||||||
type DbClient = PoolClient;
|
type DbClient = PoolClient;
|
||||||
type DataToolScope = 'pokemon' | 'habitats' | 'items' | 'artifacts' | 'recipes' | 'checklist';
|
type DataToolScope = 'pokemon' | 'habitats' | 'items' | 'artifacts' | 'recipes' | 'checklist';
|
||||||
@@ -8837,6 +8891,873 @@ 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 maxThreadLimit = 50;
|
||||||
|
const defaultThreadMessageLimit = 30;
|
||||||
|
const maxThreadMessageLimit = 80;
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanThreadReactionType(value: unknown): ThreadReactionType {
|
||||||
|
if (!isThreadReactionType(value)) {
|
||||||
|
throw validationError('server.validation.reactionInvalid');
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanThreadLimit(value: QueryValue, fallback = defaultThreadLimit, max = maxThreadLimit): number {
|
||||||
|
const raw = Number(asString(value));
|
||||||
|
return Number.isInteger(raw) && raw > 0 ? Math.min(raw, max) : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeCursor(value: unknown): string {
|
||||||
|
return Buffer.from(JSON.stringify(value), 'utf8').toString('base64url');
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeThreadCursor(value: QueryValue): ThreadCursor | null {
|
||||||
|
const cursor = asString(value);
|
||||||
|
if (!cursor) return null;
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')) as unknown;
|
||||||
|
if (payload && typeof payload === 'object') {
|
||||||
|
const record = payload as Record<string, unknown>;
|
||||||
|
if (typeof record.value === 'string' && Number.isInteger(Number(record.id))) {
|
||||||
|
return { value: record.value, id: Number(record.id) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeThreadMessageCursor(value: QueryValue): ThreadMessageCursor | null {
|
||||||
|
const cursor = asString(value);
|
||||||
|
if (!cursor) return null;
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')) as unknown;
|
||||||
|
if (payload && typeof payload === 'object') {
|
||||||
|
const record = payload as Record<string, unknown>;
|
||||||
|
if (typeof record.createdAt === 'string' && Number.isInteger(Number(record.id))) {
|
||||||
|
return { createdAt: record.createdAt, id: Number(record.id) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanThreadTitle(value: unknown): string {
|
||||||
|
const title = cleanName(value, 'server.validation.titleRequired');
|
||||||
|
if (title.length > 140) {
|
||||||
|
throw validationError('server.validation.valueTooLong');
|
||||||
|
}
|
||||||
|
return title;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanThreadMessageBody(value: unknown): string {
|
||||||
|
const body = cleanName(value, 'server.validation.commentRequired');
|
||||||
|
if (body.length > 2000) {
|
||||||
|
throw validationError('server.validation.commentTooLong');
|
||||||
|
}
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanThreadLanguageCode(value: unknown): string {
|
||||||
|
const languageCode = cleanModerationLanguageCode(value);
|
||||||
|
if (!languageCode) {
|
||||||
|
throw validationError('server.validation.languageInvalid');
|
||||||
|
}
|
||||||
|
return languageCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanThreadTagIds(value: unknown): number[] {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return cleanIds(value).slice(0, 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function publicThreadChannels(userId: number | null): Promise<ThreadChannel[]> {
|
||||||
|
const rows = await query<{
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
allowUserThreads: boolean;
|
||||||
|
sortOrder: number;
|
||||||
|
tags: ThreadChannelTag[] | null;
|
||||||
|
languages: Array<{ code: string; name: string }> | null;
|
||||||
|
unreadCount: number;
|
||||||
|
}>(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
tc.id,
|
||||||
|
tc.name,
|
||||||
|
tc.allow_user_threads AS "allowUserThreads",
|
||||||
|
tc.sort_order AS "sortOrder",
|
||||||
|
COALESCE(tags.items, '[]'::json) AS tags,
|
||||||
|
COALESCE(channel_languages.items, fallback_languages.items, '[]'::json) AS languages,
|
||||||
|
CASE
|
||||||
|
WHEN $1::integer IS NULL THEN 0
|
||||||
|
ELSE COALESCE(unread.count, 0)
|
||||||
|
END AS "unreadCount"
|
||||||
|
FROM thread_channels tc
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT json_agg(json_build_object('id', tct.id, 'name', tct.name, 'sortOrder', tct.sort_order) ORDER BY tct.sort_order, tct.id) AS items
|
||||||
|
FROM thread_channel_tags tct
|
||||||
|
WHERE tct.channel_id = tc.id
|
||||||
|
) tags ON true
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT json_agg(json_build_object('code', l.code, 'name', l.name) ORDER BY tcl.sort_order, l.sort_order, l.code) AS items
|
||||||
|
FROM thread_channel_languages tcl
|
||||||
|
JOIN languages l ON l.code = tcl.language_code
|
||||||
|
WHERE tcl.channel_id = tc.id
|
||||||
|
AND l.enabled = true
|
||||||
|
) channel_languages ON true
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT json_agg(json_build_object('code', l.code, 'name', l.name) ORDER BY l.sort_order, l.code) AS items
|
||||||
|
FROM languages l
|
||||||
|
WHERE l.enabled = true
|
||||||
|
) fallback_languages ON true
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT COUNT(*)::integer AS count
|
||||||
|
FROM thread_follows tf
|
||||||
|
JOIN threads t ON t.id = tf.thread_id
|
||||||
|
LEFT JOIN thread_reads tr ON tr.thread_id = t.id AND tr.user_id = tf.user_id
|
||||||
|
WHERE tf.user_id = $1::integer
|
||||||
|
AND t.channel_id = tc.id
|
||||||
|
AND t.deleted_at IS NULL
|
||||||
|
AND t.last_message_id IS NOT NULL
|
||||||
|
AND (tr.last_read_message_id IS NULL OR t.last_message_id > tr.last_read_message_id)
|
||||||
|
) unread ON true
|
||||||
|
ORDER BY tc.sort_order, tc.id
|
||||||
|
`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return rows.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
allowUserThreads: row.allowUserThreads,
|
||||||
|
sortOrder: row.sortOrder,
|
||||||
|
tags: row.tags ?? [],
|
||||||
|
languages: row.languages ?? [],
|
||||||
|
unreadCount: row.unreadCount
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listThreadChannels(userId: number | null): Promise<ThreadChannel[]> {
|
||||||
|
return publicThreadChannels(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listAdminThreadChannels(): Promise<ThreadChannel[]> {
|
||||||
|
return publicThreadChannels(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function channelAllowsLanguage(channelId: number, languageCode: string): Promise<boolean> {
|
||||||
|
const row = await queryOne<{ allowed: boolean }>(
|
||||||
|
`
|
||||||
|
SELECT CASE
|
||||||
|
WHEN EXISTS (SELECT 1 FROM thread_channel_languages WHERE channel_id = $1) THEN EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM thread_channel_languages tcl
|
||||||
|
JOIN languages l ON l.code = tcl.language_code
|
||||||
|
WHERE tcl.channel_id = $1 AND tcl.language_code = $2 AND l.enabled = true
|
||||||
|
)
|
||||||
|
ELSE EXISTS (SELECT 1 FROM languages WHERE code = $2 AND enabled = true)
|
||||||
|
END AS allowed
|
||||||
|
`,
|
||||||
|
[channelId, languageCode]
|
||||||
|
);
|
||||||
|
return row?.allowed === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function validateThreadTags(channelId: number, tagIds: number[]): Promise<void> {
|
||||||
|
if (!tagIds.length) return;
|
||||||
|
const rows = await query<{ id: number }>(
|
||||||
|
'SELECT id FROM thread_channel_tags WHERE channel_id = $1 AND id = ANY($2::integer[])',
|
||||||
|
[channelId, tagIds]
|
||||||
|
);
|
||||||
|
if (rows.length !== tagIds.length) {
|
||||||
|
throw validationError('server.validation.invalidField');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function threadReactionCounts(threadIds: number[], userId: number | null): Promise<{
|
||||||
|
counts: Map<number, ThreadReactionCounts>;
|
||||||
|
mine: Map<number, ThreadReactionType[]>;
|
||||||
|
}> {
|
||||||
|
const counts = new Map<number, ThreadReactionCounts>();
|
||||||
|
const mine = new Map<number, ThreadReactionType[]>();
|
||||||
|
for (const id of threadIds) counts.set(id, emptyThreadReactionCounts());
|
||||||
|
if (!threadIds.length) return { counts, mine };
|
||||||
|
|
||||||
|
const countRows = await query<{ threadId: number; reactionType: ThreadReactionType; count: number }>(
|
||||||
|
`
|
||||||
|
SELECT thread_id AS "threadId", reaction_type AS "reactionType", COUNT(*)::integer AS count
|
||||||
|
FROM thread_reactions
|
||||||
|
WHERE thread_id = ANY($1::integer[])
|
||||||
|
GROUP BY thread_id, reaction_type
|
||||||
|
`,
|
||||||
|
[threadIds]
|
||||||
|
);
|
||||||
|
for (const row of countRows) {
|
||||||
|
const item = counts.get(row.threadId);
|
||||||
|
if (item && isThreadReactionType(row.reactionType)) {
|
||||||
|
item[row.reactionType] = row.count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userId !== null) {
|
||||||
|
const myRows = await query<{ threadId: number; reactionType: ThreadReactionType }>(
|
||||||
|
`
|
||||||
|
SELECT thread_id AS "threadId", reaction_type AS "reactionType"
|
||||||
|
FROM thread_reactions
|
||||||
|
WHERE user_id = $1
|
||||||
|
AND thread_id = ANY($2::integer[])
|
||||||
|
`,
|
||||||
|
[userId, threadIds]
|
||||||
|
);
|
||||||
|
for (const row of myRows) {
|
||||||
|
if (!isThreadReactionType(row.reactionType)) continue;
|
||||||
|
mine.set(row.threadId, [...(mine.get(row.threadId) ?? []), row.reactionType]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { counts, mine };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function threadMessageReactionCounts(messageIds: number[], userId: number | null): Promise<{
|
||||||
|
counts: Map<number, ThreadReactionCounts>;
|
||||||
|
mine: Map<number, ThreadReactionType[]>;
|
||||||
|
}> {
|
||||||
|
const counts = new Map<number, ThreadReactionCounts>();
|
||||||
|
const mine = new Map<number, ThreadReactionType[]>();
|
||||||
|
for (const id of messageIds) counts.set(id, emptyThreadReactionCounts());
|
||||||
|
if (!messageIds.length) return { counts, mine };
|
||||||
|
|
||||||
|
const countRows = await query<{ messageId: number; reactionType: ThreadReactionType; count: number }>(
|
||||||
|
`
|
||||||
|
SELECT message_id AS "messageId", reaction_type AS "reactionType", COUNT(*)::integer AS count
|
||||||
|
FROM thread_message_reactions
|
||||||
|
WHERE message_id = ANY($1::integer[])
|
||||||
|
GROUP BY message_id, reaction_type
|
||||||
|
`,
|
||||||
|
[messageIds]
|
||||||
|
);
|
||||||
|
for (const row of countRows) {
|
||||||
|
const item = counts.get(row.messageId);
|
||||||
|
if (item && isThreadReactionType(row.reactionType)) {
|
||||||
|
item[row.reactionType] = row.count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userId !== null) {
|
||||||
|
const myRows = await query<{ messageId: number; reactionType: ThreadReactionType }>(
|
||||||
|
`
|
||||||
|
SELECT message_id AS "messageId", reaction_type AS "reactionType"
|
||||||
|
FROM thread_message_reactions
|
||||||
|
WHERE user_id = $1
|
||||||
|
AND message_id = ANY($2::integer[])
|
||||||
|
`,
|
||||||
|
[userId, messageIds]
|
||||||
|
);
|
||||||
|
for (const row of myRows) {
|
||||||
|
if (!isThreadReactionType(row.reactionType)) continue;
|
||||||
|
mine.set(row.messageId, [...(mine.get(row.messageId) ?? []), row.reactionType]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { counts, mine };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hydrateThreads(rows: Array<ThreadSummary & { lastActiveCursor: string }>, userId: number | null): Promise<ThreadSummary[]> {
|
||||||
|
const ids = rows.map((row) => row.id);
|
||||||
|
const tags = await query<{ threadId: number; id: number; name: string; sortOrder: number }>(
|
||||||
|
`
|
||||||
|
SELECT ttl.thread_id AS "threadId", tct.id, tct.name, tct.sort_order AS "sortOrder"
|
||||||
|
FROM thread_tag_links ttl
|
||||||
|
JOIN thread_channel_tags tct ON tct.id = ttl.tag_id
|
||||||
|
WHERE ttl.thread_id = ANY($1::integer[])
|
||||||
|
ORDER BY tct.sort_order, tct.id
|
||||||
|
`,
|
||||||
|
[ids]
|
||||||
|
);
|
||||||
|
const tagsByThread = new Map<number, ThreadChannelTag[]>();
|
||||||
|
for (const tag of tags) {
|
||||||
|
tagsByThread.set(tag.threadId, [...(tagsByThread.get(tag.threadId) ?? []), { id: tag.id, name: tag.name, sortOrder: tag.sortOrder }]);
|
||||||
|
}
|
||||||
|
const reactions = await threadReactionCounts(ids, userId);
|
||||||
|
return rows.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
channelId: row.channelId,
|
||||||
|
title: row.title,
|
||||||
|
languageCode: row.languageCode,
|
||||||
|
tags: tagsByThread.get(row.id) ?? [],
|
||||||
|
locked: row.locked,
|
||||||
|
messageCount: row.messageCount,
|
||||||
|
lastActiveAt: row.lastActiveAt,
|
||||||
|
createdAt: row.createdAt,
|
||||||
|
author: row.author,
|
||||||
|
reactionCounts: reactions.counts.get(row.id) ?? emptyThreadReactionCounts(),
|
||||||
|
myReactions: reactions.mine.get(row.id) ?? [],
|
||||||
|
followed: row.followed,
|
||||||
|
unread: row.unread
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listThreads(paramsQuery: QueryParams, userId: number | null): Promise<ThreadsPage> {
|
||||||
|
const limit = cleanThreadLimit(paramsQuery.limit);
|
||||||
|
const channelId = optionalPositiveInteger(asString(paramsQuery.channelId), 'server.validation.invalidField');
|
||||||
|
const tagId = optionalPositiveInteger(asString(paramsQuery.tagId), 'server.validation.invalidField');
|
||||||
|
const language = asString(paramsQuery.language);
|
||||||
|
const sort = asString(paramsQuery.sort) ?? 'last-active';
|
||||||
|
const cursor = decodeThreadCursor(paramsQuery.cursor);
|
||||||
|
const conditions = ['t.deleted_at IS NULL'];
|
||||||
|
const params: unknown[] = [];
|
||||||
|
|
||||||
|
if (channelId !== null) {
|
||||||
|
params.push(channelId);
|
||||||
|
conditions.push(`t.channel_id = $${params.length}`);
|
||||||
|
}
|
||||||
|
if (tagId !== null) {
|
||||||
|
params.push(tagId);
|
||||||
|
conditions.push(`EXISTS (SELECT 1 FROM thread_tag_links ttl WHERE ttl.thread_id = t.id AND ttl.tag_id = $${params.length})`);
|
||||||
|
}
|
||||||
|
if (language && language !== 'all') {
|
||||||
|
params.push(cleanThreadLanguageCode(language));
|
||||||
|
conditions.push(`t.language_code = $${params.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderField = sort === 'latest' ? 't.created_at' : sort === 'most-discussed' ? 't.message_count' : 't.last_active_at';
|
||||||
|
const orderCursorField = sort === 'latest' ? 'created_at' : sort === 'most-discussed' ? 'message_count' : 'last_active_at';
|
||||||
|
if (cursor) {
|
||||||
|
params.push(cursor.value, cursor.id);
|
||||||
|
conditions.push(`(${orderField}, t.id) < ($${params.length - 1}::${sort === 'most-discussed' ? 'integer' : 'timestamptz'}, $${params.length}::integer)`);
|
||||||
|
}
|
||||||
|
params.push(limit + 1, userId);
|
||||||
|
const limitParam = params.length - 1;
|
||||||
|
const userParam = params.length;
|
||||||
|
|
||||||
|
const rows = await query<ThreadSummary & { lastActiveCursor: string; createdAtCursor: string; messageCountCursor: number }>(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
t.id,
|
||||||
|
t.channel_id AS "channelId",
|
||||||
|
t.title,
|
||||||
|
t.language_code AS "languageCode",
|
||||||
|
t.locked,
|
||||||
|
t.message_count AS "messageCount",
|
||||||
|
t.last_active_at AS "lastActiveAt",
|
||||||
|
t.last_active_at::text AS "lastActiveCursor",
|
||||||
|
t.created_at AS "createdAt",
|
||||||
|
t.created_at::text AS "createdAtCursor",
|
||||||
|
t.message_count AS "messageCountCursor",
|
||||||
|
CASE WHEN u.id IS NULL THEN NULL ELSE json_build_object('id', u.id, 'displayName', u.display_name) END AS author,
|
||||||
|
($${userParam}::integer IS NOT NULL AND tf.user_id IS NOT NULL) AS followed,
|
||||||
|
($${userParam}::integer IS NOT NULL AND t.last_message_id IS NOT NULL AND (tr.last_read_message_id IS NULL OR t.last_message_id > tr.last_read_message_id)) AS unread
|
||||||
|
FROM threads t
|
||||||
|
LEFT JOIN users u ON u.id = t.created_by_user_id
|
||||||
|
LEFT JOIN thread_follows tf ON tf.thread_id = t.id AND tf.user_id = $${userParam}::integer
|
||||||
|
LEFT JOIN thread_reads tr ON tr.thread_id = t.id AND tr.user_id = $${userParam}::integer
|
||||||
|
WHERE ${conditions.join(' AND ')}
|
||||||
|
ORDER BY ${orderField} DESC, t.id DESC
|
||||||
|
LIMIT $${limitParam}
|
||||||
|
`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
const items = await hydrateThreads(rows.slice(0, limit), userId);
|
||||||
|
const last = rows.slice(0, limit).at(-1) as (typeof rows)[number] | undefined;
|
||||||
|
const nextValue = last ? (sort === 'latest' ? last.createdAtCursor : sort === 'most-discussed' ? String(last.messageCountCursor) : last.lastActiveCursor) : null;
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
nextCursor: rows.length > limit && last && nextValue ? encodeCursor({ value: nextValue, id: last.id }) : null,
|
||||||
|
hasMore: rows.length > limit
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getThread(threadIdValue: number, userId: number | null): Promise<ThreadSummary | null> {
|
||||||
|
const threadId = requirePositiveInteger(threadIdValue, 'server.validation.recordInvalid');
|
||||||
|
const rows = await query<ThreadSummary & { lastActiveCursor: string }>(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
t.id,
|
||||||
|
t.channel_id AS "channelId",
|
||||||
|
t.title,
|
||||||
|
t.language_code AS "languageCode",
|
||||||
|
t.locked,
|
||||||
|
t.message_count AS "messageCount",
|
||||||
|
t.last_active_at AS "lastActiveAt",
|
||||||
|
t.last_active_at::text AS "lastActiveCursor",
|
||||||
|
t.created_at AS "createdAt",
|
||||||
|
CASE WHEN u.id IS NULL THEN NULL ELSE json_build_object('id', u.id, 'displayName', u.display_name) END AS author,
|
||||||
|
($2::integer IS NOT NULL AND tf.user_id IS NOT NULL) AS followed,
|
||||||
|
($2::integer IS NOT NULL AND t.last_message_id IS NOT NULL AND (tr.last_read_message_id IS NULL OR t.last_message_id > tr.last_read_message_id)) AS unread
|
||||||
|
FROM threads t
|
||||||
|
LEFT JOIN users u ON u.id = t.created_by_user_id
|
||||||
|
LEFT JOIN thread_follows tf ON tf.thread_id = t.id AND tf.user_id = $2::integer
|
||||||
|
LEFT JOIN thread_reads tr ON tr.thread_id = t.id AND tr.user_id = $2::integer
|
||||||
|
WHERE t.id = $1
|
||||||
|
AND t.deleted_at IS NULL
|
||||||
|
`,
|
||||||
|
[threadId, userId]
|
||||||
|
);
|
||||||
|
return (await hydrateThreads(rows, userId))[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getThreadMessageById(messageId: number, userId: number | null, canViewAll = false): Promise<ThreadMessage | null> {
|
||||||
|
const rows = await query<ThreadMessage>(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
tm.id,
|
||||||
|
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
|
||||||
|
AND ${moderationVisibilitySql('tm', 'tm.created_by_user_id', userId, canViewAll)}
|
||||||
|
`,
|
||||||
|
[messageId]
|
||||||
|
);
|
||||||
|
const row = rows[0];
|
||||||
|
if (!row) return null;
|
||||||
|
const reactions = await threadMessageReactionCounts([row.id], userId);
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
reactionCounts: reactions.counts.get(row.id) ?? emptyThreadReactionCounts(),
|
||||||
|
myReactions: reactions.mine.get(row.id) ?? []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listThreadMessages(
|
||||||
|
threadIdValue: number,
|
||||||
|
paramsQuery: QueryParams,
|
||||||
|
userId: number | null,
|
||||||
|
canViewAll = false
|
||||||
|
): Promise<ThreadMessagesPage | null> {
|
||||||
|
const threadId = requirePositiveInteger(threadIdValue, 'server.validation.recordInvalid');
|
||||||
|
const thread = await getThread(threadId, userId);
|
||||||
|
if (!thread) return null;
|
||||||
|
const limit = cleanThreadLimit(paramsQuery.limit, defaultThreadMessageLimit, maxThreadMessageLimit);
|
||||||
|
const before = decodeThreadMessageCursor(paramsQuery.before);
|
||||||
|
const conditions = ['tm.thread_id = $1', 'tm.deleted_at IS NULL'];
|
||||||
|
const params: unknown[] = [threadId];
|
||||||
|
if (before) {
|
||||||
|
params.push(before.createdAt, before.id);
|
||||||
|
conditions.push(`(tm.created_at, tm.id) < ($${params.length - 1}::timestamptz, $${params.length}::integer)`);
|
||||||
|
}
|
||||||
|
if (!canViewAll) {
|
||||||
|
if (userId !== null) {
|
||||||
|
params.push(userId);
|
||||||
|
conditions.push(`(tm.ai_moderation_status = 'approved' OR tm.created_by_user_id = $${params.length})`);
|
||||||
|
} else {
|
||||||
|
conditions.push("tm.ai_moderation_status = 'approved'");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
params.push(limit + 1);
|
||||||
|
|
||||||
|
const rows = await query<ThreadMessage & { createdAtCursor: string }>(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
tm.id,
|
||||||
|
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.created_at::text AS "createdAtCursor",
|
||||||
|
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 ${conditions.join(' AND ')}
|
||||||
|
ORDER BY tm.created_at DESC, tm.id DESC
|
||||||
|
LIMIT $${params.length}
|
||||||
|
`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
const pageRows = rows.slice(0, limit).reverse();
|
||||||
|
const reactions = await threadMessageReactionCounts(pageRows.map((row) => row.id), userId);
|
||||||
|
const items = pageRows.map((row) => ({
|
||||||
|
id: row.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: reactions.counts.get(row.id) ?? emptyThreadReactionCounts(),
|
||||||
|
myReactions: reactions.mine.get(row.id) ?? []
|
||||||
|
}));
|
||||||
|
const oldest = rows.slice(0, limit).at(-1);
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
beforeCursor: rows.length > limit && oldest ? encodeCursor({ createdAt: oldest.createdAtCursor, id: oldest.id }) : null,
|
||||||
|
hasMoreBefore: rows.length > limit
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createThread(payload: Record<string, unknown>, userId: number): Promise<ThreadSummary> {
|
||||||
|
const channelId = requirePositiveInteger(payload.channelId, 'server.validation.invalidField');
|
||||||
|
const title = cleanThreadTitle(payload.title);
|
||||||
|
const languageCode = cleanThreadLanguageCode(payload.languageCode);
|
||||||
|
const tagIds = cleanThreadTagIds(payload.tagIds);
|
||||||
|
const messageBody = cleanThreadMessageBody(payload.body);
|
||||||
|
const channel = await queryOne<{ id: number; allowUserThreads: boolean }>(
|
||||||
|
'SELECT id, allow_user_threads AS "allowUserThreads" FROM thread_channels WHERE id = $1',
|
||||||
|
[channelId]
|
||||||
|
);
|
||||||
|
if (!channel || !channel.allowUserThreads) {
|
||||||
|
throw validationError('server.validation.invalidField');
|
||||||
|
}
|
||||||
|
if (!(await channelAllowsLanguage(channelId, languageCode))) {
|
||||||
|
throw validationError('server.validation.languageInvalid');
|
||||||
|
}
|
||||||
|
await validateThreadTags(channelId, tagIds);
|
||||||
|
|
||||||
|
const ids = await withTransaction(async (client) => {
|
||||||
|
const threadResult = await client.query<{ id: number }>(
|
||||||
|
`
|
||||||
|
INSERT INTO threads (channel_id, title, language_code, created_by_user_id, updated_by_user_id)
|
||||||
|
VALUES ($1, $2, $3, $4, $4)
|
||||||
|
RETURNING id
|
||||||
|
`,
|
||||||
|
[channelId, title, languageCode, userId]
|
||||||
|
);
|
||||||
|
const threadId = threadResult.rows[0].id;
|
||||||
|
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]);
|
||||||
|
}
|
||||||
|
const messageResult = await client.query<{ id: number }>(
|
||||||
|
`
|
||||||
|
INSERT INTO thread_messages (thread_id, body, ai_moderation_status, ai_moderation_language_code, created_by_user_id)
|
||||||
|
VALUES ($1, $2, 'unreviewed', $3, $4)
|
||||||
|
RETURNING id
|
||||||
|
`,
|
||||||
|
[threadId, messageBody, languageCode, userId]
|
||||||
|
);
|
||||||
|
await client.query('INSERT INTO thread_follows (thread_id, user_id) VALUES ($1, $2) ON CONFLICT DO NOTHING', [threadId, userId]);
|
||||||
|
return { threadId, messageId: messageResult.rows[0].id };
|
||||||
|
});
|
||||||
|
|
||||||
|
await requestAiModerationReview({ type: 'thread-message', id: ids.messageId }, { languageCode, resetRetries: true });
|
||||||
|
return (await getThread(ids.threadId, userId)) as ThreadSummary;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createThreadMessage(threadIdValue: number, payload: Record<string, unknown>, userId: number): Promise<ThreadMessage | null> {
|
||||||
|
const threadId = requirePositiveInteger(threadIdValue, 'server.validation.recordInvalid');
|
||||||
|
const body = cleanThreadMessageBody(payload.body);
|
||||||
|
const thread = await queryOne<{ id: number; locked: boolean; languageCode: string }>(
|
||||||
|
'SELECT id, locked, language_code AS "languageCode" FROM threads WHERE id = $1 AND deleted_at IS NULL',
|
||||||
|
[threadId]
|
||||||
|
);
|
||||||
|
if (!thread) return null;
|
||||||
|
if (thread.locked) {
|
||||||
|
throw validationError('server.validation.invalidField');
|
||||||
|
}
|
||||||
|
const result = await queryOne<{ id: number }>(
|
||||||
|
`
|
||||||
|
INSERT INTO thread_messages (thread_id, body, ai_moderation_status, ai_moderation_language_code, created_by_user_id)
|
||||||
|
VALUES ($1, $2, 'unreviewed', $3, $4)
|
||||||
|
RETURNING id
|
||||||
|
`,
|
||||||
|
[threadId, body, thread.languageCode, userId]
|
||||||
|
);
|
||||||
|
if (!result) return null;
|
||||||
|
await requestAiModerationReview({ type: 'thread-message', id: result.id }, { languageCode: thread.languageCode, resetRetries: true });
|
||||||
|
return getThreadMessageById(result.id, userId, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markThreadRead(threadIdValue: number, userId: number): Promise<ThreadSummary | null> {
|
||||||
|
const threadId = requirePositiveInteger(threadIdValue, 'server.validation.recordInvalid');
|
||||||
|
const row = await queryOne<{ lastMessageId: number | null }>(
|
||||||
|
'SELECT last_message_id AS "lastMessageId" FROM threads WHERE id = $1 AND deleted_at IS NULL',
|
||||||
|
[threadId]
|
||||||
|
);
|
||||||
|
if (!row) return null;
|
||||||
|
await pool.query(
|
||||||
|
`
|
||||||
|
INSERT INTO thread_reads (thread_id, user_id, last_read_message_id, last_read_at)
|
||||||
|
VALUES ($1, $2, $3, now())
|
||||||
|
ON CONFLICT (thread_id, user_id)
|
||||||
|
DO UPDATE SET last_read_message_id = EXCLUDED.last_read_message_id,
|
||||||
|
last_read_at = now()
|
||||||
|
`,
|
||||||
|
[threadId, userId, row.lastMessageId]
|
||||||
|
);
|
||||||
|
const thread = await getThread(threadId, userId);
|
||||||
|
await publishThreadReadUpdated(userId, threadId, thread?.unread ?? false, 0);
|
||||||
|
return thread;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function followThread(threadIdValue: number, userId: number): Promise<ThreadSummary | null> {
|
||||||
|
const threadId = requirePositiveInteger(threadIdValue, 'server.validation.recordInvalid');
|
||||||
|
const thread = await getThread(threadId, userId);
|
||||||
|
if (!thread) return null;
|
||||||
|
await pool.query('INSERT INTO thread_follows (thread_id, user_id) VALUES ($1, $2) ON CONFLICT DO NOTHING', [threadId, userId]);
|
||||||
|
return getThread(threadId, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unfollowThread(threadIdValue: number, userId: number): Promise<ThreadSummary | null> {
|
||||||
|
const threadId = requirePositiveInteger(threadIdValue, 'server.validation.recordInvalid');
|
||||||
|
const thread = await getThread(threadId, userId);
|
||||||
|
if (!thread) return null;
|
||||||
|
await pool.query('DELETE FROM thread_follows WHERE thread_id = $1 AND user_id = $2', [threadId, userId]);
|
||||||
|
return getThread(threadId, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setThreadReaction(threadIdValue: number, payload: Record<string, unknown>, userId: number): Promise<ThreadSummary | null> {
|
||||||
|
const threadId = requirePositiveInteger(threadIdValue, 'server.validation.recordInvalid');
|
||||||
|
const reactionType = cleanThreadReactionType(payload.reactionType);
|
||||||
|
const thread = await getThread(threadId, userId);
|
||||||
|
if (!thread) return null;
|
||||||
|
await pool.query(
|
||||||
|
`
|
||||||
|
INSERT INTO thread_reactions (thread_id, user_id, reaction_type)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (thread_id, user_id, reaction_type)
|
||||||
|
DO UPDATE SET updated_at = now()
|
||||||
|
`,
|
||||||
|
[threadId, userId, reactionType]
|
||||||
|
);
|
||||||
|
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 deleteThreadReaction(threadIdValue: number, payload: Record<string, unknown>, userId: number): Promise<ThreadSummary | null> {
|
||||||
|
const threadId = requirePositiveInteger(threadIdValue, 'server.validation.recordInvalid');
|
||||||
|
const reactionType = cleanThreadReactionType(payload.reactionType);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setThreadMessageReaction(messageIdValue: number, payload: Record<string, unknown>, userId: number): Promise<ThreadMessage | null> {
|
||||||
|
const messageId = requirePositiveInteger(messageIdValue, 'server.validation.recordInvalid');
|
||||||
|
const reactionType = cleanThreadReactionType(payload.reactionType);
|
||||||
|
const message = await getThreadMessageById(messageId, userId);
|
||||||
|
if (!message || message.moderationStatus !== 'approved') return null;
|
||||||
|
await pool.query(
|
||||||
|
`
|
||||||
|
INSERT INTO thread_message_reactions (message_id, user_id, reaction_type)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (message_id, user_id, reaction_type)
|
||||||
|
DO UPDATE SET updated_at = now()
|
||||||
|
`,
|
||||||
|
[messageId, userId, reactionType]
|
||||||
|
);
|
||||||
|
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 deleteThreadMessageReaction(messageIdValue: number, payload: Record<string, unknown>, userId: number): Promise<ThreadMessage | null> {
|
||||||
|
const messageId = requirePositiveInteger(messageIdValue, 'server.validation.recordInvalid');
|
||||||
|
const reactionType = cleanThreadReactionType(payload.reactionType);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function applyApprovedThreadMessage(messageId: number): Promise<void> {
|
||||||
|
const row = await queryOne<{ threadId: number }>(
|
||||||
|
`
|
||||||
|
UPDATE threads t
|
||||||
|
SET last_message_id = tm.id,
|
||||||
|
message_count = (
|
||||||
|
SELECT COUNT(*)::integer
|
||||||
|
FROM thread_messages visible_message
|
||||||
|
WHERE visible_message.thread_id = t.id
|
||||||
|
AND visible_message.deleted_at IS NULL
|
||||||
|
AND visible_message.ai_moderation_status = 'approved'
|
||||||
|
),
|
||||||
|
last_active_at = GREATEST(t.last_active_at, tm.created_at),
|
||||||
|
updated_at = now()
|
||||||
|
FROM thread_messages tm
|
||||||
|
WHERE tm.id = $1
|
||||||
|
AND tm.thread_id = t.id
|
||||||
|
AND tm.deleted_at IS NULL
|
||||||
|
AND tm.ai_moderation_status = 'approved'
|
||||||
|
RETURNING t.id AS "threadId"
|
||||||
|
`,
|
||||||
|
[messageId]
|
||||||
|
);
|
||||||
|
if (!row) return;
|
||||||
|
const message = await getThreadMessageById(messageId, null, true);
|
||||||
|
const thread = await getThread(row.threadId, null);
|
||||||
|
if (message && thread) {
|
||||||
|
await publishThreadMessageCreated(thread, message);
|
||||||
|
} else {
|
||||||
|
await publishThreadMessageModeration(row.threadId, message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createAdminThreadChannel(payload: Record<string, unknown>, userId: number): Promise<ThreadChannel[]> {
|
||||||
|
const name = cleanName(payload.name, 'server.validation.nameRequired');
|
||||||
|
const allowUserThreads = payload.allowUserThreads !== false;
|
||||||
|
const tagNames = Array.isArray(payload.tags) ? payload.tags.map((tag) => cleanName(tag, 'server.validation.nameRequired')).slice(0, 20) : [];
|
||||||
|
const languageCodes = Array.isArray(payload.languages) ? payload.languages.map(cleanThreadLanguageCode).slice(0, 20) : [];
|
||||||
|
await withTransaction(async (client) => {
|
||||||
|
const sortOrder = await nextSortOrder(client, 'thread_channels');
|
||||||
|
const result = await client.query<{ id: number }>(
|
||||||
|
`
|
||||||
|
INSERT INTO thread_channels (name, allow_user_threads, sort_order, created_by_user_id, updated_by_user_id)
|
||||||
|
VALUES ($1, $2, $3, $4, $4)
|
||||||
|
RETURNING id
|
||||||
|
`,
|
||||||
|
[name, allowUserThreads, sortOrder, userId]
|
||||||
|
);
|
||||||
|
await replaceThreadChannelConfig(client, result.rows[0].id, tagNames, languageCodes);
|
||||||
|
});
|
||||||
|
return listAdminThreadChannels();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function replaceThreadChannelConfig(client: DbClient, channelId: number, tagNames: string[], languageCodes: string[]): Promise<void> {
|
||||||
|
await client.query('DELETE FROM thread_channel_tags WHERE channel_id = $1', [channelId]);
|
||||||
|
await client.query('DELETE FROM thread_channel_languages WHERE channel_id = $1', [channelId]);
|
||||||
|
for (const [index, tagName] of [...new Set(tagNames)].entries()) {
|
||||||
|
await client.query('INSERT INTO thread_channel_tags (channel_id, name, sort_order) VALUES ($1, $2, $3)', [channelId, tagName, (index + 1) * 10]);
|
||||||
|
}
|
||||||
|
for (const [index, languageCode] of [...new Set(languageCodes)].entries()) {
|
||||||
|
await client.query('INSERT INTO thread_channel_languages (channel_id, language_code, sort_order) VALUES ($1, $2, $3)', [
|
||||||
|
channelId,
|
||||||
|
languageCode,
|
||||||
|
(index + 1) * 10
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateAdminThreadChannel(channelIdValue: number, payload: Record<string, unknown>, userId: number): Promise<ThreadChannel[] | null> {
|
||||||
|
const channelId = requirePositiveInteger(channelIdValue, 'server.validation.recordInvalid');
|
||||||
|
const name = cleanName(payload.name, 'server.validation.nameRequired');
|
||||||
|
const allowUserThreads = payload.allowUserThreads !== false;
|
||||||
|
const tagNames = Array.isArray(payload.tags) ? payload.tags.map((tag) => cleanName(tag, 'server.validation.nameRequired')).slice(0, 20) : [];
|
||||||
|
const languageCodes = Array.isArray(payload.languages) ? payload.languages.map(cleanThreadLanguageCode).slice(0, 20) : [];
|
||||||
|
const updated = await withTransaction(async (client) => {
|
||||||
|
const result = await client.query(
|
||||||
|
`
|
||||||
|
UPDATE thread_channels
|
||||||
|
SET name = $1, allow_user_threads = $2, updated_by_user_id = $3, updated_at = now()
|
||||||
|
WHERE id = $4
|
||||||
|
`,
|
||||||
|
[name, allowUserThreads, userId, channelId]
|
||||||
|
);
|
||||||
|
if (!result.rowCount) return false;
|
||||||
|
await replaceThreadChannelConfig(client, channelId, tagNames, languageCodes);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
return updated ? listAdminThreadChannels() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteAdminThreadChannel(channelIdValue: number): Promise<boolean> {
|
||||||
|
const channelId = requirePositiveInteger(channelIdValue, 'server.validation.recordInvalid');
|
||||||
|
const result = await pool.query('DELETE FROM thread_channels WHERE id = $1', [channelId]);
|
||||||
|
return Boolean(result.rowCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateThreadLock(threadIdValue: number, locked: boolean, userId: number): Promise<ThreadSummary | null> {
|
||||||
|
const threadId = requirePositiveInteger(threadIdValue, 'server.validation.recordInvalid');
|
||||||
|
const result = await pool.query(
|
||||||
|
'UPDATE threads SET locked = $1, updated_by_user_id = $2, updated_at = now() WHERE id = $3 AND deleted_at IS NULL',
|
||||||
|
[locked, userId, threadId]
|
||||||
|
);
|
||||||
|
return result.rowCount ? getThread(threadId, userId) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteThread(threadIdValue: number, userId: number): Promise<boolean> {
|
||||||
|
const threadId = requirePositiveInteger(threadIdValue, 'server.validation.recordInvalid');
|
||||||
|
const result = await pool.query(
|
||||||
|
'UPDATE threads SET deleted_at = now(), deleted_by_user_id = $1, updated_at = now() WHERE id = $2 AND deleted_at IS NULL',
|
||||||
|
[userId, threadId]
|
||||||
|
);
|
||||||
|
return Boolean(result.rowCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteThreadMessage(messageIdValue: number, userId: number): Promise<boolean> {
|
||||||
|
const messageId = requirePositiveInteger(messageIdValue, 'server.validation.recordInvalid');
|
||||||
|
const result = await pool.query<{ threadId: number }>(
|
||||||
|
`
|
||||||
|
UPDATE thread_messages
|
||||||
|
SET deleted_at = now(), deleted_by_user_id = $1, updated_at = now()
|
||||||
|
WHERE id = $2
|
||||||
|
AND deleted_at IS NULL
|
||||||
|
RETURNING thread_id AS "threadId"
|
||||||
|
`,
|
||||||
|
[userId, messageId]
|
||||||
|
);
|
||||||
|
if (!result.rowCount) return false;
|
||||||
|
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
|
||||||
|
`,
|
||||||
|
[result.rows[0].threadId]
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createThreadsWsTicketForUser(userId: number): Promise<{ ticket: string; expiresAt: Date }> {
|
||||||
|
return createThreadWebSocketTicket(userId);
|
||||||
|
}
|
||||||
|
|
||||||
export async function wipeAdminData(payload: Record<string, unknown>): Promise<{ scopes: DataToolScopeSummary[] }> {
|
export async function wipeAdminData(payload: Record<string, unknown>): Promise<{ scopes: DataToolScopeSummary[] }> {
|
||||||
const scopes = cleanDataToolScopes(payload.scopes);
|
const scopes = cleanDataToolScopes(payload.scopes);
|
||||||
await withTransaction(async (client) => {
|
await withTransaction(async (client) => {
|
||||||
|
|||||||
@@ -50,8 +50,13 @@ import {
|
|||||||
createLifePost,
|
createLifePost,
|
||||||
createPokemon,
|
createPokemon,
|
||||||
createRecipe,
|
createRecipe,
|
||||||
|
createAdminThreadChannel,
|
||||||
|
createThread,
|
||||||
|
createThreadMessage,
|
||||||
|
createThreadsWsTicketForUser,
|
||||||
deleteConfig,
|
deleteConfig,
|
||||||
deleteAncientArtifact,
|
deleteAncientArtifact,
|
||||||
|
deleteAdminThreadChannel,
|
||||||
deleteDailyChecklistItem,
|
deleteDailyChecklistItem,
|
||||||
deleteDish,
|
deleteDish,
|
||||||
deleteDishCategory,
|
deleteDishCategory,
|
||||||
@@ -67,10 +72,15 @@ import {
|
|||||||
deleteLifePostReaction,
|
deleteLifePostReaction,
|
||||||
deletePokemon,
|
deletePokemon,
|
||||||
deleteRecipe,
|
deleteRecipe,
|
||||||
|
deleteThread,
|
||||||
|
deleteThreadMessage,
|
||||||
|
deleteThreadMessageReaction,
|
||||||
|
deleteThreadReaction,
|
||||||
exportAdminData,
|
exportAdminData,
|
||||||
fetchPokemonData,
|
fetchPokemonData,
|
||||||
fetchPokemonImageOptions,
|
fetchPokemonImageOptions,
|
||||||
followUser,
|
followUser,
|
||||||
|
followThread,
|
||||||
getAdminDataToolsSummary,
|
getAdminDataToolsSummary,
|
||||||
getAncientArtifact,
|
getAncientArtifact,
|
||||||
getHabitat,
|
getHabitat,
|
||||||
@@ -81,6 +91,7 @@ import {
|
|||||||
getPokemon,
|
getPokemon,
|
||||||
getPublicUserProfile,
|
getPublicUserProfile,
|
||||||
getRecipe,
|
getRecipe,
|
||||||
|
getThread,
|
||||||
globalSearch,
|
globalSearch,
|
||||||
importAdminData,
|
importAdminData,
|
||||||
importAdminHabitatsCsv,
|
importAdminHabitatsCsv,
|
||||||
@@ -88,6 +99,7 @@ import {
|
|||||||
isConfigType,
|
isConfigType,
|
||||||
listAncientArtifacts,
|
listAncientArtifacts,
|
||||||
listEntityDiscussionComments,
|
listEntityDiscussionComments,
|
||||||
|
listAdminThreadChannels,
|
||||||
listConfig,
|
listConfig,
|
||||||
listDailyChecklistItems,
|
listDailyChecklistItems,
|
||||||
listHabitats,
|
listHabitats,
|
||||||
@@ -100,6 +112,9 @@ import {
|
|||||||
listPokemon,
|
listPokemon,
|
||||||
listPokemonFetchOptions,
|
listPokemonFetchOptions,
|
||||||
listRecipes,
|
listRecipes,
|
||||||
|
listThreadChannels,
|
||||||
|
listThreadMessages,
|
||||||
|
listThreads,
|
||||||
listUserCommentActivities,
|
listUserCommentActivities,
|
||||||
listUserLifePosts,
|
listUserLifePosts,
|
||||||
listUserReactionActivities,
|
listUserReactionActivities,
|
||||||
@@ -112,6 +127,7 @@ import {
|
|||||||
reorderItems,
|
reorderItems,
|
||||||
reorderLanguages,
|
reorderLanguages,
|
||||||
reorderRecipes,
|
reorderRecipes,
|
||||||
|
markThreadRead,
|
||||||
retryEntityDiscussionCommentModeration,
|
retryEntityDiscussionCommentModeration,
|
||||||
retryLifeCommentModeration,
|
retryLifeCommentModeration,
|
||||||
retryLifePostModeration,
|
retryLifePostModeration,
|
||||||
@@ -120,6 +136,8 @@ import {
|
|||||||
setLifePostReaction,
|
setLifePostReaction,
|
||||||
setEntityDiscussionCommentLike,
|
setEntityDiscussionCommentLike,
|
||||||
setLifeCommentLike,
|
setLifeCommentLike,
|
||||||
|
setThreadMessageReaction,
|
||||||
|
setThreadReaction,
|
||||||
updateConfig,
|
updateConfig,
|
||||||
updateAncientArtifact,
|
updateAncientArtifact,
|
||||||
updateDailyChecklistItem,
|
updateDailyChecklistItem,
|
||||||
@@ -131,7 +149,10 @@ import {
|
|||||||
updateLifePost,
|
updateLifePost,
|
||||||
updatePokemon,
|
updatePokemon,
|
||||||
updateRecipe,
|
updateRecipe,
|
||||||
|
updateAdminThreadChannel,
|
||||||
|
updateThreadLock,
|
||||||
unfollowUser,
|
unfollowUser,
|
||||||
|
unfollowThread,
|
||||||
wipeAdminData
|
wipeAdminData
|
||||||
} from './queries.ts';
|
} from './queries.ts';
|
||||||
import {
|
import {
|
||||||
@@ -160,6 +181,7 @@ import {
|
|||||||
markNotificationRead,
|
markNotificationRead,
|
||||||
setupNotificationWebSocketServer
|
setupNotificationWebSocketServer
|
||||||
} from './notifications.ts';
|
} from './notifications.ts';
|
||||||
|
import { setupThreadWebSocketServer } from './threadsRealtime.ts';
|
||||||
|
|
||||||
const app = Fastify({
|
const app = Fastify({
|
||||||
logger: true,
|
logger: true,
|
||||||
@@ -1689,6 +1711,129 @@ app.delete('/api/discussions/comments/:id/like', async (request, reply) => {
|
|||||||
return comment ? comment : notFound(reply, request);
|
return comment ? comment : notFound(reply, request);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get('/api/thread-channels', async (request) => {
|
||||||
|
const user = await optionalUser(request);
|
||||||
|
return listThreadChannels(user?.id ?? null);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/threads', async (request) => {
|
||||||
|
const user = await optionalUser(request);
|
||||||
|
return listThreads(request.query as Record<string, string | string[] | undefined>, user?.id ?? null);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/threads/ws-ticket', async (request, reply) => {
|
||||||
|
const user = await requireVerifiedUser(request, reply);
|
||||||
|
if (!user) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return createThreadsWsTicketForUser(user.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/threads', async (request, reply) => {
|
||||||
|
const user = await requirePermissionWithRateLimits(request, reply, 'threads.create', 'communityWrite');
|
||||||
|
return user ? reply.code(201).send(await createThread(request.body as Record<string, unknown>, user.id)) : undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/threads/:id', async (request, reply) => {
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const user = await optionalUser(request);
|
||||||
|
const thread = await getThread(Number(id), user?.id ?? null);
|
||||||
|
return thread ? thread : notFound(reply, request);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/threads/:id/messages', async (request, reply) => {
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const user = await optionalUser(request);
|
||||||
|
const canViewAll = user ? userHasPermission(user, 'admin.threads.messages.delete') : false;
|
||||||
|
const messages = await listThreadMessages(
|
||||||
|
Number(id),
|
||||||
|
request.query as Record<string, string | string[] | undefined>,
|
||||||
|
user?.id ?? null,
|
||||||
|
canViewAll
|
||||||
|
);
|
||||||
|
return messages ? messages : notFound(reply, request);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/threads/:id/messages', async (request, reply) => {
|
||||||
|
const user = await requirePermissionWithRateLimits(request, reply, 'threads.messages.create', 'communityWrite');
|
||||||
|
if (!user) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const message = await createThreadMessage(Number(id), request.body as Record<string, unknown>, user.id);
|
||||||
|
return message ? reply.code(201).send(message) : notFound(reply, request);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/api/threads/:id/follow', async (request, reply) => {
|
||||||
|
const user = await requirePermissionWithRateLimits(request, reply, 'threads.follow', 'communityReaction');
|
||||||
|
if (!user) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const thread = await followThread(Number(id), user.id);
|
||||||
|
return thread ? thread : notFound(reply, request);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/threads/:id/follow', async (request, reply) => {
|
||||||
|
const user = await requirePermissionWithRateLimits(request, reply, 'threads.follow', 'communityReaction');
|
||||||
|
if (!user) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const thread = await unfollowThread(Number(id), user.id);
|
||||||
|
return thread ? thread : notFound(reply, request);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/threads/:id/read', async (request, reply) => {
|
||||||
|
const user = await requirePermissionWithRateLimits(request, reply, 'threads.follow', 'communityReaction');
|
||||||
|
if (!user) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const thread = await markThreadRead(Number(id), user.id);
|
||||||
|
return thread ? thread : notFound(reply, request);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/api/threads/:id/reaction', async (request, reply) => {
|
||||||
|
const user = await requirePermissionWithRateLimits(request, reply, 'threads.reactions.set', 'communityReaction');
|
||||||
|
if (!user) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const thread = await setThreadReaction(Number(id), request.body as Record<string, unknown>, user.id);
|
||||||
|
return thread ? thread : notFound(reply, request);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/threads/:id/reaction', async (request, reply) => {
|
||||||
|
const user = await requirePermissionWithRateLimits(request, reply, 'threads.reactions.set', 'communityReaction');
|
||||||
|
if (!user) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const thread = await deleteThreadReaction(Number(id), request.body as Record<string, unknown>, user.id);
|
||||||
|
return thread ? thread : notFound(reply, request);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/api/thread-messages/:id/reaction', async (request, reply) => {
|
||||||
|
const user = await requirePermissionWithRateLimits(request, reply, 'threads.reactions.set', 'communityReaction');
|
||||||
|
if (!user) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const message = await setThreadMessageReaction(Number(id), request.body as Record<string, unknown>, user.id);
|
||||||
|
return message ? message : notFound(reply, request);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/thread-messages/:id/reaction', async (request, reply) => {
|
||||||
|
const user = await requirePermissionWithRateLimits(request, reply, 'threads.reactions.set', 'communityReaction');
|
||||||
|
if (!user) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const message = await deleteThreadMessageReaction(Number(id), request.body as Record<string, unknown>, user.id);
|
||||||
|
return message ? message : notFound(reply, request);
|
||||||
|
});
|
||||||
|
|
||||||
app.get('/api/pokemon', async (request) =>
|
app.get('/api/pokemon', async (request) =>
|
||||||
listPokemon(request.query as Record<string, string | string[] | undefined>, requestLocale(request))
|
listPokemon(request.query as Record<string, string | string[] | undefined>, requestLocale(request))
|
||||||
);
|
);
|
||||||
@@ -2215,6 +2360,69 @@ app.post('/api/admin/data-tools/wipe', async (request, reply) => {
|
|||||||
return user ? wipeAdminData(request.body as Record<string, unknown>) : undefined;
|
return user ? wipeAdminData(request.body as Record<string, unknown>) : undefined;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get('/api/admin/thread-channels', async (request, reply) => {
|
||||||
|
const user = await requirePermission(request, reply, 'admin.threads.channels.read');
|
||||||
|
return user ? listAdminThreadChannels() : undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/admin/thread-channels', async (request, reply) => {
|
||||||
|
const user = await requirePermissionWithRateLimits(request, reply, 'admin.threads.channels.create', 'adminWrite');
|
||||||
|
return user
|
||||||
|
? reply.code(201).send(await createAdminThreadChannel(request.body as Record<string, unknown>, user.id))
|
||||||
|
: undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/api/admin/thread-channels/:id', async (request, reply) => {
|
||||||
|
const user = await requirePermissionWithRateLimits(request, reply, 'admin.threads.channels.update', 'adminWrite');
|
||||||
|
if (!user) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const channels = await updateAdminThreadChannel(Number(id), request.body as Record<string, unknown>, user.id);
|
||||||
|
return channels ? channels : notFound(reply, request);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/admin/thread-channels/:id', async (request, reply) => {
|
||||||
|
const user = await requirePermissionWithRateLimits(request, reply, 'admin.threads.channels.delete', 'adminWrite');
|
||||||
|
if (!user) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const deleted = await deleteAdminThreadChannel(Number(id));
|
||||||
|
return deleted ? reply.code(204).send() : notFound(reply, request);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/api/admin/threads/:id/lock', async (request, reply) => {
|
||||||
|
const user = await requirePermissionWithRateLimits(request, reply, 'admin.threads.threads.lock', 'adminWrite');
|
||||||
|
if (!user) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const payload = request.body as Record<string, unknown>;
|
||||||
|
const thread = await updateThreadLock(Number(id), payload.locked === true, user.id);
|
||||||
|
return thread ? thread : notFound(reply, request);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/admin/threads/:id', async (request, reply) => {
|
||||||
|
const user = await requirePermissionWithRateLimits(request, reply, 'admin.threads.threads.delete', 'adminWrite');
|
||||||
|
if (!user) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const deleted = await deleteThread(Number(id), user.id);
|
||||||
|
return deleted ? reply.code(204).send() : notFound(reply, request);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/admin/thread-messages/:id', async (request, reply) => {
|
||||||
|
const user = await requirePermissionWithRateLimits(request, reply, 'admin.threads.messages.delete', 'adminWrite');
|
||||||
|
if (!user) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const deleted = await deleteThreadMessage(Number(id), user.id);
|
||||||
|
return deleted ? reply.code(204).send() : notFound(reply, request);
|
||||||
|
});
|
||||||
|
|
||||||
app.get('/api/admin/config/:type', async (request, reply) => {
|
app.get('/api/admin/config/:type', async (request, reply) => {
|
||||||
const user = await requirePermission(request, reply, 'admin.config.read');
|
const user = await requirePermission(request, reply, 'admin.config.read');
|
||||||
if (!user) {
|
if (!user) {
|
||||||
@@ -2286,6 +2494,7 @@ try {
|
|||||||
await syncSystemWordingCatalog();
|
await syncSystemWordingCatalog();
|
||||||
await startAiModerationWorker(app.log);
|
await startAiModerationWorker(app.log);
|
||||||
setupNotificationWebSocketServer(app.server, app.log);
|
setupNotificationWebSocketServer(app.server, app.log);
|
||||||
|
setupThreadWebSocketServer(app.server, app.log);
|
||||||
await app.listen({ host: '0.0.0.0', port });
|
await app.listen({ host: '0.0.0.0', port });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
app.log.error(error);
|
app.log.error(error);
|
||||||
|
|||||||
421
backend/src/threadsRealtime.ts
Normal file
421
backend/src/threadsRealtime.ts
Normal file
@@ -0,0 +1,421 @@
|
|||||||
|
import type { FastifyBaseLogger } from 'fastify';
|
||||||
|
import { Buffer } from 'node:buffer';
|
||||||
|
import { createHash, randomBytes } from 'node:crypto';
|
||||||
|
import type { Server } from 'node:http';
|
||||||
|
import type { Duplex } from 'node:stream';
|
||||||
|
import { pool, query, queryOne } from './db.ts';
|
||||||
|
import type { ThreadMessage, ThreadReactionCounts, ThreadReactionType, ThreadSummary } from './queries.ts';
|
||||||
|
|
||||||
|
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.reactions.updated';
|
||||||
|
target: 'thread' | 'message';
|
||||||
|
threadId: number;
|
||||||
|
messageId: number | null;
|
||||||
|
reactionCounts: ThreadReactionCounts;
|
||||||
|
myReactions: ThreadReactionType[];
|
||||||
|
}
|
||||||
|
| { type: 'thread.read.updated'; threadId: number; unread: boolean; unreadCount: number };
|
||||||
|
|
||||||
|
const websocketGuid = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
|
||||||
|
const websocketTicketMinutes = 2;
|
||||||
|
const threadClients = new Map<number, Set<Duplex>>();
|
||||||
|
const clientUsers = new WeakMap<Duplex, number>();
|
||||||
|
|
||||||
|
function hashToken(token: string): string {
|
||||||
|
return createHash('sha256').update(token).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createThreadWebSocketTicket(userId: number): Promise<{ ticket: string; expiresAt: Date }> {
|
||||||
|
const ticket = randomBytes(32).toString('base64url');
|
||||||
|
const expiresAt = new Date(Date.now() + websocketTicketMinutes * 60_000);
|
||||||
|
await pool.query(
|
||||||
|
`
|
||||||
|
INSERT INTO thread_ws_tickets (ticket_hash, user_id, expires_at)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
`,
|
||||||
|
[hashToken(ticket), userId, expiresAt]
|
||||||
|
);
|
||||||
|
await pool.query('DELETE FROM thread_ws_tickets WHERE expires_at < now()');
|
||||||
|
return { ticket, expiresAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function consumeThreadWebSocketTicket(ticket: string): Promise<number | null> {
|
||||||
|
if (!ticket) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = await queryOne<{ userId: number }>(
|
||||||
|
`
|
||||||
|
DELETE FROM thread_ws_tickets
|
||||||
|
WHERE ticket_hash = $1
|
||||||
|
AND expires_at > now()
|
||||||
|
RETURNING user_id AS "userId"
|
||||||
|
`,
|
||||||
|
[hashToken(ticket)]
|
||||||
|
);
|
||||||
|
return row?.userId ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function followedUnreadCount(userId: number): Promise<number> {
|
||||||
|
const row = await queryOne<{ count: number }>(
|
||||||
|
`
|
||||||
|
SELECT COUNT(*)::integer AS count
|
||||||
|
FROM thread_follows tf
|
||||||
|
JOIN threads t ON t.id = tf.thread_id
|
||||||
|
LEFT JOIN thread_reads tr ON tr.thread_id = t.id AND tr.user_id = tf.user_id
|
||||||
|
WHERE tf.user_id = $1
|
||||||
|
AND t.deleted_at IS NULL
|
||||||
|
AND t.last_message_id IS NOT NULL
|
||||||
|
AND (
|
||||||
|
tr.last_read_message_id IS NULL
|
||||||
|
OR t.last_message_id > tr.last_read_message_id
|
||||||
|
)
|
||||||
|
`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
return row?.count ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function wsFrame(data: Buffer, opcode = 0x1): Buffer {
|
||||||
|
const length = data.byteLength;
|
||||||
|
if (length < 126) {
|
||||||
|
return Buffer.concat([Buffer.from([0x80 | opcode, length]), data]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (length < 65536) {
|
||||||
|
const header = Buffer.alloc(4);
|
||||||
|
header[0] = 0x80 | opcode;
|
||||||
|
header[1] = 126;
|
||||||
|
header.writeUInt16BE(length, 2);
|
||||||
|
return Buffer.concat([header, data]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const header = Buffer.alloc(10);
|
||||||
|
header[0] = 0x80 | opcode;
|
||||||
|
header[1] = 127;
|
||||||
|
header.writeBigUInt64BE(BigInt(length), 2);
|
||||||
|
return Buffer.concat([header, data]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendWsJson(socket: Duplex, message: ThreadWsMessage): void {
|
||||||
|
if (!socket.destroyed) {
|
||||||
|
socket.write(wsFrame(Buffer.from(JSON.stringify(message), 'utf8')));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function websocketPayload(buffer: Buffer): { opcode: number; payload: Buffer } | null {
|
||||||
|
if (buffer.byteLength < 2) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const opcode = buffer[0] & 0x0f;
|
||||||
|
const masked = (buffer[1] & 0x80) !== 0;
|
||||||
|
let length = buffer[1] & 0x7f;
|
||||||
|
let offset = 2;
|
||||||
|
|
||||||
|
if (length === 126) {
|
||||||
|
if (buffer.byteLength < offset + 2) return null;
|
||||||
|
length = buffer.readUInt16BE(offset);
|
||||||
|
offset += 2;
|
||||||
|
} else if (length === 127) {
|
||||||
|
if (buffer.byteLength < offset + 8) return null;
|
||||||
|
const longLength = buffer.readBigUInt64BE(offset);
|
||||||
|
if (longLength > BigInt(Number.MAX_SAFE_INTEGER)) return null;
|
||||||
|
length = Number(longLength);
|
||||||
|
offset += 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mask: Buffer | null = null;
|
||||||
|
if (masked) {
|
||||||
|
if (buffer.byteLength < offset + 4) return null;
|
||||||
|
mask = buffer.subarray(offset, offset + 4);
|
||||||
|
offset += 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (buffer.byteLength < offset + length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = Buffer.from(buffer.subarray(offset, offset + length));
|
||||||
|
if (mask) {
|
||||||
|
for (let index = 0; index < payload.byteLength; index += 1) {
|
||||||
|
payload[index] ^= mask[index % 4];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { opcode, payload };
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeSocket(socket: Duplex, statusCode = 1000): void {
|
||||||
|
if (socket.destroyed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = Buffer.alloc(2);
|
||||||
|
payload.writeUInt16BE(statusCode, 0);
|
||||||
|
socket.end(wsFrame(payload, 0x8));
|
||||||
|
}
|
||||||
|
|
||||||
|
function rejectUpgrade(socket: Duplex, statusCode: number, statusText: string): void {
|
||||||
|
socket.write(`HTTP/1.1 ${statusCode} ${statusText}\r\nConnection: close\r\n\r\n`);
|
||||||
|
socket.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
function addThreadClient(userId: number, socket: Duplex): void {
|
||||||
|
clientUsers.set(socket, userId);
|
||||||
|
let clients = threadClients.get(userId);
|
||||||
|
if (!clients) {
|
||||||
|
clients = new Set();
|
||||||
|
threadClients.set(userId, clients);
|
||||||
|
}
|
||||||
|
clients.add(socket);
|
||||||
|
socket.on('close', () => {
|
||||||
|
clients?.delete(socket);
|
||||||
|
if (clients?.size === 0) {
|
||||||
|
threadClients.delete(userId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recipientUserIds(threadId: number): Promise<number[]> {
|
||||||
|
const rows = await query<{ userId: number }>(
|
||||||
|
`
|
||||||
|
SELECT DISTINCT user_id AS "userId"
|
||||||
|
FROM thread_follows
|
||||||
|
WHERE thread_id = $1
|
||||||
|
`,
|
||||||
|
[threadId]
|
||||||
|
);
|
||||||
|
return rows.map((row) => row.userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectedUserIds(): number[] {
|
||||||
|
return [...threadClients.keys()];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function publishToUsers(userIds: number[], message: ThreadWsMessage): Promise<void> {
|
||||||
|
for (const userId of userIds) {
|
||||||
|
const clients = threadClients.get(userId);
|
||||||
|
if (!clients) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const socket of clients) {
|
||||||
|
sendWsJson(socket, message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function publishThreadMessageCreated(thread: ThreadSummary, message: ThreadMessage): Promise<void> {
|
||||||
|
const users = [...new Set([...(await recipientUserIds(thread.id)), ...connectedUserIds()])];
|
||||||
|
if (message.author?.id && !users.includes(message.author.id)) {
|
||||||
|
users.push(message.author.id);
|
||||||
|
}
|
||||||
|
await publishToUsers(users, {
|
||||||
|
type: 'thread.message.created',
|
||||||
|
threadId: thread.id,
|
||||||
|
message,
|
||||||
|
thread
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function applyApprovedThreadMessage(messageId: number): Promise<void> {
|
||||||
|
const row = await queryOne<{
|
||||||
|
threadId: number;
|
||||||
|
channelId: number;
|
||||||
|
title: string;
|
||||||
|
languageCode: string;
|
||||||
|
locked: boolean;
|
||||||
|
messageCount: number;
|
||||||
|
lastActiveAt: Date;
|
||||||
|
threadCreatedAt: Date;
|
||||||
|
threadAuthor: { id: number; displayName: string } | null;
|
||||||
|
messageBody: string;
|
||||||
|
moderationStatus: ThreadMessage['moderationStatus'];
|
||||||
|
moderationLanguageCode: string | null;
|
||||||
|
moderationReason: string | null;
|
||||||
|
messageCreatedAt: Date;
|
||||||
|
messageUpdatedAt: Date;
|
||||||
|
messageAuthor: { id: number; displayName: string } | null;
|
||||||
|
}>(
|
||||||
|
`
|
||||||
|
WITH updated_thread AS (
|
||||||
|
UPDATE threads t
|
||||||
|
SET last_message_id = tm.id,
|
||||||
|
message_count = (
|
||||||
|
SELECT COUNT(*)::integer
|
||||||
|
FROM thread_messages visible_message
|
||||||
|
WHERE visible_message.thread_id = t.id
|
||||||
|
AND visible_message.deleted_at IS NULL
|
||||||
|
AND visible_message.ai_moderation_status = 'approved'
|
||||||
|
),
|
||||||
|
last_active_at = GREATEST(t.last_active_at, tm.created_at),
|
||||||
|
updated_at = now()
|
||||||
|
FROM thread_messages tm
|
||||||
|
WHERE tm.id = $1
|
||||||
|
AND tm.thread_id = t.id
|
||||||
|
AND tm.deleted_at IS NULL
|
||||||
|
AND tm.ai_moderation_status = 'approved'
|
||||||
|
RETURNING
|
||||||
|
t.id,
|
||||||
|
t.channel_id,
|
||||||
|
t.title,
|
||||||
|
t.language_code,
|
||||||
|
t.locked,
|
||||||
|
t.message_count,
|
||||||
|
t.last_active_at,
|
||||||
|
t.created_at,
|
||||||
|
t.created_by_user_id
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
ut.id AS "threadId",
|
||||||
|
ut.channel_id AS "channelId",
|
||||||
|
ut.title,
|
||||||
|
ut.language_code AS "languageCode",
|
||||||
|
ut.locked,
|
||||||
|
ut.message_count AS "messageCount",
|
||||||
|
ut.last_active_at AS "lastActiveAt",
|
||||||
|
ut.created_at AS "threadCreatedAt",
|
||||||
|
CASE WHEN thread_user.id IS NULL THEN NULL ELSE json_build_object('id', thread_user.id, 'displayName', thread_user.display_name) END AS "threadAuthor",
|
||||||
|
tm.body AS "messageBody",
|
||||||
|
tm.ai_moderation_status AS "moderationStatus",
|
||||||
|
tm.ai_moderation_language_code AS "moderationLanguageCode",
|
||||||
|
tm.ai_moderation_reason AS "moderationReason",
|
||||||
|
tm.created_at AS "messageCreatedAt",
|
||||||
|
tm.updated_at AS "messageUpdatedAt",
|
||||||
|
CASE WHEN message_user.id IS NULL THEN NULL ELSE json_build_object('id', message_user.id, 'displayName', message_user.display_name) END AS "messageAuthor"
|
||||||
|
FROM updated_thread ut
|
||||||
|
JOIN thread_messages tm ON tm.id = $1
|
||||||
|
LEFT JOIN users thread_user ON thread_user.id = ut.created_by_user_id
|
||||||
|
LEFT JOIN users message_user ON message_user.id = tm.created_by_user_id
|
||||||
|
`,
|
||||||
|
[messageId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await publishThreadMessageCreated(
|
||||||
|
{
|
||||||
|
id: row.threadId,
|
||||||
|
channelId: row.channelId,
|
||||||
|
title: row.title,
|
||||||
|
languageCode: row.languageCode,
|
||||||
|
tags: [],
|
||||||
|
locked: row.locked,
|
||||||
|
messageCount: row.messageCount,
|
||||||
|
lastActiveAt: row.lastActiveAt,
|
||||||
|
createdAt: row.threadCreatedAt,
|
||||||
|
author: row.threadAuthor,
|
||||||
|
reactionCounts: { 'thumbs-up': 0, heart: 0, laugh: 0, fire: 0, eyes: 0 },
|
||||||
|
myReactions: [],
|
||||||
|
followed: true,
|
||||||
|
unread: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: messageId,
|
||||||
|
threadId: row.threadId,
|
||||||
|
body: row.messageBody,
|
||||||
|
moderationStatus: row.moderationStatus,
|
||||||
|
moderationLanguageCode: row.moderationLanguageCode,
|
||||||
|
moderationReason: row.moderationReason,
|
||||||
|
createdAt: row.messageCreatedAt,
|
||||||
|
updatedAt: row.messageUpdatedAt,
|
||||||
|
author: row.messageAuthor,
|
||||||
|
reactionCounts: { 'thumbs-up': 0, heart: 0, laugh: 0, fire: 0, eyes: 0 },
|
||||||
|
myReactions: []
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function publishThreadMessageModeration(threadId: number, message: ThreadMessage | null): Promise<void> {
|
||||||
|
await publishToUsers([...new Set([...(await recipientUserIds(threadId)), ...connectedUserIds()])], {
|
||||||
|
type: 'thread.message.moderation',
|
||||||
|
threadId,
|
||||||
|
message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function publishThreadReactionUpdated(
|
||||||
|
userId: number,
|
||||||
|
message: Extract<ThreadWsMessage, { type: 'thread.reactions.updated' }>
|
||||||
|
): Promise<void> {
|
||||||
|
const users = await recipientUserIds(message.threadId);
|
||||||
|
for (const connectedUserId of connectedUserIds()) {
|
||||||
|
if (!users.includes(connectedUserId)) {
|
||||||
|
users.push(connectedUserId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!users.includes(userId)) {
|
||||||
|
users.push(userId);
|
||||||
|
}
|
||||||
|
await publishToUsers(users, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function publishThreadReadUpdated(userId: number, threadId: number, unread: boolean, unreadCount: number): Promise<void> {
|
||||||
|
await publishToUsers([userId], { type: 'thread.read.updated', threadId, unread, unreadCount });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setupThreadWebSocketServer(server: Server, logger: FastifyBaseLogger): void {
|
||||||
|
server.on('upgrade', async (request, socket) => {
|
||||||
|
const url = new URL(request.url ?? '/', 'http://localhost');
|
||||||
|
if (url.pathname !== '/api/threads/ws') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = request.headers['sec-websocket-key'];
|
||||||
|
if (request.method !== 'GET' || typeof key !== 'string' || key.trim() === '') {
|
||||||
|
rejectUpgrade(socket, 400, 'Bad Request');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ticket = url.searchParams.get('ticket') ?? '';
|
||||||
|
const userId = await consumeThreadWebSocketTicket(ticket);
|
||||||
|
if (!userId) {
|
||||||
|
rejectUpgrade(socket, 401, 'Unauthorized');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const accept = createHash('sha1').update(`${key}${websocketGuid}`).digest('base64');
|
||||||
|
socket.write(
|
||||||
|
[
|
||||||
|
'HTTP/1.1 101 Switching Protocols',
|
||||||
|
'Upgrade: websocket',
|
||||||
|
'Connection: Upgrade',
|
||||||
|
`Sec-WebSocket-Accept: ${accept}`,
|
||||||
|
'\r\n'
|
||||||
|
].join('\r\n')
|
||||||
|
);
|
||||||
|
|
||||||
|
addThreadClient(userId, socket);
|
||||||
|
sendWsJson(socket, {
|
||||||
|
type: 'threads.connected',
|
||||||
|
followedUnreadCount: await followedUnreadCount(userId)
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('data', (buffer: Buffer) => {
|
||||||
|
const frame = websocketPayload(buffer);
|
||||||
|
if (!frame) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (frame.opcode === 0x8) {
|
||||||
|
closeSocket(socket);
|
||||||
|
} else if (frame.opcode === 0x9) {
|
||||||
|
socket.write(wsFrame(frame.payload, 0x0a));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
socket.on('error', () => {
|
||||||
|
socket.destroy();
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn({ err: error }, 'Thread WebSocket upgrade failed');
|
||||||
|
rejectUpgrade(socket, 500, 'Internal Server Error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
iconLife,
|
iconLife,
|
||||||
iconPokemon,
|
iconPokemon,
|
||||||
iconRecipe,
|
iconRecipe,
|
||||||
|
iconThreads,
|
||||||
type AppIcon
|
type AppIcon
|
||||||
} from './src/icons';
|
} from './src/icons';
|
||||||
import { getCurrentLocale, loadSystemWordings, onLocaleChange, setCurrentLocale } from './src/i18n';
|
import { getCurrentLocale, loadSystemWordings, onLocaleChange, setCurrentLocale } from './src/i18n';
|
||||||
@@ -101,7 +102,8 @@ const navItems = computed<NavItem[]>(() => {
|
|||||||
{ label: t('nav.dreamIsland'), to: '/dream-island', icon: iconDreamIsland, badge: inDevBadge() },
|
{ label: t('nav.dreamIsland'), to: '/dream-island', icon: iconDreamIsland, badge: inDevBadge() },
|
||||||
{ label: t('nav.clothes'), to: '/clothes', icon: iconClothes, badge: inDevBadge() },
|
{ label: t('nav.clothes'), to: '/clothes', icon: iconClothes, badge: inDevBadge() },
|
||||||
{ label: t('nav.checklist'), to: '/checklist', icon: iconChecklist },
|
{ label: t('nav.checklist'), to: '/checklist', icon: iconChecklist },
|
||||||
{ label: t('nav.life'), to: '/life', icon: iconLife }
|
{ label: t('nav.life'), to: '/life', icon: iconLife },
|
||||||
|
{ label: t('nav.threads'), to: '/threads', icon: iconThreads }
|
||||||
];
|
];
|
||||||
|
|
||||||
if (can('admin.access')) {
|
if (can('admin.access')) {
|
||||||
|
|||||||
11
frontend/pages/threads/[id].vue
Normal file
11
frontend/pages/threads/[id].vue
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import ThreadsView from '../../src/views/ThreadsView.vue';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
title: 'Threads'
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ThreadsView />
|
||||||
|
</template>
|
||||||
11
frontend/pages/threads/index.vue
Normal file
11
frontend/pages/threads/index.vue
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import ThreadsView from '../../src/views/ThreadsView.vue';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
title: 'Threads'
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ThreadsView />
|
||||||
|
</template>
|
||||||
@@ -50,10 +50,12 @@ export const iconReactionLike: AppIcon = 'mdi:thumb-up-outline';
|
|||||||
export const iconReactionThanks: AppIcon = 'mdi:hand-heart-outline';
|
export const iconReactionThanks: AppIcon = 'mdi:hand-heart-outline';
|
||||||
export const iconSave: AppIcon = 'mdi:content-save-outline';
|
export const iconSave: AppIcon = 'mdi:content-save-outline';
|
||||||
export const iconSearch: AppIcon = 'mdi:magnify';
|
export const iconSearch: AppIcon = 'mdi:magnify';
|
||||||
|
export const iconSend: AppIcon = 'mdi:send-outline';
|
||||||
export const iconStar: AppIcon = 'mdi:star';
|
export const iconStar: AppIcon = 'mdi:star';
|
||||||
export const iconStarOutline: AppIcon = 'mdi:star-outline';
|
export const iconStarOutline: AppIcon = 'mdi:star-outline';
|
||||||
export const iconSuccess: AppIcon = 'mdi:check-circle-outline';
|
export const iconSuccess: AppIcon = 'mdi:check-circle-outline';
|
||||||
export const iconTranslate: AppIcon = 'mdi:translate';
|
export const iconTranslate: AppIcon = 'mdi:translate';
|
||||||
|
export const iconThreads: AppIcon = 'mdi:forum-outline';
|
||||||
export const iconUndo: AppIcon = 'mdi:undo';
|
export const iconUndo: AppIcon = 'mdi:undo';
|
||||||
export const iconUpload: AppIcon = 'mdi:upload-outline';
|
export const iconUpload: AppIcon = 'mdi:upload-outline';
|
||||||
export const iconVersion: AppIcon = 'mdi:tag-outline';
|
export const iconVersion: AppIcon = 'mdi:tag-outline';
|
||||||
|
|||||||
@@ -575,6 +575,121 @@ export interface LifeReactionUsersParams {
|
|||||||
reactionType?: LifeReactionType;
|
reactionType?: LifeReactionType;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ThreadReactionType = 'thumbs-up' | 'heart' | 'laugh' | 'fire' | 'eyes';
|
||||||
|
export type ThreadReactionCounts = Record<ThreadReactionType, number>;
|
||||||
|
export type ThreadSort = 'last-active' | 'latest' | 'most-discussed';
|
||||||
|
|
||||||
|
export interface ThreadChannelTag {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
sortOrder: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThreadChannel {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
allowUserThreads: boolean;
|
||||||
|
sortOrder: number;
|
||||||
|
tags: ThreadChannelTag[];
|
||||||
|
languages: Array<{ code: string; name: string }>;
|
||||||
|
unreadCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThreadSummary {
|
||||||
|
id: number;
|
||||||
|
channelId: number;
|
||||||
|
title: string;
|
||||||
|
languageCode: string;
|
||||||
|
tags: ThreadChannelTag[];
|
||||||
|
locked: boolean;
|
||||||
|
messageCount: number;
|
||||||
|
lastActiveAt: string;
|
||||||
|
createdAt: string;
|
||||||
|
author: UserSummary | null;
|
||||||
|
reactionCounts: ThreadReactionCounts;
|
||||||
|
myReactions: ThreadReactionType[];
|
||||||
|
followed: boolean;
|
||||||
|
unread: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThreadMessage {
|
||||||
|
id: number;
|
||||||
|
threadId: number;
|
||||||
|
body: string;
|
||||||
|
moderationStatus: AiModerationStatus;
|
||||||
|
moderationLanguageCode: string | null;
|
||||||
|
moderationReason: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
author: UserSummary | null;
|
||||||
|
reactionCounts: ThreadReactionCounts;
|
||||||
|
myReactions: ThreadReactionType[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThreadsPage {
|
||||||
|
items: ThreadSummary[];
|
||||||
|
nextCursor: string | null;
|
||||||
|
hasMore: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThreadMessagesPage {
|
||||||
|
items: ThreadMessage[];
|
||||||
|
beforeCursor: string | null;
|
||||||
|
hasMoreBefore: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThreadsParams {
|
||||||
|
cursor?: string | null;
|
||||||
|
limit?: number;
|
||||||
|
channelId?: number | string | null;
|
||||||
|
language?: string;
|
||||||
|
tagId?: number | string | null;
|
||||||
|
sort?: ThreadSort;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThreadMessagesParams {
|
||||||
|
before?: string | null;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThreadPayload {
|
||||||
|
channelId: number;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
languageCode: string;
|
||||||
|
tagIds: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThreadMessagePayload {
|
||||||
|
body: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThreadWsTicket {
|
||||||
|
ticket: string;
|
||||||
|
expiresAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
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.reactions.updated';
|
||||||
|
target: 'thread' | 'message';
|
||||||
|
threadId: number;
|
||||||
|
messageId: number | null;
|
||||||
|
reactionCounts: ThreadReactionCounts;
|
||||||
|
myReactions: ThreadReactionType[];
|
||||||
|
}
|
||||||
|
| { type: 'thread.read.updated'; threadId: number; unread: boolean; unreadCount: number };
|
||||||
|
|
||||||
|
export interface AdminThreadChannelPayload {
|
||||||
|
name: string;
|
||||||
|
allowUserThreads: boolean;
|
||||||
|
tags: string[];
|
||||||
|
languages: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface NotificationTarget {
|
export interface NotificationTarget {
|
||||||
type: NotificationTargetType;
|
type: NotificationTargetType;
|
||||||
id: number;
|
id: number;
|
||||||
@@ -1087,6 +1202,15 @@ export function notificationWebSocketUrl(ticket: string): string {
|
|||||||
return base.toString();
|
return base.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function threadWebSocketUrl(ticket: string): string {
|
||||||
|
const base = new URL(browserApiBaseUrl, typeof window === 'undefined' ? 'http://localhost' : window.location.origin);
|
||||||
|
base.protocol = base.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
base.pathname = '/api/threads/ws';
|
||||||
|
base.search = '';
|
||||||
|
base.searchParams.set('ticket', ticket);
|
||||||
|
return base.toString();
|
||||||
|
}
|
||||||
|
|
||||||
async function getErrorMessage(response: Response): Promise<string> {
|
async function getErrorMessage(response: Response): Promise<string> {
|
||||||
try {
|
try {
|
||||||
const data = (await response.json()) as { message?: unknown };
|
const data = (await response.json()) as { message?: unknown };
|
||||||
@@ -1127,7 +1251,7 @@ async function getJson<T>(path: string, options?: AbortSignal | ApiRequestOption
|
|||||||
return response.json() as Promise<T>;
|
return response.json() as Promise<T>;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendJson<T>(path: string, method: 'PATCH' | 'POST' | 'PUT', body: unknown): Promise<T> {
|
async function sendJson<T>(path: string, method: 'DELETE' | 'PATCH' | 'POST' | 'PUT', body: unknown): Promise<T> {
|
||||||
const headers = requestHeaders();
|
const headers = requestHeaders();
|
||||||
headers.set('Content-Type', 'application/json');
|
headers.set('Content-Type', 'application/json');
|
||||||
|
|
||||||
@@ -1352,6 +1476,51 @@ export const api = {
|
|||||||
reactionType: params.reactionType
|
reactionType: params.reactionType
|
||||||
})}`
|
})}`
|
||||||
),
|
),
|
||||||
|
threadChannels: () => getJson<ThreadChannel[]>('/api/thread-channels'),
|
||||||
|
threads: (params: ThreadsParams = {}) =>
|
||||||
|
getJson<ThreadsPage>(
|
||||||
|
`/api/threads${buildQuery({
|
||||||
|
cursor: params.cursor ?? undefined,
|
||||||
|
limit: params.limit,
|
||||||
|
channelId: params.channelId,
|
||||||
|
language: params.language,
|
||||||
|
tagId: params.tagId,
|
||||||
|
sort: params.sort
|
||||||
|
})}`
|
||||||
|
),
|
||||||
|
thread: (id: string | number) => getJson<ThreadSummary>(`/api/threads/${id}`),
|
||||||
|
createThread: (payload: ThreadPayload) => sendJson<ThreadSummary>('/api/threads', 'POST', payload),
|
||||||
|
threadMessages: (id: string | number, params: ThreadMessagesParams = {}) =>
|
||||||
|
getJson<ThreadMessagesPage>(
|
||||||
|
`/api/threads/${id}/messages${buildQuery({
|
||||||
|
before: params.before ?? undefined,
|
||||||
|
limit: params.limit
|
||||||
|
})}`
|
||||||
|
),
|
||||||
|
createThreadMessage: (id: string | number, payload: ThreadMessagePayload) =>
|
||||||
|
sendJson<ThreadMessage>(`/api/threads/${id}/messages`, 'POST', payload),
|
||||||
|
followThread: (id: string | number) => sendJson<ThreadSummary>(`/api/threads/${id}/follow`, 'PUT', {}),
|
||||||
|
unfollowThread: (id: string | number) => deleteAndGetJson<ThreadSummary>(`/api/threads/${id}/follow`),
|
||||||
|
markThreadRead: (id: string | number) => sendJson<ThreadSummary>(`/api/threads/${id}/read`, 'POST', {}),
|
||||||
|
setThreadReaction: (id: string | number, reactionType: ThreadReactionType) =>
|
||||||
|
sendJson<ThreadSummary>(`/api/threads/${id}/reaction`, 'PUT', { reactionType }),
|
||||||
|
deleteThreadReaction: (id: string | number, reactionType: ThreadReactionType) =>
|
||||||
|
sendJson<ThreadSummary>(`/api/threads/${id}/reaction`, 'DELETE', { reactionType }),
|
||||||
|
setThreadMessageReaction: (id: string | number, reactionType: ThreadReactionType) =>
|
||||||
|
sendJson<ThreadMessage>(`/api/thread-messages/${id}/reaction`, 'PUT', { reactionType }),
|
||||||
|
deleteThreadMessageReaction: (id: string | number, reactionType: ThreadReactionType) =>
|
||||||
|
sendJson<ThreadMessage>(`/api/thread-messages/${id}/reaction`, 'DELETE', { reactionType }),
|
||||||
|
threadWsTicket: () => sendJson<ThreadWsTicket>('/api/threads/ws-ticket', 'POST', {}),
|
||||||
|
adminThreadChannels: () => getJson<ThreadChannel[]>('/api/admin/thread-channels'),
|
||||||
|
createAdminThreadChannel: (payload: AdminThreadChannelPayload) =>
|
||||||
|
sendJson<ThreadChannel[]>('/api/admin/thread-channels', 'POST', payload),
|
||||||
|
updateAdminThreadChannel: (id: string | number, payload: AdminThreadChannelPayload) =>
|
||||||
|
sendJson<ThreadChannel[]>(`/api/admin/thread-channels/${id}`, 'PUT', payload),
|
||||||
|
deleteAdminThreadChannel: (id: string | number) => deleteJson(`/api/admin/thread-channels/${id}`),
|
||||||
|
lockThread: (id: string | number, locked: boolean) =>
|
||||||
|
sendJson<ThreadSummary>(`/api/admin/threads/${id}/lock`, 'PUT', { locked }),
|
||||||
|
deleteThread: (id: string | number) => deleteJson(`/api/admin/threads/${id}`),
|
||||||
|
deleteThreadMessage: (id: string | number) => deleteJson(`/api/admin/thread-messages/${id}`),
|
||||||
setLifeRating: (id: string | number, rating: number) =>
|
setLifeRating: (id: string | number, rating: number) =>
|
||||||
sendJson<LifePost>(`/api/life-posts/${id}/rating`, 'PUT', { rating }),
|
sendJson<LifePost>(`/api/life-posts/${id}/rating`, 'PUT', { rating }),
|
||||||
deleteLifeRating: (id: string | number) => deleteAndGetJson<LifePost>(`/api/life-posts/${id}/rating`),
|
deleteLifeRating: (id: string | number) => deleteAndGetJson<LifePost>(`/api/life-posts/${id}/rating`),
|
||||||
|
|||||||
@@ -9689,12 +9689,366 @@ button:disabled,
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.threads-layout {
|
||||||
|
min-height: min(78vh, 860px);
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(180px, 220px) minmax(260px, 360px) minmax(0, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.threads-sidebar,
|
||||||
|
.threads-list-panel,
|
||||||
|
.thread-chat-panel {
|
||||||
|
min-width: 0;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
background: var(--surface);
|
||||||
|
box-shadow: var(--shadow-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.threads-sidebar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.threads-sidebar h2 {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread-channel,
|
||||||
|
.thread-list-item {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: var(--radius-control);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread-channel {
|
||||||
|
min-height: 44px;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread-channel:hover,
|
||||||
|
.thread-channel.active,
|
||||||
|
.thread-list-item:hover,
|
||||||
|
.thread-list-item.active {
|
||||||
|
border-color: color-mix(in srgb, var(--pokemon-blue) 35%, var(--line));
|
||||||
|
background: color-mix(in srgb, var(--pokemon-blue) 8%, var(--surface));
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread-unread-dot {
|
||||||
|
width: 9px;
|
||||||
|
height: 9px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--pokemon-red);
|
||||||
|
box-shadow: 0 0 0 3px color-mix(in srgb, var(--pokemon-red) 16%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.threads-list-panel {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto auto minmax(0, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
padding: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread-filters {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread-filters label {
|
||||||
|
display: grid;
|
||||||
|
gap: 5px;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread-filters select,
|
||||||
|
.thread-composer textarea {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread-tag-filter {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread-chip {
|
||||||
|
min-height: 30px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 4px 9px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-small);
|
||||||
|
background: var(--surface-soft);
|
||||||
|
color: var(--ink-soft);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.thread-chip {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread-chip.active,
|
||||||
|
.thread-list-item.unread .thread-list-item__title {
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread-chip.active {
|
||||||
|
border-color: var(--pokemon-blue);
|
||||||
|
background: color-mix(in srgb, var(--pokemon-yellow) 26%, var(--surface));
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread-list {
|
||||||
|
min-height: 0;
|
||||||
|
display: grid;
|
||||||
|
align-content: start;
|
||||||
|
gap: 10px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread-list-item {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread-list-item__title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--ink);
|
||||||
|
font-weight: 900;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread-list-item__meta,
|
||||||
|
.threads-empty,
|
||||||
|
.thread-chat-header p,
|
||||||
|
.thread-message-meta time {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread-list-item__tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread-chat-panel {
|
||||||
|
position: relative;
|
||||||
|
min-height: 620px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto auto minmax(0, 1fr) auto;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread-chat-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 16px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread-chat-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 22px;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread-chat-header p {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread-chat-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread-reactions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread-reactions--message {
|
||||||
|
padding: 0;
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread-reaction {
|
||||||
|
min-width: 44px;
|
||||||
|
min-height: 34px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 5px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-control);
|
||||||
|
background: var(--surface-soft);
|
||||||
|
color: var(--ink-soft);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread-reaction.active {
|
||||||
|
border-color: var(--pokemon-blue);
|
||||||
|
background: color-mix(in srgb, var(--pokemon-blue) 10%, var(--surface));
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread-message-scroll {
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 16px;
|
||||||
|
background: color-mix(in srgb, var(--surface-soft) 58%, var(--surface));
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread-message-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread-message-group {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 42px minmax(0, 1fr);
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread-avatar {
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border: 2px solid var(--line-strong);
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--pokemon-yellow);
|
||||||
|
color: var(--pokeball-black);
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread-message-group__body {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread-message-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread-message {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread-message p {
|
||||||
|
margin: 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread-composer {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread-jump-button {
|
||||||
|
position: absolute;
|
||||||
|
right: 18px;
|
||||||
|
bottom: 86px;
|
||||||
|
min-height: 38px;
|
||||||
|
padding: 7px 12px;
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
border-radius: var(--radius-control);
|
||||||
|
background: var(--pokemon-yellow);
|
||||||
|
color: var(--pokeball-black);
|
||||||
|
font-weight: 900;
|
||||||
|
box-shadow: var(--shadow-control);
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread-load-older {
|
||||||
|
width: fit-content;
|
||||||
|
margin: 0 auto 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.threads-empty {
|
||||||
|
margin: 0;
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.threads-empty--select {
|
||||||
|
align-self: center;
|
||||||
|
justify-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.threads-layout {
|
||||||
|
grid-template-columns: minmax(180px, 220px) minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread-chat-panel {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
|
.threads-layout,
|
||||||
.dish-category-summary,
|
.dish-category-summary,
|
||||||
.dish-card {
|
.dish-card {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.thread-filters,
|
||||||
|
.thread-composer,
|
||||||
|
.thread-chat-header {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread-chat-header {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread-chat-actions {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread-chat-panel {
|
||||||
|
min-height: 70dvh;
|
||||||
|
}
|
||||||
|
|
||||||
.dish-form-row,
|
.dish-form-row,
|
||||||
.dish-form-row--3,
|
.dish-form-row--3,
|
||||||
.dish-form-row--4 {
|
.dish-form-row--4 {
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
iconProfile,
|
iconProfile,
|
||||||
iconRecipe,
|
iconRecipe,
|
||||||
iconSave,
|
iconSave,
|
||||||
|
iconThreads,
|
||||||
iconTranslate,
|
iconTranslate,
|
||||||
iconUpload,
|
iconUpload,
|
||||||
type AppIcon
|
type AppIcon
|
||||||
@@ -67,6 +68,7 @@ import {
|
|||||||
type Skill,
|
type Skill,
|
||||||
type SystemWording,
|
type SystemWording,
|
||||||
type SystemWordingSurface,
|
type SystemWordingSurface,
|
||||||
|
type ThreadChannel,
|
||||||
type TranslationMap
|
type TranslationMap
|
||||||
} from '../services/api';
|
} from '../services/api';
|
||||||
|
|
||||||
@@ -86,7 +88,8 @@ type AdminTab =
|
|||||||
| 'ancientArtifacts'
|
| 'ancientArtifacts'
|
||||||
| 'recipes'
|
| 'recipes'
|
||||||
| 'dish'
|
| 'dish'
|
||||||
| 'habitats';
|
| 'habitats'
|
||||||
|
| 'threadChannels';
|
||||||
type AdminGroup = 'content' | 'configuration' | 'localization' | 'access';
|
type AdminGroup = 'content' | 'configuration' | 'localization' | 'access';
|
||||||
type AdminNavItem = { key: AdminTab; label: string; permission: string | string[] };
|
type AdminNavItem = { key: AdminTab; label: string; permission: string | string[] };
|
||||||
type AdminNavGroup = { key: AdminGroup; label: string; items: AdminNavItem[] };
|
type AdminNavGroup = { key: AdminGroup; label: string; items: AdminNavItem[] };
|
||||||
@@ -139,7 +142,8 @@ const adminTabIcons: Record<AdminTab, AppIcon> = {
|
|||||||
ancientArtifacts: iconArtifact,
|
ancientArtifacts: iconArtifact,
|
||||||
recipes: iconRecipe,
|
recipes: iconRecipe,
|
||||||
dish: iconDish,
|
dish: iconDish,
|
||||||
habitats: iconHabitat
|
habitats: iconHabitat,
|
||||||
|
threadChannels: iconThreads
|
||||||
};
|
};
|
||||||
|
|
||||||
const { locale, t } = useI18n();
|
const { locale, t } = useI18n();
|
||||||
@@ -166,6 +170,7 @@ const adminNavigationGroups = computed<AdminNavGroup[]>(() => {
|
|||||||
{ key: 'recipes', label: t('pages.admin.recipeList'), permission: ['recipes.order', 'recipes.delete'] },
|
{ key: 'recipes', label: t('pages.admin.recipeList'), permission: ['recipes.order', 'recipes.delete'] },
|
||||||
{ key: 'dish', label: t('pages.admin.dishList'), permission: ['dish.create', 'dish.update', 'dish.delete', 'dish.order'] },
|
{ key: 'dish', label: t('pages.admin.dishList'), permission: ['dish.create', 'dish.update', 'dish.delete', 'dish.order'] },
|
||||||
{ key: 'habitats', label: t('pages.admin.habitatList'), permission: ['habitats.order', 'habitats.delete'] },
|
{ key: 'habitats', label: t('pages.admin.habitatList'), permission: ['habitats.order', 'habitats.delete'] },
|
||||||
|
{ key: 'threadChannels', label: t('pages.admin.threadChannels'), permission: 'admin.threads.channels.read' },
|
||||||
{ key: 'dataTools', label: t('pages.admin.dataTools'), permission: ['admin.data.export', 'admin.data.import'] }
|
{ key: 'dataTools', label: t('pages.admin.dataTools'), permission: ['admin.data.export', 'admin.data.import'] }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -235,6 +240,7 @@ const dishItemRows = ref<Item[]>([]);
|
|||||||
const dishSkillRows = ref<Skill[]>([]);
|
const dishSkillRows = ref<Skill[]>([]);
|
||||||
const dishFlavorRows = ref<NamedEntity[]>([]);
|
const dishFlavorRows = ref<NamedEntity[]>([]);
|
||||||
const habitatRows = ref<Habitat[]>([]);
|
const habitatRows = ref<Habitat[]>([]);
|
||||||
|
const threadChannelRows = ref<ThreadChannel[]>([]);
|
||||||
const wordingRows = ref<SystemWording[]>([]);
|
const wordingRows = ref<SystemWording[]>([]);
|
||||||
const aiModerationSettings = ref<AiModerationSettings | null>(null);
|
const aiModerationSettings = ref<AiModerationSettings | null>(null);
|
||||||
const rateLimitSettings = ref<RateLimitSettings | null>(null);
|
const rateLimitSettings = ref<RateLimitSettings | null>(null);
|
||||||
@@ -301,6 +307,7 @@ const userRoleForm = ref({ userId: 0, roleIds: [] as number[] });
|
|||||||
const roleForm = ref({ id: 0, key: '', name: '', description: '', level: 100, enabled: true });
|
const roleForm = ref({ id: 0, key: '', name: '', description: '', level: 100, enabled: true });
|
||||||
const rolePermissionForm = ref({ roleId: 0, permissionIds: [] as number[] });
|
const rolePermissionForm = ref({ roleId: 0, permissionIds: [] as number[] });
|
||||||
const permissionForm = ref({ id: 0, key: '', name: '', description: '', category: 'General', enabled: true });
|
const permissionForm = ref({ id: 0, key: '', name: '', description: '', category: 'General', enabled: true });
|
||||||
|
const threadChannelForm = ref({ id: 0, name: '', allowUserThreads: true, tagsText: '', languages: [] as string[] });
|
||||||
const editingLanguageCode = ref('');
|
const editingLanguageCode = ref('');
|
||||||
const configModalOpen = ref(false);
|
const configModalOpen = ref(false);
|
||||||
const checklistModalOpen = ref(false);
|
const checklistModalOpen = ref(false);
|
||||||
@@ -312,6 +319,7 @@ const userRoleModalOpen = ref(false);
|
|||||||
const roleModalOpen = ref(false);
|
const roleModalOpen = ref(false);
|
||||||
const rolePermissionsModalOpen = ref(false);
|
const rolePermissionsModalOpen = ref(false);
|
||||||
const permissionModalOpen = ref(false);
|
const permissionModalOpen = ref(false);
|
||||||
|
const threadChannelModalOpen = ref(false);
|
||||||
const dataToolImportModalOpen = ref(false);
|
const dataToolImportModalOpen = ref(false);
|
||||||
const dataToolWipeModalOpen = ref(false);
|
const dataToolWipeModalOpen = ref(false);
|
||||||
const wordingLocale = ref(getCurrentLocale());
|
const wordingLocale = ref(getCurrentLocale());
|
||||||
@@ -404,6 +412,9 @@ const roleModalTitle = computed(() => (roleForm.value.id ? t('pages.admin.editRo
|
|||||||
const permissionModalTitle = computed(() =>
|
const permissionModalTitle = computed(() =>
|
||||||
permissionForm.value.id ? t('pages.admin.editPermission') : t('pages.admin.newPermission')
|
permissionForm.value.id ? t('pages.admin.editPermission') : t('pages.admin.newPermission')
|
||||||
);
|
);
|
||||||
|
const threadChannelModalTitle = computed(() =>
|
||||||
|
threadChannelForm.value.id ? t('pages.admin.editThreadChannel') : t('pages.admin.newThreadChannel')
|
||||||
|
);
|
||||||
const rolePermissionsModalTitle = computed(() => t('pages.admin.rolePermissions'));
|
const rolePermissionsModalTitle = computed(() => t('pages.admin.rolePermissions'));
|
||||||
const userRoleModalTitle = computed(() => t('pages.admin.userRoles'));
|
const userRoleModalTitle = computed(() => t('pages.admin.userRoles'));
|
||||||
const editingUser = computed(() => userRows.value.find((user) => user.id === userRoleForm.value.userId) ?? null);
|
const editingUser = computed(() => userRows.value.find((user) => user.id === userRoleForm.value.userId) ?? null);
|
||||||
@@ -693,6 +704,10 @@ function resetPermissionForm() {
|
|||||||
permissionForm.value = { id: 0, key: '', name: '', description: '', category: 'General', enabled: true };
|
permissionForm.value = { id: 0, key: '', name: '', description: '', category: 'General', enabled: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resetThreadChannelForm() {
|
||||||
|
threadChannelForm.value = { id: 0, name: '', allowUserThreads: true, tagsText: '', languages: languageRows.value.map((language) => language.code) };
|
||||||
|
}
|
||||||
|
|
||||||
function selectWordingModule(module: string) {
|
function selectWordingModule(module: string) {
|
||||||
wordingModule.value = module;
|
wordingModule.value = module;
|
||||||
}
|
}
|
||||||
@@ -862,6 +877,27 @@ function closePermissionModal() {
|
|||||||
resetPermissionForm();
|
resetPermissionForm();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openNewThreadChannel() {
|
||||||
|
resetThreadChannelForm();
|
||||||
|
threadChannelModalOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeThreadChannelModal() {
|
||||||
|
threadChannelModalOpen.value = false;
|
||||||
|
resetThreadChannelForm();
|
||||||
|
}
|
||||||
|
|
||||||
|
function editThreadChannel(channel: ThreadChannel) {
|
||||||
|
threadChannelForm.value = {
|
||||||
|
id: channel.id,
|
||||||
|
name: channel.name,
|
||||||
|
allowUserThreads: channel.allowUserThreads,
|
||||||
|
tagsText: channel.tags.map((tag) => tag.name).join(', '),
|
||||||
|
languages: channel.languages.map((language) => language.code)
|
||||||
|
};
|
||||||
|
threadChannelModalOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
function editLanguage(item: Language) {
|
function editLanguage(item: Language) {
|
||||||
editingLanguageCode.value = item.code;
|
editingLanguageCode.value = item.code;
|
||||||
languageForm.value = {
|
languageForm.value = {
|
||||||
@@ -1103,6 +1139,33 @@ async function loadChecklist() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadThreadChannels() {
|
||||||
|
await loadLanguages();
|
||||||
|
threadChannelRows.value = await api.adminThreadChannels();
|
||||||
|
}
|
||||||
|
|
||||||
|
function threadChannelTagNames() {
|
||||||
|
return threadChannelForm.value.tagsText
|
||||||
|
.split(',')
|
||||||
|
.map((tag) => tag.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveThreadChannel() {
|
||||||
|
await run(async () => {
|
||||||
|
const payload = {
|
||||||
|
name: threadChannelForm.value.name,
|
||||||
|
allowUserThreads: threadChannelForm.value.allowUserThreads,
|
||||||
|
tags: threadChannelTagNames(),
|
||||||
|
languages: threadChannelForm.value.languages
|
||||||
|
};
|
||||||
|
threadChannelRows.value = threadChannelForm.value.id
|
||||||
|
? await api.updateAdminThreadChannel(threadChannelForm.value.id, payload)
|
||||||
|
: await api.createAdminThreadChannel(payload);
|
||||||
|
closeThreadChannelModal();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function saveChecklistItem() {
|
async function saveChecklistItem() {
|
||||||
await run(async () => {
|
await run(async () => {
|
||||||
const payload = {
|
const payload = {
|
||||||
@@ -1414,6 +1477,7 @@ async function loadCurrentTab(showSkeleton = false) {
|
|||||||
if (activeTab.value === 'recipes') await loadRecipes();
|
if (activeTab.value === 'recipes') await loadRecipes();
|
||||||
if (activeTab.value === 'dish') await loadDishAdmin();
|
if (activeTab.value === 'dish') await loadDishAdmin();
|
||||||
if (activeTab.value === 'habitats') await loadHabitats();
|
if (activeTab.value === 'habitats') await loadHabitats();
|
||||||
|
if (activeTab.value === 'threadChannels') await loadThreadChannels();
|
||||||
} finally {
|
} finally {
|
||||||
if (showSkeleton) {
|
if (showSkeleton) {
|
||||||
contentLoading.value = false;
|
contentLoading.value = false;
|
||||||
@@ -1485,6 +1549,16 @@ async function removeChecklistItem(id: number) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function removeThreadChannel(id: number) {
|
||||||
|
await run(async () => {
|
||||||
|
await api.deleteAdminThreadChannel(id);
|
||||||
|
if (threadChannelForm.value.id === id) {
|
||||||
|
closeThreadChannelModal();
|
||||||
|
}
|
||||||
|
await loadThreadChannels();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function removePokemon(id: number) {
|
async function removePokemon(id: number) {
|
||||||
await run(async () => {
|
await run(async () => {
|
||||||
await api.deletePokemon(id);
|
await api.deletePokemon(id);
|
||||||
@@ -2037,6 +2111,39 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section v-else-if="canEdit && activeTab === 'threadChannels'" class="detail-section">
|
||||||
|
<div class="detail-section__header">
|
||||||
|
<h2>{{ t('pages.admin.threadChannels') }}</h2>
|
||||||
|
<button v-if="can('admin.threads.channels.create')" type="button" class="ui-button ui-button--primary ui-button--small" :disabled="busy" @click="openNewThreadChannel">
|
||||||
|
<Icon :icon="iconAdd" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ t('common.new') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<ul v-if="threadChannelRows.length" class="row-list access-list">
|
||||||
|
<li v-for="channel in threadChannelRows" :key="channel.id">
|
||||||
|
<span class="access-row">
|
||||||
|
<strong>{{ channel.name }}</strong>
|
||||||
|
<span class="system-wording-row__meta">
|
||||||
|
<span class="config-flag">{{ channel.allowUserThreads ? t('pages.admin.userThreadsAllowed') : t('pages.admin.userThreadsDisabled') }}</span>
|
||||||
|
<span v-for="tag in channel.tags" :key="tag.id" class="config-flag">{{ tag.name }}</span>
|
||||||
|
<span v-for="language in channel.languages" :key="language.code" class="config-flag">{{ language.name }}</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span class="row-actions">
|
||||||
|
<button v-if="can('admin.threads.channels.update')" type="button" :disabled="busy" @click="editThreadChannel(channel)">
|
||||||
|
<Icon :icon="iconEdit" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ t('common.edit') }}
|
||||||
|
</button>
|
||||||
|
<button v-if="can('admin.threads.channels.delete')" type="button" :disabled="busy" @click="removeThreadChannel(channel.id)">
|
||||||
|
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ t('common.delete') }}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p v-else class="meta-line">{{ t('common.noRecords') }}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section v-else-if="canEdit && activeTab === 'config'" class="detail-section">
|
<section v-else-if="canEdit && activeTab === 'config'" class="detail-section">
|
||||||
<div class="detail-section__header">
|
<div class="detail-section__header">
|
||||||
<h2>{{ t('pages.admin.config') }}</h2>
|
<h2>{{ t('pages.admin.config') }}</h2>
|
||||||
@@ -2669,6 +2776,45 @@ onMounted(() => {
|
|||||||
</template>
|
</template>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<Modal v-if="threadChannelModalOpen" :title="threadChannelModalTitle" :close-label="t('common.close')" @close="closeThreadChannelModal">
|
||||||
|
<form id="admin-thread-channel-form" class="modal-edit-form" @submit.prevent="saveThreadChannel">
|
||||||
|
<div class="field">
|
||||||
|
<label for="thread-channel-name">{{ t('common.name') }}</label>
|
||||||
|
<input id="thread-channel-name" v-model="threadChannelForm.name" required maxlength="80" />
|
||||||
|
</div>
|
||||||
|
<div class="check-row">
|
||||||
|
<label>
|
||||||
|
<input v-model="threadChannelForm.allowUserThreads" type="checkbox" />
|
||||||
|
{{ t('pages.admin.allowUserThreads') }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="thread-channel-tags">{{ t('pages.threads.tags') }}</label>
|
||||||
|
<input id="thread-channel-tags" v-model="threadChannelForm.tagsText" :placeholder="t('pages.admin.threadTagsPlaceholder')" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<span class="field-label">{{ t('pages.threads.language') }}</span>
|
||||||
|
<div class="permission-groups">
|
||||||
|
<label v-for="language in languageRows" :key="language.code" class="data-tool-scope">
|
||||||
|
<input v-model="threadChannelForm.languages" type="checkbox" :value="language.code" />
|
||||||
|
<span>{{ language.name }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<button type="submit" form="admin-thread-channel-form" class="link-button" :disabled="busy || !threadChannelForm.name.trim()">
|
||||||
|
<Icon :icon="iconSave" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ busy ? t('common.saving') : t('common.save') }}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="plain-button" :disabled="busy" @click="closeThreadChannelModal">
|
||||||
|
<Icon :icon="iconCancel" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ t('common.cancel') }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
<Modal v-if="checklistModalOpen" :title="checklistModalTitle" :close-label="t('common.close')" size="wide" @close="closeChecklistModal">
|
<Modal v-if="checklistModalOpen" :title="checklistModalTitle" :close-label="t('common.close')" size="wide" @close="closeChecklistModal">
|
||||||
<form id="admin-checklist-form" class="modal-edit-form" @submit.prevent="saveChecklistItem">
|
<form id="admin-checklist-form" class="modal-edit-form" @submit.prevent="saveChecklistItem">
|
||||||
<TranslationFields
|
<TranslationFields
|
||||||
|
|||||||
774
frontend/src/views/ThreadsView.vue
Normal file
774
frontend/src/views/ThreadsView.vue
Normal file
@@ -0,0 +1,774 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Icon } from '@iconify/vue';
|
||||||
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
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 {
|
||||||
|
iconAdd,
|
||||||
|
iconBell,
|
||||||
|
iconChevronUp,
|
||||||
|
iconComment,
|
||||||
|
iconDelete,
|
||||||
|
iconSend,
|
||||||
|
iconThreads,
|
||||||
|
type AppIcon
|
||||||
|
} from '../icons';
|
||||||
|
import {
|
||||||
|
api,
|
||||||
|
threadWebSocketUrl,
|
||||||
|
type AuthUser,
|
||||||
|
type ThreadChannel,
|
||||||
|
type ThreadChannelTag,
|
||||||
|
type ThreadMessage,
|
||||||
|
type ThreadReactionType,
|
||||||
|
type ThreadSort,
|
||||||
|
type ThreadSummary,
|
||||||
|
type ThreadWsMessage
|
||||||
|
} from '../services/api';
|
||||||
|
|
||||||
|
type MessageGroup = {
|
||||||
|
key: string;
|
||||||
|
author: ThreadMessage['author'];
|
||||||
|
createdAt: string;
|
||||||
|
messages: ThreadMessage[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const channels = ref<ThreadChannel[]>([]);
|
||||||
|
const threads = ref<ThreadSummary[]>([]);
|
||||||
|
const messages = ref<ThreadMessage[]>([]);
|
||||||
|
const currentUser = ref<AuthUser | null>(null);
|
||||||
|
const activeThread = ref<ThreadSummary | null>(null);
|
||||||
|
const selectedChannelId = ref<number | null>(null);
|
||||||
|
const selectedTagId = ref<number | null>(null);
|
||||||
|
const selectedLanguage = ref('all');
|
||||||
|
const sort = ref<ThreadSort>('last-active');
|
||||||
|
const nextCursor = ref<string | null>(null);
|
||||||
|
const hasMoreThreads = ref(false);
|
||||||
|
const beforeCursor = ref<string | null>(null);
|
||||||
|
const hasMoreBefore = ref(false);
|
||||||
|
const loading = ref(true);
|
||||||
|
const loadingThreads = ref(false);
|
||||||
|
const loadingMessages = ref(false);
|
||||||
|
const loadingOlder = ref(false);
|
||||||
|
const busy = ref(false);
|
||||||
|
const errorMessage = ref('');
|
||||||
|
const createModalOpen = ref(false);
|
||||||
|
const composerBody = ref('');
|
||||||
|
const threadForm = ref({ title: '', body: '', languageCode: '', tagIds: [] as number[] });
|
||||||
|
const socket = ref<WebSocket | null>(null);
|
||||||
|
const chatScroller = ref<HTMLElement | null>(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 selectedChannel = computed(() => channels.value.find((channel) => channel.id === selectedChannelId.value) ?? null);
|
||||||
|
const activeThreadId = computed(() => {
|
||||||
|
const value = Array.isArray(route.params.id) ? route.params.id[0] : route.params.id;
|
||||||
|
const id = Number(value);
|
||||||
|
return Number.isInteger(id) && id > 0 ? id : null;
|
||||||
|
});
|
||||||
|
const canCreateThread = computed(() => currentUser.value?.permissions.includes('threads.create') === true);
|
||||||
|
const canCreateMessage = computed(() => currentUser.value?.permissions.includes('threads.messages.create') === true);
|
||||||
|
const canFollow = computed(() => currentUser.value?.permissions.includes('threads.follow') === true);
|
||||||
|
const canReact = computed(() => currentUser.value?.permissions.includes('threads.reactions.set') === 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 canDeleteMessages = computed(() => currentUser.value?.permissions.includes('admin.threads.messages.delete') === true);
|
||||||
|
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 messageGroups = computed<MessageGroup[]>(() => {
|
||||||
|
const groups: MessageGroup[] = [];
|
||||||
|
for (const message of messages.value) {
|
||||||
|
const previous = groups.at(-1);
|
||||||
|
const previousMessage = previous?.messages.at(-1);
|
||||||
|
const sameAuthor = previousMessage?.author?.id === message.author?.id;
|
||||||
|
const withinMergeWindow =
|
||||||
|
previousMessage && new Date(message.createdAt).getTime() - new Date(previousMessage.createdAt).getTime() <= 5 * 60 * 1000;
|
||||||
|
if (previous && sameAuthor && withinMergeWindow) {
|
||||||
|
previous.messages.push(message);
|
||||||
|
} else {
|
||||||
|
groups.push({ key: String(message.id), author: message.author, createdAt: message.createdAt, messages: [message] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
});
|
||||||
|
|
||||||
|
function canUseThreads() {
|
||||||
|
return currentUser.value?.emailVerified === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(value: string) {
|
||||||
|
return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function authorName(author: ThreadMessage['author']) {
|
||||||
|
return author?.displayName ?? t('pages.life.byUnknown');
|
||||||
|
}
|
||||||
|
|
||||||
|
function authorInitial(author: ThreadMessage['author']) {
|
||||||
|
return authorName(author).trim().charAt(0).toUpperCase() || '?';
|
||||||
|
}
|
||||||
|
|
||||||
|
function reactionCount(threadOrMessage: ThreadSummary | ThreadMessage, type: ThreadReactionType) {
|
||||||
|
return threadOrMessage.reactionCounts[type] ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function reactionActive(threadOrMessage: ThreadSummary | ThreadMessage, type: ThreadReactionType) {
|
||||||
|
return threadOrMessage.myReactions.includes(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateThreadInList(thread: ThreadSummary) {
|
||||||
|
const index = threads.value.findIndex((item) => item.id === thread.id);
|
||||||
|
if (index >= 0) {
|
||||||
|
threads.value[index] = thread;
|
||||||
|
} else {
|
||||||
|
threads.value = [thread, ...threads.value];
|
||||||
|
}
|
||||||
|
if (activeThread.value?.id === thread.id) {
|
||||||
|
activeThread.value = thread;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateMessageInList(message: ThreadMessage) {
|
||||||
|
const index = messages.value.findIndex((item) => item.id === message.id);
|
||||||
|
if (index >= 0) {
|
||||||
|
messages.value[index] = message;
|
||||||
|
} else {
|
||||||
|
messages.value = [...messages.value, message];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNearBottom() {
|
||||||
|
const el = chatScroller.value;
|
||||||
|
return !el || el.scrollHeight - el.scrollTop - el.clientHeight < 80;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scrollToBottom() {
|
||||||
|
await nextTick();
|
||||||
|
const el = chatScroller.value;
|
||||||
|
if (el) {
|
||||||
|
el.scrollTop = el.scrollHeight;
|
||||||
|
}
|
||||||
|
showJump.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCurrentUser() {
|
||||||
|
try {
|
||||||
|
currentUser.value = (await api.me()).user;
|
||||||
|
} catch {
|
||||||
|
currentUser.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadChannels() {
|
||||||
|
channels.value = await api.threadChannels();
|
||||||
|
if (!selectedChannelId.value && channels.value[0]) {
|
||||||
|
selectedChannelId.value = channels.value[0].id;
|
||||||
|
}
|
||||||
|
if (!threadForm.value.languageCode) {
|
||||||
|
threadForm.value.languageCode = channels.value[0]?.languages[0]?.code ?? 'en';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadThreads(reset = true) {
|
||||||
|
loadingThreads.value = true;
|
||||||
|
try {
|
||||||
|
const page = await api.threads({
|
||||||
|
cursor: reset ? null : nextCursor.value,
|
||||||
|
limit: 20,
|
||||||
|
channelId: selectedChannelId.value,
|
||||||
|
language: selectedLanguage.value,
|
||||||
|
tagId: selectedTagId.value,
|
||||||
|
sort: sort.value
|
||||||
|
});
|
||||||
|
threads.value = reset ? page.items : [...threads.value, ...page.items];
|
||||||
|
nextCursor.value = page.nextCursor;
|
||||||
|
hasMoreThreads.value = page.hasMore;
|
||||||
|
} finally {
|
||||||
|
loadingThreads.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadActiveThread() {
|
||||||
|
const id = activeThreadId.value;
|
||||||
|
if (!id) {
|
||||||
|
activeThread.value = null;
|
||||||
|
messages.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
activeThread.value = await api.thread(id);
|
||||||
|
updateThreadInList(activeThread.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMessages(reset = true) {
|
||||||
|
const id = activeThreadId.value;
|
||||||
|
if (!id) return;
|
||||||
|
if (reset) {
|
||||||
|
loadingMessages.value = true;
|
||||||
|
} else {
|
||||||
|
loadingOlder.value = true;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const page = await api.threadMessages(id, { before: reset ? null : beforeCursor.value, limit: 40 });
|
||||||
|
messages.value = reset ? page.items : [...page.items, ...messages.value];
|
||||||
|
beforeCursor.value = page.beforeCursor;
|
||||||
|
hasMoreBefore.value = page.hasMoreBefore;
|
||||||
|
if (reset) {
|
||||||
|
await scrollToBottom();
|
||||||
|
if (canFollow.value) {
|
||||||
|
try {
|
||||||
|
activeThread.value = await api.markThreadRead(id);
|
||||||
|
updateThreadInList(activeThread.value);
|
||||||
|
} catch {
|
||||||
|
// Read state is best-effort and does not block browsing.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loadingMessages.value = false;
|
||||||
|
loadingOlder.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAll() {
|
||||||
|
loading.value = true;
|
||||||
|
errorMessage.value = '';
|
||||||
|
try {
|
||||||
|
await loadCurrentUser();
|
||||||
|
await loadChannels();
|
||||||
|
await loadThreads(true);
|
||||||
|
if (activeThreadId.value) {
|
||||||
|
await loadActiveThread();
|
||||||
|
await loadMessages(true);
|
||||||
|
}
|
||||||
|
connectSocket();
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage.value = error instanceof Error && error.message ? error.message : t('errors.loadFailed');
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectThread(thread: ThreadSummary) {
|
||||||
|
await router.push(`/threads/${thread.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectChannel(channelId: number | null) {
|
||||||
|
selectedChannelId.value = channelId;
|
||||||
|
selectedTagId.value = null;
|
||||||
|
void loadThreads(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreateThread() {
|
||||||
|
const channel = selectedChannel.value ?? channels.value[0];
|
||||||
|
threadForm.value = {
|
||||||
|
title: '',
|
||||||
|
body: '',
|
||||||
|
languageCode: channel?.languages[0]?.code ?? 'en',
|
||||||
|
tagIds: []
|
||||||
|
};
|
||||||
|
createModalOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeCreateThread() {
|
||||||
|
createModalOpen.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleThreadTag(tag: ThreadChannelTag) {
|
||||||
|
const tags = new Set(threadForm.value.tagIds);
|
||||||
|
if (tags.has(tag.id)) {
|
||||||
|
tags.delete(tag.id);
|
||||||
|
} else {
|
||||||
|
tags.add(tag.id);
|
||||||
|
}
|
||||||
|
threadForm.value.tagIds = [...tags];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitThread() {
|
||||||
|
const channel = selectedChannel.value ?? channels.value[0];
|
||||||
|
if (!channel) return;
|
||||||
|
busy.value = true;
|
||||||
|
errorMessage.value = '';
|
||||||
|
try {
|
||||||
|
const thread = await api.createThread({
|
||||||
|
channelId: channel.id,
|
||||||
|
title: threadForm.value.title,
|
||||||
|
body: threadForm.value.body,
|
||||||
|
languageCode: threadForm.value.languageCode,
|
||||||
|
tagIds: threadForm.value.tagIds
|
||||||
|
});
|
||||||
|
closeCreateThread();
|
||||||
|
updateThreadInList(thread);
|
||||||
|
await router.push(`/threads/${thread.id}`);
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage.value = error instanceof Error && error.message ? error.message : t('pages.threads.createFailed');
|
||||||
|
} finally {
|
||||||
|
busy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitMessage() {
|
||||||
|
const id = activeThreadId.value;
|
||||||
|
if (!id || !composerBody.value.trim()) return;
|
||||||
|
busy.value = true;
|
||||||
|
errorMessage.value = '';
|
||||||
|
try {
|
||||||
|
const message = await api.createThreadMessage(id, { body: composerBody.value });
|
||||||
|
composerBody.value = '';
|
||||||
|
updateMessageInList(message);
|
||||||
|
await scrollToBottom();
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage.value = error instanceof Error && error.message ? error.message : t('pages.threads.messageFailed');
|
||||||
|
} finally {
|
||||||
|
busy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleFollow() {
|
||||||
|
const thread = activeThread.value;
|
||||||
|
if (!thread) return;
|
||||||
|
busy.value = true;
|
||||||
|
try {
|
||||||
|
const updated = thread.followed ? await api.unfollowThread(thread.id) : await api.followThread(thread.id);
|
||||||
|
updateThreadInList(updated);
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage.value = error instanceof Error && error.message ? error.message : t('pages.threads.followFailed');
|
||||||
|
} finally {
|
||||||
|
busy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleThreadReaction(thread: ThreadSummary, type: ThreadReactionType) {
|
||||||
|
if (!canReact.value) return;
|
||||||
|
try {
|
||||||
|
const updated = reactionActive(thread, type) ? await api.deleteThreadReaction(thread.id, type) : await api.setThreadReaction(thread.id, type);
|
||||||
|
updateThreadInList(updated);
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage.value = error instanceof Error && error.message ? error.message : t('pages.threads.reactionFailed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleThreadLock() {
|
||||||
|
const thread = activeThread.value;
|
||||||
|
if (!thread) return;
|
||||||
|
busy.value = true;
|
||||||
|
try {
|
||||||
|
const updated = await api.lockThread(thread.id, !thread.locked);
|
||||||
|
updateThreadInList(updated);
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage.value = error instanceof Error && error.message ? error.message : t('errors.operationFailed');
|
||||||
|
} finally {
|
||||||
|
busy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeActiveThread() {
|
||||||
|
const thread = activeThread.value;
|
||||||
|
if (!thread) return;
|
||||||
|
busy.value = true;
|
||||||
|
try {
|
||||||
|
await api.deleteThread(thread.id);
|
||||||
|
threads.value = threads.value.filter((item) => item.id !== thread.id);
|
||||||
|
activeThread.value = null;
|
||||||
|
messages.value = [];
|
||||||
|
await router.push('/threads');
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage.value = error instanceof Error && error.message ? error.message : t('errors.operationFailed');
|
||||||
|
} finally {
|
||||||
|
busy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeMessage(message: ThreadMessage) {
|
||||||
|
busy.value = true;
|
||||||
|
try {
|
||||||
|
await api.deleteThreadMessage(message.id);
|
||||||
|
messages.value = messages.value.filter((item) => item.id !== message.id);
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage.value = error instanceof Error && error.message ? error.message : t('errors.operationFailed');
|
||||||
|
} finally {
|
||||||
|
busy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleMessageReaction(message: ThreadMessage, type: ThreadReactionType) {
|
||||||
|
if (!canReact.value) return;
|
||||||
|
try {
|
||||||
|
const updated = reactionActive(message, type)
|
||||||
|
? await api.deleteThreadMessageReaction(message.id, type)
|
||||||
|
: await api.setThreadMessageReaction(message.id, type);
|
||||||
|
updateMessageInList(updated);
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage.value = error instanceof Error && error.message ? error.message : t('pages.threads.reactionFailed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleThreadWsMessage(message: ThreadWsMessage) {
|
||||||
|
if (message.type === 'thread.message.created') {
|
||||||
|
updateThreadInList(message.thread);
|
||||||
|
if (activeThreadId.value === message.threadId) {
|
||||||
|
const stick = isNearBottom();
|
||||||
|
updateMessageInList(message.message);
|
||||||
|
if (stick) {
|
||||||
|
void scrollToBottom();
|
||||||
|
} else {
|
||||||
|
showJump.value = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (message.type === 'thread.reactions.updated') {
|
||||||
|
if (message.target === 'thread') {
|
||||||
|
const thread = threads.value.find((item) => item.id === message.threadId);
|
||||||
|
if (thread) {
|
||||||
|
updateThreadInList({ ...thread, reactionCounts: message.reactionCounts, myReactions: message.myReactions });
|
||||||
|
}
|
||||||
|
} else if (message.messageId) {
|
||||||
|
const existing = messages.value.find((item) => item.id === message.messageId);
|
||||||
|
if (existing) {
|
||||||
|
updateMessageInList({ ...existing, reactionCounts: message.reactionCounts, myReactions: message.myReactions });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (message.type === 'thread.read.updated') {
|
||||||
|
const thread = threads.value.find((item) => item.id === message.threadId);
|
||||||
|
if (thread) {
|
||||||
|
updateThreadInList({ ...thread, unread: message.unread });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connectSocket() {
|
||||||
|
if (!canUseThreads() || socket.value) return;
|
||||||
|
try {
|
||||||
|
const { ticket } = await api.threadWsTicket();
|
||||||
|
const nextSocket = new WebSocket(threadWebSocketUrl(ticket));
|
||||||
|
socket.value = nextSocket;
|
||||||
|
nextSocket.addEventListener('message', (event) => {
|
||||||
|
try {
|
||||||
|
handleThreadWsMessage(JSON.parse(String(event.data)) as ThreadWsMessage);
|
||||||
|
} catch {
|
||||||
|
// Invalid socket frames are ignored.
|
||||||
|
}
|
||||||
|
});
|
||||||
|
nextSocket.addEventListener('close', () => {
|
||||||
|
if (socket.value === nextSocket) {
|
||||||
|
socket.value = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
socket.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onScroll() {
|
||||||
|
showJump.value = !isNearBottom();
|
||||||
|
}
|
||||||
|
|
||||||
|
watch([selectedLanguage, selectedTagId, sort], () => {
|
||||||
|
void loadThreads(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(activeThreadId, async () => {
|
||||||
|
errorMessage.value = '';
|
||||||
|
await loadActiveThread();
|
||||||
|
await loadMessages(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void loadAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
socket.value?.close();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="page-stack threads-page">
|
||||||
|
<PageHeader :title="t('pages.threads.title')" :subtitle="t('pages.threads.subtitle')">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<StatusMessage v-if="errorMessage" variant="warning">{{ errorMessage }}</StatusMessage>
|
||||||
|
|
||||||
|
<div class="threads-layout">
|
||||||
|
<aside class="threads-sidebar" :aria-label="t('pages.threads.channels')">
|
||||||
|
<h2>{{ t('pages.threads.channels') }}</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="thread-channel"
|
||||||
|
:class="{ active: selectedChannelId === null }"
|
||||||
|
@click="selectChannel(null)"
|
||||||
|
>
|
||||||
|
<Icon :icon="iconThreads" class="ui-icon" aria-hidden="true" />
|
||||||
|
<span>{{ t('pages.threads.allChannels') }}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-for="channel in channels"
|
||||||
|
:key="channel.id"
|
||||||
|
type="button"
|
||||||
|
class="thread-channel"
|
||||||
|
:class="{ active: selectedChannelId === channel.id }"
|
||||||
|
@click="selectChannel(channel.id)"
|
||||||
|
>
|
||||||
|
<Icon :icon="iconComment" class="ui-icon" aria-hidden="true" />
|
||||||
|
<span>{{ channel.name }}</span>
|
||||||
|
<span v-if="channel.unreadCount > 0" class="thread-unread-dot" :aria-label="t('pages.threads.unread')"></span>
|
||||||
|
</button>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section class="threads-list-panel">
|
||||||
|
<div class="thread-filters">
|
||||||
|
<label>
|
||||||
|
<span>{{ t('pages.threads.language') }}</span>
|
||||||
|
<select v-model="selectedLanguage">
|
||||||
|
<option value="all">{{ t('pages.threads.allLanguages') }}</option>
|
||||||
|
<option v-for="language in languageOptions" :key="language.code" :value="language.code">{{ language.name }}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>{{ t('pages.threads.sort') }}</span>
|
||||||
|
<select v-model="sort">
|
||||||
|
<option value="last-active">{{ t('pages.threads.sortLastActive') }}</option>
|
||||||
|
<option value="latest">{{ t('pages.threads.sortLatest') }}</option>
|
||||||
|
<option value="most-discussed">{{ t('pages.threads.sortMostDiscussed') }}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div v-if="tagOptions.length" class="thread-tag-filter" :aria-label="t('pages.threads.tags')">
|
||||||
|
<button type="button" class="thread-chip" :class="{ active: selectedTagId === null }" @click="selectedTagId = null">
|
||||||
|
{{ t('common.all') }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-for="tag in tagOptions"
|
||||||
|
:key="tag.id"
|
||||||
|
type="button"
|
||||||
|
class="thread-chip"
|
||||||
|
:class="{ active: selectedTagId === tag.id }"
|
||||||
|
@click="selectedTagId = tag.id"
|
||||||
|
>
|
||||||
|
{{ tag.name }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading || loadingThreads" class="thread-list" aria-busy="true">
|
||||||
|
<article v-for="index in 4" :key="index" class="thread-list-item">
|
||||||
|
<Skeleton width="70%" height="20px" />
|
||||||
|
<Skeleton width="45%" />
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="currentThreadList.length" class="thread-list">
|
||||||
|
<button
|
||||||
|
v-for="thread in currentThreadList"
|
||||||
|
:key="thread.id"
|
||||||
|
type="button"
|
||||||
|
class="thread-list-item"
|
||||||
|
:class="{ active: activeThread?.id === thread.id, unread: thread.unread }"
|
||||||
|
@click="selectThread(thread)"
|
||||||
|
>
|
||||||
|
<span class="thread-list-item__title">
|
||||||
|
<span v-if="thread.unread" class="thread-unread-dot" :aria-label="t('pages.threads.unread')"></span>
|
||||||
|
{{ thread.title }}
|
||||||
|
</span>
|
||||||
|
<span class="thread-list-item__meta">
|
||||||
|
{{ thread.author?.displayName ?? t('pages.life.byUnknown') }} · {{ formatDateTime(thread.lastActiveAt) }}
|
||||||
|
</span>
|
||||||
|
<span class="thread-list-item__tags">
|
||||||
|
<span v-for="tag in thread.tags" :key="tag.id" class="thread-chip">{{ tag.name }}</span>
|
||||||
|
<span class="thread-chip">{{ thread.messageCount }}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button v-if="hasMoreThreads" type="button" class="ui-button ui-button--ghost" :disabled="loadingThreads" @click="loadThreads(false)">
|
||||||
|
{{ t('pages.threads.loadMoreThreads') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p v-else class="threads-empty">{{ t('pages.threads.noThreads') }}</p>
|
||||||
|
</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 class="thread-chat-actions">
|
||||||
|
<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" />
|
||||||
|
{{ activeThread.followed ? t('pages.threads.unfollow') : t('pages.threads.follow') }}
|
||||||
|
</button>
|
||||||
|
<button v-if="canLockThreads" type="button" class="ui-button ui-button--small" :disabled="busy" @click="toggleThreadLock">
|
||||||
|
{{ activeThread.locked ? t('pages.threads.unlock') : t('pages.threads.lock') }}
|
||||||
|
</button>
|
||||||
|
<button v-if="canDeleteThreads" type="button" class="ui-button ui-button--red ui-button--small" :disabled="busy" @click="removeActiveThread">
|
||||||
|
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ t('common.delete') }}
|
||||||
|
</button>
|
||||||
|
<span v-if="activeThread.locked" class="config-flag">{{ t('pages.threads.locked') }}</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="thread-reactions">
|
||||||
|
<button
|
||||||
|
v-for="option in reactionOptions"
|
||||||
|
:key="option.type"
|
||||||
|
type="button"
|
||||||
|
class="thread-reaction"
|
||||||
|
:class="{ active: reactionActive(activeThread, option.type) }"
|
||||||
|
:disabled="!canReact"
|
||||||
|
@click="toggleThreadReaction(activeThread, option.type)"
|
||||||
|
>
|
||||||
|
<Icon :icon="option.icon" class="ui-icon" aria-hidden="true" />
|
||||||
|
<span>{{ reactionCount(activeThread, option.type) }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div ref="chatScroller" class="thread-message-scroll" @scroll="onScroll">
|
||||||
|
<button
|
||||||
|
v-if="hasMoreBefore"
|
||||||
|
type="button"
|
||||||
|
class="ui-button ui-button--ghost thread-load-older"
|
||||||
|
:disabled="loadingOlder"
|
||||||
|
@click="loadMessages(false)"
|
||||||
|
>
|
||||||
|
<Icon :icon="iconChevronUp" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ t('pages.threads.loadOlder') }}
|
||||||
|
</button>
|
||||||
|
<div v-if="loadingMessages" class="thread-message-list" aria-busy="true">
|
||||||
|
<article v-for="index in 4" :key="index" class="thread-message-group">
|
||||||
|
<Skeleton width="160px" />
|
||||||
|
<Skeleton width="90%" />
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="messages.length" class="thread-message-list">
|
||||||
|
<article v-for="group in messageGroups" :key="group.key" class="thread-message-group">
|
||||||
|
<div class="thread-avatar" aria-hidden="true">{{ authorInitial(group.author) }}</div>
|
||||||
|
<div class="thread-message-group__body">
|
||||||
|
<div class="thread-message-meta">
|
||||||
|
<strong>{{ authorName(group.author) }}</strong>
|
||||||
|
<time :datetime="group.createdAt">{{ formatDateTime(group.createdAt) }}</time>
|
||||||
|
</div>
|
||||||
|
<div v-for="message in group.messages" :key="message.id" class="thread-message">
|
||||||
|
<p>{{ message.body }}</p>
|
||||||
|
<span v-if="message.moderationStatus === 'reviewing'" class="config-flag">{{ t('pages.threads.messageReviewing') }}</span>
|
||||||
|
<span v-else-if="message.moderationStatus === 'rejected' || message.moderationStatus === 'failed'" class="config-flag">
|
||||||
|
{{ t('pages.threads.messageRejected') }}
|
||||||
|
</span>
|
||||||
|
<div class="thread-reactions thread-reactions--message">
|
||||||
|
<button
|
||||||
|
v-for="option in reactionOptions"
|
||||||
|
:key="option.type"
|
||||||
|
type="button"
|
||||||
|
class="thread-reaction"
|
||||||
|
:class="{ active: reactionActive(message, option.type) }"
|
||||||
|
:disabled="!canReact || message.moderationStatus !== 'approved'"
|
||||||
|
@click="toggleMessageReaction(message, option.type)"
|
||||||
|
>
|
||||||
|
<Icon :icon="option.icon" class="ui-icon" aria-hidden="true" />
|
||||||
|
<span>{{ reactionCount(message, option.type) }}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="canDeleteMessages"
|
||||||
|
type="button"
|
||||||
|
class="thread-reaction"
|
||||||
|
:disabled="busy"
|
||||||
|
:aria-label="t('common.delete')"
|
||||||
|
@click="removeMessage(message)"
|
||||||
|
>
|
||||||
|
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<p v-else class="threads-empty">{{ t('pages.threads.noMessages') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button v-if="showJump" type="button" class="thread-jump-button" @click="scrollToBottom">
|
||||||
|
{{ t('pages.threads.jumpToPresent') }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<form class="thread-composer" @submit.prevent="submitMessage">
|
||||||
|
<label class="sr-only" for="thread-message-body">{{ t('pages.threads.message') }}</label>
|
||||||
|
<textarea
|
||||||
|
id="thread-message-body"
|
||||||
|
v-model="composerBody"
|
||||||
|
rows="2"
|
||||||
|
:disabled="busy || !canCreateMessage || activeThread.locked"
|
||||||
|
:placeholder="t('pages.threads.message')"
|
||||||
|
></textarea>
|
||||||
|
<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" />
|
||||||
|
{{ busy ? t('pages.threads.sending') : t('pages.threads.send') }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
|
<p v-else class="threads-empty threads-empty--select">{{ t('pages.threads.selectThread') }}</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Modal v-if="createModalOpen" :title="t('pages.threads.newThread')" :close-label="t('common.close')" @close="closeCreateThread">
|
||||||
|
<form class="modal-edit-form" @submit.prevent="submitThread">
|
||||||
|
<div class="field">
|
||||||
|
<label for="thread-title">{{ t('pages.threads.threadTitle') }}</label>
|
||||||
|
<input id="thread-title" v-model="threadForm.title" required maxlength="140" :disabled="busy" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="thread-language">{{ t('pages.threads.language') }}</label>
|
||||||
|
<select id="thread-language" v-model="threadForm.languageCode" :disabled="busy">
|
||||||
|
<option v-for="language in languageOptions" :key="language.code" :value="language.code">{{ language.name }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div v-if="tagOptions.length" class="field">
|
||||||
|
<span class="field-label">{{ t('pages.threads.tags') }}</span>
|
||||||
|
<div class="thread-tag-filter">
|
||||||
|
<button
|
||||||
|
v-for="tag in tagOptions"
|
||||||
|
:key="tag.id"
|
||||||
|
type="button"
|
||||||
|
class="thread-chip"
|
||||||
|
:class="{ active: threadForm.tagIds.includes(tag.id) }"
|
||||||
|
:disabled="busy"
|
||||||
|
@click="toggleThreadTag(tag)"
|
||||||
|
>
|
||||||
|
{{ tag.name }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="thread-body">{{ t('pages.threads.firstMessage') }}</label>
|
||||||
|
<textarea id="thread-body" v-model="threadForm.body" rows="5" required maxlength="2000" :disabled="busy"></textarea>
|
||||||
|
</div>
|
||||||
|
<button class="ui-button ui-button--primary" type="submit" :disabled="busy || !threadForm.title.trim() || !threadForm.body.trim()">
|
||||||
|
<Icon :icon="iconAdd" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ busy ? t('common.saving') : t('common.create') }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -65,6 +65,7 @@ export const systemWordingMessages = {
|
|||||||
clothes: 'Clothes',
|
clothes: 'Clothes',
|
||||||
checklist: 'CheckList',
|
checklist: 'CheckList',
|
||||||
life: 'Life',
|
life: 'Life',
|
||||||
|
threads: 'Threads',
|
||||||
admin: 'Admin',
|
admin: 'Admin',
|
||||||
main: 'Main navigation',
|
main: 'Main navigation',
|
||||||
openMenu: 'Open navigation',
|
openMenu: 'Open navigation',
|
||||||
@@ -1020,6 +1021,45 @@ export const systemWordingMessages = {
|
|||||||
moderationRetryFailed: 'Review retry failed',
|
moderationRetryFailed: 'Review retry failed',
|
||||||
charactersLeft: '{count} characters left'
|
charactersLeft: '{count} characters left'
|
||||||
},
|
},
|
||||||
|
threads: {
|
||||||
|
kicker: 'Community Threads',
|
||||||
|
title: 'Threads',
|
||||||
|
subtitle: 'Browse channel discussions and chat inside each thread.',
|
||||||
|
channels: 'Channels',
|
||||||
|
allChannels: 'All channels',
|
||||||
|
newThread: 'New Thread',
|
||||||
|
threadTitle: 'Title',
|
||||||
|
firstMessage: 'First message',
|
||||||
|
message: 'Message',
|
||||||
|
send: 'Send',
|
||||||
|
sending: 'Sending',
|
||||||
|
follow: 'Follow',
|
||||||
|
unfollow: 'Unfollow',
|
||||||
|
lock: 'Lock',
|
||||||
|
unlock: 'Unlock',
|
||||||
|
locked: 'Locked',
|
||||||
|
unread: 'Unread',
|
||||||
|
tags: 'Tags',
|
||||||
|
language: 'Language',
|
||||||
|
allLanguages: 'All languages',
|
||||||
|
sort: 'Sort',
|
||||||
|
sortLastActive: 'Last active',
|
||||||
|
sortLatest: 'Latest',
|
||||||
|
sortMostDiscussed: 'Most discussed',
|
||||||
|
loadMoreThreads: 'Load more threads',
|
||||||
|
loadOlder: 'Load older',
|
||||||
|
jumpToPresent: 'Jump to Present',
|
||||||
|
unreadDivider: 'Unread messages',
|
||||||
|
noThreads: 'No threads yet',
|
||||||
|
noMessages: 'No messages yet',
|
||||||
|
selectThread: 'Select a thread',
|
||||||
|
messageReviewing: 'Reviewing',
|
||||||
|
messageRejected: 'Not approved',
|
||||||
|
createFailed: 'Thread could not be created',
|
||||||
|
messageFailed: 'Message could not be sent',
|
||||||
|
reactionFailed: 'Reaction could not be updated',
|
||||||
|
followFailed: 'Follow could not be updated'
|
||||||
|
},
|
||||||
admin: {
|
admin: {
|
||||||
title: 'Admin',
|
title: 'Admin',
|
||||||
subtitle: 'Manage Wiki content, configuration, localization, and access.',
|
subtitle: 'Manage Wiki content, configuration, localization, and access.',
|
||||||
@@ -1041,6 +1081,13 @@ export const systemWordingMessages = {
|
|||||||
recipeList: 'Recipe list',
|
recipeList: 'Recipe list',
|
||||||
dishList: 'Dish list',
|
dishList: 'Dish list',
|
||||||
habitatList: 'Habitat list',
|
habitatList: 'Habitat list',
|
||||||
|
threadChannels: 'Thread channels',
|
||||||
|
newThreadChannel: 'New Thread channel',
|
||||||
|
editThreadChannel: 'Edit Thread channel',
|
||||||
|
allowUserThreads: 'Allow users to create Threads',
|
||||||
|
userThreadsAllowed: 'Users can create Threads',
|
||||||
|
userThreadsDisabled: 'User Thread creation disabled',
|
||||||
|
threadTagsPlaceholder: 'tag, tag, tag',
|
||||||
dataTools: 'Data tools',
|
dataTools: 'Data tools',
|
||||||
dataToolRefresh: 'Refresh',
|
dataToolRefresh: 'Refresh',
|
||||||
dataToolExport: 'Export data',
|
dataToolExport: 'Export data',
|
||||||
@@ -1461,6 +1508,7 @@ export const systemWordingMessages = {
|
|||||||
clothes: '服装',
|
clothes: '服装',
|
||||||
checklist: 'CheckList',
|
checklist: 'CheckList',
|
||||||
life: 'Life',
|
life: 'Life',
|
||||||
|
threads: '讨论',
|
||||||
admin: '管理',
|
admin: '管理',
|
||||||
main: '主导航',
|
main: '主导航',
|
||||||
openMenu: '打开导航',
|
openMenu: '打开导航',
|
||||||
@@ -2390,6 +2438,45 @@ export const systemWordingMessages = {
|
|||||||
moderationRetryFailed: '重新审核失败',
|
moderationRetryFailed: '重新审核失败',
|
||||||
charactersLeft: '还可以输入 {count} 个字符'
|
charactersLeft: '还可以输入 {count} 个字符'
|
||||||
},
|
},
|
||||||
|
threads: {
|
||||||
|
kicker: '社区讨论',
|
||||||
|
title: '讨论',
|
||||||
|
subtitle: '按频道浏览帖子,并在帖子内用聊天消息讨论。',
|
||||||
|
channels: '频道',
|
||||||
|
allChannels: '全部频道',
|
||||||
|
newThread: '新帖子',
|
||||||
|
threadTitle: '标题',
|
||||||
|
firstMessage: '首条消息',
|
||||||
|
message: '消息',
|
||||||
|
send: '发送',
|
||||||
|
sending: '发送中',
|
||||||
|
follow: '关注',
|
||||||
|
unfollow: '取消关注',
|
||||||
|
lock: '锁定',
|
||||||
|
unlock: '解锁',
|
||||||
|
locked: '已锁定',
|
||||||
|
unread: '未读',
|
||||||
|
tags: '标签',
|
||||||
|
language: '语言',
|
||||||
|
allLanguages: '全部语言',
|
||||||
|
sort: '排序',
|
||||||
|
sortLastActive: '最后活跃',
|
||||||
|
sortLatest: '最新发布',
|
||||||
|
sortMostDiscussed: '讨论数',
|
||||||
|
loadMoreThreads: '加载更多帖子',
|
||||||
|
loadOlder: '加载旧消息',
|
||||||
|
jumpToPresent: '跳到最新',
|
||||||
|
unreadDivider: '未读消息',
|
||||||
|
noThreads: '暂无帖子',
|
||||||
|
noMessages: '暂无消息',
|
||||||
|
selectThread: '选择一个帖子',
|
||||||
|
messageReviewing: '审核中',
|
||||||
|
messageRejected: '未通过',
|
||||||
|
createFailed: '帖子创建失败',
|
||||||
|
messageFailed: '消息发送失败',
|
||||||
|
reactionFailed: 'Reaction 更新失败',
|
||||||
|
followFailed: '关注状态更新失败'
|
||||||
|
},
|
||||||
admin: {
|
admin: {
|
||||||
title: '管理',
|
title: '管理',
|
||||||
subtitle: '管理 Wiki 内容、配置、本地化和访问权限。',
|
subtitle: '管理 Wiki 内容、配置、本地化和访问权限。',
|
||||||
@@ -2411,6 +2498,13 @@ export const systemWordingMessages = {
|
|||||||
recipeList: '材料单列表',
|
recipeList: '材料单列表',
|
||||||
dishList: '料理列表',
|
dishList: '料理列表',
|
||||||
habitatList: '栖息地列表',
|
habitatList: '栖息地列表',
|
||||||
|
threadChannels: '讨论频道',
|
||||||
|
newThreadChannel: '新增讨论频道',
|
||||||
|
editThreadChannel: '编辑讨论频道',
|
||||||
|
allowUserThreads: '允许用户创建帖子',
|
||||||
|
userThreadsAllowed: '用户可创建帖子',
|
||||||
|
userThreadsDisabled: '用户不可创建帖子',
|
||||||
|
threadTagsPlaceholder: '标签, 标签, 标签',
|
||||||
dataTools: '数据工具',
|
dataTools: '数据工具',
|
||||||
dataToolRefresh: '刷新',
|
dataToolRefresh: '刷新',
|
||||||
dataToolExport: '导出数据',
|
dataToolExport: '导出数据',
|
||||||
|
|||||||
Reference in New Issue
Block a user