import type { AppBskyActorStatus, AppBskyEmbedExternal } from '@atcute/bluesky';
import { ClientResponseError } from '@atcute/client';
import type { $type, GenericUri } from '@atcute/lexicons';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { deleteRecord, getRecord, putRecord, uploadBlob } from '#/lib/api/records';
import { imageToThumb } from '#/lib/api/resolve';
import { getLinkMeta, type LinkMeta } from '#/lib/link-meta';
import { retry } from '#/lib/utils/retry';
import { updateProfileShadow } from '#/state/cache/profile-shadow';
import { getClients, useSession } from '#/state/session';
import { useLiveNowConfig } from '#/features/liveNow/use-actor-status';
import { getLiveServiceNames, isLiveNowUrlAllowed } from '#/features/liveNow/utils';
import type { DialogHandle } from '#/components/Dialog';
import * as Toast from '#/components/Toast';
import { m } from '#/paraglide/messages';
export * from '#/features/liveNow/utils';
export function useLiveLinkMetaQuery(url: string | null) {
const liveNowConfig = useLiveNowConfig();
return useQuery({
queryKey: ['link-meta', url],
enabled: !!url,
queryFn: async ({ signal }) => {
if (!url) {
return undefined;
}
if (!isLiveNowUrlAllowed(url, liveNowConfig.currentAccountAllowedHosts)) {
const { formatted } = getLiveServiceNames(liveNowConfig.currentAccountAllowedHosts);
throw new Error(m['features.liveNow.service.unsupported']({ formatted }));
}
return await getLinkMeta(url, { signal });
},
});
}
export function useUpsertLiveStatusMutation(
handle: DialogHandle,
duration: number,
linkMeta: LinkMeta | null | undefined,
createdAt?: string,
) {
const { currentAccount } = useSession();
const { pds } = getClients();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async () => {
if (!currentAccount) {
throw new Error('Not logged in');
}
let embed: $type.enforce<AppBskyEmbedExternal.Main> | undefined;
if (linkMeta) {
let thumb;
if (linkMeta.image) {
try {
const img = await imageToThumb(linkMeta.image);
if (img) {
thumb = await uploadBlob(pds!, img.source.blob);
}
} catch (e) {
console.error('Failed to upload thumbnail for live status', linkMeta.image, e);
}
}
embed = {
$type: 'app.bsky.embed.external',
external: {
$type: 'app.bsky.embed.external#external',
title: linkMeta.title ?? '',
description: linkMeta.description ?? '',
// oxlint-disable-next-line typescript/no-unsafe-type-assertion -- `LinkMeta.url` always carries a scheme
uri: linkMeta.url as GenericUri,
thumb,
},
};
}
const record = {
$type: 'app.bsky.actor.status',
status: 'app.bsky.actor.status#live',
createdAt: createdAt ?? new Date().toISOString(),
durationMinutes: duration,
embed,
} satisfies AppBskyActorStatus.Main;
const upsert = async () => {
const repo = currentAccount.did;
const collection = 'app.bsky.actor.status';
const existing = await getRecord(pds!, { repo, collection, rkey: 'self' }).catch(() => undefined);
await putRecord(pds!, {
repo,
rkey: 'self',
record,
swapRecord: existing?.cid ?? null,
});
};
await retry(5, (e) => e instanceof ClientResponseError && e.error === 'InvalidSwap', upsert);
return {
record,
image: linkMeta?.image,
};
},
onSuccess: ({ record, image }) => {
Toast.show(m['features.liveNow.goLive.started']());
handle.close();
if (!currentAccount) {
return;
}
const expiresAt = new Date(record.createdAt);
expiresAt.setMinutes(expiresAt.getMinutes() + record.durationMinutes);
updateProfileShadow(queryClient, currentAccount.did, {
status: {
$type: 'app.bsky.actor.defs#statusView',
status: 'app.bsky.actor.status#live',
isActive: true,
expiresAt: expiresAt.toISOString(),
embed:
record.embed && image
? {
$type: 'app.bsky.embed.external#view',
external: {
$type: 'app.bsky.embed.external#viewExternal',
description: record.embed.external.description,
// oxlint-disable-next-line typescript/no-unsafe-type-assertion -- the resolver hands back an absolute thumbnail url
thumb: image as GenericUri,
title: record.embed.external.title,
uri: record.embed.external.uri,
},
}
: undefined,
record,
},
});
},
onError: (e) => {
console.error('Failed to upsert live status', linkMeta?.url, e);
},
});
}
export function useRemoveLiveStatusMutation(handle: DialogHandle) {
const { currentAccount } = useSession();
const { pds } = getClients();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async () => {
if (!currentAccount) {
throw new Error('Not logged in');
}
await deleteRecord(pds!, {
repo: currentAccount.did,
collection: 'app.bsky.actor.status',
rkey: 'self',
});
},
onSuccess: () => {
Toast.show(m['features.liveNow.goLive.ended']());
handle.close();
if (!currentAccount) {
return;
}
updateProfileShadow(queryClient, currentAccount.did, {
status: undefined,
});
},
onError: (e) => {
console.error('Failed to remove live status', e);
},
});
}