import type { AnyProfileView, AppBskyGraphGetStarterPacksWithMembership } from '@atcute/bluesky';
import type { Did } from '@atcute/lexicons';
import { getStarterPackRecord } from '#/lib/api/record-casts';
import { useModerationOpts } from '#/state/moderation/moderation-opts';
import { useActorStarterPacksWithMembershipsQuery } from '#/state/queries/actor-starter-packs';
import {
useListMembershipAddMutation,
useListMembershipRemoveMutation,
} from '#/state/queries/list-memberships';
import { useProfileQuery } from '#/state/queries/profile';
import { useSession } from '#/state/session';
import { AvatarStack } from '#/components/AvatarStack';
import { CenteredSpinner } from '#/components/CenteredSpinner';
import * as Dialog from '#/components/Dialog';
import {
markStarterPackWizardLaunched,
useStarterPackDialogReopen,
} from '#/components/dialogs/starter-pack-dialog-reopen';
import * as css from '#/components/dialogs/StarterPackDialog.css';
import { Spinner } from '#/components/Spinner';
import { Text } from '#/components/Text';
import * as Toast from '#/components/Toast';
import { Button, ButtonIcon, ButtonText } from '#/components/web/Button';
import XIcon from '#/icons/central/CrossLarge_round_outlined_radius1_stroke2.svg';
import PlusIcon from '#/icons/central/PlusLarge_round_outlined_radius1_stroke2.svg';
import StarterPack from '#/icons/original/StarterPack.svg';
import { m } from '#/paraglide/messages';
import { useLocation, useRouter } from '#/router';
type StarterPackWithMembership = AppBskyGraphGetStarterPacksWithMembership.StarterPackWithMembership;
type StarterPackDialogProps = {
handle: Dialog.DialogHandle;
targetDid: Did;
};
export function StarterPackDialog({ handle, targetDid }: StarterPackDialogProps) {
// the dialog is where the wizard is launched from, so it is also where the user is brought back to
useStarterPackDialogReopen(handle, targetDid);
return (
<Dialog.Root handle={handle}>
<Dialog.Popup className={css.popup} scroll="body" label={m['common.starterPack.action.add']()}>
<DialogInner handle={handle} targetDid={targetDid} />
</Dialog.Popup>
</Dialog.Root>
);
}
const STARTER_PACK_ITEM_HEIGHT_ESTIMATE = 74;
type Item = { type: 'pack'; pack: StarterPackWithMembership } | { type: 'create' };
function keyExtractor(item: Item): string {
return item.type === 'pack' ? item.pack.starterPack.uri : item.type;
}
function DialogInner({ handle, targetDid }: StarterPackDialogProps) {
const router = useRouter();
// capture the origin history entry so the reopen coordinator can key the reopen to it.
const { key: originKey } = useLocation();
const { data: subject } = useProfileQuery({ did: targetDid });
const { data, isError, isLoading, hasNextPage, isFetchingNextPage, fetchNextPage } =
useActorStarterPacksWithMembershipsQuery({ did: targetDid });
const membershipItems = data?.pages.flatMap((page) => page.starterPacksWithMembership) ?? [];
// a "create new" row leads the list, sharing the same row chrome as the packs below it.
const items: Item[] = membershipItems.map((pack): Item => ({ type: 'pack', pack }));
if (membershipItems.length > 0) {
items.unshift({ type: 'create' });
}
const onStartWizard = () => {
handle.close();
markStarterPackWizardLaunched(originKey, targetDid);
router.navigate({ to: { name: 'StarterPackWizard', targetDid } });
};
const onEndReached = () => {
if (isFetchingNextPage || !hasNextPage || isError) {
return;
}
void fetchNextPage();
};
return (
<>
<div className={css.header}>
<Text className={css.title} numberOfLines={1} size="lg" weight="semiBold">
{m['common.starterPack.action.add']()}
</Text>
<Button
className={css.closeButton}
color="secondary"
label={m['common.action.close']()}
onClick={() => handle.close()}
shape="round"
size="small"
variant="ghost"
>
<ButtonIcon icon={XIcon} />
</Button>
</div>
<Dialog.List
data={items}
estimateHeight={STARTER_PACK_ITEM_HEIGHT_ESTIMATE}
keyExtractor={keyExtractor}
renderItem={({ index, item }) => {
switch (item.type) {
case 'create': {
return <CreateRow onStartWizard={onStartWizard} topBorder={index !== 0} />;
}
case 'pack': {
return (
<StarterPackItem
starterPackWithMembership={item.pack}
subject={subject}
targetDid={targetDid}
topBorder={index !== 0}
/>
);
}
}
}}
onEndReached={onEndReached}
ListFooterComponent={
isFetchingNextPage ? <CenteredSpinner label={m['common.status.loading']()} size="xl" /> : null
}
ListEmptyComponent={
isLoading ? (
<div className={css.loading}>
<CenteredSpinner label={m['common.status.loading']()} size="xl" />
</div>
) : (
<Empty onStartWizard={onStartWizard} />
)
}
/>
</>
);
}
function CreateButton({ onStartWizard }: { onStartWizard: () => void }) {
return (
<Button
color="secondary_inverted"
label={m['components.dialogs.starterPack.createTitle']()}
onClick={onStartWizard}
size="small"
>
<ButtonText>{m['common.action.create']()}</ButtonText>
<ButtonIcon icon={PlusIcon} />
</Button>
);
}
function CreateRow({ onStartWizard, topBorder }: { onStartWizard: () => void; topBorder: boolean }) {
return (
<div className={css.item({ topBorder })}>
<div className={css.itemInfo}>
<Text numberOfLines={1} size="md" weight="semiBold">
{m['components.dialogs.starterPack.newTitle']()}
</Text>
</div>
<CreateButton onStartWizard={onStartWizard} />
</div>
);
}
function Empty({ onStartWizard }: { onStartWizard: () => void }) {
return (
<div className={css.empty}>
<div className={css.emptyText}>
<StarterPack className={css.starterPackIcon} />
<Text align="center">{m['components.dialogs.starterPack.empty']()}</Text>
</div>
<CreateButton onStartWizard={onStartWizard} />
</div>
);
}
function StarterPackItem({
starterPackWithMembership,
subject,
targetDid,
topBorder,
}: {
starterPackWithMembership: StarterPackWithMembership;
subject?: AnyProfileView;
targetDid: Did;
topBorder: boolean;
}) {
const { currentAccount } = useSession();
const moderationOpts = useModerationOpts();
const isSelf = subject?.did === currentAccount?.did;
const starterPack = starterPackWithMembership.starterPack;
const isInPack = !!starterPackWithMembership.listItem;
const { mutate: addMembership, isPending: isPendingAdd } = useListMembershipAddMutation({
onSuccess: () => {
Toast.show(m['components.dialogs.starterPack.addedToast']());
},
onError: (err) => {
console.error('Failed to add to starter pack', err);
Toast.show(m['components.dialogs.starterPack.error.add'](), { type: 'error' });
},
});
const { mutate: removeMembership, isPending: isPendingRemove } = useListMembershipRemoveMutation({
onSuccess: () => {
Toast.show(m['components.dialogs.starterPack.removedToast']());
},
onError: (err) => {
console.error('Failed to remove from starter pack', err);
Toast.show(m['components.dialogs.starterPack.error.remove'](), { type: 'error' });
},
});
const isPending = isPendingAdd || isPendingRemove;
const handleToggleMembership = () => {
if (!starterPack.list?.uri || isPending) {
return;
}
const listUri = starterPack.list.uri;
if (!isInPack) {
addMembership({ actorDid: targetDid, listUri, subject });
} else {
if (!starterPackWithMembership.listItem?.uri) {
console.error('Cannot remove from starter pack: missing membership URI');
return;
}
removeMembership({
actorDid: targetDid,
listUri,
membershipUri: starterPackWithMembership.listItem.uri,
});
}
};
const record = getStarterPackRecord(starterPack);
const sample = starterPack.listItemsSample ?? [];
const listItemCount = starterPack.list?.listItemCount ?? 0;
return (
<div className={css.item({ topBorder })}>
<div className={css.itemInfo}>
<Text numberOfLines={1} size="md" weight="semiBold">
{record.name}
</Text>
{sample.length > 0 && (
<div className={css.itemMeta}>
<AvatarStack
moderationOpts={moderationOpts}
profiles={sample.slice(0, 4).map((p) => p.subject as AnyProfileView)}
size={24}
/>
{listItemCount > 4 && (
<Text className={css.moreCount} color="textContrastMedium" size="sm">
{m['components.dialogs.list.moreCount']({ count: listItemCount - 4 })}
</Text>
)}
</div>
)}
</div>
<Button
color={isInPack ? 'secondary' : 'primary_subtle'}
disabled={isPending || isSelf}
label={isInPack ? m['common.action.remove']() : m['common.action.add']()}
onClick={handleToggleMembership}
size="small"
>
{isPending && <Spinner color="default" label={m['common.status.saving']()} size="sm" />}
<ButtonText>
{isSelf
? m['components.dialogs.list.owner']()
: isInPack
? m['common.action.remove']()
: m['common.action.add']()}
</ButtonText>
</Button>
</div>
);
}