Bluesky app fork with some witchin' additions 馃挮
0
fork

Configure Feed

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

at 4e3c3e9905f4926b0533e56d23664c4e381ee93c 360 lines 11 kB view raw
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}