feat(comments) : show heart giver profiles on comments and notifications

This commit is contained in:
2026-10-13 00:45:02 +07:00 Unverified
parent f8ef86cb5e
commit d1dff1a763
8 changed files with 135 additions and 23 deletions
+4
View File
@@ -78,6 +78,7 @@ export async function authorizeComment(id: string, viewer: CommentViewer | null,
const root = alias(comments, "discussion_root");
const parent = alias(comments, "discussion_parent");
const recipient = alias(users, "discussion_recipient");
const heartGiver = alias(users, "discussion_heart_giver");
const likes = sql<number>`(select count(*)::int from ${commentReactions} where ${commentReactions.commentId} = ${comments.id} and ${commentReactions.value} = 1)`;
const replyCount = sql<number>`(select count(*)::int from ${comments} replies where replies.root_id = ${comments.id})`;
const publicTarget = sql<boolean>`(${commentThreads.guideId} is null or (${guides.isPublic} and ${guides.trashedAt} is null))`;
@@ -146,6 +147,7 @@ export async function listComments(options: {
authorAdmin: sql<boolean>`coalesce(${users.role} = 'admin' and ${users.emailVerified}, false)`,
authorBanned: users.banned,
replyToName: recipient.name, rootHidden: root.hidden, likes, replyCount,
heartedByName: heartGiver.name, heartedByImage: heartGiver.image,
hasReplies: sql<boolean>`exists(select 1 from ${comments} children where children.root_id = coalesce(${comments.rootId}, ${comments.id}) and children.reply_to_id = ${comments.id} and children.deleted_at is null)`,
reaction: viewer ? sql<number>`coalesce((select value from ${commentReactions} where ${commentReactions.commentId} = ${comments.id} and ${commentReactions.userId} = ${viewer.id}), 0)` : sql<number>`0`,
guideId: commentThreads.guideId, scheduleId: commentThreads.scheduleId, guideName: guides.name, guideCoverId: guides.coverMediaId, guideSlug: guides.slug, guideTrashedAt: guides.trashedAt,
@@ -154,6 +156,7 @@ export async function listComments(options: {
.innerJoin(commentThreads, eq(commentThreads.id, comments.threadId))
.innerJoin(commentRevisions, and(eq(commentRevisions.commentId, comments.id), eq(commentRevisions.version, comments.version)))
.innerJoin(users, eq(users.id, comments.authorId))
.leftJoin(heartGiver, eq(heartGiver.id, comments.heartedById))
.leftJoin(root, eq(root.id, comments.rootId)).leftJoin(parent, eq(parent.id, comments.replyToId)).leftJoin(recipient, eq(recipient.id, parent.authorId))
.leftJoin(guides, eq(guides.id, commentThreads.guideId)).leftJoin(stygianSchedules, eq(stygianSchedules.scheduleId, commentThreads.scheduleId))
.where(and(...conditions)).orderBy(...(top ? [desc(likes)] : []), ascending ? asc(comments.createdAt) : desc(comments.createdAt), ascending ? asc(comments.id) : desc(comments.id)).limit(21);
@@ -172,6 +175,7 @@ export async function listComments(options: {
text: contentAllowed ? row.revision.text : "", images: contentAllowed ? images.get(row.revision.id) ?? [] : [],
version: c.version, createdAt: c.createdAt.toISOString(), editedAt: row.revision.createdAt.toISOString(), hidden, ownHidden: c.hidden,
deleted: Boolean(c.deletedAt), hearted: Boolean(c.heartedById), likes: Number(row.likes), reaction: Number(row.reaction),
heartedBy: c.heartedById && row.heartedByName !== null ? { id: c.heartedById, name: row.heartedByName, image: row.heartedByImage } : null,
replyCount: Number(row.replyCount), hasReplies: row.hasReplies, canEdit: viewer?.id === c.authorId && !c.deletedAt && !hidden && !row.guideTrashedAt,
canModerate: Boolean(viewer?.admin) && !row.guideTrashedAt,
canInteract: !c.deletedAt && !hidden && !row.guideTrashedAt && (row.publicTarget || Boolean(viewer?.admin)),
+1
View File
@@ -19,6 +19,7 @@ export interface CommentItem {
ownHidden: boolean;
deleted: boolean;
hearted: boolean;
heartedBy?: { id: string; name: string; image: string | null } | null;
likes: number;
reaction: number;
replyCount: number;
+16 -2
View File
@@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { PgDialect } from 'drizzle-orm/pg-core';
import type { SQL } from 'drizzle-orm';
const state = vi.hoisted(() => ({ rows: [] as unknown[][], filters: [] as unknown[], select: vi.fn() }));
const state = vi.hoisted(() => ({ rows: [] as unknown[][], filters: [] as unknown[], joins: [] as unknown[], select: vi.fn() }));
vi.mock('server-only', () => ({}));
vi.mock('./events', () => ({ notifyNotificationChange: vi.fn() }));
vi.mock('@/lib/media/storage', () => ({ publicMediaUrl: (key: string) => `https://cdn.example/${key}` }));
@@ -20,10 +20,11 @@ function row(kind: string, eventKey: string) {
beforeEach(() => {
state.rows = [];
state.filters = [];
state.joins = [];
state.select.mockReset().mockImplementation(() => {
const rows = state.rows.shift() ?? [];
const query = {
from: () => query, leftJoin: () => query, innerJoin: () => query,
from: () => query, leftJoin: (_table: unknown, condition: unknown) => { state.joins.push(condition); return query; }, innerJoin: () => query,
where: (filter: unknown) => { state.filters.push(filter); return query; }, orderBy: () => query, limit: () => query,
then: (resolve: (value: unknown[]) => unknown) => Promise.resolve(rows).then(resolve),
};
@@ -61,4 +62,17 @@ describe('guide overview images in notification feeds', () => {
state.rows = [[row('guide_published', `guide:${guideId}:published`)], [{ count: 1 }], []];
expect((await listNotifications(user)).items[0].imageUrl).toBeNull();
});
it('uses the heart giver for heart notifications and the author for new comments and replies', async () => {
state.rows = [[{ ...row('comment_heart', 'comment:1:heart'), authorName: 'Admin', authorImage: 'https://profile.example/admin.png' }], [{ count: 1 }]];
const result = await listNotifications(user);
expect(result.items[0]).toMatchObject({ authorName: 'Admin', authorImage: 'https://profile.example/admin.png', imageUrl: null });
const joins = state.joins.map(condition => new PgDialect().sqlToQuery(condition as SQL));
const profileJoin = joins.find(join => join.sql.includes('case when'))!;
expect(profileJoin.sql).toContain("= 'comment_heart' then");
expect(profileJoin.sql).toContain('"hearted_by_id" else');
expect(profileJoin.sql).toContain('"author_id" end');
expect(profileJoin.params).toContain('comment_heart');
expect(state.select).toHaveBeenCalledTimes(2);
});
});
+2 -1
View File
@@ -24,7 +24,8 @@ export async function listNotifications(user: { id: string; role: string | null;
))));
const query = () => getDb().select({ notification: notifications, authorName: users.name, authorImage: users.image, cursorTime: sql<string>`to_char(${notifications.createdAt} at time zone 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"')` }).from(notifications)
.leftJoin(comments, eq(comments.id, notifications.commentId)).leftJoin(root, eq(root.id, comments.rootId))
.leftJoin(users, and(eq(users.id, comments.authorId), inArray(notifications.kind, ['comment_new', 'comment_reply', 'comment_edit'])))
.leftJoin(users, and(eq(users.id, sql`case when ${notifications.kind} = 'comment_heart' then ${comments.heartedById} else ${comments.authorId} end`),
inArray(notifications.kind, ['comment_new', 'comment_reply', 'comment_edit', 'comment_heart'])))
.leftJoin(commentThreads, eq(commentThreads.id, comments.threadId)).leftJoin(guides, eq(guides.id, commentThreads.guideId));
const [rows, count] = await Promise.all([
query().where(and(visible, before ? sql`(${notifications.createdAt}, ${notifications.id}) < (${before.time}::timestamptz, ${before.id}::uuid)` : undefined)).orderBy(desc(notifications.createdAt), desc(notifications.id)).limit(21),