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