forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
1import {
2 type $Typed,
3 type AppBskyActorStatus,
4 type AppBskyEmbedExternal,
5 ComAtprotoRepoPutRecord,
6} from '@atproto/api'
7import {retry} from '@atproto/common-web'
8import {msg} from '@lingui/macro'
9import {useLingui} from '@lingui/react'
10import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
11
12import {uploadBlob} from '#/lib/api'
13import {imageToThumb} from '#/lib/api/resolve'
14import {getLinkMeta, type LinkMeta} from '#/lib/link-meta/link-meta'
15import {logger} from '#/logger'
16import {updateProfileShadow} from '#/state/cache/profile-shadow'
17import {useLiveNowConfig} from '#/state/service-config'
18import {useAgent, useSession} from '#/state/session'
19import * as Toast from '#/view/com/util/Toast'
20import {useDialogContext} from '#/components/Dialog'
21
22export function useLiveLinkMetaQuery(url: string | null) {
23 const liveNowConfig = useLiveNowConfig()
24 const {currentAccount} = useSession()
25 const {_} = useLingui()
26
27 const agent = useAgent()
28 return useQuery({
29 enabled: !!url,
30 queryKey: ['link-meta', url],
31 queryFn: async () => {
32 if (!url) return undefined
33 const config = liveNowConfig.find(cfg => cfg.did === currentAccount?.did)
34
35 if (!config) throw new Error(_(msg`You are not allowed to go live`))
36
37 const urlp = new URL(url)
38 if (!config.domains.includes(urlp.hostname)) {
39 throw new Error(_(msg`${urlp.hostname} is not a valid URL`))
40 }
41
42 return await getLinkMeta(agent, url)
43 },
44 })
45}
46
47export function useUpsertLiveStatusMutation(
48 duration: number,
49 linkMeta: LinkMeta | null | undefined,
50 createdAt?: string,
51) {
52 const {currentAccount} = useSession()
53 const agent = useAgent()
54 const queryClient = useQueryClient()
55 const control = useDialogContext()
56 const {_} = useLingui()
57
58 return useMutation({
59 mutationFn: async () => {
60 if (!currentAccount) throw new Error('Not logged in')
61
62 let embed: $Typed<AppBskyEmbedExternal.Main> | undefined
63
64 if (linkMeta) {
65 let thumb
66
67 if (linkMeta.image) {
68 try {
69 const img = await imageToThumb(linkMeta.image)
70 if (img) {
71 const blob = await uploadBlob(
72 agent,
73 img.source.path,
74 img.source.mime,
75 )
76 thumb = blob.data.blob
77 }
78 } catch (e: any) {
79 logger.error(`Failed to upload thumbnail for live status`, {
80 url: linkMeta.url,
81 image: linkMeta.image,
82 safeMessage: e,
83 })
84 }
85 }
86
87 embed = {
88 $type: 'app.bsky.embed.external',
89 external: {
90 $type: 'app.bsky.embed.external#external',
91 title: linkMeta.title ?? '',
92 description: linkMeta.description ?? '',
93 uri: linkMeta.url,
94 thumb,
95 },
96 }
97 }
98
99 const record = {
100 $type: 'app.bsky.actor.status',
101 createdAt: createdAt ?? new Date().toISOString(),
102 status: 'app.bsky.actor.status#live',
103 durationMinutes: duration,
104 embed,
105 } satisfies AppBskyActorStatus.Record
106
107 const upsert = async () => {
108 const repo = currentAccount.did
109 const collection = 'app.bsky.actor.status'
110
111 const existing = await agent.com.atproto.repo
112 .getRecord({repo, collection, rkey: 'self'})
113 .catch(_e => undefined)
114
115 await agent.com.atproto.repo.putRecord({
116 repo,
117 collection,
118 rkey: 'self',
119 record,
120 swapRecord: existing?.data.cid || null,
121 })
122 }
123
124 await retry(upsert, {
125 maxRetries: 5,
126 retryable: e => e instanceof ComAtprotoRepoPutRecord.InvalidSwapError,
127 })
128
129 return {
130 record,
131 image: linkMeta?.image,
132 }
133 },
134 onError: (e: any) => {
135 logger.error(`Failed to upsert live status`, {
136 url: linkMeta?.url,
137 image: linkMeta?.image,
138 safeMessage: e,
139 })
140 },
141 onSuccess: ({record, image}) => {
142 if (createdAt) {
143 logger.metric(
144 'live:edit',
145 {duration: record.durationMinutes},
146 {statsig: true},
147 )
148 } else {
149 logger.metric(
150 'live:create',
151 {duration: record.durationMinutes},
152 {statsig: true},
153 )
154 }
155
156 Toast.show(_(msg`You are now live!`))
157 control.close(() => {
158 if (!currentAccount) return
159
160 const expiresAt = new Date(record.createdAt)
161 expiresAt.setMinutes(expiresAt.getMinutes() + record.durationMinutes)
162
163 updateProfileShadow(queryClient, currentAccount.did, {
164 status: {
165 $type: 'app.bsky.actor.defs#statusView',
166 status: 'app.bsky.actor.status#live',
167 isActive: true,
168 expiresAt: expiresAt.toISOString(),
169 embed:
170 record.embed && image
171 ? {
172 $type: 'app.bsky.embed.external#view',
173 external: {
174 ...record.embed.external,
175 $type: 'app.bsky.embed.external#viewExternal',
176 thumb: image,
177 },
178 }
179 : undefined,
180 record,
181 },
182 })
183 })
184 },
185 })
186}
187
188export function useRemoveLiveStatusMutation() {
189 const {currentAccount} = useSession()
190 const agent = useAgent()
191 const queryClient = useQueryClient()
192 const control = useDialogContext()
193 const {_} = useLingui()
194
195 return useMutation({
196 mutationFn: async () => {
197 if (!currentAccount) throw new Error('Not logged in')
198
199 await agent.app.bsky.actor.status.delete({
200 repo: currentAccount.did,
201 rkey: 'self',
202 })
203 },
204 onError: (e: any) => {
205 logger.error(`Failed to remove live status`, {
206 safeMessage: e,
207 })
208 },
209 onSuccess: () => {
210 logger.metric('live:remove', {}, {statsig: true})
211 Toast.show(_(msg`You are no longer live`))
212 control.close(() => {
213 if (!currentAccount) return
214
215 updateProfileShadow(queryClient, currentAccount.did, {
216 status: undefined,
217 })
218 })
219 },
220 })
221}