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