Bluesky app fork with some witchin' additions 馃挮
witchsky.app
bluesky
fork
client
1import {createContext, useContext, useMemo} from 'react'
2import {hasMutedWord} from '@atproto/api'
3import {QueryClient, useQuery} from '@tanstack/react-query'
4
5import {useOnAppStateChange} from '#/lib/appState'
6import {useIsBskyTeam} from '#/lib/hooks/useIsBskyTeam'
7import {
8 convertBskyAppUrlIfNeeded,
9 isBskyCustomFeedUrl,
10 makeRecordUri,
11} from '#/lib/strings/url-helpers'
12import {usePreferencesQuery} from '#/state/queries/preferences'
13import {IS_DEV, LIVE_EVENTS_URL} from '#/env'
14import {useLiveEventPreferences} from '#/features/liveEvents/preferences'
15import {type LiveEventsWorkerResponse} from '#/features/liveEvents/types'
16import {useDevMode} from '#/storage/hooks/dev-mode'
17
18const qc = new QueryClient()
19const liveEventsQueryKey = ['live-events']
20
21export const DEFAULT_LIVE_EVENTS = {
22 feeds: [],
23}
24
25async function fetchLiveEvents(): Promise<LiveEventsWorkerResponse | null> {
26 try {
27 const res = await fetch(`${LIVE_EVENTS_URL}/config`)
28 if (!res.ok) return null
29 const data = await res.json()
30 return data
31 } catch {
32 return null
33 }
34}
35
36const Context = createContext<LiveEventsWorkerResponse>(DEFAULT_LIVE_EVENTS)
37
38export function Provider({children}: React.PropsWithChildren<{}>) {
39 const [isDevMode] = useDevMode()
40 const isBskyTeam = useIsBskyTeam()
41 const {data: preferences} = usePreferencesQuery()
42 const mutedWords = useMemo(
43 () => preferences?.moderationPrefs?.mutedWords ?? [],
44 [preferences?.moderationPrefs?.mutedWords],
45 )
46
47 const {data, refetch} = useQuery(
48 {
49 // keep this, prefectching handles initial load
50 staleTime: 1000 * 15,
51 queryKey: liveEventsQueryKey,
52 refetchInterval: 1000 * 60 * 5, // refetch every 5 minutes
53 async queryFn() {
54 return fetchLiveEvents()
55 },
56 },
57 qc,
58 )
59
60 useOnAppStateChange(state => {
61 if (state === 'active') void refetch()
62 })
63
64 const ctx = useMemo(() => {
65 if (!data) return DEFAULT_LIVE_EVENTS
66 const skipMuteFilter = isBskyTeam || IS_DEV
67 const feeds = data.feeds.filter(f => {
68 if (f.preview && !isBskyTeam) return false
69 if (!skipMuteFilter && mutedWords.length > 0) {
70 const text = [
71 f.title,
72 f.layouts?.wide?.title,
73 f.layouts?.compact?.title,
74 ]
75 .filter(Boolean)
76 .join(' ')
77 if (hasMutedWord({mutedWords, text})) return false
78 }
79 return true
80 })
81 return {
82 ...data,
83 // only one at a time for now, unless bsky team and dev mode
84 feeds: isBskyTeam && isDevMode ? feeds : feeds.slice(0, 1),
85 }
86 }, [data, isBskyTeam, isDevMode, mutedWords])
87
88 return <Context.Provider value={ctx}>{children}</Context.Provider>
89}
90
91export async function prefetchLiveEvents() {
92 const data = await fetchLiveEvents()
93 if (data) {
94 qc.setQueryData(liveEventsQueryKey, data)
95 }
96}
97
98export function useLiveEvents() {
99 const ctx = useContext(Context)
100 if (!ctx) {
101 throw new Error('useLiveEventsContext must be used within a Provider')
102 }
103 return ctx
104}
105
106export function useUserPreferencedLiveEvents() {
107 const events = useLiveEvents()
108 const {data, isLoading} = useLiveEventPreferences()
109 if (isLoading) return DEFAULT_LIVE_EVENTS
110 const {hideAllFeeds, hiddenFeedIds} = data
111 return {
112 ...events,
113 feeds: hideAllFeeds
114 ? []
115 : events.feeds.filter(f => {
116 const hidden = f?.id ? hiddenFeedIds.includes(f?.id || '') : false
117 return !hidden
118 }),
119 }
120}
121
122export function useActiveLiveEventFeedUris() {
123 const {feeds} = useLiveEvents()
124
125 return new Set(
126 feeds
127 // insurance
128 .filter(f => isBskyCustomFeedUrl(f.url))
129 .map(f => {
130 const uri = convertBskyAppUrlIfNeeded(f.url)
131 const [_0, did, _1, rkey] = uri.split('/').filter(Boolean)
132 const urip = makeRecordUri(did, 'app.bsky.feed.generator', rkey)
133 return urip.toString()
134 }),
135 )
136}