feat(comments): paginate life post and entity discussion comments
Implement cursor-based pagination for Life and Entity comments Optimize Life Post queries to return comment counts and previews Add "Load more" functionality to frontend discussion panels
This commit is contained in:
@@ -292,6 +292,7 @@
|
|||||||
- 评论作者拥有 `discussions.comments.delete` 权限时可以删除自己的评论;拥有 `discussions.comments.delete-any` 权限的用户可以删除其他用户评论;删除后正文不再展示,已有回复保留在原位置。
|
- 评论作者拥有 `discussions.comments.delete` 权限时可以删除自己的评论;拥有 `discussions.comments.delete-any` 权限的用户可以删除其他用户评论;删除后正文不再展示,已有回复保留在原位置。
|
||||||
- 被删除实体的讨论会随实体删除一并清理。
|
- 被删除实体的讨论会随实体删除一并清理。
|
||||||
- 讨论按创建时间正序展示。
|
- 讨论按创建时间正序展示。
|
||||||
|
- 讨论列表按顶层评论分页读取,支持 `limit` / `cursor`;每页顶层评论携带其一层回复,响应包含 `items`、`nextCursor`、`hasMore`、`total`。
|
||||||
- 讨论内容是用户生成内容,正文按作者输入展示,不进入 `entity_translations`。
|
- 讨论内容是用户生成内容,正文按作者输入展示,不进入 `entity_translations`。
|
||||||
- API 对外只返回评论作者的 `id` 和 `displayName`。
|
- API 对外只返回评论作者的 `id` 和 `displayName`。
|
||||||
- API 不返回邮箱、token/hash、内部调试字段、`deleted_at`、`deleted_by_user_id` 等内部删除字段。
|
- API 不返回邮箱、token/hash、内部调试字段、`deleted_at`、`deleted_by_user_id` 等内部删除字段。
|
||||||
@@ -623,6 +624,7 @@ Life Post 可配置:
|
|||||||
- 评论作者拥有 `life.comments.delete` 权限时可以删除自己的评论;拥有 `life.comments.delete-any` 权限的用户可以删除其他用户评论;删除评论后正文不再展示,已有回复保留在原位置。
|
- 评论作者拥有 `life.comments.delete` 权限时可以删除自己的评论;拥有 `life.comments.delete-any` 权限的用户可以删除其他用户评论;删除评论后正文不再展示,已有回复保留在原位置。
|
||||||
- 已软删除的 Life Post 不出现在信息流、搜索或标签筛选结果中,也不能继续编辑、评论或设置 Reaction。
|
- 已软删除的 Life Post 不出现在信息流、搜索或标签筛选结果中,也不能继续编辑、评论或设置 Reaction。
|
||||||
- 每条 Life Post 默认只展示评论入口与评论数量;评论列表、回复和评论输入默认折叠,用户点击后展开。
|
- 每条 Life Post 默认只展示评论入口与评论数量;评论列表、回复和评论输入默认折叠,用户点击后展开。
|
||||||
|
- Life Feed 只随每条 Life Post 返回评论总数和最近少量评论预览;完整评论列表在展开评论区后通过独立分页接口按顶层评论正序读取,每页顶层评论携带其一层回复。
|
||||||
- 已注册并完成邮箱验证且拥有 `life.reactions.set` 权限的用户可以对每条 Life Post 选择一个 Reaction;普通点击默认设置 `like`,再次点击 `like` 会取消,当前为其他 Reaction 时普通点击会替换为 `like`。
|
- 已注册并完成邮箱验证且拥有 `life.reactions.set` 权限的用户可以对每条 Life Post 选择一个 Reaction;普通点击默认设置 `like`,再次点击 `like` 会取消,当前为其他 Reaction 时普通点击会替换为 `like`。
|
||||||
- Life Reaction 的其他类型通过右键 / context menu 或可见展开按钮打开 Popup 选择;再次选择当前 Reaction 会取消,选择其他 Reaction 会替换原 Reaction。
|
- Life Reaction 的其他类型通过右键 / context menu 或可见展开按钮打开 Popup 选择;再次选择当前 Reaction 会取消,选择其他 Reaction 会替换原 Reaction。
|
||||||
- 支持按 Life Post 正文搜索;用户按 Enter 或点击 Search 按钮后提交搜索,不随输入实时请求;搜索结果仍按创建时间倒序展示并分页加载。
|
- 支持按 Life Post 正文搜索;用户按 Enter 或点击 Search 按钮后提交搜索,不随输入实时请求;搜索结果仍按创建时间倒序展示并分页加载。
|
||||||
@@ -637,7 +639,8 @@ API 暴露边界:
|
|||||||
- Life Post 标签只返回 `id` 和按当前语言解析后的 `name`。
|
- Life Post 标签只返回 `id` 和按当前语言解析后的 `name`。
|
||||||
- Life Comment 作者信息只返回 `id` 和 `displayName`。
|
- Life Comment 作者信息只返回 `id` 和 `displayName`。
|
||||||
- Life Reaction 对外只返回按类型汇总的数量和当前用户自己的 Reaction,不返回其他用户的 Reaction 明细。
|
- Life Reaction 对外只返回按类型汇总的数量和当前用户自己的 Reaction,不返回其他用户的 Reaction 明细。
|
||||||
- Life Post 列表 API 返回分页结果:`items`、`nextCursor`、`hasMore`;`cursor` 是不透明分页令牌。
|
- Life Post 列表 API 返回分页结果:`items`、`nextCursor`、`hasMore`;`cursor` 是不透明分页令牌。每个 Life Post 的评论字段只包含 `commentCount` 和 `commentPreview`,不内嵌完整评论列表。
|
||||||
|
- Life Comment 列表 API 返回分页结果:`items`、`nextCursor`、`hasMore`、`total`;`cursor` 是不透明分页令牌。
|
||||||
- API 不返回邮箱、token/hash、内部调试字段或不必要的审计 payload。
|
- API 不返回邮箱、token/hash、内部调试字段或不必要的审计 payload。
|
||||||
- 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。
|
||||||
@@ -725,11 +728,12 @@ API 暴露边界:
|
|||||||
- `GET /api/recipes`
|
- `GET /api/recipes`
|
||||||
- `GET /api/recipes/:id`
|
- `GET /api/recipes/:id`
|
||||||
- `GET /api/life-posts`:支持 `cursor` / `limit` 分页读取;支持 `search` 按 Life Post 正文搜索;支持 `tagId` 按 Life 标签筛选。
|
- `GET /api/life-posts`:支持 `cursor` / `limit` 分页读取;支持 `search` 按 Life Post 正文搜索;支持 `tagId` 按 Life 标签筛选。
|
||||||
|
- `GET /api/life-posts/:postId/comments`:支持 `cursor` / `limit` 分页读取 Life Post 评论。
|
||||||
- `GET /api/users/:id/profile`:读取公开用户 Profile 摘要、Wiki 贡献统计和公开社区统计。
|
- `GET /api/users/:id/profile`:读取公开用户 Profile 摘要、Wiki 贡献统计和公开社区统计。
|
||||||
- `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。
|
||||||
- `GET /api/users/:id/comments`:分页读取该用户未删除的 Life 评论和实体讨论评论。
|
- `GET /api/users/:id/comments`:分页读取该用户未删除的 Life 评论和实体讨论评论。
|
||||||
- `GET /api/discussions/:entityType/:entityId/comments`:读取实体讨论;`entityType` 支持 `pokemon`、`items`、`recipes`、`habitats`。
|
- `GET /api/discussions/:entityType/:entityId/comments`:支持 `cursor` / `limit` 分页读取实体讨论;`entityType` 支持 `pokemon`、`items`、`recipes`、`habitats`。
|
||||||
|
|
||||||
认证 API:
|
认证 API:
|
||||||
|
|
||||||
|
|||||||
@@ -196,12 +196,19 @@ type EntityDiscussionCommentRow = {
|
|||||||
body: string;
|
body: string;
|
||||||
deleted: boolean;
|
deleted: boolean;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
createdAtCursor?: string;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
author: { id: number; displayName: string } | null;
|
author: { id: number; displayName: string } | null;
|
||||||
};
|
};
|
||||||
type EntityDiscussionComment = EntityDiscussionCommentRow & {
|
type EntityDiscussionComment = Omit<EntityDiscussionCommentRow, 'createdAtCursor'> & {
|
||||||
replies: EntityDiscussionComment[];
|
replies: EntityDiscussionComment[];
|
||||||
};
|
};
|
||||||
|
type EntityDiscussionCommentsPage = {
|
||||||
|
items: EntityDiscussionComment[];
|
||||||
|
nextCursor: string | null;
|
||||||
|
hasMore: boolean;
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
|
||||||
type LifeReactionType = 'like' | 'helpful' | 'fun' | 'thanks';
|
type LifeReactionType = 'like' | 'helpful' | 'fun' | 'thanks';
|
||||||
type LifeReactionCounts = Record<LifeReactionType, number>;
|
type LifeReactionCounts = Record<LifeReactionType, number>;
|
||||||
@@ -213,11 +220,12 @@ type LifeCommentRow = {
|
|||||||
body: string;
|
body: string;
|
||||||
deleted: boolean;
|
deleted: boolean;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
createdAtCursor?: string;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
author: { id: number; displayName: string } | null;
|
author: { id: number; displayName: string } | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type LifeComment = LifeCommentRow & {
|
type LifeComment = Omit<LifeCommentRow, 'createdAtCursor'> & {
|
||||||
replies: LifeComment[];
|
replies: LifeComment[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -233,7 +241,8 @@ type LifePostRow = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type LifePost = Omit<LifePostRow, 'createdAtCursor'> & {
|
type LifePost = Omit<LifePostRow, 'createdAtCursor'> & {
|
||||||
comments: LifeComment[];
|
commentPreview: LifeComment[];
|
||||||
|
commentCount: number;
|
||||||
reactionCounts: LifeReactionCounts;
|
reactionCounts: LifeReactionCounts;
|
||||||
myReaction: LifeReactionType | null;
|
myReaction: LifeReactionType | null;
|
||||||
};
|
};
|
||||||
@@ -253,6 +262,13 @@ type LifePostsPage = {
|
|||||||
hasMore: boolean;
|
hasMore: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type LifeCommentsPage = {
|
||||||
|
items: LifeComment[];
|
||||||
|
nextCursor: string | null;
|
||||||
|
hasMore: boolean;
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
|
||||||
type PublicProfileUser = {
|
type PublicProfileUser = {
|
||||||
id: number;
|
id: number;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
@@ -405,6 +421,9 @@ const defaultLocale = 'en';
|
|||||||
const localePattern = /^[a-z]{2}(-[A-Z]{2})?$/;
|
const localePattern = /^[a-z]{2}(-[A-Z]{2})?$/;
|
||||||
const defaultLifePostLimit = 20;
|
const defaultLifePostLimit = 20;
|
||||||
const maxLifePostLimit = 50;
|
const maxLifePostLimit = 50;
|
||||||
|
const defaultCommentLimit = 20;
|
||||||
|
const maxCommentLimit = 50;
|
||||||
|
const lifeCommentPreviewLimit = 2;
|
||||||
const lifeReactionTypes = ['like', 'helpful', 'fun', 'thanks'] as const;
|
const lifeReactionTypes = ['like', 'helpful', 'fun', 'thanks'] as const;
|
||||||
const pokemonTypeIconIds = new Set(Array.from({ length: 19 }, (_value, index) => index + 1));
|
const pokemonTypeIconIds = new Set(Array.from({ length: 19 }, (_value, index) => index + 1));
|
||||||
const pokemonSpriteBaseUrl = 'https://pokesprite.tootaio.com';
|
const pokemonSpriteBaseUrl = 'https://pokesprite.tootaio.com';
|
||||||
@@ -2267,6 +2286,16 @@ function cleanLifePostLimit(value: QueryValue): number {
|
|||||||
return Number.isInteger(limit) && limit > 0 ? Math.min(limit, maxLifePostLimit) : defaultLifePostLimit;
|
return Number.isInteger(limit) && limit > 0 ? Math.min(limit, maxLifePostLimit) : defaultLifePostLimit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cleanCommentLimit(value: QueryValue): number {
|
||||||
|
const rawLimit = asString(value);
|
||||||
|
if (rawLimit === undefined || rawLimit === '') {
|
||||||
|
return defaultCommentLimit;
|
||||||
|
}
|
||||||
|
|
||||||
|
const limit = Number(rawLimit);
|
||||||
|
return Number.isInteger(limit) && limit > 0 ? Math.min(limit, maxCommentLimit) : defaultCommentLimit;
|
||||||
|
}
|
||||||
|
|
||||||
function decodeLifePostCursor(value: QueryValue): LifePostCursor | null {
|
function decodeLifePostCursor(value: QueryValue): LifePostCursor | null {
|
||||||
const rawCursor = asString(value);
|
const rawCursor = asString(value);
|
||||||
if (!rawCursor) {
|
if (!rawCursor) {
|
||||||
@@ -2336,7 +2365,8 @@ function encodeUserCommentActivityCursor(cursor: UserCommentActivityCursor): str
|
|||||||
|
|
||||||
function hydrateLifePost(
|
function hydrateLifePost(
|
||||||
post: LifePostRow,
|
post: LifePostRow,
|
||||||
commentsByPost: Map<number, LifeComment[]>,
|
commentPreviewByPost: Map<number, LifeComment[]>,
|
||||||
|
commentCountsByPost: Map<number, number>,
|
||||||
countsByPost: Map<number, LifeReactionCounts>,
|
countsByPost: Map<number, LifeReactionCounts>,
|
||||||
myReactionsByPost: Map<number, LifeReactionType>
|
myReactionsByPost: Map<number, LifeReactionType>
|
||||||
): LifePost {
|
): LifePost {
|
||||||
@@ -2348,7 +2378,8 @@ function hydrateLifePost(
|
|||||||
author: post.author,
|
author: post.author,
|
||||||
updatedBy: post.updatedBy,
|
updatedBy: post.updatedBy,
|
||||||
tags: post.tags,
|
tags: post.tags,
|
||||||
comments: commentsByPost.get(post.id) ?? [],
|
commentPreview: commentPreviewByPost.get(post.id) ?? [],
|
||||||
|
commentCount: commentCountsByPost.get(post.id) ?? 0,
|
||||||
reactionCounts: countsByPost.get(post.id) ?? emptyLifeReactionCounts(),
|
reactionCounts: countsByPost.get(post.id) ?? emptyLifeReactionCounts(),
|
||||||
myReaction: myReactionsByPost.get(post.id) ?? null
|
myReaction: myReactionsByPost.get(post.id) ?? null
|
||||||
};
|
};
|
||||||
@@ -2363,6 +2394,7 @@ function lifeCommentProjection(whereClause: string): string {
|
|||||||
CASE WHEN lc.deleted_at IS NULL THEN lc.body ELSE '' END AS body,
|
CASE WHEN lc.deleted_at IS NULL THEN lc.body ELSE '' END AS body,
|
||||||
lc.deleted_at IS NOT NULL AS deleted,
|
lc.deleted_at IS NOT NULL AS deleted,
|
||||||
lc.created_at AS "createdAt",
|
lc.created_at AS "createdAt",
|
||||||
|
lc.created_at::text AS "createdAtCursor",
|
||||||
lc.updated_at AS "updatedAt",
|
lc.updated_at AS "updatedAt",
|
||||||
CASE
|
CASE
|
||||||
WHEN lc.deleted_at IS NOT NULL OR comment_user.id IS NULL THEN NULL
|
WHEN lc.deleted_at IS NOT NULL OR comment_user.id IS NULL THEN NULL
|
||||||
@@ -2379,7 +2411,8 @@ function buildLifeCommentTree(rows: LifeCommentRow[]): LifeComment[] {
|
|||||||
const topLevelComments: LifeComment[] = [];
|
const topLevelComments: LifeComment[] = [];
|
||||||
|
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
comments.set(row.id, { ...row, replies: [] });
|
const { createdAtCursor: _createdAtCursor, ...comment } = row;
|
||||||
|
comments.set(row.id, { ...comment, replies: [] });
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const comment of comments.values()) {
|
for (const comment of comments.values()) {
|
||||||
@@ -2399,7 +2432,34 @@ function buildLifeCommentTree(rows: LifeCommentRow[]): LifeComment[] {
|
|||||||
return topLevelComments;
|
return topLevelComments;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function lifeCommentsForPosts(postIds: number[]): Promise<Map<number, LifeComment[]>> {
|
async function lifeCommentCountsForPosts(postIds: number[]): Promise<Map<number, number>> {
|
||||||
|
const countsByPost = new Map<number, number>();
|
||||||
|
for (const postId of postIds) {
|
||||||
|
countsByPost.set(postId, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (postIds.length === 0) {
|
||||||
|
return countsByPost;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await query<{ postId: number; total: number }>(
|
||||||
|
`
|
||||||
|
SELECT post_id AS "postId", COUNT(*)::integer AS total
|
||||||
|
FROM life_post_comments
|
||||||
|
WHERE post_id = ANY($1::integer[])
|
||||||
|
GROUP BY post_id
|
||||||
|
`,
|
||||||
|
[postIds]
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
countsByPost.set(row.postId, row.total);
|
||||||
|
}
|
||||||
|
|
||||||
|
return countsByPost;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function lifeCommentPreviewForPosts(postIds: number[]): Promise<Map<number, LifeComment[]>> {
|
||||||
const commentsByPost = new Map<number, LifeComment[]>();
|
const commentsByPost = new Map<number, LifeComment[]>();
|
||||||
if (postIds.length === 0) {
|
if (postIds.length === 0) {
|
||||||
return commentsByPost;
|
return commentsByPost;
|
||||||
@@ -2407,10 +2467,22 @@ async function lifeCommentsForPosts(postIds: number[]): Promise<Map<number, Life
|
|||||||
|
|
||||||
const rows = await query<LifeCommentRow>(
|
const rows = await query<LifeCommentRow>(
|
||||||
`
|
`
|
||||||
${lifeCommentProjection('WHERE lc.post_id = ANY($1::integer[])')}
|
WITH preview_top AS (
|
||||||
ORDER BY lc.created_at, lc.id
|
SELECT id
|
||||||
|
FROM (
|
||||||
|
SELECT
|
||||||
|
lc.id,
|
||||||
|
ROW_NUMBER() OVER (PARTITION BY lc.post_id ORDER BY lc.created_at DESC, lc.id DESC) AS preview_rank
|
||||||
|
FROM life_post_comments lc
|
||||||
|
WHERE lc.post_id = ANY($1::integer[])
|
||||||
|
AND lc.parent_comment_id IS NULL
|
||||||
|
) ranked
|
||||||
|
WHERE preview_rank <= $2
|
||||||
|
)
|
||||||
|
${lifeCommentProjection('WHERE lc.id IN (SELECT id FROM preview_top)')}
|
||||||
|
ORDER BY lc.post_id, lc.created_at, lc.id
|
||||||
`,
|
`,
|
||||||
[postIds]
|
[postIds, lifeCommentPreviewLimit]
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const postId of postIds) {
|
for (const postId of postIds) {
|
||||||
@@ -2420,6 +2492,80 @@ async function lifeCommentsForPosts(postIds: number[]): Promise<Map<number, Life
|
|||||||
return commentsByPost;
|
return commentsByPost;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function listLifeComments(postIdValue: number, paramsQuery: QueryParams = {}): Promise<LifeCommentsPage | null> {
|
||||||
|
const postId = requirePositiveInteger(postIdValue, 'server.validation.recordInvalid');
|
||||||
|
const cursor = decodeLifePostCursor(paramsQuery.cursor);
|
||||||
|
const limit = cleanCommentLimit(paramsQuery.limit);
|
||||||
|
const exists = await queryOne<{ exists: boolean }>(
|
||||||
|
`
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM life_posts
|
||||||
|
WHERE id = $1
|
||||||
|
AND deleted_at IS NULL
|
||||||
|
) AS exists
|
||||||
|
`,
|
||||||
|
[postId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (exists?.exists !== true) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const params: unknown[] = [postId];
|
||||||
|
const topLevelConditions = ['lc.post_id = $1', 'lc.parent_comment_id IS NULL'];
|
||||||
|
|
||||||
|
if (cursor) {
|
||||||
|
params.push(cursor.createdAt, cursor.id);
|
||||||
|
topLevelConditions.push(`(lc.created_at, lc.id) > ($${params.length - 1}::timestamptz, $${params.length}::integer)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
params.push(limit + 1);
|
||||||
|
const topLevelRows = await query<LifeCommentRow>(
|
||||||
|
`
|
||||||
|
${lifeCommentProjection(`WHERE ${topLevelConditions.join(' AND ')}`)}
|
||||||
|
ORDER BY lc.created_at, lc.id
|
||||||
|
LIMIT $${params.length}
|
||||||
|
`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
const hasMore = topLevelRows.length > limit;
|
||||||
|
const topLevelComments = hasMore ? topLevelRows.slice(0, limit) : topLevelRows;
|
||||||
|
const topLevelIds = topLevelComments.map((comment) => comment.id);
|
||||||
|
const replyRows = topLevelIds.length
|
||||||
|
? await query<LifeCommentRow>(
|
||||||
|
`
|
||||||
|
${lifeCommentProjection('WHERE lc.parent_comment_id = ANY($1::integer[])')}
|
||||||
|
ORDER BY lc.created_at, lc.id
|
||||||
|
`,
|
||||||
|
[topLevelIds]
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
const total = await queryOne<{ total: number }>(
|
||||||
|
`
|
||||||
|
SELECT COUNT(*)::integer AS total
|
||||||
|
FROM life_post_comments
|
||||||
|
WHERE post_id = $1
|
||||||
|
`,
|
||||||
|
[postId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: buildLifeCommentTree([...topLevelComments, ...replyRows]),
|
||||||
|
nextCursor:
|
||||||
|
hasMore && topLevelComments.length > 0
|
||||||
|
? encodeProfileCursor({
|
||||||
|
createdAt:
|
||||||
|
topLevelComments[topLevelComments.length - 1].createdAtCursor ??
|
||||||
|
topLevelComments[topLevelComments.length - 1].createdAt.toISOString(),
|
||||||
|
id: topLevelComments[topLevelComments.length - 1].id
|
||||||
|
})
|
||||||
|
: null,
|
||||||
|
hasMore,
|
||||||
|
total: total?.total ?? 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function lifeReactionsForPosts(
|
async function lifeReactionsForPosts(
|
||||||
postIds: number[],
|
postIds: number[],
|
||||||
userId: number | null
|
userId: number | null
|
||||||
@@ -2487,7 +2633,12 @@ async function getLifeCommentById(id: number): Promise<LifeComment | null> {
|
|||||||
[id]
|
[id]
|
||||||
);
|
);
|
||||||
|
|
||||||
return row ? { ...row, replies: [] } : null;
|
if (!row) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { createdAtCursor: _createdAtCursor, ...comment } = row;
|
||||||
|
return { ...comment, replies: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function listLifePostsWithFilters(
|
async function listLifePostsWithFilters(
|
||||||
@@ -2544,11 +2695,12 @@ async function listLifePostsWithFilters(
|
|||||||
const posts = hasMore ? rows.slice(0, limit) : rows;
|
const posts = hasMore ? rows.slice(0, limit) : rows;
|
||||||
|
|
||||||
const postIds = posts.map((post) => post.id);
|
const postIds = posts.map((post) => post.id);
|
||||||
const commentsByPost = await lifeCommentsForPosts(postIds);
|
const commentPreviewByPost = await lifeCommentPreviewForPosts(postIds);
|
||||||
|
const commentCountsByPost = await lifeCommentCountsForPosts(postIds);
|
||||||
const { countsByPost, myReactionsByPost } = await lifeReactionsForPosts(postIds, userId);
|
const { countsByPost, myReactionsByPost } = await lifeReactionsForPosts(postIds, userId);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
items: posts.map((post) => hydrateLifePost(post, commentsByPost, countsByPost, myReactionsByPost)),
|
items: posts.map((post) => hydrateLifePost(post, commentPreviewByPost, commentCountsByPost, countsByPost, myReactionsByPost)),
|
||||||
nextCursor: hasMore && posts.length > 0 ? encodeLifePostCursor(posts[posts.length - 1]) : null,
|
nextCursor: hasMore && posts.length > 0 ? encodeLifePostCursor(posts[posts.length - 1]) : null,
|
||||||
hasMore
|
hasMore
|
||||||
};
|
};
|
||||||
@@ -2690,11 +2842,12 @@ async function hydrateLifePostsById(
|
|||||||
`,
|
`,
|
||||||
[postIds]
|
[postIds]
|
||||||
);
|
);
|
||||||
const commentsByPost = await lifeCommentsForPosts(postIds);
|
const commentPreviewByPost = await lifeCommentPreviewForPosts(postIds);
|
||||||
|
const commentCountsByPost = await lifeCommentCountsForPosts(postIds);
|
||||||
const { countsByPost, myReactionsByPost } = await lifeReactionsForPosts(postIds, viewerUserId);
|
const { countsByPost, myReactionsByPost } = await lifeReactionsForPosts(postIds, viewerUserId);
|
||||||
|
|
||||||
for (const post of posts) {
|
for (const post of posts) {
|
||||||
postById.set(post.id, hydrateLifePost(post, commentsByPost, countsByPost, myReactionsByPost));
|
postById.set(post.id, hydrateLifePost(post, commentPreviewByPost, commentCountsByPost, countsByPost, myReactionsByPost));
|
||||||
}
|
}
|
||||||
|
|
||||||
return postById;
|
return postById;
|
||||||
@@ -2934,9 +3087,10 @@ async function getLifePostById(id: number, userId: number | null = null, locale
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const commentsByPost = await lifeCommentsForPosts([post.id]);
|
const commentPreviewByPost = await lifeCommentPreviewForPosts([post.id]);
|
||||||
|
const commentCountsByPost = await lifeCommentCountsForPosts([post.id]);
|
||||||
const { countsByPost, myReactionsByPost } = await lifeReactionsForPosts([post.id], userId);
|
const { countsByPost, myReactionsByPost } = await lifeReactionsForPosts([post.id], userId);
|
||||||
return hydrateLifePost(post, commentsByPost, countsByPost, myReactionsByPost);
|
return hydrateLifePost(post, commentPreviewByPost, commentCountsByPost, countsByPost, myReactionsByPost);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function replaceLifePostTags(client: DbClient, postId: number, tagIds: number[]): Promise<void> {
|
async function replaceLifePostTags(client: DbClient, postId: number, tagIds: number[]): Promise<void> {
|
||||||
@@ -3180,6 +3334,7 @@ function entityDiscussionCommentProjection(whereClause: string): string {
|
|||||||
CASE WHEN edc.deleted_at IS NULL THEN edc.body ELSE '' END AS body,
|
CASE WHEN edc.deleted_at IS NULL THEN edc.body ELSE '' END AS body,
|
||||||
edc.deleted_at IS NOT NULL AS deleted,
|
edc.deleted_at IS NOT NULL AS deleted,
|
||||||
edc.created_at AS "createdAt",
|
edc.created_at AS "createdAt",
|
||||||
|
edc.created_at::text AS "createdAtCursor",
|
||||||
edc.updated_at AS "updatedAt",
|
edc.updated_at AS "updatedAt",
|
||||||
CASE
|
CASE
|
||||||
WHEN edc.deleted_at IS NOT NULL OR comment_user.id IS NULL THEN NULL
|
WHEN edc.deleted_at IS NOT NULL OR comment_user.id IS NULL THEN NULL
|
||||||
@@ -3196,7 +3351,8 @@ function buildEntityDiscussionCommentTree(rows: EntityDiscussionCommentRow[]): E
|
|||||||
const topLevelComments: EntityDiscussionComment[] = [];
|
const topLevelComments: EntityDiscussionComment[] = [];
|
||||||
|
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
comments.set(row.id, { ...row, replies: [] });
|
const { createdAtCursor: _createdAtCursor, ...comment } = row;
|
||||||
|
comments.set(row.id, { ...comment, replies: [] });
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const comment of comments.values()) {
|
for (const comment of comments.values()) {
|
||||||
@@ -3224,29 +3380,81 @@ async function getEntityDiscussionCommentById(id: number): Promise<EntityDiscuss
|
|||||||
[id]
|
[id]
|
||||||
);
|
);
|
||||||
|
|
||||||
return row ? { ...row, replies: [] } : null;
|
if (!row) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { createdAtCursor: _createdAtCursor, ...comment } = row;
|
||||||
|
return { ...comment, replies: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listEntityDiscussionComments(
|
export async function listEntityDiscussionComments(
|
||||||
entityTypeValue: string,
|
entityTypeValue: string,
|
||||||
entityIdValue: number
|
entityIdValue: number,
|
||||||
): Promise<EntityDiscussionComment[] | null> {
|
paramsQuery: QueryParams = {}
|
||||||
|
): Promise<EntityDiscussionCommentsPage | null> {
|
||||||
const entityType = cleanDiscussionEntityType(entityTypeValue);
|
const entityType = cleanDiscussionEntityType(entityTypeValue);
|
||||||
const entityId = requirePositiveInteger(entityIdValue, 'server.validation.recordInvalid');
|
const entityId = requirePositiveInteger(entityIdValue, 'server.validation.recordInvalid');
|
||||||
|
const cursor = decodeLifePostCursor(paramsQuery.cursor);
|
||||||
|
const limit = cleanCommentLimit(paramsQuery.limit);
|
||||||
|
|
||||||
if (!(await entityDiscussionExists(pool, entityType, entityId))) {
|
if (!(await entityDiscussionExists(pool, entityType, entityId))) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const rows = await query<EntityDiscussionCommentRow>(
|
const params: unknown[] = [entityType, entityId];
|
||||||
|
const topLevelConditions = ['edc.entity_type = $1', 'edc.entity_id = $2', 'edc.parent_comment_id IS NULL'];
|
||||||
|
|
||||||
|
if (cursor) {
|
||||||
|
params.push(cursor.createdAt, cursor.id);
|
||||||
|
topLevelConditions.push(`(edc.created_at, edc.id) > ($${params.length - 1}::timestamptz, $${params.length}::integer)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
params.push(limit + 1);
|
||||||
|
const topLevelRows = await query<EntityDiscussionCommentRow>(
|
||||||
`
|
`
|
||||||
${entityDiscussionCommentProjection('WHERE edc.entity_type = $1 AND edc.entity_id = $2')}
|
${entityDiscussionCommentProjection(`WHERE ${topLevelConditions.join(' AND ')}`)}
|
||||||
ORDER BY edc.created_at, edc.id
|
ORDER BY edc.created_at, edc.id
|
||||||
|
LIMIT $${params.length}
|
||||||
|
`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
const hasMore = topLevelRows.length > limit;
|
||||||
|
const topLevelComments = hasMore ? topLevelRows.slice(0, limit) : topLevelRows;
|
||||||
|
const topLevelIds = topLevelComments.map((comment) => comment.id);
|
||||||
|
const replyRows = topLevelIds.length
|
||||||
|
? await query<EntityDiscussionCommentRow>(
|
||||||
|
`
|
||||||
|
${entityDiscussionCommentProjection('WHERE edc.parent_comment_id = ANY($1::integer[])')}
|
||||||
|
ORDER BY edc.created_at, edc.id
|
||||||
|
`,
|
||||||
|
[topLevelIds]
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
const total = await queryOne<{ total: number }>(
|
||||||
|
`
|
||||||
|
SELECT COUNT(*)::integer AS total
|
||||||
|
FROM entity_discussion_comments
|
||||||
|
WHERE entity_type = $1
|
||||||
|
AND entity_id = $2
|
||||||
`,
|
`,
|
||||||
[entityType, entityId]
|
[entityType, entityId]
|
||||||
);
|
);
|
||||||
|
|
||||||
return buildEntityDiscussionCommentTree(rows);
|
return {
|
||||||
|
items: buildEntityDiscussionCommentTree([...topLevelComments, ...replyRows]),
|
||||||
|
nextCursor:
|
||||||
|
hasMore && topLevelComments.length > 0
|
||||||
|
? encodeProfileCursor({
|
||||||
|
createdAt:
|
||||||
|
topLevelComments[topLevelComments.length - 1].createdAtCursor ??
|
||||||
|
topLevelComments[topLevelComments.length - 1].createdAt.toISOString(),
|
||||||
|
id: topLevelComments[topLevelComments.length - 1].id
|
||||||
|
})
|
||||||
|
: null,
|
||||||
|
hasMore,
|
||||||
|
total: total?.total ?? 0
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createEntityDiscussionComment(
|
export async function createEntityDiscussionComment(
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ import {
|
|||||||
listDailyChecklistItems,
|
listDailyChecklistItems,
|
||||||
listHabitats,
|
listHabitats,
|
||||||
listItems,
|
listItems,
|
||||||
|
listLifeComments,
|
||||||
listLanguages,
|
listLanguages,
|
||||||
listLifePosts,
|
listLifePosts,
|
||||||
listPokemon,
|
listPokemon,
|
||||||
@@ -793,6 +794,12 @@ app.get('/api/life-posts', async (request) => {
|
|||||||
return listLifePosts(request.query as Record<string, string | string[] | undefined>, user?.id ?? null, requestLocale(request));
|
return listLifePosts(request.query as Record<string, string | string[] | undefined>, user?.id ?? null, requestLocale(request));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get('/api/life-posts/:postId/comments', async (request, reply) => {
|
||||||
|
const { postId } = request.params as { postId: string };
|
||||||
|
const comments = await listLifeComments(Number(postId), request.query as Record<string, string | string[] | undefined>);
|
||||||
|
return comments ? comments : notFound(reply, request);
|
||||||
|
});
|
||||||
|
|
||||||
app.post('/api/life-posts', async (request, reply) => {
|
app.post('/api/life-posts', async (request, reply) => {
|
||||||
const user = await requirePermissionWithRateLimits(request, reply, 'life.posts.create', 'communityWrite');
|
const user = await requirePermissionWithRateLimits(request, reply, 'life.posts.create', 'communityWrite');
|
||||||
return user
|
return user
|
||||||
@@ -898,7 +905,11 @@ app.delete('/api/life-comments/:id', async (request, reply) => {
|
|||||||
|
|
||||||
app.get('/api/discussions/:entityType/:entityId/comments', async (request, reply) => {
|
app.get('/api/discussions/:entityType/:entityId/comments', async (request, reply) => {
|
||||||
const { entityType, entityId } = request.params as { entityType: string; entityId: string };
|
const { entityType, entityId } = request.params as { entityType: string; entityId: string };
|
||||||
const comments = await listEntityDiscussionComments(entityType, Number(entityId));
|
const comments = await listEntityDiscussionComments(
|
||||||
|
entityType,
|
||||||
|
Number(entityId),
|
||||||
|
request.query as Record<string, string | string[] | undefined>
|
||||||
|
);
|
||||||
return comments ? comments : notFound(reply, request);
|
return comments ? comments : notFound(reply, request);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ const { locale, t } = useI18n();
|
|||||||
const comments = ref<EntityDiscussionComment[]>([]);
|
const comments = ref<EntityDiscussionComment[]>([]);
|
||||||
const currentUser = ref<AuthUser | null>(null);
|
const currentUser = ref<AuthUser | null>(null);
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
|
const loadingMore = ref(false);
|
||||||
const authReady = ref(false);
|
const authReady = ref(false);
|
||||||
const body = ref('');
|
const body = ref('');
|
||||||
const replyBodies = ref<Record<number, string>>({});
|
const replyBodies = ref<Record<number, string>>({});
|
||||||
@@ -33,8 +34,12 @@ const formError = ref('');
|
|||||||
const commentErrors = ref<Record<string, string>>({});
|
const commentErrors = ref<Record<string, string>>({});
|
||||||
const commentInput = ref<HTMLTextAreaElement | null>(null);
|
const commentInput = ref<HTMLTextAreaElement | null>(null);
|
||||||
const commentMaxLength = 1000;
|
const commentMaxLength = 1000;
|
||||||
|
const discussionPageSize = 20;
|
||||||
let requestId = 0;
|
let requestId = 0;
|
||||||
let removeAuthListener: (() => void) | null = null;
|
let removeAuthListener: (() => void) | null = null;
|
||||||
|
const nextCursor = ref<string | null>(null);
|
||||||
|
const hasMoreComments = ref(false);
|
||||||
|
const commentTotal = ref(0);
|
||||||
|
|
||||||
function can(permissionKey: string) {
|
function can(permissionKey: string) {
|
||||||
return currentUser.value?.permissions.includes(permissionKey) === true;
|
return currentUser.value?.permissions.includes(permissionKey) === true;
|
||||||
@@ -42,7 +47,6 @@ function can(permissionKey: string) {
|
|||||||
|
|
||||||
const canComment = computed(() => can('discussions.comments.create'));
|
const canComment = computed(() => can('discussions.comments.create'));
|
||||||
const charactersLeft = computed(() => Math.max(0, commentMaxLength - body.value.length));
|
const charactersLeft = computed(() => Math.max(0, commentMaxLength - body.value.length));
|
||||||
const commentTotal = computed(() => comments.value.reduce((total, comment) => total + 1 + comment.replies.length, 0));
|
|
||||||
|
|
||||||
async function loadCurrentUser() {
|
async function loadCurrentUser() {
|
||||||
authReady.value = false;
|
authReady.value = false;
|
||||||
@@ -64,15 +68,34 @@ async function loadCurrentUser() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadDiscussion() {
|
function mergeComments(existing: EntityDiscussionComment[], incoming: EntityDiscussionComment[]) {
|
||||||
|
const ids = new Set(existing.map((comment) => comment.id));
|
||||||
|
return [...existing, ...incoming.filter((comment) => !ids.has(comment.id))];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDiscussion(reset = true) {
|
||||||
|
if (!reset && (loadingMore.value || !hasMoreComments.value)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const nextRequestId = ++requestId;
|
const nextRequestId = ++requestId;
|
||||||
|
if (reset) {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
|
} else {
|
||||||
|
loadingMore.value = true;
|
||||||
|
}
|
||||||
loadError.value = '';
|
loadError.value = '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const rows = await api.entityDiscussion(props.entityType, props.entityId);
|
const page = await api.entityDiscussion(props.entityType, props.entityId, {
|
||||||
|
limit: discussionPageSize,
|
||||||
|
cursor: reset ? null : nextCursor.value
|
||||||
|
});
|
||||||
if (nextRequestId === requestId) {
|
if (nextRequestId === requestId) {
|
||||||
comments.value = rows;
|
comments.value = reset ? page.items : mergeComments(comments.value, page.items);
|
||||||
|
nextCursor.value = page.nextCursor;
|
||||||
|
hasMoreComments.value = page.hasMore;
|
||||||
|
commentTotal.value = page.total;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (nextRequestId === requestId) {
|
if (nextRequestId === requestId) {
|
||||||
@@ -81,6 +104,7 @@ async function loadDiscussion() {
|
|||||||
} finally {
|
} finally {
|
||||||
if (nextRequestId === requestId) {
|
if (nextRequestId === requestId) {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
|
loadingMore.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -168,6 +192,7 @@ async function submitComment() {
|
|||||||
try {
|
try {
|
||||||
const comment = await api.createEntityDiscussionComment(props.entityType, props.entityId, { body: nextBody });
|
const comment = await api.createEntityDiscussionComment(props.entityType, props.entityId, { body: nextBody });
|
||||||
comments.value = [...comments.value, comment];
|
comments.value = [...comments.value, comment];
|
||||||
|
commentTotal.value += 1;
|
||||||
body.value = '';
|
body.value = '';
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
formError.value = error instanceof Error && error.message ? error.message : t('discussion.commentFailed');
|
formError.value = error instanceof Error && error.message ? error.message : t('discussion.commentFailed');
|
||||||
@@ -190,6 +215,7 @@ async function submitReply(comment: EntityDiscussionComment) {
|
|||||||
try {
|
try {
|
||||||
const reply = await api.createEntityDiscussionReply(props.entityType, props.entityId, comment.id, { body: nextBody });
|
const reply = await api.createEntityDiscussionReply(props.entityType, props.entityId, comment.id, { body: nextBody });
|
||||||
comment.replies.push(reply);
|
comment.replies.push(reply);
|
||||||
|
commentTotal.value += 1;
|
||||||
cancelReply(comment.id);
|
cancelReply(comment.id);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setCommentError(key, error instanceof Error && error.message ? error.message : t('discussion.replyFailed'));
|
setCommentError(key, error instanceof Error && error.message ? error.message : t('discussion.replyFailed'));
|
||||||
@@ -239,6 +265,9 @@ watch(
|
|||||||
() => {
|
() => {
|
||||||
resetComposer();
|
resetComposer();
|
||||||
comments.value = [];
|
comments.value = [];
|
||||||
|
nextCursor.value = null;
|
||||||
|
hasMoreComments.value = false;
|
||||||
|
commentTotal.value = 0;
|
||||||
void loadDiscussion();
|
void loadDiscussion();
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -419,6 +448,18 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
|
<div v-if="hasMoreComments" class="life-feed__retry">
|
||||||
|
<button
|
||||||
|
class="ui-button ui-button--ghost ui-button--small"
|
||||||
|
type="button"
|
||||||
|
:disabled="loadingMore"
|
||||||
|
@click="loadDiscussion(false)"
|
||||||
|
>
|
||||||
|
<Icon :icon="iconComment" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ loadingMore ? t('common.loading') : t('discussion.loadMore') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="entity-discussion-empty">
|
<div v-else class="entity-discussion-empty">
|
||||||
|
|||||||
@@ -260,7 +260,8 @@ export interface LifePost {
|
|||||||
author: UserSummary | null;
|
author: UserSummary | null;
|
||||||
updatedBy: UserSummary | null;
|
updatedBy: UserSummary | null;
|
||||||
tags: NamedEntity[];
|
tags: NamedEntity[];
|
||||||
comments: LifeComment[];
|
commentPreview: LifeComment[];
|
||||||
|
commentCount: number;
|
||||||
reactionCounts: LifeReactionCounts;
|
reactionCounts: LifeReactionCounts;
|
||||||
myReaction: LifeReactionType | null;
|
myReaction: LifeReactionType | null;
|
||||||
}
|
}
|
||||||
@@ -278,6 +279,11 @@ export interface LifePostsParams {
|
|||||||
tagId?: string | number;
|
tagId?: string | number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CommentPageParams {
|
||||||
|
cursor?: string | null;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface LifeComment {
|
export interface LifeComment {
|
||||||
id: number;
|
id: number;
|
||||||
postId: number;
|
postId: number;
|
||||||
@@ -290,6 +296,13 @@ export interface LifeComment {
|
|||||||
replies: LifeComment[];
|
replies: LifeComment[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LifeCommentsPage {
|
||||||
|
items: LifeComment[];
|
||||||
|
nextCursor: string | null;
|
||||||
|
hasMore: boolean;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface RecipeDetail extends Recipe {
|
export interface RecipeDetail extends Recipe {
|
||||||
acquisition_methods: NamedEntity[];
|
acquisition_methods: NamedEntity[];
|
||||||
editHistory: EditHistoryEntry[];
|
editHistory: EditHistoryEntry[];
|
||||||
@@ -566,6 +579,13 @@ export interface EntityDiscussionComment {
|
|||||||
replies: EntityDiscussionComment[];
|
replies: EntityDiscussionComment[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface EntityDiscussionCommentsPage {
|
||||||
|
items: EntityDiscussionComment[];
|
||||||
|
nextCursor: string | null;
|
||||||
|
hasMore: boolean;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface UserCommentActivity {
|
export interface UserCommentActivity {
|
||||||
id: number;
|
id: number;
|
||||||
source: ProfileCommentSource;
|
source: ProfileCommentSource;
|
||||||
@@ -833,11 +853,23 @@ export const api = {
|
|||||||
deleteLifeReaction: (id: string | number) => deleteAndGetJson<LifePost>(`/api/life-posts/${id}/reaction`),
|
deleteLifeReaction: (id: string | number) => deleteAndGetJson<LifePost>(`/api/life-posts/${id}/reaction`),
|
||||||
createLifeComment: (postId: string | number, payload: LifeCommentPayload) =>
|
createLifeComment: (postId: string | number, payload: LifeCommentPayload) =>
|
||||||
sendJson<LifeComment>(`/api/life-posts/${postId}/comments`, 'POST', payload),
|
sendJson<LifeComment>(`/api/life-posts/${postId}/comments`, 'POST', payload),
|
||||||
|
lifeComments: (postId: string | number, params: CommentPageParams = {}) =>
|
||||||
|
getJson<LifeCommentsPage>(
|
||||||
|
`/api/life-posts/${postId}/comments${buildQuery({
|
||||||
|
cursor: params.cursor ?? undefined,
|
||||||
|
limit: params.limit
|
||||||
|
})}`
|
||||||
|
),
|
||||||
createLifeCommentReply: (postId: string | number, commentId: string | number, payload: LifeCommentPayload) =>
|
createLifeCommentReply: (postId: string | number, commentId: string | number, payload: LifeCommentPayload) =>
|
||||||
sendJson<LifeComment>(`/api/life-posts/${postId}/comments/${commentId}/replies`, 'POST', payload),
|
sendJson<LifeComment>(`/api/life-posts/${postId}/comments/${commentId}/replies`, 'POST', payload),
|
||||||
deleteLifeComment: (id: string | number) => deleteJson(`/api/life-comments/${id}`),
|
deleteLifeComment: (id: string | number) => deleteJson(`/api/life-comments/${id}`),
|
||||||
entityDiscussion: (entityType: DiscussionEntityType, entityId: string | number) =>
|
entityDiscussion: (entityType: DiscussionEntityType, entityId: string | number, params: CommentPageParams = {}) =>
|
||||||
getJson<EntityDiscussionComment[]>(`/api/discussions/${entityType}/${entityId}/comments`),
|
getJson<EntityDiscussionCommentsPage>(
|
||||||
|
`/api/discussions/${entityType}/${entityId}/comments${buildQuery({
|
||||||
|
cursor: params.cursor ?? undefined,
|
||||||
|
limit: params.limit
|
||||||
|
})}`
|
||||||
|
),
|
||||||
createEntityDiscussionComment: (
|
createEntityDiscussionComment: (
|
||||||
entityType: DiscussionEntityType,
|
entityType: DiscussionEntityType,
|
||||||
entityId: string | number,
|
entityId: string | number,
|
||||||
|
|||||||
@@ -38,6 +38,17 @@ import {
|
|||||||
type NamedEntity
|
type NamedEntity
|
||||||
} from '../services/api';
|
} from '../services/api';
|
||||||
|
|
||||||
|
type LifeCommentPageState = {
|
||||||
|
items: LifeComment[];
|
||||||
|
nextCursor: string | null;
|
||||||
|
hasMore: boolean;
|
||||||
|
total: number;
|
||||||
|
loading: boolean;
|
||||||
|
loadingMore: boolean;
|
||||||
|
loaded: boolean;
|
||||||
|
error: string;
|
||||||
|
};
|
||||||
|
|
||||||
const { locale, t } = useI18n();
|
const { locale, t } = useI18n();
|
||||||
const posts = ref<LifePost[]>([]);
|
const posts = ref<LifePost[]>([]);
|
||||||
const lifeTags = ref<NamedEntity[]>([]);
|
const lifeTags = ref<NamedEntity[]>([]);
|
||||||
@@ -59,6 +70,7 @@ const commentBodies = ref<Record<number, string>>({});
|
|||||||
const replyBodies = ref<Record<number, string>>({});
|
const replyBodies = ref<Record<number, string>>({});
|
||||||
const replyTargetId = ref<number | null>(null);
|
const replyTargetId = ref<number | null>(null);
|
||||||
const expandedComments = ref<Record<number, boolean>>({});
|
const expandedComments = ref<Record<number, boolean>>({});
|
||||||
|
const commentPages = ref<Record<number, LifeCommentPageState>>({});
|
||||||
const commentBusyKey = ref('');
|
const commentBusyKey = ref('');
|
||||||
const commentErrors = ref<Record<string, string>>({});
|
const commentErrors = ref<Record<string, string>>({});
|
||||||
const reactionPickerPostId = ref<number | null>(null);
|
const reactionPickerPostId = ref<number | null>(null);
|
||||||
@@ -67,6 +79,7 @@ const reactionErrors = ref<Record<number, string>>({});
|
|||||||
const bodyInput = ref<HTMLTextAreaElement | null>(null);
|
const bodyInput = ref<HTMLTextAreaElement | null>(null);
|
||||||
const loadMoreSentinel = ref<HTMLElement | null>(null);
|
const loadMoreSentinel = ref<HTMLElement | null>(null);
|
||||||
const lifePostPageSize = 20;
|
const lifePostPageSize = 20;
|
||||||
|
const lifeCommentPageSize = 20;
|
||||||
const bodyMaxLength = 2000;
|
const bodyMaxLength = 2000;
|
||||||
const commentMaxLength = 1000;
|
const commentMaxLength = 1000;
|
||||||
const skeletonPostCount = 3;
|
const skeletonPostCount = 3;
|
||||||
@@ -158,6 +171,8 @@ async function loadPosts() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
posts.value = page.items;
|
posts.value = page.items;
|
||||||
|
expandedComments.value = {};
|
||||||
|
commentPages.value = {};
|
||||||
nextCursor.value = page.nextCursor;
|
nextCursor.value = page.nextCursor;
|
||||||
hasMorePosts.value = page.hasMore;
|
hasMorePosts.value = page.hasMore;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -338,8 +353,36 @@ function replyKey(commentId: number) {
|
|||||||
return `reply-${commentId}`;
|
return `reply-${commentId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function initialCommentPage(post: LifePost): LifeCommentPageState {
|
||||||
|
return {
|
||||||
|
items: post.commentPreview,
|
||||||
|
nextCursor: null,
|
||||||
|
hasMore: post.commentCount > post.commentPreview.reduce((count, comment) => count + 1 + comment.replies.length, 0),
|
||||||
|
total: post.commentCount,
|
||||||
|
loading: false,
|
||||||
|
loadingMore: false,
|
||||||
|
loaded: false,
|
||||||
|
error: ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function commentPage(post: LifePost) {
|
||||||
|
return commentPages.value[post.id] ?? initialCommentPage(post);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCommentPage(postId: number, page: LifeCommentPageState) {
|
||||||
|
commentPages.value = {
|
||||||
|
...commentPages.value,
|
||||||
|
[postId]: page
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function commentsForPost(post: LifePost) {
|
||||||
|
return commentPage(post).items;
|
||||||
|
}
|
||||||
|
|
||||||
function commentCount(post: LifePost) {
|
function commentCount(post: LifePost) {
|
||||||
return post.comments.reduce((count, comment) => count + 1 + comment.replies.length, 0);
|
return commentPage(post).total;
|
||||||
}
|
}
|
||||||
|
|
||||||
function reactionTotal(post: LifePost) {
|
function reactionTotal(post: LifePost) {
|
||||||
@@ -375,6 +418,13 @@ function replacePost(updatedPost: LifePost) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const existingComments = commentPages.value[updatedPost.id];
|
||||||
|
if (existingComments) {
|
||||||
|
setCommentPage(updatedPost.id, {
|
||||||
|
...existingComments,
|
||||||
|
total: updatedPost.commentCount
|
||||||
|
});
|
||||||
|
}
|
||||||
posts.value = posts.value.map((post) => (post.id === updatedPost.id ? updatedPost : post));
|
posts.value = posts.value.map((post) => (post.id === updatedPost.id ? updatedPost : post));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -389,8 +439,56 @@ function setCommentsExpanded(postId: number, expanded: boolean) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleComments(postId: number) {
|
function mergeComments(existing: LifeComment[], incoming: LifeComment[]) {
|
||||||
setCommentsExpanded(postId, !areCommentsExpanded(postId));
|
const ids = new Set(existing.map((comment) => comment.id));
|
||||||
|
return [...existing, ...incoming.filter((comment) => !ids.has(comment.id))];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadComments(post: LifePost, reset = false) {
|
||||||
|
const existing = commentPage(post);
|
||||||
|
if (existing.loading || existing.loadingMore || (!reset && existing.loaded && !existing.hasMore)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cursor = reset || !existing.loaded ? null : existing.nextCursor;
|
||||||
|
setCommentPage(post.id, {
|
||||||
|
...existing,
|
||||||
|
items: reset || !existing.loaded ? [] : existing.items,
|
||||||
|
loading: reset || !existing.loaded,
|
||||||
|
loadingMore: !reset && existing.loaded,
|
||||||
|
error: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const page = await api.lifeComments(post.id, { limit: lifeCommentPageSize, cursor });
|
||||||
|
const nextItems = reset || !existing.loaded ? page.items : mergeComments(existing.items, page.items);
|
||||||
|
setCommentPage(post.id, {
|
||||||
|
items: nextItems,
|
||||||
|
nextCursor: page.nextCursor,
|
||||||
|
hasMore: page.hasMore,
|
||||||
|
total: page.total,
|
||||||
|
loading: false,
|
||||||
|
loadingMore: false,
|
||||||
|
loaded: true,
|
||||||
|
error: ''
|
||||||
|
});
|
||||||
|
post.commentCount = page.total;
|
||||||
|
} catch (error) {
|
||||||
|
setCommentPage(post.id, {
|
||||||
|
...existing,
|
||||||
|
loading: false,
|
||||||
|
loadingMore: false,
|
||||||
|
error: error instanceof Error && error.message ? error.message : t('errors.loadFailed')
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleComments(post: LifePost) {
|
||||||
|
const expanded = !areCommentsExpanded(post.id);
|
||||||
|
setCommentsExpanded(post.id, expanded);
|
||||||
|
if (expanded) {
|
||||||
|
void loadComments(post);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function isCommentBusy(key: string) {
|
function isCommentBusy(key: string) {
|
||||||
@@ -420,6 +518,10 @@ function clearCommentError(key: string) {
|
|||||||
commentErrors.value = nextErrors;
|
commentErrors.value = nextErrors;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateCommentPage(post: LifePost, updater: (page: LifeCommentPageState) => LifeCommentPageState) {
|
||||||
|
setCommentPage(post.id, updater(commentPage(post)));
|
||||||
|
}
|
||||||
|
|
||||||
function setReactionError(postId: number, message: string) {
|
function setReactionError(postId: number, message: string) {
|
||||||
reactionErrors.value = { ...reactionErrors.value, [postId]: message };
|
reactionErrors.value = { ...reactionErrors.value, [postId]: message };
|
||||||
}
|
}
|
||||||
@@ -555,7 +657,14 @@ async function submitComment(post: LifePost) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const comment = await api.createLifeComment(post.id, { body: nextBody });
|
const comment = await api.createLifeComment(post.id, { body: nextBody });
|
||||||
post.comments.push(comment);
|
const nextTotal = commentCount(post) + 1;
|
||||||
|
post.commentCount = nextTotal;
|
||||||
|
updateCommentPage(post, (page) => ({
|
||||||
|
...page,
|
||||||
|
items: mergeComments(page.items, [comment]),
|
||||||
|
total: nextTotal,
|
||||||
|
loaded: page.loaded || areCommentsExpanded(post.id)
|
||||||
|
}));
|
||||||
commentBodies.value[post.id] = '';
|
commentBodies.value[post.id] = '';
|
||||||
setCommentsExpanded(post.id, true);
|
setCommentsExpanded(post.id, true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -578,7 +687,13 @@ async function submitReply(post: LifePost, comment: LifeComment) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const reply = await api.createLifeCommentReply(post.id, comment.id, { body: nextBody });
|
const reply = await api.createLifeCommentReply(post.id, comment.id, { body: nextBody });
|
||||||
|
const nextTotal = commentCount(post) + 1;
|
||||||
|
post.commentCount = nextTotal;
|
||||||
comment.replies.push(reply);
|
comment.replies.push(reply);
|
||||||
|
updateCommentPage(post, (page) => ({
|
||||||
|
...page,
|
||||||
|
total: nextTotal
|
||||||
|
}));
|
||||||
setCommentsExpanded(post.id, true);
|
setCommentsExpanded(post.id, true);
|
||||||
cancelReply(comment.id);
|
cancelReply(comment.id);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -615,7 +730,7 @@ async function deleteComment(post: LifePost, comment: LifeComment) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await api.deleteLifeComment(comment.id);
|
await api.deleteLifeComment(comment.id);
|
||||||
markCommentDeleted(post.comments, comment.id);
|
markCommentDeleted(commentsForPost(post), comment.id);
|
||||||
if (replyTargetId.value === comment.id) {
|
if (replyTargetId.value === comment.id) {
|
||||||
cancelReply(comment.id);
|
cancelReply(comment.id);
|
||||||
}
|
}
|
||||||
@@ -921,7 +1036,7 @@ onUnmounted(() => {
|
|||||||
:aria-controls="`life-comments-${post.id}`"
|
:aria-controls="`life-comments-${post.id}`"
|
||||||
:aria-expanded="areCommentsExpanded(post.id)"
|
:aria-expanded="areCommentsExpanded(post.id)"
|
||||||
:aria-label="areCommentsExpanded(post.id) ? t('pages.life.hideComments') : t('pages.life.comment')"
|
:aria-label="areCommentsExpanded(post.id) ? t('pages.life.hideComments') : t('pages.life.comment')"
|
||||||
@click="toggleComments(post.id)"
|
@click="toggleComments(post)"
|
||||||
>
|
>
|
||||||
<Icon :icon="iconComment" class="ui-icon" aria-hidden="true" />
|
<Icon :icon="iconComment" class="ui-icon" aria-hidden="true" />
|
||||||
<span class="life-action-tooltip" role="tooltip">
|
<span class="life-action-tooltip" role="tooltip">
|
||||||
@@ -955,7 +1070,7 @@ onUnmounted(() => {
|
|||||||
:aria-controls="`life-comments-${post.id}`"
|
:aria-controls="`life-comments-${post.id}`"
|
||||||
:aria-expanded="areCommentsExpanded(post.id)"
|
:aria-expanded="areCommentsExpanded(post.id)"
|
||||||
:aria-label="t('pages.life.commentsCount', { count: commentCount(post) })"
|
:aria-label="t('pages.life.commentsCount', { count: commentCount(post) })"
|
||||||
@click="toggleComments(post.id)"
|
@click="toggleComments(post)"
|
||||||
>
|
>
|
||||||
<Icon :icon="iconComment" class="ui-icon" aria-hidden="true" />
|
<Icon :icon="iconComment" class="ui-icon" aria-hidden="true" />
|
||||||
<span>{{ commentCount(post) }}</span>
|
<span>{{ commentCount(post) }}</span>
|
||||||
@@ -1000,9 +1115,23 @@ onUnmounted(() => {
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div v-if="post.comments.length" class="life-comment-list">
|
<div v-if="commentPage(post).loading && !commentsForPost(post).length" class="life-comment-list" :aria-label="t('pages.life.loadingComments')">
|
||||||
|
<article v-for="index in 2" :key="`life-comments-loading-${post.id}-${index}`" class="life-comment">
|
||||||
|
<div class="life-comment__main">
|
||||||
|
<Skeleton variant="box" width="36px" height="36px" />
|
||||||
|
<div class="life-comment__content">
|
||||||
|
<Skeleton width="132px" />
|
||||||
|
<Skeleton width="86%" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-else-if="commentPage(post).error" class="life-form__error" role="alert">{{ commentPage(post).error }}</p>
|
||||||
|
|
||||||
|
<div v-else-if="commentsForPost(post).length" class="life-comment-list">
|
||||||
<article
|
<article
|
||||||
v-for="comment in post.comments"
|
v-for="comment in commentsForPost(post)"
|
||||||
:key="comment.id"
|
:key="comment.id"
|
||||||
class="life-comment"
|
class="life-comment"
|
||||||
:class="{ 'is-deleted': comment.deleted }"
|
:class="{ 'is-deleted': comment.deleted }"
|
||||||
@@ -1116,6 +1245,18 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p v-else class="life-comments__empty">{{ t('pages.life.noComments') }}</p>
|
<p v-else class="life-comments__empty">{{ t('pages.life.noComments') }}</p>
|
||||||
|
|
||||||
|
<div v-if="commentPage(post).hasMore && !commentPage(post).loading" class="life-feed__retry">
|
||||||
|
<button
|
||||||
|
class="ui-button ui-button--ghost ui-button--small"
|
||||||
|
type="button"
|
||||||
|
:disabled="commentPage(post).loadingMore"
|
||||||
|
@click="loadComments(post)"
|
||||||
|
>
|
||||||
|
<Icon :icon="iconChevronDown" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ commentPage(post).loadingMore ? t('common.loading') : t('pages.life.loadMoreComments') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
|
|||||||
@@ -548,7 +548,7 @@ function authorInitial(post: LifePost): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function commentTotal(post: LifePost): number {
|
function commentTotal(post: LifePost): number {
|
||||||
return post.comments.reduce((total, comment) => total + 1 + comment.replies.length, 0);
|
return post.commentCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
function reactionTotal(post: LifePost): number {
|
function reactionTotal(post: LifePost): number {
|
||||||
|
|||||||
@@ -483,6 +483,8 @@ export const systemWordingMessages = {
|
|||||||
postingReply: 'Posting reply',
|
postingReply: 'Posting reply',
|
||||||
cancelReply: 'Cancel reply',
|
cancelReply: 'Cancel reply',
|
||||||
noComments: 'No comments yet',
|
noComments: 'No comments yet',
|
||||||
|
loadingComments: 'Loading comments',
|
||||||
|
loadMoreComments: 'Load more comments',
|
||||||
deleteComment: 'Delete comment',
|
deleteComment: 'Delete comment',
|
||||||
deleteCommentConfirm: 'Delete this comment?',
|
deleteCommentConfirm: 'Delete this comment?',
|
||||||
commentDeleted: 'Comment deleted',
|
commentDeleted: 'Comment deleted',
|
||||||
@@ -659,6 +661,7 @@ export const systemWordingMessages = {
|
|||||||
replyFailed: 'Reply failed',
|
replyFailed: 'Reply failed',
|
||||||
deleteFailed: 'Delete failed',
|
deleteFailed: 'Delete failed',
|
||||||
loading: 'Loading discussion',
|
loading: 'Loading discussion',
|
||||||
|
loadMore: 'Load more comments',
|
||||||
empty: 'No discussion yet',
|
empty: 'No discussion yet',
|
||||||
emptyHint: 'Start a new discussion now.',
|
emptyHint: 'Start a new discussion now.',
|
||||||
loginPrompt: 'Log in with a verified email to comment.',
|
loginPrompt: 'Log in with a verified email to comment.',
|
||||||
@@ -1265,6 +1268,8 @@ export const systemWordingMessages = {
|
|||||||
postingReply: '回复中',
|
postingReply: '回复中',
|
||||||
cancelReply: '取消回复',
|
cancelReply: '取消回复',
|
||||||
noComments: '暂无评论',
|
noComments: '暂无评论',
|
||||||
|
loadingComments: '正在加载评论',
|
||||||
|
loadMoreComments: '加载更多评论',
|
||||||
deleteComment: '删除评论',
|
deleteComment: '删除评论',
|
||||||
deleteCommentConfirm: '确认删除这条评论?',
|
deleteCommentConfirm: '确认删除这条评论?',
|
||||||
commentDeleted: '评论已删除',
|
commentDeleted: '评论已删除',
|
||||||
@@ -1441,6 +1446,7 @@ export const systemWordingMessages = {
|
|||||||
replyFailed: '回复失败',
|
replyFailed: '回复失败',
|
||||||
deleteFailed: '删除失败',
|
deleteFailed: '删除失败',
|
||||||
loading: '正在加载讨论',
|
loading: '正在加载讨论',
|
||||||
|
loadMore: '加载更多评论',
|
||||||
empty: '暂无讨论',
|
empty: '暂无讨论',
|
||||||
emptyHint: '现在发起新的讨论。',
|
emptyHint: '现在发起新的讨论。',
|
||||||
loginPrompt: '使用已验证邮箱登录后即可评论。',
|
loginPrompt: '使用已验证邮箱登录后即可评论。',
|
||||||
|
|||||||
Reference in New Issue
Block a user