forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
1import {useMemo, useState} from 'react'
2import {View} from 'react-native'
3import {
4 type AppBskyActorDefs,
5 AppBskyActorStatus,
6 type AppBskyEmbedExternal,
7} from '@atproto/api'
8import {msg, Trans} from '@lingui/macro'
9import {useLingui} from '@lingui/react'
10import {differenceInMinutes} from 'date-fns'
11
12import {cleanError} from '#/lib/strings/errors'
13import {definitelyUrl} from '#/lib/strings/url-helpers'
14import {useTickEveryMinute} from '#/state/shell'
15import {atoms as a, platform, useTheme, web} from '#/alf'
16import {Admonition} from '#/components/Admonition'
17import {Button, ButtonIcon, ButtonText} from '#/components/Button'
18import * as Dialog from '#/components/Dialog'
19import * as TextField from '#/components/forms/TextField'
20import {Clock_Stroke2_Corner0_Rounded as ClockIcon} from '#/components/icons/Clock'
21import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning'
22import {Loader} from '#/components/Loader'
23import {Text} from '#/components/Typography'
24import {LinkPreview} from './LinkPreview'
25import {
26 useLiveLinkMetaQuery,
27 useRemoveLiveStatusMutation,
28 useUpsertLiveStatusMutation,
29} from './queries'
30import {displayDuration, useDebouncedValue} from './utils'
31
32export function EditLiveDialog({
33 control,
34 status,
35 embed,
36}: {
37 control: Dialog.DialogControlProps
38 status: AppBskyActorDefs.StatusView
39 embed: AppBskyEmbedExternal.View
40}) {
41 return (
42 <Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}>
43 <Dialog.Handle />
44 <DialogInner status={status} embed={embed} />
45 </Dialog.Outer>
46 )
47}
48
49function DialogInner({
50 status,
51 embed,
52}: {
53 status: AppBskyActorDefs.StatusView
54 embed: AppBskyEmbedExternal.View
55}) {
56 const control = Dialog.useDialogContext()
57 const {_, i18n} = useLingui()
58 const t = useTheme()
59
60 const [liveLink, setLiveLink] = useState(embed.external.uri)
61 const [liveLinkError, setLiveLinkError] = useState('')
62 const tick = useTickEveryMinute()
63
64 const liveLinkUrl = definitelyUrl(liveLink)
65 const debouncedUrl = useDebouncedValue(liveLinkUrl, 500)
66
67 const isDirty = liveLinkUrl !== embed.external.uri
68
69 const {
70 data: linkMeta,
71 isSuccess: hasValidLinkMeta,
72 isLoading: linkMetaLoading,
73 error: linkMetaError,
74 } = useLiveLinkMetaQuery(debouncedUrl)
75
76 const record = useMemo(() => {
77 if (!AppBskyActorStatus.isRecord(status.record)) return null
78 const validation = AppBskyActorStatus.validateRecord(status.record)
79 if (validation.success) {
80 return validation.value
81 }
82 return null
83 }, [status])
84
85 const {
86 mutate: goLive,
87 isPending: isGoingLive,
88 error: goLiveError,
89 } = useUpsertLiveStatusMutation(
90 record?.durationMinutes ?? 0,
91 linkMeta,
92 record?.createdAt,
93 )
94
95 const {
96 mutate: removeLiveStatus,
97 isPending: isRemovingLiveStatus,
98 error: removeLiveStatusError,
99 } = useRemoveLiveStatusMutation()
100
101 const {minutesUntilExpiry, expiryDateTime} = useMemo(() => {
102 tick!
103
104 const expiry = new Date(status.expiresAt ?? new Date())
105 return {
106 expiryDateTime: expiry,
107 minutesUntilExpiry: differenceInMinutes(expiry, new Date()),
108 }
109 }, [tick, status.expiresAt])
110
111 const submitDisabled =
112 isGoingLive ||
113 !hasValidLinkMeta ||
114 debouncedUrl !== liveLinkUrl ||
115 isRemovingLiveStatus
116
117 return (
118 <Dialog.ScrollableInner
119 label={_(msg`You are Live`)}
120 style={web({maxWidth: 420})}>
121 <View style={[a.gap_lg]}>
122 <View style={[a.gap_sm]}>
123 <Text style={[a.font_semi_bold, a.text_2xl]}>
124 <Trans>You are Live</Trans>
125 </Text>
126 <View style={[a.flex_row, a.align_center, a.gap_xs]}>
127 <ClockIcon style={[t.atoms.text_contrast_high]} size="sm" />
128 <Text
129 style={[a.text_md, a.leading_snug, t.atoms.text_contrast_high]}>
130 {typeof record?.durationMinutes === 'number' ? (
131 <Trans>
132 Expires in {displayDuration(i18n, minutesUntilExpiry)} at{' '}
133 {i18n.date(expiryDateTime, {
134 hour: 'numeric',
135 minute: '2-digit',
136 hour12: true,
137 })}
138 </Trans>
139 ) : (
140 <Trans>No expiry set</Trans>
141 )}
142 </Text>
143 </View>
144 </View>
145 <View style={[a.gap_sm]}>
146 <View>
147 <TextField.LabelText>
148 <Trans>Live link</Trans>
149 </TextField.LabelText>
150 <TextField.Root isInvalid={!!liveLinkError || !!linkMetaError}>
151 <TextField.Input
152 label={_(msg`Live link`)}
153 placeholder={_(msg`www.mylivestream.tv`)}
154 value={liveLink}
155 onChangeText={setLiveLink}
156 onFocus={() => setLiveLinkError('')}
157 onBlur={() => {
158 if (!definitelyUrl(liveLink)) {
159 setLiveLinkError('Invalid URL')
160 }
161 }}
162 returnKeyType="done"
163 autoCapitalize="none"
164 autoComplete="url"
165 autoCorrect={false}
166 onSubmitEditing={() => {
167 if (isDirty && !submitDisabled) {
168 goLive()
169 }
170 }}
171 />
172 </TextField.Root>
173 </View>
174 {(liveLinkError || linkMetaError) && (
175 <View style={[a.flex_row, a.gap_xs, a.align_center]}>
176 <WarningIcon
177 style={[{color: t.palette.negative_500}]}
178 size="sm"
179 />
180 <Text
181 style={[
182 a.text_sm,
183 a.leading_snug,
184 a.flex_1,
185 a.font_semi_bold,
186 {color: t.palette.negative_500},
187 ]}>
188 {liveLinkError ? (
189 <Trans>This is not a valid link</Trans>
190 ) : (
191 cleanError(linkMetaError)
192 )}
193 </Text>
194 </View>
195 )}
196
197 <LinkPreview linkMeta={linkMeta} loading={linkMetaLoading} />
198 </View>
199
200 {goLiveError && (
201 <Admonition type="error">{cleanError(goLiveError)}</Admonition>
202 )}
203 {removeLiveStatusError && (
204 <Admonition type="error">
205 {cleanError(removeLiveStatusError)}
206 </Admonition>
207 )}
208
209 <View
210 style={platform({
211 native: [a.gap_md, a.pt_lg],
212 web: [a.flex_row_reverse, a.gap_md, a.align_center],
213 })}>
214 {isDirty ? (
215 <Button
216 label={_(msg`Save`)}
217 size={platform({native: 'large', web: 'small'})}
218 color="primary"
219 variant="solid"
220 onPress={() => goLive()}
221 disabled={submitDisabled}>
222 <ButtonText>
223 <Trans>Save</Trans>
224 </ButtonText>
225 {isGoingLive && <ButtonIcon icon={Loader} />}
226 </Button>
227 ) : (
228 <Button
229 label={_(msg`Close`)}
230 size={platform({native: 'large', web: 'small'})}
231 color="primary"
232 variant="solid"
233 onPress={() => control.close()}>
234 <ButtonText>
235 <Trans>Close</Trans>
236 </ButtonText>
237 </Button>
238 )}
239 <Button
240 label={_(msg`Remove live status`)}
241 onPress={() => removeLiveStatus()}
242 size={platform({native: 'large', web: 'small'})}
243 color="negative_subtle"
244 variant="solid"
245 disabled={isRemovingLiveStatus || isGoingLive}>
246 <ButtonText>
247 <Trans>Remove live status</Trans>
248 </ButtonText>
249 {isRemovingLiveStatus && <ButtonIcon icon={Loader} />}
250 </Button>
251 </View>
252 </View>
253 <Dialog.Close />
254 </Dialog.ScrollableInner>
255 )
256}