alpha
Login
or
Join now
jack.is
/
leaflet
Star
0
Fork
0
Atom
Configure Feed
Issues
Pull Requests
Commits
Tags
Feed URL
Select the types of activity you want to include in your feed.
a tool for shared writing and social publishing
Star
0
Fork
0
Atom
Configure Feed
Issues
Pull Requests
Commits
Tags
Feed URL
Select the types of activity you want to include in your feed.
Overview
Issues
Pulls
Pipelines
use profile cache on subs list
author
Jared Pereira
date
2 months ago
(May 27, 2026, 5:03 PM -0400)
commit
5cc5627d
5cc5627df4061340b77697983f5a9cd62d36a2d8
parent
27886d33
27886d3375654143197c6d52b68fc7df14769095
+252
-169
4 changed files
Expand all
Collapse all
Unified
Split
app
(app)
lish
[did]
[publication]
dashboard
PublicationSubscribers.tsx
SubsPageShell.tsx
SubscribersList.tsx
subs
page.tsx
+40
-68
app/(app)/lish/[did]/[publication]/dashboard/PublicationSubscribers.tsx
View file
Reviewed
···
1
1
"use client";
2
2
import { usePublicationData } from "./PublicationSWRProvider";
3
3
import { ButtonPrimary } from "components/Buttons";
4
4
-
import { getPublicationURL } from "app/(app)/lish/createPub/getPublicationURL";
5
4
import { useSmoker } from "components/Toast";
6
6
-
import { Menu, MenuItem } from "components/Menu";
7
5
import { Separator } from "components/Layout";
8
8
-
import { MoreOptionsVerticalTiny } from "components/Icons/MoreOptionsVerticalTiny";
9
6
import { useLocalizedDate } from "src/hooks/useLocalizedDate";
10
7
import { useDashboardState } from "components/PageLayouts/dashboardState";
11
8
import { AtmosphereAccount } from "components/Icons/AtmosphereAccount";
12
9
import { EmailTiny } from "components/Icons/EmailTiny";
13
10
14
14
-
type subscriber = { email: string | undefined; did: string | undefined };
11
11
+
export type SubscriberStatus = "subscribed" | "unconfirmed" | "unsubscribed";
15
12
16
16
-
type SubscriberStatus = "subscribed" | "unconfirmed" | "unsubscribed";
17
17
-
18
18
-
type MergedSubscriber = {
13
13
+
export type MergedSubscriber = {
19
14
key: string;
20
15
did: string | undefined;
21
16
handle: string | undefined;
···
86
81
return [...byDid.values(), ...emailOnly];
87
82
}
88
83
89
89
-
export function PublicationSubscribers(props: {
84
84
+
export function SubscribersListView(props: {
85
85
+
subscribers: MergedSubscriber[];
86
86
+
publicationShareUrl: string;
90
87
showPageBackground?: boolean;
91
88
}) {
92
89
let smoker = useSmoker();
93
93
-
let { data: publication } = usePublicationData();
94
90
let { subscriberStatus } = useDashboardState();
95
95
-
let subscribers = useMergedSubscribers();
96
96
-
97
97
-
if (!publication || !subscribers) return <div>null</div>;
98
98
-
99
99
-
// useEffect(() => {
100
100
-
// const allSubscribersSelected = subscribers.every((subscriber) =>
101
101
-
// checkedSubscribers.some(
102
102
-
// (checked) =>
103
103
-
// checked.email === "dummyemail@email.com" &&
104
104
-
// checked.did === subscriber.identities?.bsky_profiles?.did,
105
105
-
// ),
106
106
-
// );
107
107
-
108
108
-
// if (allSubscribersSelected && subscribers.length > 0) {
109
109
-
// setCheckAll(true);
110
110
-
// } else {
111
111
-
// setCheckAll(false);
112
112
-
// }
113
113
-
// }, [checkedSubscribers]);
114
91
115
92
let activeStatuses = (
116
93
Object.keys(subscriberStatus) as SubscriberStatus[]
···
118
95
let isDefaultStatusFilter =
119
96
activeStatuses.length === 1 && activeStatuses[0] === "subscribed";
120
97
121
121
-
if (subscribers.length === 0) {
98
98
+
let filtered = props.subscribers.filter((s) => subscriberStatus[s.status]);
99
99
+
100
100
+
if (filtered.length === 0) {
122
101
if (!isDefaultStatusFilter) {
123
123
-
let label =
124
124
-
activeStatuses.length === 0
125
125
-
? "any status"
126
126
-
: activeStatuses
127
127
-
.map((s) => (s === "unconfirmed" ? "unconfirmed" : s))
128
128
-
.join(", ");
129
102
return (
130
103
<div
131
104
className={`italic text-tertiary
···
161
134
onClick={(e) => {
162
135
e.preventDefault();
163
136
let rect = (e.currentTarget as Element)?.getBoundingClientRect();
164
164
-
navigator.clipboard.writeText(
165
165
-
getPublicationURL(publication.publication!),
166
166
-
);
137
137
+
navigator.clipboard.writeText(props.publicationShareUrl);
167
138
smoker({
168
139
position: {
169
140
x: rect ? rect.left + (rect.right - rect.left) / 2 : 0,
···
191
162
}
192
163
>
193
164
<div className="subscriberListContent flex gap-2 flex-col ">
194
194
-
{subscribers
165
165
+
{filtered
195
166
.sort((a, b) => b.created_at.localeCompare(a.created_at))
196
167
.map((subscriber) => (
197
197
-
<>
168
168
+
<div key={subscriber.key}>
198
169
<SubscriberListItem
199
199
-
key={subscriber.key}
200
170
handle={subscriber.handle}
201
171
did={subscriber.did}
202
172
email={subscriber.email}
203
173
createdAt={subscriber.created_at}
204
174
status={subscriber.status}
205
175
/>
206
206
-
<hr className="border-border-light last:hidden" />
207
207
-
</>
176
176
+
<hr className="border-border-light mt-2 last:hidden" />
177
177
+
</div>
208
178
))}
179
179
+
</div>
180
180
+
</div>
181
181
+
);
182
182
+
}
183
183
+
184
184
+
export function SubscribersListSkeleton(props: { showPageBackground?: boolean }) {
185
185
+
return (
186
186
+
<div
187
187
+
className={`rounded-md ${props.showPageBackground ? "border-border-light p-2" : "border-transparent"}`}
188
188
+
style={
189
189
+
props.showPageBackground
190
190
+
? {
191
191
+
backgroundColor: "rgba(var(--bg-page), var(--bg-page-alpha)) ",
192
192
+
}
193
193
+
: { backgroundColor: "transparent" }
194
194
+
}
195
195
+
>
196
196
+
<div className="flex gap-2 flex-col">
197
197
+
{[0, 1, 2, 3].map((i) => (
198
198
+
<div
199
199
+
key={i}
200
200
+
className="flex flex-row justify-between gap-2 w-full animate-pulse"
201
201
+
>
202
202
+
<div className="h-5 w-40 bg-border-light rounded-md" />
203
203
+
<div className="h-5 w-16 bg-border-light rounded-md" />
204
204
+
</div>
205
205
+
))}
209
206
</div>
210
207
</div>
211
208
);
···
260
257
<SubscriberDate createdAt={props.createdAt} />
261
258
</div>
262
259
</div>
263
263
-
);
264
264
-
};
265
265
-
266
266
-
const SubscriberOptions = (props: {
267
267
-
checkedSubscribers: subscriber[];
268
268
-
allSelected: boolean;
269
269
-
}) => {
270
270
-
return (
271
271
-
<Menu
272
272
-
asChild
273
273
-
className=""
274
274
-
trigger={
275
275
-
<ButtonPrimary compact className="-mt-[1px]">
276
276
-
{props.allSelected ? "All" : props.checkedSubscribers.length} Selected{" "}
277
277
-
<MoreOptionsVerticalTiny />
278
278
-
</ButtonPrimary>
279
279
-
}
280
280
-
>
281
281
-
<MenuItem className="justify-center" onSelect={() => {}}>
282
282
-
Export {props.allSelected ? "All" : "Selected"}
283
283
-
</MenuItem>
284
284
-
<MenuItem className="justify-center" onSelect={() => {}}>
285
285
-
Remove {props.allSelected ? "All" : "Selected"}
286
286
-
</MenuItem>
287
287
-
</Menu>
288
260
);
289
261
};
290
262
+98
app/(app)/lish/[did]/[publication]/dashboard/SubsPageShell.tsx
View file
Reviewed
···
1
1
+
"use client";
2
2
+
3
3
+
import { DashboardPageLayout } from "components/PageLayouts/DashboardPageLayout";
4
4
+
import { Popover } from "components/Popover";
5
5
+
import { Checkbox } from "components/Checkbox";
6
6
+
import {
7
7
+
useDashboardState,
8
8
+
useSetDashboardState,
9
9
+
} from "components/PageLayouts/dashboardState";
10
10
+
import { usePublicationData } from "./PublicationSWRProvider";
11
11
+
import { useMergedSubscribers } from "./PublicationSubscribers";
12
12
+
13
13
+
export function SubsPageShell({ children }: { children: React.ReactNode }) {
14
14
+
let { data } = usePublicationData();
15
15
+
let pubUri = data?.publication?.uri || "";
16
16
+
let subscribers = useMergedSubscribers();
17
17
+
let count = subscribers?.length ?? 0;
18
18
+
19
19
+
return (
20
20
+
<DashboardPageLayout
21
21
+
scrollKey={`dashboard-${pubUri}-Subs`}
22
22
+
pageTitle="Subscribers"
23
23
+
mobileActions={<SubscriberStatusFilter />}
24
24
+
publication={pubUri}
25
25
+
showHeader={true}
26
26
+
controls={
27
27
+
<div className="flex items-center justify-between gap-3 text-sm">
28
28
+
<div className="font-bold text-secondary px-1">
29
29
+
{count} Subscriber{count !== 1 && "s"}
30
30
+
</div>
31
31
+
<SubscriberStatusFilter />
32
32
+
</div>
33
33
+
}
34
34
+
>
35
35
+
{children}
36
36
+
</DashboardPageLayout>
37
37
+
);
38
38
+
}
39
39
+
40
40
+
const SubscriberStatusFilter = () => {
41
41
+
let { subscriberStatus } = useDashboardState();
42
42
+
let setState = useSetDashboardState();
43
43
+
let count = Object.values(subscriberStatus).filter(Boolean).length;
44
44
+
45
45
+
return (
46
46
+
<Popover
47
47
+
className="text-sm px-2! py-1!"
48
48
+
trigger={
49
49
+
<div className="text-sm text-tertiary">
50
50
+
Filters {count > 0 && `(${count})`}
51
51
+
</div>
52
52
+
}
53
53
+
>
54
54
+
<Checkbox
55
55
+
small
56
56
+
checked={subscriberStatus.subscribed}
57
57
+
onChange={(e) =>
58
58
+
setState({
59
59
+
subscriberStatus: {
60
60
+
...subscriberStatus,
61
61
+
subscribed: !!e.target.checked,
62
62
+
},
63
63
+
})
64
64
+
}
65
65
+
>
66
66
+
Subscribed
67
67
+
</Checkbox>
68
68
+
<Checkbox
69
69
+
small
70
70
+
checked={subscriberStatus.unconfirmed}
71
71
+
onChange={(e) =>
72
72
+
setState({
73
73
+
subscriberStatus: {
74
74
+
...subscriberStatus,
75
75
+
unconfirmed: !!e.target.checked,
76
76
+
},
77
77
+
})
78
78
+
}
79
79
+
>
80
80
+
Unconfirmed
81
81
+
</Checkbox>
82
82
+
<Checkbox
83
83
+
small
84
84
+
checked={subscriberStatus.unsubscribed}
85
85
+
onChange={(e) =>
86
86
+
setState({
87
87
+
subscriberStatus: {
88
88
+
...subscriberStatus,
89
89
+
unsubscribed: !!e.target.checked,
90
90
+
},
91
91
+
})
92
92
+
}
93
93
+
>
94
94
+
Unsubscribed
95
95
+
</Checkbox>
96
96
+
</Popover>
97
97
+
);
98
98
+
};
+99
app/(app)/lish/[did]/[publication]/dashboard/SubscribersList.tsx
View file
Reviewed
···
1
1
+
import { supabaseServerClient } from "supabase/serverClient";
2
2
+
import { getProfiles } from "src/identity";
3
3
+
import { get_publication_data } from "app/api/rpc/[command]/get_publication_data";
4
4
+
import { getPublicationURL } from "app/(app)/lish/createPub/getPublicationURL";
5
5
+
import { normalizePublicationRecord } from "src/utils/normalizeRecords";
6
6
+
import {
7
7
+
SubscribersListView,
8
8
+
type MergedSubscriber,
9
9
+
type SubscriberStatus,
10
10
+
} from "./PublicationSubscribers";
11
11
+
12
12
+
export async function SubscribersList({
13
13
+
did,
14
14
+
publication,
15
15
+
}: {
16
16
+
did: string;
17
17
+
publication: string;
18
18
+
}) {
19
19
+
const { result } = await get_publication_data.handler(
20
20
+
{ did, publication_name: publication },
21
21
+
{ supabase: supabaseServerClient },
22
22
+
);
23
23
+
const pub = result.publication;
24
24
+
if (!pub) {
25
25
+
return (
26
26
+
<SubscribersListView subscribers={[]} publicationShareUrl="" />
27
27
+
);
28
28
+
}
29
29
+
const record = normalizePublicationRecord(pub.record);
30
30
+
const showPageBackground = !!record?.theme?.showPageBackground;
31
31
+
32
32
+
const atprotoSubs = pub.publication_subscriptions || [];
33
33
+
const newsletterEnabled = !!pub.publication_newsletter_settings?.enabled;
34
34
+
const emailSubs = newsletterEnabled
35
35
+
? pub.publication_email_subscribers || []
36
36
+
: [];
37
37
+
38
38
+
const dids = new Set<string>();
39
39
+
for (const s of atprotoSubs) {
40
40
+
const d = s.identities?.bsky_profiles?.did;
41
41
+
if (d) dids.add(d);
42
42
+
}
43
43
+
for (const s of emailSubs) {
44
44
+
if (s.identities?.atp_did) dids.add(s.identities.atp_did);
45
45
+
}
46
46
+
47
47
+
const profiles = await getProfiles(Array.from(dids));
48
48
+
49
49
+
const byDid = new Map<string, MergedSubscriber>();
50
50
+
const emailOnly: MergedSubscriber[] = [];
51
51
+
52
52
+
for (const s of atprotoSubs) {
53
53
+
const d = s.identities?.bsky_profiles?.did;
54
54
+
if (!d) continue;
55
55
+
const p = profiles.get(d);
56
56
+
byDid.set(d, {
57
57
+
key: `did:${d}`,
58
58
+
did: d,
59
59
+
handle: p?.handle ?? undefined,
60
60
+
email: undefined,
61
61
+
created_at: s.created_at,
62
62
+
status: "subscribed",
63
63
+
});
64
64
+
}
65
65
+
66
66
+
for (const s of emailSubs) {
67
67
+
const status: SubscriberStatus =
68
68
+
s.state === "pending"
69
69
+
? "unconfirmed"
70
70
+
: s.state === "unsubscribed"
71
71
+
? "unsubscribed"
72
72
+
: "subscribed";
73
73
+
const linkedDid = s.identities?.atp_did ?? undefined;
74
74
+
const existing = linkedDid ? byDid.get(linkedDid) : undefined;
75
75
+
if (existing && status === "subscribed") {
76
76
+
existing.email = s.email;
77
77
+
continue;
78
78
+
}
79
79
+
const p = linkedDid ? profiles.get(linkedDid) : null;
80
80
+
emailOnly.push({
81
81
+
key: `email:${s.id}`,
82
82
+
did: linkedDid,
83
83
+
handle: p?.handle ?? undefined,
84
84
+
email: s.email,
85
85
+
created_at: s.created_at,
86
86
+
status,
87
87
+
});
88
88
+
}
89
89
+
90
90
+
const subscribers = [...byDid.values(), ...emailOnly];
91
91
+
92
92
+
return (
93
93
+
<SubscribersListView
94
94
+
subscribers={subscribers}
95
95
+
publicationShareUrl={getPublicationURL(pub)}
96
96
+
showPageBackground={showPageBackground}
97
97
+
/>
98
98
+
);
99
99
+
}
+15
-101
app/(app)/lish/[did]/[publication]/dashboard/subs/page.tsx
View file
Reviewed
···
1
1
-
"use client";
1
1
+
import { Suspense } from "react";
2
2
+
import { SubsPageShell } from "../SubsPageShell";
3
3
+
import { SubscribersList } from "../SubscribersList";
4
4
+
import { SubscribersListSkeleton } from "../PublicationSubscribers";
2
5
3
3
-
import { DashboardPageLayout } from "components/PageLayouts/DashboardPageLayout";
4
4
-
import {
5
5
-
PublicationSubscribers,
6
6
-
useMergedSubscribers,
7
7
-
} from "../PublicationSubscribers";
8
8
-
import {
9
9
-
usePublicationData,
10
10
-
useNormalizedPublicationRecord,
11
11
-
} from "../PublicationSWRProvider";
12
12
-
import { Popover } from "components/Popover";
13
13
-
import { Checkbox } from "components/Checkbox";
14
14
-
import {
15
15
-
useDashboardState,
16
16
-
useSetDashboardState,
17
17
-
} from "components/PageLayouts/dashboardState";
18
18
-
19
19
-
export default function SubsPage() {
20
20
-
let { data } = usePublicationData();
21
21
-
let record = useNormalizedPublicationRecord();
22
22
-
let pubUri = data?.publication?.uri || "";
23
23
-
const showPageBackground = !!record?.theme?.showPageBackground;
24
24
-
let subscribers = useMergedSubscribers();
25
25
-
let count = subscribers?.length ?? 0;
6
6
+
export default async function SubsPage(props: {
7
7
+
params: Promise<{ did: string; publication: string }>;
8
8
+
}) {
9
9
+
const params = await props.params;
10
10
+
const did = decodeURIComponent(params.did);
11
11
+
const publication = decodeURIComponent(params.publication);
26
12
27
13
return (
28
28
-
<DashboardPageLayout
29
29
-
scrollKey={`dashboard-${pubUri}-Subs`}
30
30
-
pageTitle="Subscribers"
31
31
-
mobileActions={<SubscriberStatusFilter />}
32
32
-
publication={pubUri}
33
33
-
showHeader={true}
34
34
-
controls={
35
35
-
<div className="flex items-center justify-between gap-3 text-sm">
36
36
-
<div className="font-bold text-secondary px-1">
37
37
-
{count} Subscriber{count !== 1 && "s"}
38
38
-
</div>
39
39
-
<SubscriberStatusFilter />
40
40
-
</div>
41
41
-
}
42
42
-
>
43
43
-
<PublicationSubscribers showPageBackground={showPageBackground} />
44
44
-
</DashboardPageLayout>
14
14
+
<SubsPageShell>
15
15
+
<Suspense fallback={<SubscribersListSkeleton />}>
16
16
+
<SubscribersList did={did} publication={publication} />
17
17
+
</Suspense>
18
18
+
</SubsPageShell>
45
19
);
46
20
}
47
47
-
48
48
-
const SubscriberStatusFilter = () => {
49
49
-
let { subscriberStatus } = useDashboardState();
50
50
-
let setState = useSetDashboardState();
51
51
-
let count = Object.values(subscriberStatus).filter(Boolean).length;
52
52
-
53
53
-
return (
54
54
-
<Popover
55
55
-
className="text-sm px-2! py-1!"
56
56
-
trigger={
57
57
-
<div className="text-sm text-tertiary">
58
58
-
Filters {count > 0 && `(${count})`}
59
59
-
</div>
60
60
-
}
61
61
-
>
62
62
-
<Checkbox
63
63
-
small
64
64
-
checked={subscriberStatus.subscribed}
65
65
-
onChange={(e) =>
66
66
-
setState({
67
67
-
subscriberStatus: {
68
68
-
...subscriberStatus,
69
69
-
subscribed: !!e.target.checked,
70
70
-
},
71
71
-
})
72
72
-
}
73
73
-
>
74
74
-
Subscribed
75
75
-
</Checkbox>
76
76
-
<Checkbox
77
77
-
small
78
78
-
checked={subscriberStatus.unconfirmed}
79
79
-
onChange={(e) =>
80
80
-
setState({
81
81
-
subscriberStatus: {
82
82
-
...subscriberStatus,
83
83
-
unconfirmed: !!e.target.checked,
84
84
-
},
85
85
-
})
86
86
-
}
87
87
-
>
88
88
-
Unconfirmed
89
89
-
</Checkbox>
90
90
-
<Checkbox
91
91
-
small
92
92
-
checked={subscriberStatus.unsubscribed}
93
93
-
onChange={(e) =>
94
94
-
setState({
95
95
-
subscriberStatus: {
96
96
-
...subscriberStatus,
97
97
-
unsubscribed: !!e.target.checked,
98
98
-
},
99
99
-
})
100
100
-
}
101
101
-
>
102
102
-
Unsubscribed
103
103
-
</Checkbox>
104
104
-
</Popover>
105
105
-
);
106
106
-
};