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