Skip to content

Commit e58cd16

Browse files
committed
fix: grouped like notifications
1 parent d185ae9 commit e58cd16

File tree

10 files changed

+147
-41
lines changed

10 files changed

+147
-41
lines changed

src/components/PostCard.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,12 @@ export function PostCard({ post, context, className, onClick }: PostCardProps) {
7474
{/* {!!post.record.reply && <PostCard post={reply} />} */}
7575
<div className="flex items-center space-x-3 mb-2">
7676
{post.author.avatar && (
77-
<Image type="avatar" src={post.author.avatar} alt={post.author.handle} className="w-10 h-10 rounded-full" />
77+
<Image
78+
type="avatar"
79+
src={post.author.avatar}
80+
alt={post.author.handle}
81+
classNames={{ image: 'w-10 h-10 rounded-full' }}
82+
/>
7883
)}
7984
<div>
8085
<div>

src/components/PostEmbed.tsx

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,9 @@ export const PostEmbed = ({ embed }: { embed?: BSkyPostEmbed | null }) => {
2727
key={image.thumb}
2828
src={image.thumb}
2929
alt={image.alt}
30-
className={cn(embed.images.length >= 2 && 'h-48', 'rounded-lg w-full object-cover')}
30+
classNames={{
31+
image: cn(embed.images.length >= 2 && 'h-48', 'rounded-lg w-full object-cover'),
32+
}}
3133
/>
3234
))}
3335
</div>
@@ -62,7 +64,9 @@ export const PostEmbed = ({ embed }: { embed?: BSkyPostEmbed | null }) => {
6264
type="post"
6365
src={embed.external.uri ?? embed.external.thumb}
6466
alt={embed.external.title}
65-
className="rounded-lg w-full aspect-square object-cover"
67+
classNames={{
68+
image: 'rounded-lg w-full aspect-square object-cover',
69+
}}
6670
/>
6771
</div>
6872
);
@@ -84,7 +88,12 @@ export const PostEmbed = ({ embed }: { embed?: BSkyPostEmbed | null }) => {
8488
{embed.record.$type === 'app.bsky.embed.record#viewRecord' && (
8589
<div className="flex items-center space-x-3 mb-2">
8690
{author.avatar && (
87-
<Image type="avatar" src={author.avatar} alt={author.handle} className="w-10 h-10 rounded-full" />
91+
<Image
92+
type="avatar"
93+
src={author.avatar}
94+
alt={author.handle}
95+
classNames={{ image: 'w-10 h-10 rounded-full' }}
96+
/>
8897
)}
8998
<div>
9099
<div className="font-medium text-gray-900 dark:text-gray-100">
@@ -160,7 +169,9 @@ export const PostEmbed = ({ embed }: { embed?: BSkyPostEmbed | null }) => {
160169
key={embed.media.external.uri}
161170
src={embed.media.external.uri ?? embed.media.external.thumb}
162171
alt={embed.media.external.description}
163-
className="rounded-lg w-full object-cover"
172+
classNames={{
173+
image: 'rounded-lg w-full object-cover',
174+
}}
164175
/>
165176
</div>
166177
</>

src/components/ui/Image.tsx

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,15 @@ import { cn } from '../../lib/utils';
33
import { useSettings } from '../../hooks/useSetting';
44
import { useTranslation } from 'react-i18next';
55

6-
type ImageProps = React.ImgHTMLAttributes<HTMLImageElement> & {
6+
type ImageProps = Omit<React.ImgHTMLAttributes<HTMLImageElement>, 'className'> & {
77
type: 'avatar' | 'post' | 'banner';
8+
classNames: {
9+
wrapper?: string;
10+
image?: string;
11+
};
812
};
913

10-
export const Image = ({ src, alt, type, ...props }: ImageProps) => {
14+
export const Image = ({ src, alt, type, classNames, ...props }: ImageProps) => {
1115
const { experiments } = useSettings();
1216
const { t } = useTranslation('image');
1317
const [showAltText, setShowAltText] = useState(false);
@@ -37,7 +41,7 @@ export const Image = ({ src, alt, type, ...props }: ImageProps) => {
3741

3842
return (
3943
<>
40-
<div className="relative">
44+
<div className={cn('relative', classNames.wrapper)}>
4145
{alt &&
4246
type === 'post' &&
4347
(showAltText ? (
@@ -58,7 +62,7 @@ export const Image = ({ src, alt, type, ...props }: ImageProps) => {
5862
alt={alt}
5963
{...props}
6064
onClick={imageOnClick}
61-
className={cn(props.className, experiments.streamerMode && 'filter blur-md')}
65+
className={cn(classNames.image, experiments.streamerMode && 'filter blur-md')}
6266
/>
6367
</div>
6468
{isFullscreen && (
@@ -71,7 +75,7 @@ export const Image = ({ src, alt, type, ...props }: ImageProps) => {
7175
src={src}
7276
alt={alt}
7377
{...props}
74-
className={cn(props.className, 'h-full w-full', experiments.streamerMode && 'filter blur-md')}
78+
className={cn(classNames.image, 'h-full w-full', experiments.streamerMode && 'filter blur-md')}
7579
/>
7680
</div>
7781
</div>

src/lib/bluesky/hooks/useNotifications.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { useQuery } from '@tanstack/react-query';
22
import { useBlueskyStore } from '../store';
3+
import { BSkyNotification } from '../types/BSkyNotification';
34

45
export function useNotifications() {
56
const { agent } = useBlueskyStore();
@@ -10,7 +11,7 @@ export function useNotifications() {
1011
if (!agent) throw new Error('Not authenticated');
1112

1213
const response = await agent.api.app.bsky.notification.listNotifications();
13-
return response.data.notifications;
14+
return response.data.notifications as BSkyNotification[];
1415
},
1516
enabled: !!agent,
1617
});

src/lib/bluesky/types/Author.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,15 @@ export const Author = Type.Object({
1515
}),
1616
labels: Type.Array(BSkyPostLabel),
1717
createdAt: Type.String(),
18+
indexedAt: Type.Optional(Type.String()),
1819
associated: Type.Optional(
1920
Type.Object({
2021
chat: Type.Object({
2122
allowIncoming: Type.String(),
2223
}),
2324
}),
2425
),
26+
description: Type.Optional(Type.String()),
2527
});
2628

2729
export type Author = Static<typeof Author>;
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { Static, Type } from '@sinclair/typebox';
2+
import { BSkyAuthor } from './BskyAuthor';
3+
4+
export const BSkyNotificationFeedLike = Type.Object({
5+
$type: Type.Literal('app.bsky.feed.like'),
6+
createdAt: Type.String(),
7+
subject: Type.Object({
8+
cid: Type.String(),
9+
uri: Type.String(),
10+
}),
11+
});
12+
13+
export const BSkyNotification = Type.Object({
14+
uri: Type.String(),
15+
cid: Type.String(),
16+
author: BSkyAuthor,
17+
reason: Type.Union([
18+
Type.Literal('like'),
19+
Type.Literal('repost'),
20+
Type.Literal('follow'),
21+
Type.Literal('mention'),
22+
Type.Literal('reply'),
23+
Type.Literal('quote'),
24+
Type.Literal('starterpack-joined'),
25+
]),
26+
reasonSubject: Type.Optional(Type.String()),
27+
record: Type.Union([BSkyNotificationFeedLike]),
28+
isRead: Type.Boolean(),
29+
indexedAt: Type.String(),
30+
labels: Type.Optional(Type.Array(Type.Any())),
31+
});
32+
33+
export type BSkyNotification = Static<typeof BSkyNotification>;

src/lib/bluesky/types/BlockedAuthor.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export const BlockedAuthor = Type.Object({
1616
}),
1717
labels: Type.Array(BSkyPostLabel),
1818
createdAt: Type.String(),
19+
indexedAt: Type.Optional(Type.String()),
1920
associated: Type.Optional(
2021
Type.Object({
2122
chat: Type.Object({

src/routes/messages/index.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,9 @@ function Messages() {
2828
<Image
2929
type="avatar"
3030
src={member.avatar}
31-
className={cn('size-24', member.associated?.labeler ? 'aspect-square' : 'rounded-full')}
31+
classNames={{
32+
image: cn('size-24', member.associated?.labeler ? 'aspect-square' : 'rounded-full'),
33+
}}
3234
/>
3335
<div>{convo.lastMessage?.text as string}</div>
3436
</div>

src/routes/notifications.tsx

Lines changed: 72 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,13 @@ import { createFileRoute } from '@tanstack/react-router';
33
import { useTranslation } from 'react-i18next';
44
import { useNotifications } from '../lib/bluesky/hooks/useNotifications';
55
import { Debug } from '../components/ui/Debug';
6-
import { Notification as BskyNotification } from '@atproto/api/dist/client/types/app/bsky/notification/listNotifications';
76
import { useState } from 'react';
87
import { cn } from '../lib/utils';
8+
import { BSkyNotification } from '../lib/bluesky/types/BSkyNotification';
9+
import { Link } from '../components/ui/Link';
10+
import { useBlueskyStore } from '../lib/bluesky/store';
11+
import { Image } from '../components/ui/Image';
12+
import { Handle } from '../components/ui/Handle';
913

1014
export const Route = createFileRoute('/notifications')({
1115
component: RouteComponent,
@@ -62,7 +66,7 @@ function RouteComponent() {
6266
</Ariakit.TabList>
6367
<div className="p-2">
6468
<Ariakit.TabPanel tabId="grouped">
65-
<GroupedNotifications notifications={notifications} />
69+
{notifications && <GroupedNotifications notifications={notifications} />}
6670
</Ariakit.TabPanel>
6771
<Ariakit.TabPanel tabId="all">
6872
{notifications?.map((notification) => <Notification key={notification.uri} notification={notification} />)}
@@ -76,46 +80,63 @@ function RouteComponent() {
7680
);
7781
}
7882

79-
// group notifications by uri and group up to 3 notifications per group
80-
function GroupedNotifications({ notifications }: { notifications: BskyNotification[] }) {
83+
// group notifications by uri
84+
function GroupedNotifications({ notifications }: { notifications: BSkyNotification[] }) {
8185
const { t } = useTranslation('notifications');
8286
const grouped = notifications.reduce(
8387
(acc, notification) => {
84-
if (notification.reason === 'follow') {
85-
return acc;
88+
if (notification.reason === 'like') {
89+
if (acc[notification.record.subject.uri]) {
90+
acc[notification.record.subject.uri]?.push(notification);
91+
} else {
92+
acc[notification.record.subject.uri] = [notification];
93+
}
8694
}
87-
88-
if (!acc[uri]) {
89-
acc[uri] = [];
90-
}
91-
92-
acc[uri].push(notification.record);
93-
9495
return acc;
9596
},
96-
{} as Record<string, BskyNotification[]>,
97+
{} as Record<string, BSkyNotification[]>,
9798
);
9899

99100
return (
100101
<div>
101102
{Object.entries(grouped).map(([uri, notifications]) => (
102103
<div key={uri} className="p-2 bg-neutral-800 rounded-lg mb-2">
103104
<div className="text-sm text-neutral-400">{t('groupedNotifications')}</div>
104-
{notifications.map((notification) => (
105-
<Notification key={notification.uri} notification={notification} />
106-
))}
105+
<GroupNotification key={notifications[0]?.uri} notifications={notifications} />
107106
</div>
108107
))}
109108
</div>
110109
);
111110
}
112111

113-
function Notification({ notification }: { notification: BskyNotification }) {
112+
function GroupNotification({ notifications }: { notifications: BSkyNotification[] }) {
113+
const notification = notifications[0];
114+
if (!notification) return null;
115+
116+
switch (notification?.reason) {
117+
case 'follow':
118+
return <FollowNotification notification={notification} />;
119+
case 'like':
120+
return <LikeNotification notifications={notifications} />;
121+
case 'repost':
122+
return <RepostNotification notification={notification} />;
123+
case 'reply':
124+
return <ReplyNotification notification={notification} />;
125+
case 'mention':
126+
return <MentionNotification notification={notification} />;
127+
case 'quote':
128+
return <QuoteNotification notification={notification} />;
129+
case 'starterpack-joined':
130+
return <StarterpackJoinedNotification notification={notification} />;
131+
}
132+
}
133+
134+
function Notification({ notification }: { notification: BSkyNotification }) {
114135
switch (notification.reason) {
115136
case 'follow':
116137
return <FollowNotification notification={notification} />;
117138
case 'like':
118-
return <LikeNotification notification={notification} />;
139+
return <LikeNotification notifications={[notification]} />;
119140
case 'repost':
120141
return <RepostNotification notification={notification} />;
121142
case 'reply':
@@ -136,7 +157,7 @@ function Notification({ notification }: { notification: BskyNotification }) {
136157
);
137158
}
138159

139-
function FollowNotification({ notification }: { notification: BskyNotification }) {
160+
function FollowNotification({ notification }: { notification: BSkyNotification }) {
140161
const { t } = useTranslation('notifications');
141162
return (
142163
<div>
@@ -145,16 +166,40 @@ function FollowNotification({ notification }: { notification: BskyNotification }
145166
);
146167
}
147168

148-
function LikeNotification({ notification }: { notification: BskyNotification }) {
169+
function LikeNotification({ notifications }: { notifications: BSkyNotification[] }) {
149170
const { t } = useTranslation('notifications');
171+
const { session } = useBlueskyStore();
172+
const notification = notifications[0];
173+
if (!notification || !session) return null;
174+
const othersCount = notifications.length - 1;
175+
150176
return (
151177
<div>
152-
{notification.author.displayName} {t('likedYourPost')}
178+
<div className="flex flex-row gap-1 overflow-hidden max-h-16">
179+
{notifications.map((notification) => (
180+
<Image type="avatar" classNames={{ wrapper: 'aspect-square size-8' }} src={notification.author.avatar} />
181+
))}
182+
</div>
183+
<div>
184+
<Handle handle={notification.author.handle} />
185+
{notifications.map((notification) => notification.author.displayName).slice(-1)}
186+
{notifications.length - 1 >= 1 &&
187+
`${t('and')} ${othersCount} ${othersCount >= 1 && (othersCount === 1 ? t('other') : t('others'))} `}{' '}
188+
<Link
189+
to="/profile/$handle/post/$postId"
190+
params={{
191+
handle: session.did!,
192+
postId: notification.record.subject.uri.split('/')[notification.record.subject.uri.split('/').length - 1]!,
193+
}}
194+
>
195+
{t('likedYourPost')}
196+
</Link>
197+
</div>
153198
</div>
154199
);
155200
}
156201

157-
function RepostNotification({ notification }: { notification: BskyNotification }) {
202+
function RepostNotification({ notification }: { notification: BSkyNotification }) {
158203
const { t } = useTranslation('notifications');
159204
return (
160205
<div>
@@ -163,7 +208,7 @@ function RepostNotification({ notification }: { notification: BskyNotification }
163208
);
164209
}
165210

166-
function ReplyNotification({ notification }: { notification: BskyNotification }) {
211+
function ReplyNotification({ notification }: { notification: BSkyNotification }) {
167212
const { t } = useTranslation('notifications');
168213
return (
169214
<div>
@@ -172,7 +217,7 @@ function ReplyNotification({ notification }: { notification: BskyNotification })
172217
);
173218
}
174219

175-
function MentionNotification({ notification }: { notification: BskyNotification }) {
220+
function MentionNotification({ notification }: { notification: BSkyNotification }) {
176221
const { t } = useTranslation('notifications');
177222
return (
178223
<div>
@@ -181,7 +226,7 @@ function MentionNotification({ notification }: { notification: BskyNotification
181226
);
182227
}
183228

184-
function QuoteNotification({ notification }: { notification: BskyNotification }) {
229+
function QuoteNotification({ notification }: { notification: BSkyNotification }) {
185230
const { t } = useTranslation('notifications');
186231
return (
187232
<div>
@@ -190,7 +235,7 @@ function QuoteNotification({ notification }: { notification: BskyNotification })
190235
);
191236
}
192237

193-
function StarterpackJoinedNotification({ notification }: { notification: BskyNotification }) {
238+
function StarterpackJoinedNotification({ notification }: { notification: BSkyNotification }) {
194239
const { t } = useTranslation('notifications');
195240
return (
196241
<div>

0 commit comments

Comments
 (0)