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