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