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