Bluesky app fork with some witchin' additions 馃挮
at post-text-option 256 lines 8.0 kB view raw
1import {useMemo, useState} from 'react' 2import {View} from 'react-native' 3import { 4 type AppBskyActorDefs, 5 AppBskyActorStatus, 6 type AppBskyEmbedExternal, 7} from '@atproto/api' 8import {msg, Trans} from '@lingui/macro' 9import {useLingui} from '@lingui/react' 10import {differenceInMinutes} from 'date-fns' 11 12import {cleanError} from '#/lib/strings/errors' 13import {definitelyUrl} from '#/lib/strings/url-helpers' 14import {useTickEveryMinute} from '#/state/shell' 15import {atoms as a, platform, useTheme, web} from '#/alf' 16import {Admonition} from '#/components/Admonition' 17import {Button, ButtonIcon, ButtonText} from '#/components/Button' 18import * as Dialog from '#/components/Dialog' 19import * as TextField from '#/components/forms/TextField' 20import {Clock_Stroke2_Corner0_Rounded as ClockIcon} from '#/components/icons/Clock' 21import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning' 22import {Loader} from '#/components/Loader' 23import {Text} from '#/components/Typography' 24import {LinkPreview} from './LinkPreview' 25import { 26 useLiveLinkMetaQuery, 27 useRemoveLiveStatusMutation, 28 useUpsertLiveStatusMutation, 29} from './queries' 30import {displayDuration, useDebouncedValue} from './utils' 31 32export function EditLiveDialog({ 33 control, 34 status, 35 embed, 36}: { 37 control: Dialog.DialogControlProps 38 status: AppBskyActorDefs.StatusView 39 embed: AppBskyEmbedExternal.View 40}) { 41 return ( 42 <Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}> 43 <Dialog.Handle /> 44 <DialogInner status={status} embed={embed} /> 45 </Dialog.Outer> 46 ) 47} 48 49function DialogInner({ 50 status, 51 embed, 52}: { 53 status: AppBskyActorDefs.StatusView 54 embed: AppBskyEmbedExternal.View 55}) { 56 const control = Dialog.useDialogContext() 57 const {_, i18n} = useLingui() 58 const t = useTheme() 59 60 const [liveLink, setLiveLink] = useState(embed.external.uri) 61 const [liveLinkError, setLiveLinkError] = useState('') 62 const tick = useTickEveryMinute() 63 64 const liveLinkUrl = definitelyUrl(liveLink) 65 const debouncedUrl = useDebouncedValue(liveLinkUrl, 500) 66 67 const isDirty = liveLinkUrl !== embed.external.uri 68 69 const { 70 data: linkMeta, 71 isSuccess: hasValidLinkMeta, 72 isLoading: linkMetaLoading, 73 error: linkMetaError, 74 } = useLiveLinkMetaQuery(debouncedUrl) 75 76 const record = useMemo(() => { 77 if (!AppBskyActorStatus.isRecord(status.record)) return null 78 const validation = AppBskyActorStatus.validateRecord(status.record) 79 if (validation.success) { 80 return validation.value 81 } 82 return null 83 }, [status]) 84 85 const { 86 mutate: goLive, 87 isPending: isGoingLive, 88 error: goLiveError, 89 } = useUpsertLiveStatusMutation( 90 record?.durationMinutes ?? 0, 91 linkMeta, 92 record?.createdAt, 93 ) 94 95 const { 96 mutate: removeLiveStatus, 97 isPending: isRemovingLiveStatus, 98 error: removeLiveStatusError, 99 } = useRemoveLiveStatusMutation() 100 101 const {minutesUntilExpiry, expiryDateTime} = useMemo(() => { 102 tick! 103 104 const expiry = new Date(status.expiresAt ?? new Date()) 105 return { 106 expiryDateTime: expiry, 107 minutesUntilExpiry: differenceInMinutes(expiry, new Date()), 108 } 109 }, [tick, status.expiresAt]) 110 111 const submitDisabled = 112 isGoingLive || 113 !hasValidLinkMeta || 114 debouncedUrl !== liveLinkUrl || 115 isRemovingLiveStatus 116 117 return ( 118 <Dialog.ScrollableInner 119 label={_(msg`You are Live`)} 120 style={web({maxWidth: 420})}> 121 <View style={[a.gap_lg]}> 122 <View style={[a.gap_sm]}> 123 <Text style={[a.font_semi_bold, a.text_2xl]}> 124 <Trans>You are Live</Trans> 125 </Text> 126 <View style={[a.flex_row, a.align_center, a.gap_xs]}> 127 <ClockIcon style={[t.atoms.text_contrast_high]} size="sm" /> 128 <Text 129 style={[a.text_md, a.leading_snug, t.atoms.text_contrast_high]}> 130 {typeof record?.durationMinutes === 'number' ? ( 131 <Trans> 132 Expires in {displayDuration(i18n, minutesUntilExpiry)} at{' '} 133 {i18n.date(expiryDateTime, { 134 hour: 'numeric', 135 minute: '2-digit', 136 hour12: true, 137 })} 138 </Trans> 139 ) : ( 140 <Trans>No expiry set</Trans> 141 )} 142 </Text> 143 </View> 144 </View> 145 <View style={[a.gap_sm]}> 146 <View> 147 <TextField.LabelText> 148 <Trans>Live link</Trans> 149 </TextField.LabelText> 150 <TextField.Root isInvalid={!!liveLinkError || !!linkMetaError}> 151 <TextField.Input 152 label={_(msg`Live link`)} 153 placeholder={_(msg`www.mylivestream.tv`)} 154 value={liveLink} 155 onChangeText={setLiveLink} 156 onFocus={() => setLiveLinkError('')} 157 onBlur={() => { 158 if (!definitelyUrl(liveLink)) { 159 setLiveLinkError('Invalid URL') 160 } 161 }} 162 returnKeyType="done" 163 autoCapitalize="none" 164 autoComplete="url" 165 autoCorrect={false} 166 onSubmitEditing={() => { 167 if (isDirty && !submitDisabled) { 168 goLive() 169 } 170 }} 171 /> 172 </TextField.Root> 173 </View> 174 {(liveLinkError || linkMetaError) && ( 175 <View style={[a.flex_row, a.gap_xs, a.align_center]}> 176 <WarningIcon 177 style={[{color: t.palette.negative_500}]} 178 size="sm" 179 /> 180 <Text 181 style={[ 182 a.text_sm, 183 a.leading_snug, 184 a.flex_1, 185 a.font_semi_bold, 186 {color: t.palette.negative_500}, 187 ]}> 188 {liveLinkError ? ( 189 <Trans>This is not a valid link</Trans> 190 ) : ( 191 cleanError(linkMetaError) 192 )} 193 </Text> 194 </View> 195 )} 196 197 <LinkPreview linkMeta={linkMeta} loading={linkMetaLoading} /> 198 </View> 199 200 {goLiveError && ( 201 <Admonition type="error">{cleanError(goLiveError)}</Admonition> 202 )} 203 {removeLiveStatusError && ( 204 <Admonition type="error"> 205 {cleanError(removeLiveStatusError)} 206 </Admonition> 207 )} 208 209 <View 210 style={platform({ 211 native: [a.gap_md, a.pt_lg], 212 web: [a.flex_row_reverse, a.gap_md, a.align_center], 213 })}> 214 {isDirty ? ( 215 <Button 216 label={_(msg`Save`)} 217 size={platform({native: 'large', web: 'small'})} 218 color="primary" 219 variant="solid" 220 onPress={() => goLive()} 221 disabled={submitDisabled}> 222 <ButtonText> 223 <Trans>Save</Trans> 224 </ButtonText> 225 {isGoingLive && <ButtonIcon icon={Loader} />} 226 </Button> 227 ) : ( 228 <Button 229 label={_(msg`Close`)} 230 size={platform({native: 'large', web: 'small'})} 231 color="primary" 232 variant="solid" 233 onPress={() => control.close()}> 234 <ButtonText> 235 <Trans>Close</Trans> 236 </ButtonText> 237 </Button> 238 )} 239 <Button 240 label={_(msg`Remove live status`)} 241 onPress={() => removeLiveStatus()} 242 size={platform({native: 'large', web: 'small'})} 243 color="negative_subtle" 244 variant="solid" 245 disabled={isRemovingLiveStatus || isGoingLive}> 246 <ButtonText> 247 <Trans>Remove live status</Trans> 248 </ButtonText> 249 {isRemovingLiveStatus && <ButtonIcon icon={Loader} />} 250 </Button> 251 </View> 252 </View> 253 <Dialog.Close /> 254 </Dialog.ScrollableInner> 255 ) 256}