From c1f3afa103ccf6bced6a2ee49f3aaff92285624c Mon Sep 17 00:00:00 2001 From: cyph3rasi Date: Wed, 15 Jul 2026 04:43:46 -0700 Subject: [PATCH] Show the liked post in notifications with text or media preview Hop-State: A_06FPB5RGJ37K5NPB79MT66G Hop-Proposal: R_06FPB5QJB3W279N6JSGB8R0 Hop-Task: T_06FPB4PW583MAWM13VA1VT8 Hop-Attempt: AT_06FPB4PW5A561YFQGJBWW1G --- src/app/api/notifications/route.ts | 20 ++++++++-- src/app/notifications/page.tsx | 45 ++++++++++++++++++--- src/lib/notifications/post-preview.test.ts | 46 ++++++++++++++++++++++ src/lib/notifications/post-preview.ts | 42 ++++++++++++++++++++ 4 files changed, 144 insertions(+), 9 deletions(-) create mode 100644 src/lib/notifications/post-preview.test.ts create mode 100644 src/lib/notifications/post-preview.ts diff --git a/src/app/api/notifications/route.ts b/src/app/api/notifications/route.ts index 229d6d7..d79b8d6 100644 --- a/src/app/api/notifications/route.ts +++ b/src/app/api/notifications/route.ts @@ -1,7 +1,7 @@ import { NextResponse } from 'next/server'; import { db, notifications } from '@/db'; import { requireAuth } from '@/lib/auth'; -import { and, desc, eq, inArray, isNull } from 'drizzle-orm'; +import { and, eq, inArray } from 'drizzle-orm'; import { z } from 'zod'; const markSchema = z.object({ @@ -52,6 +52,14 @@ export async function GET(request: Request) { }, orderBy: (notifications, { desc }) => [desc(notifications.createdAt)], limit, + with: { + post: { + with: { + author: true, + media: true, + }, + }, + }, }); // For remote actors missing avatar, fetch fresh data @@ -108,8 +116,14 @@ export async function GET(request: Request) { } : null, post: row.postId ? { id: row.postId, - content: row.postContent, - authorHandle: row.actorHandle, // The actor is the post author for likes/reposts + content: row.post?.content || row.postContent, + authorHandle: row.post?.author?.handle || null, + media: row.post?.media.map((item) => ({ + url: item.url, + mimeType: item.mimeType, + altText: item.altText, + })) || [], + linkPreviewImage: row.post?.linkPreviewImage || null, } : null, }; }); diff --git a/src/app/notifications/page.tsx b/src/app/notifications/page.tsx index 2583d82..99483a2 100644 --- a/src/app/notifications/page.tsx +++ b/src/app/notifications/page.tsx @@ -6,6 +6,7 @@ import Link from 'next/link'; import { useAuth } from '@/lib/contexts/AuthContext'; import { getProfilePath } from '@/lib/utils/handle'; import { AvatarImage } from '@/components/AvatarImage'; +import { getNotificationPostPreview } from '@/lib/notifications/post-preview'; interface NotificationActor { id: string; @@ -24,7 +25,14 @@ interface NotificationTarget { interface NotificationPost { id: string; - content: string; + content: string | null; + authorHandle: string | null; + media: Array<{ + url: string; + mimeType: string | null; + altText: string | null; + }>; + linkPreviewImage: string | null; } interface Notification { @@ -206,6 +214,9 @@ function NotificationItem({ const isUnread = !notification.readAt; const actor = notification.actor; const actorProfilePath = actor ? getProfilePath(actor.handle) : '#'; + const postPreview = notification.post + ? getNotificationPostPreview(notification.post) + : null; return (
+ {postPreview?.imageUrl && ( + // eslint-disable-next-line @next/next/no-img-element + {postPreview.imageAlt} + )} + - {notification.post.content} + }}> + {postPreview?.label || 'View post'} + )}
diff --git a/src/lib/notifications/post-preview.test.ts b/src/lib/notifications/post-preview.test.ts new file mode 100644 index 0000000..bbfd305 --- /dev/null +++ b/src/lib/notifications/post-preview.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; +import { getNotificationPostPreview } from './post-preview'; + +describe('getNotificationPostPreview', () => { + it('uses the post text when a caption is present', () => { + expect(getNotificationPostPreview({ + content: 'The post that was liked', + media: [], + linkPreviewImage: null, + })).toMatchObject({ + label: 'The post that was liked', + imageUrl: null, + }); + }); + + it('identifies and previews a media-only image post', () => { + expect(getNotificationPostPreview({ + content: '', + media: [{ url: 'https://media.example/photo.jpg', mimeType: 'image/jpeg', altText: 'Sunset' }], + linkPreviewImage: null, + })).toEqual({ + label: 'Photo', + imageUrl: 'https://media.example/photo.jpg', + imageAlt: 'Sunset', + }); + }); + + it('labels non-image media and counts additional attachments', () => { + expect(getNotificationPostPreview({ + content: null, + media: [ + { url: 'https://media.example/song.mp3', mimeType: 'audio/mpeg', altText: null }, + { url: 'https://media.example/cover.jpg', mimeType: 'image/jpeg', altText: null }, + ], + linkPreviewImage: null, + }).label).toBe('Audio + 1 more'); + }); + + it('never renders an empty preview', () => { + expect(getNotificationPostPreview({ + content: ' ', + media: [], + linkPreviewImage: null, + }).label).toBe('View post'); + }); +}); diff --git a/src/lib/notifications/post-preview.ts b/src/lib/notifications/post-preview.ts new file mode 100644 index 0000000..d491729 --- /dev/null +++ b/src/lib/notifications/post-preview.ts @@ -0,0 +1,42 @@ +interface NotificationPostMedia { + url: string; + mimeType: string | null; + altText: string | null; +} + +interface NotificationPostPreviewInput { + content: string | null; + media: NotificationPostMedia[]; + linkPreviewImage: string | null; +} + +export interface NotificationPostPreview { + label: string; + imageUrl: string | null; + imageAlt: string; +} + +function mediaTypeLabel(mimeType: string | null): string { + if (mimeType?.startsWith('image/')) return 'Photo'; + if (mimeType?.startsWith('video/')) return 'Video'; + if (mimeType?.startsWith('audio/')) return 'Audio'; + return 'Media'; +} + +export function getNotificationPostPreview( + post: NotificationPostPreviewInput, +): NotificationPostPreview { + const content = post.content?.trim(); + const firstMedia = post.media[0]; + const mediaLabel = firstMedia ? mediaTypeLabel(firstMedia.mimeType) : null; + const extraMediaCount = Math.max(0, post.media.length - 1); + const attachmentSuffix = extraMediaCount > 0 ? ` + ${extraMediaCount} more` : ''; + + return { + label: content || (mediaLabel ? `${mediaLabel}${attachmentSuffix}` : 'View post'), + imageUrl: firstMedia?.mimeType?.startsWith('image/') + ? firstMedia.url + : post.linkPreviewImage, + imageAlt: firstMedia?.altText || (mediaLabel ? `${mediaLabel} preview` : 'Post preview'), + }; +}