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 {useDebouncedValue} from '#/lib/hooks/useDebouncedValue'
13import {cleanError} from '#/lib/strings/errors'
14import {definitelyUrl} from '#/lib/strings/url-helpers'
15import {useTickEveryMinute} from '#/state/shell'
16import {atoms as a, platform, useTheme, web} from '#/alf'
17import {Admonition} from '#/components/Admonition'
18import {Button, ButtonIcon, ButtonText} from '#/components/Button'
19import * as Dialog from '#/components/Dialog'
20import * as TextField from '#/components/forms/TextField'
21import {Clock_Stroke2_Corner0_Rounded as ClockIcon} from '#/components/icons/Clock'
22import {Loader} from '#/components/Loader'
23import {Text} from '#/components/Typography'
24import {
25 displayDuration,
26 useLiveLinkMetaQuery,
27 useRemoveLiveStatusMutation,
28 useUpsertLiveStatusMutation,
29} from '#/features/liveNow'
30import {LinkPreview} from '#/features/liveNow/components/LinkPreview'
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 void 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 <Admonition type="error">
176 {liveLinkError ? (
177 <Trans>This is not a valid link</Trans>
178 ) : (
179 cleanError(linkMetaError)
180 )}
181 </Admonition>
182 )}
183
184 <LinkPreview linkMeta={linkMeta} loading={linkMetaLoading} />
185 </View>
186
187 {goLiveError && (
188 <Admonition type="error">{cleanError(goLiveError)}</Admonition>
189 )}
190 {removeLiveStatusError && (
191 <Admonition type="error">
192 {cleanError(removeLiveStatusError)}
193 </Admonition>
194 )}
195
196 <View
197 style={platform({
198 native: [a.gap_md, a.pt_lg],
199 web: [a.flex_row_reverse, a.gap_md, a.align_center],
200 })}>
201 {isDirty ? (
202 <Button
203 label={_(msg`Save`)}
204 size={platform({native: 'large', web: 'small'})}
205 color="primary"
206 variant="solid"
207 onPress={() => goLive()}
208 disabled={submitDisabled}>
209 <ButtonText>
210 <Trans>Save</Trans>
211 </ButtonText>
212 {isGoingLive && <ButtonIcon icon={Loader} />}
213 </Button>
214 ) : (
215 <Button
216 label={_(msg`Close`)}
217 size={platform({native: 'large', web: 'small'})}
218 color="primary"
219 variant="solid"
220 onPress={() => control.close()}>
221 <ButtonText>
222 <Trans>Close</Trans>
223 </ButtonText>
224 </Button>
225 )}
226 <Button
227 label={_(msg`Remove live status`)}
228 onPress={() => removeLiveStatus()}
229 size={platform({native: 'large', web: 'small'})}
230 color="negative_subtle"
231 variant="solid"
232 disabled={isRemovingLiveStatus || isGoingLive}>
233 <ButtonText>
234 <Trans>Remove live status</Trans>
235 </ButtonText>
236 {isRemovingLiveStatus && <ButtonIcon icon={Loader} />}
237 </Button>
238 </View>
239 </View>
240 <Dialog.Close />
241 </Dialog.ScrollableInner>
242 )
243}