a tool for shared writing and social publishing
0

Configure Feed

Select the types of activity you want to include in your feed.

use profile cache on subs list

+252 -169
+40 -68
app/(app)/lish/[did]/[publication]/dashboard/PublicationSubscribers.tsx
··· 1 1 "use client"; 2 2 import { usePublicationData } from "./PublicationSWRProvider"; 3 3 import { ButtonPrimary } from "components/Buttons"; 4 - import { getPublicationURL } from "app/(app)/lish/createPub/getPublicationURL"; 5 4 import { useSmoker } from "components/Toast"; 6 - import { Menu, MenuItem } from "components/Menu"; 7 5 import { Separator } from "components/Layout"; 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 - type subscriber = { email: string | undefined; did: string | undefined }; 11 + export type SubscriberStatus = "subscribed" | "unconfirmed" | "unsubscribed"; 15 12 16 - type SubscriberStatus = "subscribed" | "unconfirmed" | "unsubscribed"; 17 - 18 - type MergedSubscriber = { 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 - export function PublicationSubscribers(props: { 84 + export function SubscribersListView(props: { 85 + subscribers: MergedSubscriber[]; 86 + publicationShareUrl: string; 90 87 showPageBackground?: boolean; 91 88 }) { 92 89 let smoker = useSmoker(); 93 - let { data: publication } = usePublicationData(); 94 90 let { subscriberStatus } = useDashboardState(); 95 - let subscribers = useMergedSubscribers(); 96 - 97 - if (!publication || !subscribers) return <div>null</div>; 98 - 99 - // useEffect(() => { 100 - // const allSubscribersSelected = subscribers.every((subscriber) => 101 - // checkedSubscribers.some( 102 - // (checked) => 103 - // checked.email === "dummyemail@email.com" && 104 - // checked.did === subscriber.identities?.bsky_profiles?.did, 105 - // ), 106 - // ); 107 - 108 - // if (allSubscribersSelected && subscribers.length > 0) { 109 - // setCheckAll(true); 110 - // } else { 111 - // setCheckAll(false); 112 - // } 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 - if (subscribers.length === 0) { 98 + let filtered = props.subscribers.filter((s) => subscriberStatus[s.status]); 99 + 100 + if (filtered.length === 0) { 122 101 if (!isDefaultStatusFilter) { 123 - let label = 124 - activeStatuses.length === 0 125 - ? "any status" 126 - : activeStatuses 127 - .map((s) => (s === "unconfirmed" ? "unconfirmed" : s)) 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 - navigator.clipboard.writeText( 165 - getPublicationURL(publication.publication!), 166 - ); 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 - {subscribers 165 + {filtered 195 166 .sort((a, b) => b.created_at.localeCompare(a.created_at)) 196 167 .map((subscriber) => ( 197 - <> 168 + <div key={subscriber.key}> 198 169 <SubscriberListItem 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 - <hr className="border-border-light last:hidden" /> 207 - </> 176 + <hr className="border-border-light mt-2 last:hidden" /> 177 + </div> 208 178 ))} 179 + </div> 180 + </div> 181 + ); 182 + } 183 + 184 + export function SubscribersListSkeleton(props: { showPageBackground?: boolean }) { 185 + return ( 186 + <div 187 + className={`rounded-md ${props.showPageBackground ? "border-border-light p-2" : "border-transparent"}`} 188 + style={ 189 + props.showPageBackground 190 + ? { 191 + backgroundColor: "rgba(var(--bg-page), var(--bg-page-alpha)) ", 192 + } 193 + : { backgroundColor: "transparent" } 194 + } 195 + > 196 + <div className="flex gap-2 flex-col"> 197 + {[0, 1, 2, 3].map((i) => ( 198 + <div 199 + key={i} 200 + className="flex flex-row justify-between gap-2 w-full animate-pulse" 201 + > 202 + <div className="h-5 w-40 bg-border-light rounded-md" /> 203 + <div className="h-5 w-16 bg-border-light rounded-md" /> 204 + </div> 205 + ))} 209 206 </div> 210 207 </div> 211 208 ); ··· 260 257 <SubscriberDate createdAt={props.createdAt} /> 261 258 </div> 262 259 </div> 263 - ); 264 - }; 265 - 266 - const SubscriberOptions = (props: { 267 - checkedSubscribers: subscriber[]; 268 - allSelected: boolean; 269 - }) => { 270 - return ( 271 - <Menu 272 - asChild 273 - className="" 274 - trigger={ 275 - <ButtonPrimary compact className="-mt-[1px]"> 276 - {props.allSelected ? "All" : props.checkedSubscribers.length} Selected{" "} 277 - <MoreOptionsVerticalTiny /> 278 - </ButtonPrimary> 279 - } 280 - > 281 - <MenuItem className="justify-center" onSelect={() => {}}> 282 - Export {props.allSelected ? "All" : "Selected"} 283 - </MenuItem> 284 - <MenuItem className="justify-center" onSelect={() => {}}> 285 - Remove {props.allSelected ? "All" : "Selected"} 286 - </MenuItem> 287 - </Menu> 288 260 ); 289 261 }; 290 262
+98
app/(app)/lish/[did]/[publication]/dashboard/SubsPageShell.tsx
··· 1 + "use client"; 2 + 3 + import { DashboardPageLayout } from "components/PageLayouts/DashboardPageLayout"; 4 + import { Popover } from "components/Popover"; 5 + import { Checkbox } from "components/Checkbox"; 6 + import { 7 + useDashboardState, 8 + useSetDashboardState, 9 + } from "components/PageLayouts/dashboardState"; 10 + import { usePublicationData } from "./PublicationSWRProvider"; 11 + import { useMergedSubscribers } from "./PublicationSubscribers"; 12 + 13 + export function SubsPageShell({ children }: { children: React.ReactNode }) { 14 + let { data } = usePublicationData(); 15 + let pubUri = data?.publication?.uri || ""; 16 + let subscribers = useMergedSubscribers(); 17 + let count = subscribers?.length ?? 0; 18 + 19 + return ( 20 + <DashboardPageLayout 21 + scrollKey={`dashboard-${pubUri}-Subs`} 22 + pageTitle="Subscribers" 23 + mobileActions={<SubscriberStatusFilter />} 24 + publication={pubUri} 25 + showHeader={true} 26 + controls={ 27 + <div className="flex items-center justify-between gap-3 text-sm"> 28 + <div className="font-bold text-secondary px-1"> 29 + {count} Subscriber{count !== 1 && "s"} 30 + </div> 31 + <SubscriberStatusFilter /> 32 + </div> 33 + } 34 + > 35 + {children} 36 + </DashboardPageLayout> 37 + ); 38 + } 39 + 40 + const SubscriberStatusFilter = () => { 41 + let { subscriberStatus } = useDashboardState(); 42 + let setState = useSetDashboardState(); 43 + let count = Object.values(subscriberStatus).filter(Boolean).length; 44 + 45 + return ( 46 + <Popover 47 + className="text-sm px-2! py-1!" 48 + trigger={ 49 + <div className="text-sm text-tertiary"> 50 + Filters {count > 0 && `(${count})`} 51 + </div> 52 + } 53 + > 54 + <Checkbox 55 + small 56 + checked={subscriberStatus.subscribed} 57 + onChange={(e) => 58 + setState({ 59 + subscriberStatus: { 60 + ...subscriberStatus, 61 + subscribed: !!e.target.checked, 62 + }, 63 + }) 64 + } 65 + > 66 + Subscribed 67 + </Checkbox> 68 + <Checkbox 69 + small 70 + checked={subscriberStatus.unconfirmed} 71 + onChange={(e) => 72 + setState({ 73 + subscriberStatus: { 74 + ...subscriberStatus, 75 + unconfirmed: !!e.target.checked, 76 + }, 77 + }) 78 + } 79 + > 80 + Unconfirmed 81 + </Checkbox> 82 + <Checkbox 83 + small 84 + checked={subscriberStatus.unsubscribed} 85 + onChange={(e) => 86 + setState({ 87 + subscriberStatus: { 88 + ...subscriberStatus, 89 + unsubscribed: !!e.target.checked, 90 + }, 91 + }) 92 + } 93 + > 94 + Unsubscribed 95 + </Checkbox> 96 + </Popover> 97 + ); 98 + };
+99
app/(app)/lish/[did]/[publication]/dashboard/SubscribersList.tsx
··· 1 + import { supabaseServerClient } from "supabase/serverClient"; 2 + import { getProfiles } from "src/identity"; 3 + import { get_publication_data } from "app/api/rpc/[command]/get_publication_data"; 4 + import { getPublicationURL } from "app/(app)/lish/createPub/getPublicationURL"; 5 + import { normalizePublicationRecord } from "src/utils/normalizeRecords"; 6 + import { 7 + SubscribersListView, 8 + type MergedSubscriber, 9 + type SubscriberStatus, 10 + } from "./PublicationSubscribers"; 11 + 12 + export async function SubscribersList({ 13 + did, 14 + publication, 15 + }: { 16 + did: string; 17 + publication: string; 18 + }) { 19 + const { result } = await get_publication_data.handler( 20 + { did, publication_name: publication }, 21 + { supabase: supabaseServerClient }, 22 + ); 23 + const pub = result.publication; 24 + if (!pub) { 25 + return ( 26 + <SubscribersListView subscribers={[]} publicationShareUrl="" /> 27 + ); 28 + } 29 + const record = normalizePublicationRecord(pub.record); 30 + const showPageBackground = !!record?.theme?.showPageBackground; 31 + 32 + const atprotoSubs = pub.publication_subscriptions || []; 33 + const newsletterEnabled = !!pub.publication_newsletter_settings?.enabled; 34 + const emailSubs = newsletterEnabled 35 + ? pub.publication_email_subscribers || [] 36 + : []; 37 + 38 + const dids = new Set<string>(); 39 + for (const s of atprotoSubs) { 40 + const d = s.identities?.bsky_profiles?.did; 41 + if (d) dids.add(d); 42 + } 43 + for (const s of emailSubs) { 44 + if (s.identities?.atp_did) dids.add(s.identities.atp_did); 45 + } 46 + 47 + const profiles = await getProfiles(Array.from(dids)); 48 + 49 + const byDid = new Map<string, MergedSubscriber>(); 50 + const emailOnly: MergedSubscriber[] = []; 51 + 52 + for (const s of atprotoSubs) { 53 + const d = s.identities?.bsky_profiles?.did; 54 + if (!d) continue; 55 + const p = profiles.get(d); 56 + byDid.set(d, { 57 + key: `did:${d}`, 58 + did: d, 59 + handle: p?.handle ?? undefined, 60 + email: undefined, 61 + created_at: s.created_at, 62 + status: "subscribed", 63 + }); 64 + } 65 + 66 + for (const s of emailSubs) { 67 + const status: SubscriberStatus = 68 + s.state === "pending" 69 + ? "unconfirmed" 70 + : s.state === "unsubscribed" 71 + ? "unsubscribed" 72 + : "subscribed"; 73 + const linkedDid = s.identities?.atp_did ?? undefined; 74 + const existing = linkedDid ? byDid.get(linkedDid) : undefined; 75 + if (existing && status === "subscribed") { 76 + existing.email = s.email; 77 + continue; 78 + } 79 + const p = linkedDid ? profiles.get(linkedDid) : null; 80 + emailOnly.push({ 81 + key: `email:${s.id}`, 82 + did: linkedDid, 83 + handle: p?.handle ?? undefined, 84 + email: s.email, 85 + created_at: s.created_at, 86 + status, 87 + }); 88 + } 89 + 90 + const subscribers = [...byDid.values(), ...emailOnly]; 91 + 92 + return ( 93 + <SubscribersListView 94 + subscribers={subscribers} 95 + publicationShareUrl={getPublicationURL(pub)} 96 + showPageBackground={showPageBackground} 97 + /> 98 + ); 99 + }
+15 -101
app/(app)/lish/[did]/[publication]/dashboard/subs/page.tsx
··· 1 - "use client"; 1 + import { Suspense } from "react"; 2 + import { SubsPageShell } from "../SubsPageShell"; 3 + import { SubscribersList } from "../SubscribersList"; 4 + import { SubscribersListSkeleton } from "../PublicationSubscribers"; 2 5 3 - import { DashboardPageLayout } from "components/PageLayouts/DashboardPageLayout"; 4 - import { 5 - PublicationSubscribers, 6 - useMergedSubscribers, 7 - } from "../PublicationSubscribers"; 8 - import { 9 - usePublicationData, 10 - useNormalizedPublicationRecord, 11 - } from "../PublicationSWRProvider"; 12 - import { Popover } from "components/Popover"; 13 - import { Checkbox } from "components/Checkbox"; 14 - import { 15 - useDashboardState, 16 - useSetDashboardState, 17 - } from "components/PageLayouts/dashboardState"; 18 - 19 - export default function SubsPage() { 20 - let { data } = usePublicationData(); 21 - let record = useNormalizedPublicationRecord(); 22 - let pubUri = data?.publication?.uri || ""; 23 - const showPageBackground = !!record?.theme?.showPageBackground; 24 - let subscribers = useMergedSubscribers(); 25 - let count = subscribers?.length ?? 0; 6 + export default async function SubsPage(props: { 7 + params: Promise<{ did: string; publication: string }>; 8 + }) { 9 + const params = await props.params; 10 + const did = decodeURIComponent(params.did); 11 + const publication = decodeURIComponent(params.publication); 26 12 27 13 return ( 28 - <DashboardPageLayout 29 - scrollKey={`dashboard-${pubUri}-Subs`} 30 - pageTitle="Subscribers" 31 - mobileActions={<SubscriberStatusFilter />} 32 - publication={pubUri} 33 - showHeader={true} 34 - controls={ 35 - <div className="flex items-center justify-between gap-3 text-sm"> 36 - <div className="font-bold text-secondary px-1"> 37 - {count} Subscriber{count !== 1 && "s"} 38 - </div> 39 - <SubscriberStatusFilter /> 40 - </div> 41 - } 42 - > 43 - <PublicationSubscribers showPageBackground={showPageBackground} /> 44 - </DashboardPageLayout> 14 + <SubsPageShell> 15 + <Suspense fallback={<SubscribersListSkeleton />}> 16 + <SubscribersList did={did} publication={publication} /> 17 + </Suspense> 18 + </SubsPageShell> 45 19 ); 46 20 } 47 - 48 - const SubscriberStatusFilter = () => { 49 - let { subscriberStatus } = useDashboardState(); 50 - let setState = useSetDashboardState(); 51 - let count = Object.values(subscriberStatus).filter(Boolean).length; 52 - 53 - return ( 54 - <Popover 55 - className="text-sm px-2! py-1!" 56 - trigger={ 57 - <div className="text-sm text-tertiary"> 58 - Filters {count > 0 && `(${count})`} 59 - </div> 60 - } 61 - > 62 - <Checkbox 63 - small 64 - checked={subscriberStatus.subscribed} 65 - onChange={(e) => 66 - setState({ 67 - subscriberStatus: { 68 - ...subscriberStatus, 69 - subscribed: !!e.target.checked, 70 - }, 71 - }) 72 - } 73 - > 74 - Subscribed 75 - </Checkbox> 76 - <Checkbox 77 - small 78 - checked={subscriberStatus.unconfirmed} 79 - onChange={(e) => 80 - setState({ 81 - subscriberStatus: { 82 - ...subscriberStatus, 83 - unconfirmed: !!e.target.checked, 84 - }, 85 - }) 86 - } 87 - > 88 - Unconfirmed 89 - </Checkbox> 90 - <Checkbox 91 - small 92 - checked={subscriberStatus.unsubscribed} 93 - onChange={(e) => 94 - setState({ 95 - subscriberStatus: { 96 - ...subscriberStatus, 97 - unsubscribed: !!e.target.checked, 98 - }, 99 - }) 100 - } 101 - > 102 - Unsubscribed 103 - </Checkbox> 104 - </Popover> 105 - ); 106 - };