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 reply notifications
awarm.space
4 months ago
b9be48d7
f58771d1
+294
-72
7 changed files
expand all
collapse all
unified
split
app
(home-pages)
discover
SortedPublicationList.tsx
getPublications.ts
page.tsx
notifications
NotificationList.tsx
ReplyNotification.tsx
lish
[did]
[publication]
[rkey]
Interactions
Comments
commentAction.ts
src
notifications.ts
+74
-22
app/(home-pages)/discover/SortedPublicationList.tsx
···
1
1
"use client";
2
2
import Link from "next/link";
3
3
-
import { useState } from "react";
3
3
+
import { useState, useEffect, useRef } from "react";
4
4
import { theme } from "tailwind.config";
5
5
-
import { PublicationsList } from "./page";
6
5
import { PubListing } from "./PubListing";
6
6
+
import useSWRInfinite from "swr/infinite";
7
7
+
import { getPublications, type Cursor, type Publication } from "./getPublications";
7
8
8
9
export function SortedPublicationList(props: {
9
9
-
publications: PublicationsList;
10
10
+
publications: Publication[];
10
11
order: string;
12
12
+
nextCursor: Cursor | null;
11
13
}) {
12
14
let [order, setOrder] = useState(props.order);
15
15
+
16
16
+
const getKey = (
17
17
+
pageIndex: number,
18
18
+
previousPageData: { publications: Publication[]; nextCursor: Cursor | null } | null,
19
19
+
) => {
20
20
+
// Reached the end
21
21
+
if (previousPageData && !previousPageData.nextCursor) return null;
22
22
+
23
23
+
// First page, we don't have previousPageData
24
24
+
if (pageIndex === 0) return ["discover-publications", order, null] as const;
25
25
+
26
26
+
// Add the cursor to the key
27
27
+
return ["discover-publications", order, previousPageData?.nextCursor] as const;
28
28
+
};
29
29
+
30
30
+
const { data, error, size, setSize, isValidating } = useSWRInfinite(
31
31
+
getKey,
32
32
+
([_, orderValue, cursor]) => {
33
33
+
const orderParam = orderValue === "popular" ? "popular" : "recentlyUpdated";
34
34
+
return getPublications(orderParam, cursor);
35
35
+
},
36
36
+
{
37
37
+
fallbackData: order === props.order
38
38
+
? [{ publications: props.publications, nextCursor: props.nextCursor }]
39
39
+
: undefined,
40
40
+
revalidateFirstPage: false,
41
41
+
},
42
42
+
);
43
43
+
44
44
+
const loadMoreRef = useRef<HTMLDivElement>(null);
45
45
+
46
46
+
// Set up intersection observer to load more when trigger element is visible
47
47
+
useEffect(() => {
48
48
+
const observer = new IntersectionObserver(
49
49
+
(entries) => {
50
50
+
if (entries[0].isIntersecting && !isValidating) {
51
51
+
const hasMore = data && data[data.length - 1]?.nextCursor;
52
52
+
if (hasMore) {
53
53
+
setSize(size + 1);
54
54
+
}
55
55
+
}
56
56
+
},
57
57
+
{ threshold: 0.1 },
58
58
+
);
59
59
+
60
60
+
if (loadMoreRef.current) {
61
61
+
observer.observe(loadMoreRef.current);
62
62
+
}
63
63
+
64
64
+
return () => observer.disconnect();
65
65
+
}, [data, size, setSize, isValidating]);
66
66
+
67
67
+
const allPublications = data ? data.flatMap((page) => page.publications) : [];
68
68
+
13
69
return (
14
70
<div className="discoverHeader flex flex-col items-center ">
15
71
<SortButtons
···
21
77
setOrder(o);
22
78
}}
23
79
/>
24
24
-
<div className="discoverPubList flex flex-col gap-3 pt-6 w-full">
25
25
-
{props.publications
26
26
-
?.filter((pub) => pub.documents_in_publications.length > 0)
27
27
-
?.sort((a, b) => {
28
28
-
if (order === "popular") {
29
29
-
return (
30
30
-
b.publication_subscriptions[0].count -
31
31
-
a.publication_subscriptions[0].count
32
32
-
);
33
33
-
}
34
34
-
const aDate = new Date(
35
35
-
a.documents_in_publications[0]?.indexed_at || 0,
36
36
-
);
37
37
-
const bDate = new Date(
38
38
-
b.documents_in_publications[0]?.indexed_at || 0,
39
39
-
);
40
40
-
return bDate.getTime() - aDate.getTime();
41
41
-
})
42
42
-
.map((pub) => <PubListing resizeHeight key={pub.uri} {...pub} />)}
80
80
+
<div className="discoverPubList flex flex-col gap-3 pt-6 w-full relative">
81
81
+
{allPublications.map((pub) => (
82
82
+
<PubListing resizeHeight key={pub.uri} {...pub} />
83
83
+
))}
84
84
+
{/* Trigger element for loading more publications */}
85
85
+
<div
86
86
+
ref={loadMoreRef}
87
87
+
className="absolute bottom-96 left-0 w-full h-px pointer-events-none"
88
88
+
aria-hidden="true"
89
89
+
/>
90
90
+
{isValidating && (
91
91
+
<div className="text-center text-tertiary py-4">
92
92
+
Loading more publications...
93
93
+
</div>
94
94
+
)}
43
95
</div>
44
96
</div>
45
97
);
+119
app/(home-pages)/discover/getPublications.ts
···
1
1
+
"use server";
2
2
+
3
3
+
import { supabaseServerClient } from "supabase/serverClient";
4
4
+
5
5
+
export type Cursor = {
6
6
+
indexed_at?: string;
7
7
+
count?: number;
8
8
+
uri: string;
9
9
+
};
10
10
+
11
11
+
export type Publication = Awaited<
12
12
+
ReturnType<typeof getPublications>
13
13
+
>["publications"][number];
14
14
+
15
15
+
export async function getPublications(
16
16
+
order: "recentlyUpdated" | "popular" = "recentlyUpdated",
17
17
+
cursor?: Cursor | null,
18
18
+
): Promise<{ publications: any[]; nextCursor: Cursor | null }> {
19
19
+
const limit = 25;
20
20
+
21
21
+
// Fetch all publications with their most recent document
22
22
+
let { data: publications, error } = await supabaseServerClient
23
23
+
.from("publications")
24
24
+
.select(
25
25
+
"*, documents_in_publications(*, documents(*)), publication_subscriptions(count)",
26
26
+
)
27
27
+
.or(
28
28
+
"record->preferences->showInDiscover.is.null,record->preferences->>showInDiscover.eq.true",
29
29
+
)
30
30
+
.order("indexed_at", {
31
31
+
referencedTable: "documents_in_publications",
32
32
+
ascending: false,
33
33
+
})
34
34
+
.limit(1, { referencedTable: "documents_in_publications" });
35
35
+
36
36
+
if (error) {
37
37
+
console.error("Error fetching publications:", error);
38
38
+
return { publications: [], nextCursor: null };
39
39
+
}
40
40
+
41
41
+
// Filter out publications without documents
42
42
+
const allPubs = (publications || []).filter(
43
43
+
(pub) => pub.documents_in_publications.length > 0,
44
44
+
);
45
45
+
46
46
+
// Sort on the server
47
47
+
allPubs.sort((a, b) => {
48
48
+
if (order === "popular") {
49
49
+
const aCount = a.publication_subscriptions[0]?.count || 0;
50
50
+
const bCount = b.publication_subscriptions[0]?.count || 0;
51
51
+
if (bCount !== aCount) {
52
52
+
return bCount - aCount;
53
53
+
}
54
54
+
// Secondary sort by uri for stability
55
55
+
return b.uri.localeCompare(a.uri);
56
56
+
} else {
57
57
+
// recentlyUpdated
58
58
+
const aDate = new Date(
59
59
+
a.documents_in_publications[0]?.indexed_at || 0,
60
60
+
).getTime();
61
61
+
const bDate = new Date(
62
62
+
b.documents_in_publications[0]?.indexed_at || 0,
63
63
+
).getTime();
64
64
+
if (bDate !== aDate) {
65
65
+
return bDate - aDate;
66
66
+
}
67
67
+
// Secondary sort by uri for stability
68
68
+
return b.uri.localeCompare(a.uri);
69
69
+
}
70
70
+
});
71
71
+
72
72
+
// Find cursor position and slice
73
73
+
let startIndex = 0;
74
74
+
if (cursor) {
75
75
+
startIndex = allPubs.findIndex((pub) => {
76
76
+
if (order === "popular") {
77
77
+
const pubCount = pub.publication_subscriptions[0]?.count || 0;
78
78
+
// Find first pub after cursor
79
79
+
return (
80
80
+
pubCount < (cursor.count || 0) ||
81
81
+
(pubCount === cursor.count && pub.uri < cursor.uri)
82
82
+
);
83
83
+
} else {
84
84
+
const pubDate = pub.documents_in_publications[0]?.indexed_at || "";
85
85
+
// Find first pub after cursor
86
86
+
return (
87
87
+
pubDate < (cursor.indexed_at || "") ||
88
88
+
(pubDate === cursor.indexed_at && pub.uri < cursor.uri)
89
89
+
);
90
90
+
}
91
91
+
});
92
92
+
// If not found, we're at the end
93
93
+
if (startIndex === -1) {
94
94
+
return { publications: [], nextCursor: null };
95
95
+
}
96
96
+
}
97
97
+
98
98
+
// Get the page
99
99
+
const page = allPubs.slice(startIndex, startIndex + limit);
100
100
+
101
101
+
// Create next cursor
102
102
+
const nextCursor =
103
103
+
page.length === limit && startIndex + limit < allPubs.length
104
104
+
? order === "recentlyUpdated"
105
105
+
? {
106
106
+
indexed_at: page[page.length - 1].documents_in_publications[0]?.indexed_at,
107
107
+
uri: page[page.length - 1].uri,
108
108
+
}
109
109
+
: {
110
110
+
count: page[page.length - 1].publication_subscriptions[0]?.count || 0,
111
111
+
uri: page[page.length - 1].uri,
112
112
+
}
113
113
+
: null;
114
114
+
115
115
+
return {
116
116
+
publications: page,
117
117
+
nextCursor,
118
118
+
};
119
119
+
}
+9
-21
app/(home-pages)/discover/page.tsx
···
1
1
-
import { supabaseServerClient } from "supabase/serverClient";
2
1
import Link from "next/link";
3
2
import { SortedPublicationList } from "./SortedPublicationList";
4
3
import { Metadata } from "next";
5
4
import { DashboardLayout } from "components/PageLayouts/DashboardLayout";
6
6
-
7
7
-
export type PublicationsList = Awaited<ReturnType<typeof getPublications>>;
8
8
-
async function getPublications() {
9
9
-
let { data: publications, error } = await supabaseServerClient
10
10
-
.from("publications")
11
11
-
.select(
12
12
-
"*, documents_in_publications(*, documents(*)), publication_subscriptions(count)",
13
13
-
)
14
14
-
.or(
15
15
-
"record->preferences->showInDiscover.is.null,record->preferences->>showInDiscover.eq.true",
16
16
-
)
17
17
-
.order("indexed_at", {
18
18
-
referencedTable: "documents_in_publications",
19
19
-
ascending: false,
20
20
-
})
21
21
-
.limit(1, { referencedTable: "documents_in_publications" });
22
22
-
return publications;
23
23
-
}
5
5
+
import { getPublications } from "./getPublications";
24
6
25
7
export const metadata: Metadata = {
26
8
title: "Leaflet Discover",
···
50
32
}
51
33
52
34
const DiscoverContent = async (props: { order: string }) => {
53
53
-
let publications = await getPublications();
35
35
+
const orderValue =
36
36
+
props.order === "popular" ? "popular" : "recentlyUpdated";
37
37
+
let { publications, nextCursor } = await getPublications(orderValue);
54
38
55
39
return (
56
40
<div className="max-w-prose mx-auto w-full">
···
61
45
<Link href="/lish/createPub">make your own</Link>!
62
46
</p>
63
47
</div>
64
64
-
<SortedPublicationList publications={publications} order={props.order} />
48
48
+
<SortedPublicationList
49
49
+
publications={publications}
50
50
+
order={props.order}
51
51
+
nextCursor={nextCursor}
52
52
+
/>
65
53
</div>
66
54
);
67
55
};
+9
-1
app/(home-pages)/notifications/NotificationList.tsx
···
5
5
import { useEntity, useReplicache } from "src/replicache";
6
6
import { useEffect } from "react";
7
7
import { markAsRead } from "./getNotifications";
8
8
+
import { ReplyNotification } from "./ReplyNotification";
8
9
9
10
export function NotificationList({
10
11
notifications,
···
31
32
<div className={`flex flex-col ${cardBorderHidden ? "gap-6" : "gap-2"}`}>
32
33
{notifications.map((n) => {
33
34
if (n.type === "comment") {
34
34
-
n;
35
35
+
if (n.parentData)
36
36
+
return (
37
37
+
<ReplyNotification
38
38
+
cardBorderHidden={!!cardBorderHidden}
39
39
+
key={n.id}
40
40
+
{...n}
41
41
+
/>
42
42
+
);
35
43
return (
36
44
<CommentNotification
37
45
cardBorderHidden={!!cardBorderHidden}
+58
-20
app/(home-pages)/notifications/ReplyNotification.tsx
···
6
6
ContentLayout,
7
7
Notification,
8
8
} from "./Notification";
9
9
+
import { HydratedCommentNotification } from "src/notifications";
10
10
+
import {
11
11
+
PubLeafletComment,
12
12
+
PubLeafletDocument,
13
13
+
PubLeafletPublication,
14
14
+
} from "lexicons/api";
15
15
+
import { AppBskyActorProfile, AtUri } from "@atproto/api";
16
16
+
import { blobRefToSrc } from "src/utils/blobRefToSrc";
9
17
10
10
-
export const DummyReplyNotification = (props: {
11
11
-
cardBorderHidden: boolean;
12
12
-
}) => {
18
18
+
export const ReplyNotification = (
19
19
+
props: { cardBorderHidden: boolean } & HydratedCommentNotification,
20
20
+
) => {
21
21
+
let docRecord = props.commentData.documents
22
22
+
?.data as PubLeafletDocument.Record;
23
23
+
let commentRecord = props.commentData.record as PubLeafletComment.Record;
24
24
+
let profileRecord = props.commentData.bsky_profiles
25
25
+
?.record as AppBskyActorProfile.Record;
26
26
+
const displayName =
27
27
+
profileRecord.displayName ||
28
28
+
props.commentData.bsky_profiles?.handle ||
29
29
+
"Someone";
30
30
+
31
31
+
let parentRecord = props.parentData?.record as PubLeafletComment.Record;
32
32
+
let parentProfile = props.parentData?.bsky_profiles
33
33
+
?.record as AppBskyActorProfile.Record;
34
34
+
const parentDisplayName =
35
35
+
parentProfile.displayName ||
36
36
+
props.parentData?.bsky_profiles?.handle ||
37
37
+
"Someone";
38
38
+
39
39
+
let rkey = new AtUri(props.commentData.documents?.uri!).rkey;
40
40
+
const pubRecord = props.commentData.documents?.documents_in_publications[0]
41
41
+
?.publications?.record as PubLeafletPublication.Record;
42
42
+
13
43
return (
14
44
<Notification
15
15
-
href="/"
45
45
+
href={`https://${pubRecord.base_path}/${rkey}?interactionDrawer=comments`}
16
46
icon={<ReplyTiny />}
17
17
-
actionText={<>jared replied to your comment</>}
47
47
+
actionText={`${displayName} replied to your comment`}
18
48
cardBorderHidden={props.cardBorderHidden}
19
49
content={
20
50
<ContentLayout
21
51
cardBorderHidden={props.cardBorderHidden}
22
22
-
postTitle="This is the Post Title"
23
23
-
pubRecord={{ name: "My Publication" } as any}
52
52
+
postTitle={docRecord.title}
53
53
+
pubRecord={pubRecord}
24
54
>
25
55
<CommentInNotification
26
26
-
className="text-tertiary italic line-clamp-1!"
27
27
-
avatar={undefined}
28
28
-
displayName="celine"
56
56
+
className=""
57
57
+
avatar={
58
58
+
parentProfile?.avatar?.ref &&
59
59
+
blobRefToSrc(
60
60
+
parentProfile?.avatar?.ref,
61
61
+
props.parentData?.bsky_profiles?.did || "",
62
62
+
)
63
63
+
}
64
64
+
displayName={parentDisplayName}
29
65
index={[]}
30
30
-
plaintext={
31
31
-
"This the original comment. To make a point I'm gonna make the comment really pretty long so you can see for youself how it truncates"
32
32
-
}
33
33
-
facets={[]}
66
66
+
plaintext={parentRecord.plaintext}
67
67
+
facets={parentRecord.facets}
34
68
/>
35
69
<div className="h-3 -mt-[1px] ml-[10px] border-l border-border" />
36
70
<CommentInNotification
37
71
className=""
38
38
-
avatar={undefined}
39
39
-
displayName="celine"
72
72
+
avatar={
73
73
+
profileRecord?.avatar?.ref &&
74
74
+
blobRefToSrc(
75
75
+
profileRecord?.avatar?.ref,
76
76
+
props.commentData.bsky_profiles?.did || "",
77
77
+
)
78
78
+
}
79
79
+
displayName={displayName}
40
80
index={[]}
41
41
-
plaintext={
42
42
-
"This is a thoughful and very respectful reply. Violating the code of conduct for me is literally like water for the wicked witch of the west. EeEeEEeeK IT BURNSssSs!!!"
43
43
-
}
44
44
-
facets={[]}
81
81
+
plaintext={commentRecord.plaintext}
82
82
+
facets={commentRecord.facets}
45
83
/>
46
84
</ContentLayout>
47
85
}
+16
-6
app/lish/[did]/[publication]/[rkey]/Interactions/Comments/commentAction.ts
···
67
67
} as unknown as Json,
68
68
})
69
69
.select();
70
70
-
let notifications: Notification[] = [
71
71
-
{
70
70
+
let notifications: Notification[] = [];
71
71
+
if (
72
72
+
!args.comment.replyTo &&
73
73
+
new AtUri(args.document).host !== credentialSession.did
74
74
+
)
75
75
+
notifications.push({
72
76
id: v7(),
73
77
recipient: new AtUri(args.document).host,
74
78
data: { type: "comment", comment_uri: uri.toString() },
75
75
-
},
76
76
-
];
77
77
-
if (args.comment.replyTo)
79
79
+
});
80
80
+
if (
81
81
+
args.comment.replyTo &&
82
82
+
new AtUri(args.comment.replyTo).host !== credentialSession.did
83
83
+
)
78
84
notifications.push({
79
85
id: v7(),
80
86
recipient: new AtUri(args.comment.replyTo).host,
81
81
-
data: { type: "comment", comment_uri: uri.toString() },
87
87
+
data: {
88
88
+
type: "comment",
89
89
+
comment_uri: uri.toString(),
90
90
+
parent_uri: args.comment.replyTo,
91
91
+
},
82
92
});
83
93
// SOMEDAY: move this out the action with inngest or workflows
84
94
await supabaseServerClient.from("notifications").insert(notifications);
+9
-2
src/notifications.ts
···
10
10
};
11
11
12
12
export type NotificationData =
13
13
-
| { type: "comment"; comment_uri: string }
13
13
+
| { type: "comment"; comment_uri: string; parent_uri?: string }
14
14
| { type: "subscribe"; subscription_uri: string };
15
15
16
16
export type HydratedNotification =
···
58
58
}
59
59
60
60
// Fetch comment data from the database
61
61
-
const commentUris = commentNotifications.map((n) => n.data.comment_uri);
61
61
+
const commentUris = commentNotifications.flatMap((n) =>
62
62
+
n.data.parent_uri
63
63
+
? [n.data.comment_uri, n.data.parent_uri]
64
64
+
: [n.data.comment_uri],
65
65
+
);
62
66
const { data: comments } = await supabaseServerClient
63
67
.from("comments_on_documents")
64
68
.select(
···
72
76
created_at: notification.created_at,
73
77
type: "comment" as const,
74
78
comment_uri: notification.data.comment_uri,
79
79
+
parentData: notification.data.parent_uri
80
80
+
? comments?.find((c) => c.uri === notification.data.parent_uri)!
81
81
+
: undefined,
75
82
commentData: comments?.find(
76
83
(c) => c.uri === notification.data.comment_uri,
77
84
)!,