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 hot feed
awarm.space
1 month ago
147e1cf5
360e12d4
+278
-79
6 changed files
expand all
collapse all
unified
split
app
(home-pages)
reader
GlobalContent.tsx
InboxContent.tsx
InteractionDrawers.tsx
api
rpc
[command]
get_hot_feed.ts
route.ts
components
PostListing.tsx
+37
-2
app/(home-pages)/reader/GlobalContent.tsx
···
1
1
"use client";
2
2
+
import useSWR from "swr";
3
3
+
import { callRPC } from "app/api/rpc/client";
4
4
+
import { PostListing } from "components/PostListing";
5
5
+
import type { Post } from "./getReaderFeed";
6
6
+
import {
7
7
+
DesktopInteractionPreviewDrawer,
8
8
+
MobileInteractionPreviewDrawer,
9
9
+
} from "./InteractionDrawers";
2
10
3
11
export const GlobalContent = () => {
12
12
+
const { data, isLoading } = useSWR("hot_feed", async () => {
13
13
+
const res = await callRPC("get_hot_feed", {});
14
14
+
return res as unknown as { posts: Post[] };
15
15
+
});
16
16
+
17
17
+
const posts = data?.posts ?? [];
18
18
+
19
19
+
if (isLoading) {
20
20
+
return (
21
21
+
<div className="text-center text-tertiary py-8">Loading posts...</div>
22
22
+
);
23
23
+
}
24
24
+
25
25
+
if (posts.length === 0) {
26
26
+
return (
27
27
+
<div className="flex flex-col gap-2 container bg-[rgba(var(--bg-page),.7)] sm:p-4 p-3 justify-between text-center text-tertiary">
28
28
+
Nothing trending right now. Check back soon!
29
29
+
</div>
30
30
+
);
31
31
+
}
32
32
+
4
33
return (
5
5
-
<div className="flex flex-col gap-2 container bg-[rgba(var(--bg-page),.7)] sm:p-4 p-3 justify-between text-center text-tertiary">
6
6
-
Nothing here yet…
34
34
+
<div className="flex flex-row gap-6 w-full">
35
35
+
<div className="flex flex-col gap-8 w-full">
36
36
+
{posts.map((p) => (
37
37
+
<PostListing {...p} key={p.documents.uri} />
38
38
+
))}
39
39
+
</div>
40
40
+
<DesktopInteractionPreviewDrawer />
41
41
+
<MobileInteractionPreviewDrawer />
7
42
</div>
8
43
);
9
44
};
+4
-77
app/(home-pages)/reader/InboxContent.tsx
···
4
4
import type { Cursor, Post } from "./getReaderFeed";
5
5
import useSWRInfinite from "swr/infinite";
6
6
import { getReaderFeed } from "./getReaderFeed";
7
7
-
import { useEffect, useRef, useState } from "react";
7
7
+
import { useEffect, useRef } from "react";
8
8
import Link from "next/link";
9
9
import { PostListing } from "components/PostListing";
10
10
import { useHasBackgroundImage } from "components/Pages/useHasBackgroundImage";
11
11
import {
12
12
-
SelectedPostListing,
13
13
-
useSelectedPostListing,
14
14
-
} from "src/useSelectedPostState";
15
15
-
import { CommentsDrawerContent } from "app/lish/[did]/[publication]/[rkey]/Interactions/Comments";
16
16
-
import { CloseTiny } from "components/Icons/CloseTiny";
17
17
-
import { SpeedyLink } from "components/SpeedyLink";
18
18
-
import { GoToArrow } from "components/Icons/GoToArrow";
12
12
+
DesktopInteractionPreviewDrawer,
13
13
+
MobileInteractionPreviewDrawer,
14
14
+
} from "./InteractionDrawers";
19
15
20
16
export const InboxContent = (props: {
21
17
posts: Post[];
···
107
103
<MobileInteractionPreviewDrawer />
108
104
</div>
109
105
);
110
110
-
};
111
111
-
112
112
-
const MobileInteractionPreviewDrawer = () => {
113
113
-
let selectedPost = useSelectedPostListing((s) => s.selectedPostListing);
114
114
-
115
115
-
return (
116
116
-
<div
117
117
-
className={`z-20 fixed bottom-0 left-0 right-0 border border-border-light shrink-0 w-screen h-[90vh] px-3 bg-bg-leaflet rounded-t-lg overflow-auto ${selectedPost === null ? "hidden" : "block md:hidden "}`}
118
118
-
>
119
119
-
<PreviewDrawerContent selectedPost={selectedPost} />
120
120
-
</div>
121
121
-
);
122
122
-
};
123
123
-
const DesktopInteractionPreviewDrawer = () => {
124
124
-
let selectedPost = useSelectedPostListing((s) => s.selectedPostListing);
125
125
-
126
126
-
return (
127
127
-
<div
128
128
-
className={`hidden md:block border border-border-light shrink-0 w-96 mr-2 px-3 h-[calc(100vh-100px)] sticky top-11 bottom-4 right-0 rounded-lg overflow-auto ${selectedPost === null ? "shadow-none border-dashed bg-transparent" : "shadow-md border-border bg-bg-page "}`}
129
129
-
>
130
130
-
<PreviewDrawerContent selectedPost={selectedPost} />
131
131
-
</div>
132
132
-
);
133
133
-
};
134
134
-
135
135
-
const PreviewDrawerContent = (props: {
136
136
-
selectedPost: SelectedPostListing | null;
137
137
-
}) => {
138
138
-
if (!props.selectedPost || !props.selectedPost.document) return;
139
139
-
140
140
-
if (props.selectedPost.drawer === "quotes") {
141
141
-
return (
142
142
-
<>
143
143
-
{/*<MentionsDrawerContent
144
144
-
did={selectedPost.document_uri}
145
145
-
quotesAndMentions={[]}
146
146
-
/>*/}
147
147
-
</>
148
148
-
);
149
149
-
} else
150
150
-
return (
151
151
-
<>
152
152
-
<div className="w-full text-sm text-tertiary flex justify-between pt-3 gap-3">
153
153
-
<div className="truncate min-w-0 grow">
154
154
-
Comments for {props.selectedPost.document.title}
155
155
-
</div>
156
156
-
<button
157
157
-
className="text-tertiary"
158
158
-
onClick={() =>
159
159
-
useSelectedPostListing.getState().setSelectedPostListing(null)
160
160
-
}
161
161
-
>
162
162
-
<CloseTiny />
163
163
-
</button>
164
164
-
</div>
165
165
-
<SpeedyLink
166
166
-
className="shrink-0 flex gap-1 items-center "
167
167
-
href={"/"}
168
168
-
></SpeedyLink>
169
169
-
<ButtonPrimary fullWidth compact className="text-sm! mt-1">
170
170
-
See Full Post <GoToArrow />
171
171
-
</ButtonPrimary>
172
172
-
<CommentsDrawerContent
173
173
-
noCommentBox
174
174
-
document_uri={props.selectedPost.document_uri}
175
175
-
comments={[]}
176
176
-
/>
177
177
-
</>
178
178
-
);
179
106
};
180
107
181
108
export const ReaderEmpty = () => {
+80
app/(home-pages)/reader/InteractionDrawers.tsx
···
1
1
+
"use client";
2
2
+
import { ButtonPrimary } from "components/Buttons";
3
3
+
import {
4
4
+
SelectedPostListing,
5
5
+
useSelectedPostListing,
6
6
+
} from "src/useSelectedPostState";
7
7
+
import { CommentsDrawerContent } from "app/lish/[did]/[publication]/[rkey]/Interactions/Comments";
8
8
+
import { CloseTiny } from "components/Icons/CloseTiny";
9
9
+
import { SpeedyLink } from "components/SpeedyLink";
10
10
+
import { GoToArrow } from "components/Icons/GoToArrow";
11
11
+
12
12
+
export const MobileInteractionPreviewDrawer = () => {
13
13
+
let selectedPost = useSelectedPostListing((s) => s.selectedPostListing);
14
14
+
15
15
+
return (
16
16
+
<div
17
17
+
className={`z-20 fixed bottom-0 left-0 right-0 border border-border-light shrink-0 w-screen h-[90vh] px-3 bg-bg-leaflet rounded-t-lg overflow-auto ${selectedPost === null ? "hidden" : "block md:hidden "}`}
18
18
+
>
19
19
+
<PreviewDrawerContent selectedPost={selectedPost} />
20
20
+
</div>
21
21
+
);
22
22
+
};
23
23
+
24
24
+
export const DesktopInteractionPreviewDrawer = () => {
25
25
+
let selectedPost = useSelectedPostListing((s) => s.selectedPostListing);
26
26
+
27
27
+
return (
28
28
+
<div
29
29
+
className={`hidden md:block border border-border-light shrink-0 w-96 mr-2 px-3 h-[calc(100vh-100px)] sticky top-11 bottom-4 right-0 rounded-lg overflow-auto ${selectedPost === null ? "shadow-none border-dashed bg-transparent" : "shadow-md border-border bg-bg-page "}`}
30
30
+
>
31
31
+
<PreviewDrawerContent selectedPost={selectedPost} />
32
32
+
</div>
33
33
+
);
34
34
+
};
35
35
+
36
36
+
const PreviewDrawerContent = (props: {
37
37
+
selectedPost: SelectedPostListing | null;
38
38
+
}) => {
39
39
+
if (!props.selectedPost || !props.selectedPost.document) return;
40
40
+
41
41
+
if (props.selectedPost.drawer === "quotes") {
42
42
+
return (
43
43
+
<>
44
44
+
{/*<MentionsDrawerContent
45
45
+
did={selectedPost.document_uri}
46
46
+
quotesAndMentions={[]}
47
47
+
/>*/}
48
48
+
</>
49
49
+
);
50
50
+
} else
51
51
+
return (
52
52
+
<>
53
53
+
<div className="w-full text-sm text-tertiary flex justify-between pt-3 gap-3">
54
54
+
<div className="truncate min-w-0 grow">
55
55
+
Comments for {props.selectedPost.document.title}
56
56
+
</div>
57
57
+
<button
58
58
+
className="text-tertiary"
59
59
+
onClick={() =>
60
60
+
useSelectedPostListing.getState().setSelectedPostListing(null)
61
61
+
}
62
62
+
>
63
63
+
<CloseTiny />
64
64
+
</button>
65
65
+
</div>
66
66
+
<SpeedyLink
67
67
+
className="shrink-0 flex gap-1 items-center "
68
68
+
href={"/"}
69
69
+
></SpeedyLink>
70
70
+
<ButtonPrimary fullWidth compact className="text-sm! mt-1">
71
71
+
See Full Post <GoToArrow />
72
72
+
</ButtonPrimary>
73
73
+
<CommentsDrawerContent
74
74
+
noCommentBox
75
75
+
document_uri={props.selectedPost.document_uri}
76
76
+
comments={[]}
77
77
+
/>
78
78
+
</>
79
79
+
);
80
80
+
};
+148
app/api/rpc/[command]/get_hot_feed.ts
···
1
1
+
import { z } from "zod";
2
2
+
import { makeRoute } from "../lib";
3
3
+
import type { Env } from "./route";
4
4
+
import { drizzle } from "drizzle-orm/node-postgres";
5
5
+
import { sql } from "drizzle-orm";
6
6
+
import { pool } from "supabase/pool";
7
7
+
import Client from "ioredis";
8
8
+
import { AtUri } from "@atproto/api";
9
9
+
import { idResolver } from "app/(home-pages)/reader/idResolver";
10
10
+
import { getPublicationURL } from "app/lish/createPub/getPublicationURL";
11
11
+
import {
12
12
+
normalizeDocumentRecord,
13
13
+
normalizePublicationRecord,
14
14
+
} from "src/utils/normalizeRecords";
15
15
+
import type { Post } from "app/(home-pages)/reader/getReaderFeed";
16
16
+
17
17
+
let redisClient: Client | null = null;
18
18
+
if (process.env.REDIS_URL && process.env.NODE_ENV === "production") {
19
19
+
redisClient = new Client(process.env.REDIS_URL);
20
20
+
}
21
21
+
22
22
+
const CACHE_KEY = "hot_feed_v1";
23
23
+
const CACHE_TTL = 300; // 5 minutes
24
24
+
25
25
+
export type GetHotFeedReturnType = Awaited<
26
26
+
ReturnType<(typeof get_hot_feed)["handler"]>
27
27
+
>;
28
28
+
29
29
+
export const get_hot_feed = makeRoute({
30
30
+
route: "get_hot_feed",
31
31
+
input: z.object({}),
32
32
+
handler: async ({}, { supabase }: Pick<Env, "supabase">) => {
33
33
+
// Check Redis cache
34
34
+
if (redisClient) {
35
35
+
const cached = await redisClient.get(CACHE_KEY);
36
36
+
if (cached) {
37
37
+
return JSON.parse(cached) as { posts: Post[] };
38
38
+
}
39
39
+
}
40
40
+
41
41
+
// Run ranked SQL query to get top 50 URIs
42
42
+
const client = await pool.connect();
43
43
+
const db = drizzle(client);
44
44
+
45
45
+
let uris: string[];
46
46
+
try {
47
47
+
const ranked = await db.execute(sql`
48
48
+
SELECT uri
49
49
+
FROM documents
50
50
+
WHERE indexed = true
51
51
+
AND sort_date > now() - interval '7 days'
52
52
+
ORDER BY
53
53
+
(bsky_like_count + recommend_count * 5)::numeric
54
54
+
/ power(extract(epoch from (now() - sort_date)) / 3600 + 2, 1.5) DESC
55
55
+
LIMIT 50
56
56
+
`);
57
57
+
uris = ranked.rows.map((row: any) => row.uri as string);
58
58
+
} finally {
59
59
+
client.release();
60
60
+
}
61
61
+
62
62
+
if (uris.length === 0) {
63
63
+
return { posts: [] as Post[] };
64
64
+
}
65
65
+
66
66
+
// Batch-fetch documents with publication joins and interaction counts
67
67
+
const { data: documents } = await supabase
68
68
+
.from("documents")
69
69
+
.select(
70
70
+
`*,
71
71
+
comments_on_documents(count),
72
72
+
document_mentions_in_bsky(count),
73
73
+
recommends_on_documents(count),
74
74
+
documents_in_publications(publications(*))`,
75
75
+
)
76
76
+
.in("uri", uris);
77
77
+
78
78
+
// Build lookup map for enrichment
79
79
+
const docMap = new Map(
80
80
+
(documents || []).map((d) => [d.uri, d]),
81
81
+
);
82
82
+
83
83
+
// Process in ranked order, deduplicating by identity key (DID/rkey)
84
84
+
const seen = new Set<string>();
85
85
+
const orderedDocs: (typeof documents extends (infer T)[] | null ? T : never)[] = [];
86
86
+
for (const uri of uris) {
87
87
+
try {
88
88
+
const parsed = new AtUri(uri);
89
89
+
const identityKey = `${parsed.host}/${parsed.rkey}`;
90
90
+
if (seen.has(identityKey)) continue;
91
91
+
seen.add(identityKey);
92
92
+
} catch {
93
93
+
// invalid URI, skip dedup check
94
94
+
}
95
95
+
const doc = docMap.get(uri);
96
96
+
if (doc) orderedDocs.push(doc);
97
97
+
}
98
98
+
99
99
+
// Enrich into Post[]
100
100
+
const posts = (
101
101
+
await Promise.all(
102
102
+
orderedDocs.map(async (doc) => {
103
103
+
const pub = doc.documents_in_publications?.[0]?.publications;
104
104
+
const uri = new AtUri(doc.uri);
105
105
+
const handle = await idResolver.did.resolve(uri.host);
106
106
+
107
107
+
const normalizedData = normalizeDocumentRecord(doc.data, doc.uri);
108
108
+
if (!normalizedData) return null;
109
109
+
110
110
+
const normalizedPubRecord = pub
111
111
+
? normalizePublicationRecord(pub.record)
112
112
+
: null;
113
113
+
114
114
+
const post: Post = {
115
115
+
publication: pub
116
116
+
? {
117
117
+
href: getPublicationURL(pub),
118
118
+
pubRecord: normalizedPubRecord,
119
119
+
uri: pub.uri || "",
120
120
+
}
121
121
+
: undefined,
122
122
+
author: handle?.alsoKnownAs?.[0]
123
123
+
? `@${handle.alsoKnownAs[0].slice(5)}`
124
124
+
: null,
125
125
+
documents: {
126
126
+
comments_on_documents: doc.comments_on_documents,
127
127
+
document_mentions_in_bsky: doc.document_mentions_in_bsky,
128
128
+
recommends_on_documents: doc.recommends_on_documents,
129
129
+
data: normalizedData,
130
130
+
uri: doc.uri,
131
131
+
sort_date: doc.sort_date,
132
132
+
},
133
133
+
};
134
134
+
return post;
135
135
+
}),
136
136
+
)
137
137
+
).filter((post): post is Post => post !== null);
138
138
+
139
139
+
const response = { posts };
140
140
+
141
141
+
// Cache in Redis
142
142
+
if (redisClient) {
143
143
+
await redisClient.setex(CACHE_KEY, CACHE_TTL, JSON.stringify(response));
144
144
+
}
145
145
+
146
146
+
return response;
147
147
+
},
148
148
+
});
+2
app/api/rpc/[command]/route.ts
···
15
15
import { search_publication_documents } from "./search_publication_documents";
16
16
import { get_profile_data } from "./get_profile_data";
17
17
import { get_user_recommendations } from "./get_user_recommendations";
18
18
+
import { get_hot_feed } from "./get_hot_feed";
18
19
19
20
let supabase = createClient<Database>(
20
21
process.env.NEXT_PUBLIC_SUPABASE_API_URL as string,
···
43
44
search_publication_documents,
44
45
get_profile_data,
45
46
get_user_recommendations,
47
47
+
get_hot_feed,
46
48
];
47
49
export async function POST(
48
50
req: Request,
+7
components/PostListing.tsx
···
22
22
import { mergePreferences } from "src/utils/mergePreferences";
23
23
import { ExternalLinkTiny } from "./Icons/ExternalLinkTiny";
24
24
import { getDocumentURL } from "app/lish/createPub/getPublicationURL";
25
25
+
import { RecommendButton } from "./RecommendButton";
25
26
26
27
export const PostListing = (props: Post) => {
27
28
let pubRecord = props.publication?.pubRecord as
···
146
147
postUrl={postUrl}
147
148
quotesCount={quotes}
148
149
commentsCount={comments}
150
150
+
recommendsCount={recommends}
149
151
tags={tags}
150
152
showComments={mergedPrefs.showComments !== false}
151
153
showMentions={mergedPrefs.showMentions !== false}
···
205
207
const Interactions = (props: {
206
208
quotesCount: number;
207
209
commentsCount: number;
210
210
+
recommendsCount: number;
208
211
tags?: string[];
209
212
postUrl: string;
210
213
showComments: boolean;
···
228
231
className={`flex gap-2 text-tertiary text-sm items-center justify-between px-1`}
229
232
>
230
233
<div className="postListingsInteractions flex gap-3">
234
234
+
<RecommendButton
235
235
+
documentUri={props.documentUri}
236
236
+
recommendsCount={props.recommendsCount}
237
237
+
/>
231
238
{!props.showMentions || props.quotesCount === 0 ? null : (
232
239
<button
233
240
aria-label="Post quotes"