Bluesky app fork with some witchin' additions 💫
at main 262 lines 8.7 kB view raw
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, theyll 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}