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