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