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