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