feat(comments) : show heart giver profiles on comments and notifications
This commit is contained in:
@@ -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)),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user