forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 💫
1import {useCallback, useState} from 'react'
2import {View} from 'react-native'
3import {msg, Trans} from '@lingui/macro'
4import {useLingui} from '@lingui/react'
5
6import {cleanError} from '#/lib/strings/errors'
7import {definitelyUrl} from '#/lib/strings/url-helpers'
8import {useModerationOpts} from '#/state/preferences/moderation-opts'
9import {useTickEveryMinute} from '#/state/shell'
10import {atoms as a, ios, native, platform, useTheme, web} from '#/alf'
11import {Admonition} from '#/components/Admonition'
12import {Button, ButtonIcon, ButtonText} from '#/components/Button'
13import * as Dialog from '#/components/Dialog'
14import * as TextField from '#/components/forms/TextField'
15import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning'
16import {Loader} from '#/components/Loader'
17import * as ProfileCard from '#/components/ProfileCard'
18import * as Select from '#/components/Select'
19import {Text} from '#/components/Typography'
20import type * as bsky from '#/types/bsky'
21import {LinkPreview} from './LinkPreview'
22import {useLiveLinkMetaQuery, useUpsertLiveStatusMutation} from './queries'
23import {displayDuration, useDebouncedValue} from './utils'
24
25export function GoLiveDialog({
26 control,
27 profile,
28}: {
29 control: Dialog.DialogControlProps
30 profile: bsky.profile.AnyProfileView
31}) {
32 return (
33 <Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}>
34 <Dialog.Handle />
35 <DialogInner profile={profile} />
36 </Dialog.Outer>
37 )
38}
39
40// Possible durations: max 4 hours, 5 minute intervals
41const DURATIONS = Array.from({length: (4 * 60) / 5}).map((_, i) => (i + 1) * 5)
42
43function DialogInner({profile}: {profile: bsky.profile.AnyProfileView}) {
44 const control = Dialog.useDialogContext()
45 const {_, i18n} = useLingui()
46 const t = useTheme()
47 const [liveLink, setLiveLink] = useState('')
48 const [liveLinkError, setLiveLinkError] = useState('')
49 const [duration, setDuration] = useState(60)
50 const moderationOpts = useModerationOpts()
51 const tick = useTickEveryMinute()
52
53 const time = useCallback(
54 (offset: number) => {
55 tick!
56
57 const date = new Date()
58 date.setMinutes(date.getMinutes() + offset)
59 return i18n.date(date, {hour: 'numeric', minute: '2-digit', hour12: true})
60 },
61 [tick, i18n],
62 )
63
64 const onChangeDuration = useCallback((newDuration: string) => {
65 setDuration(Number(newDuration))
66 }, [])
67
68 const liveLinkUrl = definitelyUrl(liveLink)
69 const debouncedUrl = useDebouncedValue(liveLinkUrl, 500)
70
71 const {
72 data: linkMeta,
73 isSuccess: hasValidLinkMeta,
74 isLoading: linkMetaLoading,
75 error: linkMetaError,
76 } = useLiveLinkMetaQuery(debouncedUrl)
77
78 const {
79 mutate: goLive,
80 isPending: isGoingLive,
81 error: goLiveError,
82 } = useUpsertLiveStatusMutation(duration, linkMeta)
83
84 const isSourceInvalid = !!liveLinkError || !!linkMetaError
85
86 const hasLink = !!debouncedUrl && !isSourceInvalid
87
88 return (
89 <Dialog.ScrollableInner
90 label={_(msg`Go Live`)}
91 style={web({maxWidth: 420})}>
92 <View style={[a.gap_xl]}>
93 <View style={[a.gap_sm]}>
94 <Text style={[a.font_semi_bold, a.text_2xl]}>
95 <Trans>Go Live</Trans>
96 </Text>
97 <Text style={[a.text_md, a.leading_snug, t.atoms.text_contrast_high]}>
98 <Trans>
99 Add a temporary live status to your profile. When someone clicks
100 on your avatar, they’ll see information about your live event.
101 </Trans>
102 </Text>
103 </View>
104 {moderationOpts && (
105 <ProfileCard.Header>
106 <ProfileCard.Avatar
107 profile={profile}
108 moderationOpts={moderationOpts}
109 liveOverride
110 disabledPreview
111 />
112 <ProfileCard.NameAndHandle
113 profile={profile}
114 moderationOpts={moderationOpts}
115 />
116 </ProfileCard.Header>
117 )}
118 <View style={[a.gap_sm]}>
119 <View>
120 <TextField.LabelText>
121 <Trans>Live link</Trans>
122 </TextField.LabelText>
123 <TextField.Root isInvalid={isSourceInvalid}>
124 <TextField.Input
125 label={_(msg`Live link`)}
126 placeholder={_(msg`www.mylivestream.tv`)}
127 value={liveLink}
128 onChangeText={setLiveLink}
129 onFocus={() => setLiveLinkError('')}
130 onBlur={() => {
131 if (!definitelyUrl(liveLink)) {
132 setLiveLinkError('Invalid URL')
133 }
134 }}
135 returnKeyType="done"
136 autoCapitalize="none"
137 autoComplete="url"
138 autoCorrect={false}
139 />
140 </TextField.Root>
141 </View>
142 {(liveLinkError || linkMetaError) && (
143 <View style={[a.flex_row, a.gap_xs, a.align_center]}>
144 <WarningIcon
145 style={[{color: t.palette.negative_500}]}
146 size="sm"
147 />
148 <Text
149 style={[
150 a.text_sm,
151 a.leading_snug,
152 a.flex_1,
153 a.font_semi_bold,
154 {color: t.palette.negative_500},
155 ]}>
156 {liveLinkError ? (
157 <Trans>This is not a valid link</Trans>
158 ) : (
159 cleanError(linkMetaError)
160 )}
161 </Text>
162 </View>
163 )}
164
165 <LinkPreview linkMeta={linkMeta} loading={linkMetaLoading} />
166 </View>
167
168 {hasLink && (
169 <View>
170 <TextField.LabelText>
171 <Trans>Go live for</Trans>
172 </TextField.LabelText>
173 <Select.Root
174 value={String(duration)}
175 onValueChange={onChangeDuration}>
176 <Select.Trigger label={_(msg`Select duration`)}>
177 <Text style={[ios(a.py_xs)]}>
178 {displayDuration(i18n, duration)}
179 {' '}
180 <Text style={[t.atoms.text_contrast_low]}>
181 {time(duration)}
182 </Text>
183 </Text>
184
185 <Select.Icon />
186 </Select.Trigger>
187 <Select.Content
188 renderItem={(item, _i, selectedValue) => {
189 const label = displayDuration(i18n, item)
190 return (
191 <Select.Item value={String(item)} label={label}>
192 <Select.ItemIndicator />
193 <Select.ItemText>
194 {label}
195 {' '}
196 <Text
197 style={[
198 native(a.text_md),
199 web(a.ml_xs),
200 selectedValue === String(item)
201 ? t.atoms.text_contrast_medium
202 : t.atoms.text_contrast_low,
203 a.font_normal,
204 ]}>
205 {time(item)}
206 </Text>
207 </Select.ItemText>
208 </Select.Item>
209 )
210 }}
211 items={DURATIONS}
212 valueExtractor={d => String(d)}
213 />
214 </Select.Root>
215 </View>
216 )}
217
218 {goLiveError && (
219 <Admonition type="error">{cleanError(goLiveError)}</Admonition>
220 )}
221
222 <View
223 style={platform({
224 native: [a.gap_md, a.pt_lg],
225 web: [a.flex_row_reverse, a.gap_md, a.align_center],
226 })}>
227 {hasLink && (
228 <Button
229 label={_(msg`Go Live`)}
230 size={platform({native: 'large', web: 'small'})}
231 color="primary"
232 variant="solid"
233 onPress={() => goLive()}
234 disabled={
235 isGoingLive || !hasValidLinkMeta || debouncedUrl !== liveLinkUrl
236 }>
237 <ButtonText>
238 <Trans>Go Live</Trans>
239 </ButtonText>
240 {isGoingLive && <ButtonIcon icon={Loader} />}
241 </Button>
242 )}
243 <Button
244 label={_(msg`Cancel`)}
245 onPress={() => control.close()}
246 size={platform({native: 'large', web: 'small'})}
247 color="secondary"
248 variant={platform({native: 'solid', web: 'ghost'})}>
249 <ButtonText>
250 <Trans>Cancel</Trans>
251 </ButtonText>
252 </Button>
253 </View>
254 </View>
255 <Dialog.Close />
256 </Dialog.ScrollableInner>
257 )
258}