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
+

+ )}
+
- {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'),
+ };
+}