forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
1import {createContext, useContext, useMemo, useState} from 'react'
2import {
3 type AppBskyActorDefs,
4 type AppBskyFeedDefs,
5 type AppBskyUnspeccedGetPostThreadV2,
6 type BlobRef,
7 type ModerationDecision,
8} from '@atproto/api'
9import {msg} from '@lingui/core/macro'
10import {useLingui} from '@lingui/react'
11import {useQueryClient} from '@tanstack/react-query'
12
13import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
14import {postUriToRelativePath, toBskyAppUrl} from '#/lib/strings/url-helpers'
15import {purgeTemporaryImageFiles} from '#/state/gallery'
16import {
17 precacheResolveLinkQuery,
18 RQKEY_GIF_ROOT,
19 RQKEY_LINK_ROOT,
20} from '#/state/queries/resolve-link'
21import * as Toast from '#/components/Toast'
22
23export interface ComposerOptsPostRef {
24 uri: string
25 cid: string
26 text: string
27 langs?: string[]
28 // remove this after updating atproto api package
29 author: AppBskyActorDefs.ProfileViewBasic & {
30 pronouns?: string
31 }
32 embed?: AppBskyFeedDefs.PostView['embed']
33 moderation?: ModerationDecision
34}
35
36export type OnPostSuccessData =
37 | {
38 replyToUri?: string
39 posts: AppBskyUnspeccedGetPostThreadV2.ThreadItem[]
40 }
41 | undefined
42
43export type ComposerLogContext =
44 | 'Fab'
45 | 'PostReply'
46 | 'QuotePost'
47 | 'ProfileFeed'
48 | 'Deeplink'
49 | 'Other'
50
51export interface ComposerOpts {
52 activeAccountDid?: string
53 replyTo?: ComposerOptsPostRef
54 onPost?: (postUri: string | undefined) => void
55 onPostSuccess?: (data: OnPostSuccessData) => void
56 quote?: AppBskyFeedDefs.PostView
57 mention?: string // handle of user to mention
58 text?: string
59 imageUris?: {
60 uri: string
61 width: number
62 height: number
63 altText?: string
64 blobRef?: BlobRef
65 }[]
66 videoUri?: {
67 uri: string
68 width: number
69 height: number
70 blobRef?: BlobRef
71 altText?: string
72 }
73 openGallery?: boolean
74 logContext?: ComposerLogContext
75}
76
77type StateContext = ComposerOpts | undefined
78type ControlsContext = {
79 openComposer: (opts: ComposerOpts) => void
80 closeComposer: () => boolean
81}
82
83const stateContext = createContext<StateContext>(undefined)
84stateContext.displayName = 'ComposerStateContext'
85const controlsContext = createContext<ControlsContext>({
86 openComposer(_opts: ComposerOpts) {},
87 closeComposer() {
88 return false
89 },
90})
91controlsContext.displayName = 'ComposerControlsContext'
92
93export function Provider({children}: React.PropsWithChildren<{}>) {
94 const {_} = useLingui()
95 const [state, setState] = useState<StateContext>()
96 const queryClient = useQueryClient()
97
98 const openComposer = useNonReactiveCallback((opts: ComposerOpts) => {
99 if (opts.quote) {
100 const path = postUriToRelativePath(opts.quote.uri)
101 if (path) {
102 const appUrl = toBskyAppUrl(path)
103 precacheResolveLinkQuery(queryClient, appUrl, {
104 type: 'record',
105 kind: 'post',
106 record: {
107 cid: opts.quote.cid,
108 uri: opts.quote.uri,
109 },
110 view: opts.quote,
111 })
112 }
113 }
114 const author = opts.replyTo?.author || opts.quote?.author
115 const isBlocked = Boolean(
116 author &&
117 (author.viewer?.blocking ||
118 author.viewer?.blockedBy ||
119 author.viewer?.blockingByList),
120 )
121 if (isBlocked) {
122 Toast.show(_(msg`Cannot interact with a blocked user`), {
123 type: 'warning',
124 })
125 } else {
126 setState(prevOpts => {
127 if (prevOpts) {
128 // Never replace an already open composer.
129 return prevOpts
130 }
131 return opts
132 })
133 }
134 })
135
136 const closeComposer = useNonReactiveCallback(() => {
137 let wasOpen = !!state
138 if (wasOpen) {
139 setState(undefined)
140 purgeTemporaryImageFiles()
141 // Purging deletes cached thumbnails on disk, so remove the query
142 // caches that may hold references to those now-deleted file paths.
143 // Without this, restoring a draft would serve stale ResolvedLink
144 // data pointing at missing files, causing "Failed to load blob".
145 queryClient.removeQueries({queryKey: [RQKEY_LINK_ROOT]})
146 queryClient.removeQueries({queryKey: [RQKEY_GIF_ROOT]})
147 }
148
149 return wasOpen
150 })
151
152 const api = useMemo(
153 () => ({
154 openComposer,
155 closeComposer,
156 }),
157 [openComposer, closeComposer],
158 )
159
160 return (
161 <stateContext.Provider value={state}>
162 <controlsContext.Provider value={api}>
163 {children}
164 </controlsContext.Provider>
165 </stateContext.Provider>
166 )
167}
168
169export function useComposerState() {
170 return useContext(stateContext)
171}
172
173export function useComposerControls() {
174 const {closeComposer} = useContext(controlsContext)
175 return useMemo(() => ({closeComposer}), [closeComposer])
176}
177
178/**
179 * DO NOT USE DIRECTLY. The deprecation notice as a warning only, it's not
180 * actually deprecated.
181 *
182 * @deprecated use `#/lib/hooks/useOpenComposer` instead
183 */
184export function useOpenComposer() {
185 const {openComposer} = useContext(controlsContext)
186 return useMemo(() => ({openComposer}), [openComposer])
187}