tangled
alpha
login
or
join now
leaflet.pub
/
leaflet
289
fork
atom
a tool for shared writing and social publishing
289
fork
atom
overview
issues
28
pulls
pipelines
add basic notification page
awarm.space
4 months ago
5e94e40d
5ed5328f
+159
-76
4 changed files
expand all
collapse all
unified
split
app
(home-pages)
notifications
page.tsx
components
ActionBar
Navigation.tsx
Icons
NotificationSmall.tsx
src
notifications.ts
+87
-1
app/(home-pages)/notifications/page.tsx
···
1
1
+
import { getIdentityData } from "actions/getIdentityData";
2
2
+
import { BaseTextBlock } from "app/lish/[did]/[publication]/[rkey]/BaseTextBlock";
3
3
+
import { DashboardLayout } from "components/PageLayouts/DashboardLayout";
4
4
+
import { PubLeafletComment, PubLeafletDocument } from "lexicons/api";
5
5
+
import { redirect } from "next/navigation";
6
6
+
import {
7
7
+
HydratedCommentNotification,
8
8
+
hydrateNotifications,
9
9
+
} from "src/notifications";
10
10
+
import { supabaseServerClient } from "supabase/serverClient";
11
11
+
1
12
export default async function Notifications() {
2
2
-
return <div>Notifications</div>;
13
13
+
let identity = await getIdentityData();
14
14
+
if (!identity?.atp_did) return redirect("/home");
15
15
+
let { data, error } = await supabaseServerClient
16
16
+
.from("notifications")
17
17
+
.select("*")
18
18
+
.eq("recipient", identity.atp_did);
19
19
+
let notifications = await hydrateNotifications(data || []);
20
20
+
return (
21
21
+
<DashboardLayout
22
22
+
id="discover"
23
23
+
cardBorderHidden={false}
24
24
+
currentPage="notifications"
25
25
+
defaultTab="default"
26
26
+
actions={null}
27
27
+
tabs={{
28
28
+
default: {
29
29
+
controls: null,
30
30
+
content: (
31
31
+
<div>
32
32
+
<h2>Notifications</h2>
33
33
+
{notifications.map((n) => {
34
34
+
if (n.type === "comment") {
35
35
+
n;
36
36
+
return <CommentNotification key={n.id} {...n} />;
37
37
+
}
38
38
+
})}
39
39
+
</div>
40
40
+
),
41
41
+
},
42
42
+
}}
43
43
+
/>
44
44
+
);
3
45
}
46
46
+
47
47
+
const CommentNotification = (props: HydratedCommentNotification) => {
48
48
+
let docRecord = props.commentData.documents
49
49
+
?.data as PubLeafletDocument.Record;
50
50
+
let commentRecord = props.commentData.record as PubLeafletComment.Record;
51
51
+
return (
52
52
+
<Notification
53
53
+
identity={props.commentData.bsky_profiles?.handle || "Someone"}
54
54
+
action="commented on your post"
55
55
+
content={
56
56
+
<div>
57
57
+
<h4>{docRecord.title}</h4>
58
58
+
<div className="border">
59
59
+
<pre
60
60
+
style={{ wordBreak: "break-word" }}
61
61
+
className="whitespace-pre-wrap text-secondary pb-[4px] "
62
62
+
>
63
63
+
<BaseTextBlock
64
64
+
index={[]}
65
65
+
plaintext={commentRecord.plaintext}
66
66
+
facets={commentRecord.facets}
67
67
+
/>
68
68
+
</pre>
69
69
+
</div>
70
70
+
</div>
71
71
+
}
72
72
+
/>
73
73
+
);
74
74
+
};
75
75
+
76
76
+
const Notification = (props: {
77
77
+
identity: string;
78
78
+
action: string;
79
79
+
content: React.ReactNode;
80
80
+
}) => {
81
81
+
return (
82
82
+
<div className="flex flex-col gap-2 border">
83
83
+
<div>
84
84
+
{props.identity} {props.action}
85
85
+
</div>
86
86
+
{props.content}
87
87
+
</div>
88
88
+
);
89
89
+
};
+22
-15
components/ActionBar/Navigation.tsx
···
11
11
ReaderReadSmall,
12
12
ReaderUnreadSmall,
13
13
} from "components/Icons/ReaderSmall";
14
14
+
import { NotificationsUnreadSmall } from "components/Icons/NotificationSmall";
15
15
+
import { SpeedyLink } from "components/SpeedyLink";
14
16
15
15
-
export type navPages = "home" | "reader" | "pub" | "discover";
17
17
+
export type navPages = "home" | "reader" | "pub" | "discover" | "notifications";
16
18
17
19
export const DesktopNavigation = (props: {
18
20
currentPage: navPages;
···
27
29
/>
28
30
</Sidebar>
29
31
{/*<Sidebar alwaysOpen>
30
30
-
<ActionButton
31
31
-
icon={
32
32
-
unreadNotifications ? (
33
33
-
<NotificationsUnreadSmall />
34
34
-
) : (
35
35
-
<NotificationsReadSmall />
36
36
-
)
37
37
-
}
38
38
-
label="Notifications"
39
39
-
/>
40
32
</Sidebar>*/}
41
33
</div>
42
34
);
···
97
89
/>
98
90
<DiscoverButton current={props.currentPage === "discover"} />
99
91
92
92
+
{identity && (
93
93
+
<SpeedyLink href={"/notifications"}>
94
94
+
<ActionButton
95
95
+
nav
96
96
+
icon={<NotificationsUnreadSmall />}
97
97
+
label="Notifications"
98
98
+
className={
99
99
+
props.currentPage === "notifications"
100
100
+
? "bg-bg-page! border-border-light!"
101
101
+
: ""
102
102
+
}
103
103
+
/>
104
104
+
</SpeedyLink>
105
105
+
)}
106
106
+
100
107
<hr className="border-border-light my-1" />
101
108
<PublicationButtons currentPubUri={thisPublication?.uri} />
102
109
</>
···
105
112
106
113
const HomeButton = (props: { current?: boolean }) => {
107
114
return (
108
108
-
<Link href={"/home"} className="hover:!no-underline">
115
115
+
<SpeedyLink href={"/home"} className="hover:!no-underline">
109
116
<ActionButton
110
117
nav
111
118
icon={<HomeSmall />}
112
119
label="Home"
113
120
className={props.current ? "bg-bg-page! border-border-light!" : ""}
114
121
/>
115
115
-
</Link>
122
122
+
</SpeedyLink>
116
123
);
117
124
};
118
125
···
121
128
122
129
if (!props.subs) return;
123
130
return (
124
124
-
<Link href={"/reader"} className="hover:no-underline!">
131
131
+
<SpeedyLink href={"/reader"} className="hover:no-underline!">
125
132
<ActionButton
126
133
nav
127
134
icon={readerUnreads ? <ReaderUnreadSmall /> : <ReaderReadSmall />}
···
131
138
${props.current && "border-accent-contrast!"}
132
139
`}
133
140
/>
134
134
-
</Link>
141
141
+
</SpeedyLink>
135
142
);
136
143
};
137
144
+17
components/Icons/NotificationSmall.tsx
···
26
26
viewBox="0 0 24 24"
27
27
fill="none"
28
28
xmlns="http://www.w3.org/2000/svg"
29
29
+
>
30
30
+
<path
31
31
+
d="M12.3779 0.890636C13.5297 0.868361 14.2312 1.35069 14.6104 1.8047C15.1942 2.50387 15.2636 3.34086 15.2129 3.95314C17.7074 4.96061 18.8531 7.45818 19.375 10.3975C19.5903 11.1929 20.0262 11.5635 20.585 11.9336C21.1502 12.3079 22.0847 12.7839 22.5879 13.7998C23.4577 15.556 22.8886 17.8555 20.9297 19.083C20.1439 19.5754 19.2029 20.1471 17.8496 20.5869C17.1962 20.7993 16.454 20.9768 15.5928 21.1055C15.2068 22.4811 13.9287 23.4821 12.4238 23.4824C10.9225 23.4824 9.64464 22.4867 9.25489 21.1162C8.37384 20.9871 7.61998 20.8046 6.95899 20.5869C5.62158 20.1464 4.69688 19.5723 3.91602 19.083C1.95717 17.8555 1.38802 15.556 2.25782 13.7998C2.76329 12.7794 3.60199 12.3493 4.18653 12.0068C4.7551 11.6737 5.1753 11.386 5.45606 10.7432C5.62517 9.31217 5.93987 8.01645 6.4668 6.92482C7.1312 5.54855 8.13407 4.49633 9.56251 3.92482C9.53157 3.34709 9.6391 2.63284 10.1133 1.98927C10.1972 1.87543 10.4043 1.594 10.7822 1.34669C11.1653 1.09611 11.6872 0.904101 12.3779 0.890636ZM14.1709 21.2608C13.6203 21.3007 13.0279 21.3242 12.3887 21.3242C11.7757 21.3242 11.2072 21.3024 10.6777 21.2656C11.0335 21.8421 11.6776 22.2324 12.4238 22.2324C13.1718 22.2321 13.816 21.8396 14.1709 21.2608ZM12.4004 2.38966C11.9872 2.39776 11.7419 2.50852 11.5996 2.60157C11.4528 2.6977 11.3746 2.801 11.3193 2.87599C11.088 3.19 11.031 3.56921 11.0664 3.92677C11.084 4.10311 11.1233 4.258 11.1631 4.37013C11.1875 4.43883 11.205 4.47361 11.21 4.48341C11.452 4.78119 11.4299 5.22068 11.1484 5.49415C10.8507 5.78325 10.3748 5.77716 10.0869 5.48048C10.0533 5.44582 10.0231 5.40711 9.99415 5.3672C9.0215 5.79157 8.31886 6.53162 7.81641 7.57228C7.21929 8.80941 6.91013 10.4656 6.82129 12.4746L6.81934 12.5137L6.81446 12.5518C6.73876 13.0607 6.67109 13.5103 6.53418 13.9121C6.38567 14.3476 6.16406 14.7061 5.82032 15.0899C5.54351 15.3988 5.06973 15.4268 4.76172 15.1514C4.45392 14.8758 4.42871 14.4019 4.70508 14.0928C4.93763 13.8332 5.04272 13.6453 5.11524 13.4326C5.14365 13.3492 5.16552 13.2588 5.18848 13.1553C5.10586 13.2062 5.02441 13.2544 4.94532 13.3008C4.28651 13.6868 3.87545 13.9129 3.60157 14.4658C3.08548 15.5082 3.38433 16.9793 4.71192 17.8115C5.4776 18.2913 6.27423 18.7818 7.42872 19.1621C8.58507 19.543 10.1358 19.8242 12.3887 19.8242C14.6416 19.8242 16.2108 19.5429 17.3857 19.1611C18.5582 18.7801 19.3721 18.2882 20.1328 17.8115C21.4611 16.9793 21.7595 15.5084 21.2432 14.4658C20.9668 13.9081 20.515 13.6867 19.7568 13.1846C19.7553 13.1835 19.7535 13.1827 19.752 13.1817C19.799 13.3591 19.8588 13.5202 19.9287 13.6514C20.021 13.8244 20.1034 13.8927 20.1533 13.917C20.5249 14.0981 20.6783 14.5465 20.4961 14.919C20.3135 15.2913 19.8639 15.4467 19.4922 15.2656C19.0607 15.0553 18.7821 14.6963 18.6035 14.3613C18.4238 14.0242 18.3154 13.6559 18.2471 13.3379C18.1778 13.0155 18.1437 12.7147 18.127 12.4971C18.1185 12.3873 18.1145 12.2956 18.1123 12.2305C18.1115 12.2065 18.1107 12.1856 18.1104 12.169C18.0569 11.6585 17.9885 11.1724 17.9082 10.7109C17.9002 10.6794 17.8913 10.6476 17.8838 10.6152L17.8906 10.6133C17.4166 7.97573 16.4732 6.17239 14.791 5.40821C14.5832 5.64607 14.2423 5.73912 13.9365 5.61036C13.5557 5.44988 13.3777 5.01056 13.5391 4.62892C13.5394 4.62821 13.5397 4.62699 13.54 4.62599C13.5425 4.61977 13.5479 4.6087 13.5537 4.59278C13.5658 4.55999 13.5837 4.50758 13.6035 4.44142C13.6438 4.30713 13.6903 4.12034 13.7139 3.91212C13.7631 3.47644 13.7038 3.06402 13.457 2.76857C13.3434 2.63264 13.0616 2.37678 12.4004 2.38966ZM10.1055 16.625C11.6872 16.8411 12.8931 16.8585 13.8174 16.7539C14.2287 16.7076 14.5997 17.0028 14.6465 17.4141C14.693 17.8256 14.3969 18.1976 13.9854 18.2442C12.9038 18.3665 11.5684 18.3389 9.90235 18.1113C9.49223 18.0551 9.20488 17.6768 9.26075 17.2666C9.3168 16.8563 9.6952 16.5691 10.1055 16.625ZM16.3887 16.3047C16.7403 16.086 17.203 16.1935 17.4219 16.5449C17.6406 16.8967 17.5324 17.3594 17.1807 17.5781C16.9689 17.7097 16.6577 17.8424 16.4033 17.9131C16.0045 18.0237 15.5914 17.7904 15.4805 17.3916C15.3696 16.9926 15.6031 16.5788 16.002 16.4678C16.1344 16.431 16.3112 16.3527 16.3887 16.3047Z"
32
32
+
fill="currentColor"
33
33
+
/>
34
34
+
</svg>
35
35
+
);
36
36
+
};
37
37
+
38
38
+
export const ReaderUnread = (props: Props) => {
39
39
+
return (
40
40
+
<svg
41
41
+
width="24"
42
42
+
height="24"
43
43
+
viewBox="0 0 24 24"
44
44
+
fill="none"
45
45
+
xmlns="http://www.w3.org/2000/svg"
29
46
{...props}
30
47
>
31
48
<path
+33
-60
src/notifications.ts
···
8
8
export type Notification = Omit<TablesInsert<"notifications">, "data"> & {
9
9
data: NotificationData;
10
10
};
11
11
-
// Notification data types (for writing to the notifications table)
11
11
+
12
12
export type NotificationData =
13
13
| { type: "comment"; comment_uri: string }
14
14
| { type: "subscribe"; subscription_uri: string };
15
15
16
16
-
// Hydrated notification types
17
17
-
export type HydratedCommentNotification = {
18
18
-
id: string;
19
19
-
recipient: string;
20
20
-
created_at: string;
21
21
-
type: "comment";
22
22
-
comment_uri: string;
23
23
-
commentData?: Tables<"comments_on_documents">;
24
24
-
};
16
16
+
export async function hydrateNotifications(notifications: NotificationRow[]) {
17
17
+
// Call all hydrators in parallel
18
18
+
const [commentNotifications, subscribeNotifications] = await Promise.all([
19
19
+
hydrateCommentNotifications(notifications),
20
20
+
hydrateSubscribeNotifications(notifications),
21
21
+
]);
25
22
26
26
-
export type HydratedSubscribeNotification = {
27
27
-
id: string;
28
28
-
recipient: string;
29
29
-
created_at: string;
30
30
-
type: "subscribe";
31
31
-
subscription_uri: string;
32
32
-
subscriptionData?: Tables<"publication_subscriptions">;
33
33
-
};
23
23
+
// Combine all hydrated notifications
24
24
+
const allHydrated = [...commentNotifications, ...subscribeNotifications];
25
25
+
26
26
+
// Sort by created_at to maintain order
27
27
+
allHydrated.sort(
28
28
+
(a, b) =>
29
29
+
new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
30
30
+
);
34
31
35
35
-
export type HydratedNotification =
36
36
-
| HydratedCommentNotification
37
37
-
| HydratedSubscribeNotification;
32
32
+
return allHydrated;
33
33
+
}
38
34
39
35
// Type guard to extract notification type
40
36
type ExtractNotificationType<T extends NotificationData["type"]> = Extract<
···
42
38
{ type: T }
43
39
>;
44
40
45
45
-
// Hydrator function type
46
46
-
type NotificationHydrator<T extends NotificationData["type"]> = (
47
47
-
notifications: NotificationRow[],
48
48
-
) => Promise<Array<HydratedNotification & { type: T }>>;
41
41
+
export type HydratedCommentNotification = Awaited<
42
42
+
ReturnType<typeof hydrateCommentNotifications>
43
43
+
>[0];
49
44
50
50
-
/**
51
51
-
* Hydrates comment notifications
52
52
-
*/
53
53
-
async function hydrateCommentNotifications(
54
54
-
notifications: NotificationRow[],
55
55
-
): Promise<HydratedCommentNotification[]> {
45
45
+
async function hydrateCommentNotifications(notifications: NotificationRow[]) {
56
46
const commentNotifications = notifications.filter(
57
47
(n): n is NotificationRow & { data: ExtractNotificationType<"comment"> } =>
58
48
(n.data as NotificationData)?.type === "comment",
···
66
56
const commentUris = commentNotifications.map((n) => n.data.comment_uri);
67
57
const { data: comments } = await supabaseServerClient
68
58
.from("comments_on_documents")
69
69
-
.select("*")
59
59
+
.select("*,bsky_profiles(*), documents(*)")
70
60
.in("uri", commentUris);
71
61
72
62
return commentNotifications.map((notification) => ({
···
75
65
created_at: notification.created_at,
76
66
type: "comment" as const,
77
67
comment_uri: notification.data.comment_uri,
78
78
-
commentData: comments?.find((c) => c.uri === notification.data.comment_uri),
68
68
+
commentData: comments?.find(
69
69
+
(c) => c.uri === notification.data.comment_uri,
70
70
+
)!,
79
71
}));
80
72
}
81
73
82
82
-
/**
83
83
-
* Hydrates subscribe notifications
84
84
-
*/
74
74
+
export type HydratedSubscribeNotification = {
75
75
+
id: string;
76
76
+
recipient: string;
77
77
+
created_at: string;
78
78
+
type: "subscribe";
79
79
+
subscription_uri: string;
80
80
+
subscriptionData?: Tables<"publication_subscriptions">;
81
81
+
};
85
82
async function hydrateSubscribeNotifications(
86
83
notifications: NotificationRow[],
87
84
): Promise<HydratedSubscribeNotification[]> {
···
116
113
),
117
114
}));
118
115
}
119
119
-
120
120
-
/**
121
121
-
* Main hydration function that processes all notifications
122
122
-
*/
123
123
-
export async function hydrateNotifications(
124
124
-
notifications: NotificationRow[],
125
125
-
): Promise<HydratedNotification[]> {
126
126
-
// Call all hydrators in parallel
127
127
-
const [commentNotifications, subscribeNotifications] = await Promise.all([
128
128
-
hydrateCommentNotifications(notifications),
129
129
-
hydrateSubscribeNotifications(notifications),
130
130
-
]);
131
131
-
132
132
-
// Combine all hydrated notifications
133
133
-
const allHydrated = [...commentNotifications, ...subscribeNotifications];
134
134
-
135
135
-
// Sort by created_at to maintain order
136
136
-
allHydrated.sort(
137
137
-
(a, b) =>
138
138
-
new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
139
139
-
);
140
140
-
141
141
-
return allHydrated;
142
142
-
}