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:
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
@@ -244,20 +255,42 @@ function NotificationItem({
|
||||
<Link
|
||||
href={`/posts/${notification.post.id}`}
|
||||
style={{
|
||||
display: 'block',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
marginTop: '8px',
|
||||
padding: '8px 12px',
|
||||
padding: '8px',
|
||||
background: 'var(--background-secondary)',
|
||||
borderRadius: '8px',
|
||||
color: 'var(--foreground-secondary)',
|
||||
fontSize: '14px',
|
||||
textDecoration: 'none',
|
||||
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',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{notification.post.content}
|
||||
}}>
|
||||
{postPreview?.label || 'View post'}
|
||||
</span>
|
||||
</Link>
|
||||
)}
|
||||
</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');
|
||||
});
|
||||
});
|
||||
@@ -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'),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user