forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
1import {useMemo} from 'react'
2import {
3 type $Typed,
4 type AppBskyActorDefs,
5 type AppBskyActorStatus,
6 AppBskyEmbedExternal,
7 AtUri,
8 ComAtprotoRepoPutRecord,
9} from '@atproto/api'
10import {retry} from '@atproto/common-web'
11import {msg} from '@lingui/macro'
12import {useLingui} from '@lingui/react'
13import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
14import {isAfter, parseISO} from 'date-fns'
15
16import {uploadBlob} from '#/lib/api'
17import {imageToThumb} from '#/lib/api/resolve'
18import {getLinkMeta, type LinkMeta} from '#/lib/link-meta/link-meta'
19import {useAppConfig} from '#/state/appConfig'
20import {
21 updateProfileShadow,
22 useMaybeProfileShadow,
23} from '#/state/cache/profile-shadow'
24import {useAgent, useSession} from '#/state/session'
25import {useTickEveryMinute} from '#/state/shell'
26import * as Toast from '#/view/com/util/Toast'
27import {useDialogContext} from '#/components/Dialog'
28import {useAnalytics} from '#/analytics'
29import {getLiveNowHost, getLiveServiceNames} from '#/features/liveNow/utils'
30import type * as bsky from '#/types/bsky'
31
32export * from '#/features/liveNow/utils'
33
34export const DEFAULT_ALLOWED_DOMAINS = [
35 'twitch.tv',
36 'stream.place',
37 'bluecast.app',
38]
39
40export type LiveNowConfig = {
41 canGoLive: boolean
42 currentAccountAllowedHosts: Set<string>
43 defaultAllowedHosts: Set<string>
44 allowedHostsExceptionsByDid: Map<string, Set<string>>
45}
46
47export function useLiveNowConfig(): LiveNowConfig {
48 const ax = useAnalytics()
49 const {liveNow} = useAppConfig()
50 const {currentAccount} = useSession()
51
52 return useMemo(() => {
53 const disabled = ax.features.enabled(ax.features.LiveNowBetaDisable)
54
55 const defaultAllowedHosts = new Set(
56 DEFAULT_ALLOWED_DOMAINS.concat(liveNow.allow),
57 )
58 const allowedHostsExceptionsByDid = new Map<string, Set<string>>()
59 for (const ex of liveNow.exceptions) {
60 allowedHostsExceptionsByDid.set(
61 ex.did,
62 new Set(DEFAULT_ALLOWED_DOMAINS.concat(ex.allow)),
63 )
64 }
65
66 if (!currentAccount?.did || disabled) {
67 return {
68 canGoLive: false,
69 currentAccountAllowedHosts: new Set(),
70 defaultAllowedHosts,
71 allowedHostsExceptionsByDid,
72 }
73 }
74
75 return {
76 canGoLive: true,
77 currentAccountAllowedHosts:
78 allowedHostsExceptionsByDid.get(currentAccount.did) ??
79 defaultAllowedHosts,
80 defaultAllowedHosts,
81 allowedHostsExceptionsByDid,
82 }
83 }, [ax, liveNow, currentAccount])
84}
85
86export function useActorStatus(actor?: bsky.profile.AnyProfileView) {
87 const shadowed = useMaybeProfileShadow(actor)
88 const tick = useTickEveryMinute()
89 const config = useLiveNowConfig()
90
91 return useMemo(() => {
92 void tick // revalidate every minute
93
94 if (shadowed && 'status' in shadowed && shadowed.status) {
95 const isValid = isStatusValidForViewers(shadowed.status, config)
96 const isDisabled = shadowed.status.isDisabled || false
97 const isActive = isStatusStillActive(shadowed.status.expiresAt)
98 if (isValid && !isDisabled && isActive) {
99 return {
100 uri: shadowed.status.uri,
101 cid: shadowed.status.cid,
102 isDisabled: false,
103 isActive: true,
104 status: 'app.bsky.actor.status#live',
105 embed: shadowed.status.embed as $Typed<AppBskyEmbedExternal.View>, // temp_isStatusValid asserts this
106 expiresAt: shadowed.status.expiresAt!, // isStatusStillActive asserts this
107 record: shadowed.status.record,
108 } satisfies AppBskyActorDefs.StatusView
109 }
110 return {
111 uri: shadowed.status.uri,
112 cid: shadowed.status.cid,
113 isDisabled,
114 isActive: false,
115 status: 'app.bsky.actor.status#live',
116 embed: shadowed.status.embed as $Typed<AppBskyEmbedExternal.View>, // temp_isStatusValid asserts this
117 expiresAt: shadowed.status.expiresAt!, // isStatusStillActive asserts this
118 record: shadowed.status.record,
119 } satisfies AppBskyActorDefs.StatusView
120 } else {
121 return {
122 status: '',
123 isDisabled: false,
124 isActive: false,
125 record: {},
126 } satisfies AppBskyActorDefs.StatusView
127 }
128 }, [shadowed, config, tick])
129}
130
131export function isStatusStillActive(timeStr: string | undefined) {
132 if (!timeStr) return false
133 const now = new Date()
134 const expiry = parseISO(timeStr)
135
136 return isAfter(expiry, now)
137}
138
139/**
140 * Validates whether the live status is valid for display in the app. Does NOT
141 * validate if the status is valid for the acting user e.g. as they go live.
142 */
143export function isStatusValidForViewers(
144 status: AppBskyActorDefs.StatusView,
145 config: LiveNowConfig,
146) {
147 if (status.status !== 'app.bsky.actor.status#live') return false
148 if (!status.uri) return false // should not happen, just backwards compat
149 try {
150 const {host: liveDid} = new AtUri(status.uri)
151 if (AppBskyEmbedExternal.isView(status.embed)) {
152 const host = getLiveNowHost(status.embed.external.uri)
153 const exception = config.allowedHostsExceptionsByDid.get(liveDid)
154 const isValidException = exception ? exception.has(host) : false
155 const isValidForAnyone = config.defaultAllowedHosts.has(host)
156 return isValidException || isValidForAnyone
157 } else {
158 return false
159 }
160 } catch {
161 return false
162 }
163}
164
165export function useLiveLinkMetaQuery(url: string | null) {
166 const liveNowConfig = useLiveNowConfig()
167 const {_} = useLingui()
168
169 const agent = useAgent()
170 return useQuery({
171 enabled: !!url,
172 queryKey: ['link-meta', url],
173 queryFn: async () => {
174 if (!url) return undefined
175 const host = getLiveNowHost(url)
176 if (!liveNowConfig.currentAccountAllowedHosts.has(host)) {
177 const {formatted} = getLiveServiceNames(
178 liveNowConfig.currentAccountAllowedHosts,
179 )
180 throw new Error(
181 _(
182 msg`This service is not supported while the Live feature is in beta. Allowed services: ${formatted}.`,
183 ),
184 )
185 }
186
187 return await getLinkMeta(agent, url)
188 },
189 })
190}
191
192export function useUpsertLiveStatusMutation(
193 duration: number,
194 linkMeta: LinkMeta | null | undefined,
195 createdAt?: string,
196) {
197 const ax = useAnalytics()
198 const {currentAccount} = useSession()
199 const agent = useAgent()
200 const queryClient = useQueryClient()
201 const control = useDialogContext()
202 const {_} = useLingui()
203
204 return useMutation({
205 mutationFn: async () => {
206 if (!currentAccount) throw new Error('Not logged in')
207
208 let embed: $Typed<AppBskyEmbedExternal.Main> | undefined
209
210 if (linkMeta) {
211 let thumb
212
213 if (linkMeta.image) {
214 try {
215 const img = await imageToThumb(linkMeta.image)
216 if (img) {
217 const blob = await uploadBlob(
218 agent,
219 img.source.path,
220 img.source.mime,
221 )
222 thumb = blob.data.blob
223 }
224 } catch (e: any) {
225 ax.logger.error(`Failed to upload thumbnail for live status`, {
226 url: linkMeta.url,
227 image: linkMeta.image,
228 safeMessage: e,
229 })
230 }
231 }
232
233 embed = {
234 $type: 'app.bsky.embed.external',
235 external: {
236 $type: 'app.bsky.embed.external#external',
237 title: linkMeta.title ?? '',
238 description: linkMeta.description ?? '',
239 uri: linkMeta.url,
240 thumb,
241 },
242 }
243 }
244
245 const record = {
246 $type: 'app.bsky.actor.status',
247 createdAt: createdAt ?? new Date().toISOString(),
248 status: 'app.bsky.actor.status#live',
249 durationMinutes: duration,
250 embed,
251 } satisfies AppBskyActorStatus.Record
252
253 const upsert = async () => {
254 const repo = currentAccount.did
255 const collection = 'app.bsky.actor.status'
256
257 const existing = await agent.com.atproto.repo
258 .getRecord({repo, collection, rkey: 'self'})
259 .catch(_e => undefined)
260
261 await agent.com.atproto.repo.putRecord({
262 repo,
263 collection,
264 rkey: 'self',
265 record,
266 swapRecord: existing?.data.cid || null,
267 })
268 }
269
270 await retry(upsert, {
271 maxRetries: 5,
272 retryable: e => e instanceof ComAtprotoRepoPutRecord.InvalidSwapError,
273 })
274
275 return {
276 record,
277 image: linkMeta?.image,
278 }
279 },
280 onError: (e: any) => {
281 ax.logger.error(`Failed to upsert live status`, {
282 url: linkMeta?.url,
283 image: linkMeta?.image,
284 safeMessage: e,
285 })
286 },
287 onSuccess: ({record, image}) => {
288 if (createdAt) {
289 ax.metric('live:edit', {duration: record.durationMinutes})
290 } else {
291 ax.metric('live:create', {duration: record.durationMinutes})
292 }
293
294 Toast.show(_(msg`You are now live!`))
295 control.close(() => {
296 if (!currentAccount) return
297
298 const expiresAt = new Date(record.createdAt)
299 expiresAt.setMinutes(expiresAt.getMinutes() + record.durationMinutes)
300
301 updateProfileShadow(queryClient, currentAccount.did, {
302 status: {
303 $type: 'app.bsky.actor.defs#statusView',
304 status: 'app.bsky.actor.status#live',
305 isActive: true,
306 expiresAt: expiresAt.toISOString(),
307 embed:
308 record.embed && image
309 ? {
310 $type: 'app.bsky.embed.external#view',
311 external: {
312 ...record.embed.external,
313 $type: 'app.bsky.embed.external#viewExternal',
314 thumb: image,
315 },
316 }
317 : undefined,
318 record,
319 },
320 })
321 })
322 },
323 })
324}
325
326export function useRemoveLiveStatusMutation() {
327 const ax = useAnalytics()
328 const {currentAccount} = useSession()
329 const agent = useAgent()
330 const queryClient = useQueryClient()
331 const control = useDialogContext()
332 const {_} = useLingui()
333
334 return useMutation({
335 mutationFn: async () => {
336 if (!currentAccount) throw new Error('Not logged in')
337
338 await agent.app.bsky.actor.status.delete({
339 repo: currentAccount.did,
340 rkey: 'self',
341 })
342 },
343 onError: (e: any) => {
344 ax.logger.error(`Failed to remove live status`, {
345 safeMessage: e,
346 })
347 },
348 onSuccess: () => {
349 ax.metric('live:remove', {})
350 Toast.show(_(msg`You are no longer live`))
351 control.close(() => {
352 if (!currentAccount) return
353
354 updateProfileShadow(queryClient, currentAccount.did, {
355 status: undefined,
356 })
357 })
358 },
359 })
360}