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
This commit is contained in:
2026-07-15 04:43:46 -07:00
committed by Hop
parent 93be1cffd8
commit c1f3afa103
4 changed files with 144 additions and 9 deletions
+17 -3
View File
@@ -1,7 +1,7 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { db, notifications } from '@/db'; import { db, notifications } from '@/db';
import { requireAuth } from '@/lib/auth'; 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'; import { z } from 'zod';
const markSchema = z.object({ const markSchema = z.object({
@@ -52,6 +52,14 @@ export async function GET(request: Request) {
}, },
orderBy: (notifications, { desc }) => [desc(notifications.createdAt)], orderBy: (notifications, { desc }) => [desc(notifications.createdAt)],
limit, limit,
with: {
post: {
with: {
author: true,
media: true,
},
},
},
}); });
// For remote actors missing avatar, fetch fresh data // For remote actors missing avatar, fetch fresh data
@@ -108,8 +116,14 @@ export async function GET(request: Request) {
} : null, } : null,
post: row.postId ? { post: row.postId ? {
id: row.postId, id: row.postId,
content: row.postContent, content: row.post?.content || row.postContent,
authorHandle: row.actorHandle, // The actor is the post author for likes/reposts 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, } : null,
}; };
}); });
+39 -6
View File
@@ -6,6 +6,7 @@ import Link from 'next/link';
import { useAuth } from '@/lib/contexts/AuthContext'; import { useAuth } from '@/lib/contexts/AuthContext';
import { getProfilePath } from '@/lib/utils/handle'; import { getProfilePath } from '@/lib/utils/handle';
import { AvatarImage } from '@/components/AvatarImage'; import { AvatarImage } from '@/components/AvatarImage';
import { getNotificationPostPreview } from '@/lib/notifications/post-preview';
interface NotificationActor { interface NotificationActor {
id: string; id: string;
@@ -24,7 +25,14 @@ interface NotificationTarget {
interface NotificationPost { interface NotificationPost {
id: string; 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 { interface Notification {
@@ -206,6 +214,9 @@ function NotificationItem({
const isUnread = !notification.readAt; const isUnread = !notification.readAt;
const actor = notification.actor; const actor = notification.actor;
const actorProfilePath = actor ? getProfilePath(actor.handle) : '#'; const actorProfilePath = actor ? getProfilePath(actor.handle) : '#';
const postPreview = notification.post
? getNotificationPostPreview(notification.post)
: null;
return ( return (
<div <div
@@ -244,20 +255,42 @@ function NotificationItem({
<Link <Link
href={`/posts/${notification.post.id}`} href={`/posts/${notification.post.id}`}
style={{ style={{
display: 'block', display: 'flex',
alignItems: 'center',
gap: '10px',
marginTop: '8px', marginTop: '8px',
padding: '8px 12px', padding: '8px',
background: 'var(--background-secondary)', background: 'var(--background-secondary)',
borderRadius: '8px', borderRadius: '8px',
color: 'var(--foreground-secondary)', color: 'var(--foreground-secondary)',
fontSize: '14px', fontSize: '14px',
textDecoration: 'none', textDecoration: 'none',
overflow: 'hidden', overflow: 'hidden',
}}
aria-label={`View liked post: ${postPreview?.label || 'View post'}`}
>
{postPreview?.imageUrl && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={postPreview.imageUrl}
alt={postPreview.imageAlt}
style={{
width: 52,
height: 52,
borderRadius: '6px',
objectFit: 'cover',
flexShrink: 0,
}}
/>
)}
<span style={{
minWidth: 0,
overflow: 'hidden',
textOverflow: 'ellipsis', textOverflow: 'ellipsis',
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
}} }}>
> {postPreview?.label || 'View post'}
{notification.post.content} </span>
</Link> </Link>
)} )}
</div> </div>
@@ -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');
});
});
+42
View File
@@ -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'),
};
}