Bluesky app fork with some witchin' additions 💫 witchsky.app
bluesky fork client

Delete unused modals and invite code stuff (#8244)

* delete waitlist modal, which doesn't exist

* delete invite code modal

* rip out copied invites persisted state

* delete invite query

* delete unused modal implementations

authored by samuel.fm and committed by

GitHub 46a97fad e90cfdc8

+27 -1019
+16 -19
src/App.native.tsx
··· 38 38 } from '#/state/geolocation' 39 39 import {GlobalGestureEventsProvider} from '#/state/global-gesture-events' 40 40 import {Provider as HomeBadgeProvider} from '#/state/home-badge' 41 - import {Provider as InvitesStateProvider} from '#/state/invites' 42 41 import {Provider as LightboxStateProvider} from '#/state/lightbox' 43 42 import {MessagesProvider} from '#/state/messages' 44 43 import {Provider as ModalStateProvider} from '#/state/modals' ··· 225 224 <PrefsStateProvider> 226 225 <I18nProvider> 227 226 <ShellStateProvider> 228 - <InvitesStateProvider> 229 - <ModalStateProvider> 230 - <DialogStateProvider> 231 - <LightboxStateProvider> 232 - <PortalProvider> 233 - <BottomSheetProvider> 234 - <StarterPackProvider> 235 - <SafeAreaProvider 236 - initialMetrics={initialWindowMetrics}> 237 - <InnerApp /> 238 - </SafeAreaProvider> 239 - </StarterPackProvider> 240 - </BottomSheetProvider> 241 - </PortalProvider> 242 - </LightboxStateProvider> 243 - </DialogStateProvider> 244 - </ModalStateProvider> 245 - </InvitesStateProvider> 227 + <ModalStateProvider> 228 + <DialogStateProvider> 229 + <LightboxStateProvider> 230 + <PortalProvider> 231 + <BottomSheetProvider> 232 + <StarterPackProvider> 233 + <SafeAreaProvider 234 + initialMetrics={initialWindowMetrics}> 235 + <InnerApp /> 236 + </SafeAreaProvider> 237 + </StarterPackProvider> 238 + </BottomSheetProvider> 239 + </PortalProvider> 240 + </LightboxStateProvider> 241 + </DialogStateProvider> 242 + </ModalStateProvider> 246 243 </ShellStateProvider> 247 244 </I18nProvider> 248 245 </PrefsStateProvider>
+11 -14
src/App.web.tsx
··· 26 26 Provider as GeolocationProvider, 27 27 } from '#/state/geolocation' 28 28 import {Provider as HomeBadgeProvider} from '#/state/home-badge' 29 - import {Provider as InvitesStateProvider} from '#/state/invites' 30 29 import {Provider as LightboxStateProvider} from '#/state/lightbox' 31 30 import {MessagesProvider} from '#/state/messages' 32 31 import {Provider as ModalStateProvider} from '#/state/modals' ··· 199 198 <PrefsStateProvider> 200 199 <I18nProvider> 201 200 <ShellStateProvider> 202 - <InvitesStateProvider> 203 - <ModalStateProvider> 204 - <DialogStateProvider> 205 - <LightboxStateProvider> 206 - <PortalProvider> 207 - <StarterPackProvider> 208 - <InnerApp /> 209 - </StarterPackProvider> 210 - </PortalProvider> 211 - </LightboxStateProvider> 212 - </DialogStateProvider> 213 - </ModalStateProvider> 214 - </InvitesStateProvider> 201 + <ModalStateProvider> 202 + <DialogStateProvider> 203 + <LightboxStateProvider> 204 + <PortalProvider> 205 + <StarterPackProvider> 206 + <InnerApp /> 207 + </StarterPackProvider> 208 + </PortalProvider> 209 + </LightboxStateProvider> 210 + </DialogStateProvider> 211 + </ModalStateProvider> 215 212 </ShellStateProvider> 216 213 </I18nProvider> 217 214 </PrefsStateProvider>
-59
src/state/invites.tsx
··· 1 - import React from 'react' 2 - 3 - import * as persisted from '#/state/persisted' 4 - 5 - type StateContext = persisted.Schema['invites'] 6 - type ApiContext = { 7 - setInviteCopied: (code: string) => void 8 - } 9 - 10 - const stateContext = React.createContext<StateContext>( 11 - persisted.defaults.invites, 12 - ) 13 - stateContext.displayName = 'InvitesStateContext' 14 - const apiContext = React.createContext<ApiContext>({ 15 - setInviteCopied(_: string) {}, 16 - }) 17 - apiContext.displayName = 'InvitesApiContext' 18 - 19 - export function Provider({children}: React.PropsWithChildren<{}>) { 20 - const [state, setState] = React.useState(persisted.get('invites')) 21 - 22 - const api = React.useMemo( 23 - () => ({ 24 - setInviteCopied(code: string) { 25 - setState(state => { 26 - state = { 27 - ...state, 28 - copiedInvites: state.copiedInvites.includes(code) 29 - ? state.copiedInvites 30 - : state.copiedInvites.concat([code]), 31 - } 32 - persisted.write('invites', state) 33 - return state 34 - }) 35 - }, 36 - }), 37 - [setState], 38 - ) 39 - 40 - React.useEffect(() => { 41 - return persisted.onUpdate('invites', nextInvites => { 42 - setState(nextInvites) 43 - }) 44 - }, [setState]) 45 - 46 - return ( 47 - <stateContext.Provider value={state}> 48 - <apiContext.Provider value={api}>{children}</apiContext.Provider> 49 - </stateContext.Provider> 50 - ) 51 - } 52 - 53 - export function useInvitesState() { 54 - return React.useContext(stateContext) 55 - } 56 - 57 - export function useInvitesAPI() { 58 - return React.useContext(apiContext) 59 - }
-12
src/state/modals/index.tsx
··· 15 15 name: 'delete-account' 16 16 } 17 17 18 - export interface WaitlistModal { 19 - name: 'waitlist' 20 - } 21 - 22 - export interface InviteCodesModal { 23 - name: 'invite-codes' 24 - } 25 - 26 18 export interface ContentLanguagesSettingsModal { 27 19 name: 'content-languages-settings' 28 20 } ··· 39 31 40 32 // Lists 41 33 | UserAddRemoveListsModal 42 - 43 - // Bluesky access 44 - | WaitlistModal 45 - | InviteCodesModal 46 34 47 35 const ModalContext = React.createContext<{ 48 36 isModalActive: boolean
-65
src/state/queries/invites.ts
··· 1 - import {type ComAtprotoServerDefs} from '@atproto/api' 2 - import {useQuery} from '@tanstack/react-query' 3 - 4 - import {cleanError} from '#/lib/strings/errors' 5 - import {STALE} from '#/state/queries' 6 - import {useAgent} from '#/state/session' 7 - 8 - function isInviteAvailable(invite: ComAtprotoServerDefs.InviteCode): boolean { 9 - return invite.available - invite.uses.length > 0 && !invite.disabled 10 - } 11 - 12 - const inviteCodesQueryKeyRoot = 'inviteCodes' 13 - 14 - export type InviteCodesQueryResponse = Exclude< 15 - ReturnType<typeof useInviteCodesQuery>['data'], 16 - undefined 17 - > 18 - export function useInviteCodesQuery() { 19 - const agent = useAgent() 20 - return useQuery({ 21 - staleTime: STALE.MINUTES.FIVE, 22 - queryKey: [inviteCodesQueryKeyRoot], 23 - queryFn: async () => { 24 - const res = await agent.com.atproto.server 25 - .getAccountInviteCodes({}) 26 - .catch(e => { 27 - if (cleanError(e) === 'Bad token scope') { 28 - return null 29 - } else { 30 - throw e 31 - } 32 - }) 33 - 34 - if (res === null) { 35 - return { 36 - disabled: true, 37 - all: [], 38 - available: [], 39 - used: [], 40 - } 41 - } 42 - 43 - if (!res.data?.codes) { 44 - throw new Error(`useInviteCodesQuery: no codes returned`) 45 - } 46 - 47 - const available = res.data.codes.filter(isInviteAvailable) 48 - const used = res.data.codes 49 - .filter(code => !isInviteAvailable(code)) 50 - .sort((a, b) => { 51 - return ( 52 - new Date(b.uses[0].usedAt).getTime() - 53 - new Date(a.uses[0].usedAt).getTime() 54 - ) 55 - }) 56 - 57 - return { 58 - disabled: false, 59 - all: [...available, ...used], 60 - available, 61 - used, 62 - } 63 - }, 64 - }) 65 - }
-403
src/view/com/modals/CreateOrEditList.tsx
··· 1 - import {useCallback, useMemo, useState} from 'react' 2 - import { 3 - ActivityIndicator, 4 - KeyboardAvoidingView, 5 - ScrollView, 6 - StyleSheet, 7 - TextInput, 8 - TouchableOpacity, 9 - View, 10 - } from 'react-native' 11 - import {LinearGradient} from 'expo-linear-gradient' 12 - import {type AppBskyGraphDefs, RichText as RichTextAPI} from '@atproto/api' 13 - import {msg, Trans} from '@lingui/macro' 14 - import {useLingui} from '@lingui/react' 15 - 16 - import {usePalette} from '#/lib/hooks/usePalette' 17 - import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' 18 - import {cleanError, isNetworkError} from '#/lib/strings/errors' 19 - import {enforceLen} from '#/lib/strings/helpers' 20 - import {richTextToString} from '#/lib/strings/rich-text-helpers' 21 - import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip' 22 - import {colors, gradients, s} from '#/lib/styles' 23 - import {useTheme} from '#/lib/ThemeContext' 24 - import {type ImageMeta} from '#/state/gallery' 25 - import {useModalControls} from '#/state/modals' 26 - import { 27 - useListCreateMutation, 28 - useListMetadataMutation, 29 - } from '#/state/queries/list' 30 - import {useAgent} from '#/state/session' 31 - import {ErrorMessage} from '#/view/com/util/error/ErrorMessage' 32 - import {Text} from '#/view/com/util/text/Text' 33 - import * as Toast from '#/view/com/util/Toast' 34 - import {EditableUserAvatar} from '#/view/com/util/UserAvatar' 35 - 36 - const MAX_NAME = 64 // todo 37 - const MAX_DESCRIPTION = 300 // todo 38 - 39 - export const snapPoints = ['fullscreen'] 40 - 41 - export function Component({ 42 - purpose, 43 - onSave, 44 - list, 45 - }: { 46 - purpose?: string 47 - onSave?: (uri: string) => void 48 - list?: AppBskyGraphDefs.ListView 49 - }) { 50 - const {closeModal} = useModalControls() 51 - const {isMobile} = useWebMediaQueries() 52 - const [error, setError] = useState<string>('') 53 - const pal = usePalette('default') 54 - const theme = useTheme() 55 - const {_} = useLingui() 56 - const listCreateMutation = useListCreateMutation() 57 - const listMetadataMutation = useListMetadataMutation() 58 - const agent = useAgent() 59 - 60 - const activePurpose = useMemo(() => { 61 - if (list?.purpose) { 62 - return list.purpose 63 - } 64 - if (purpose) { 65 - return purpose 66 - } 67 - return 'app.bsky.graph.defs#curatelist' 68 - }, [list, purpose]) 69 - const isCurateList = activePurpose === 'app.bsky.graph.defs#curatelist' 70 - 71 - const [isProcessing, setProcessing] = useState<boolean>(false) 72 - const [name, setName] = useState<string>(list?.name || '') 73 - 74 - const [descriptionRt, setDescriptionRt] = useState<RichTextAPI>(() => { 75 - const text = list?.description 76 - const facets = list?.descriptionFacets 77 - 78 - if (!text || !facets) { 79 - return new RichTextAPI({text: text || ''}) 80 - } 81 - 82 - // We want to be working with a blank state here, so let's get the 83 - // serialized version and turn it back into a RichText 84 - const serialized = richTextToString(new RichTextAPI({text, facets}), false) 85 - 86 - const richText = new RichTextAPI({text: serialized}) 87 - richText.detectFacetsWithoutResolution() 88 - 89 - return richText 90 - }) 91 - const graphemeLength = useMemo(() => { 92 - return shortenLinks(descriptionRt).graphemeLength 93 - }, [descriptionRt]) 94 - const isDescriptionOver = graphemeLength > MAX_DESCRIPTION 95 - 96 - const [avatar, setAvatar] = useState<string | undefined>(list?.avatar) 97 - const [newAvatar, setNewAvatar] = useState<ImageMeta | undefined | null>() 98 - 99 - const onDescriptionChange = useCallback( 100 - (newText: string) => { 101 - const richText = new RichTextAPI({text: newText}) 102 - richText.detectFacetsWithoutResolution() 103 - 104 - setDescriptionRt(richText) 105 - }, 106 - [setDescriptionRt], 107 - ) 108 - 109 - const onPressCancel = useCallback(() => { 110 - closeModal() 111 - }, [closeModal]) 112 - 113 - const onSelectNewAvatar = useCallback( 114 - (img: ImageMeta | null) => { 115 - if (!img) { 116 - setNewAvatar(null) 117 - setAvatar(undefined) 118 - return 119 - } 120 - try { 121 - setNewAvatar(img) 122 - setAvatar(img.path) 123 - } catch (e: any) { 124 - setError(cleanError(e)) 125 - } 126 - }, 127 - [setNewAvatar, setAvatar, setError], 128 - ) 129 - 130 - const onPressSave = useCallback(async () => { 131 - const nameTrimmed = name.trim() 132 - if (!nameTrimmed) { 133 - setError(_(msg`Name is required`)) 134 - return 135 - } 136 - setProcessing(true) 137 - if (error) { 138 - setError('') 139 - } 140 - try { 141 - let richText = new RichTextAPI( 142 - {text: descriptionRt.text.trimEnd()}, 143 - {cleanNewlines: true}, 144 - ) 145 - 146 - await richText.detectFacets(agent) 147 - richText = shortenLinks(richText) 148 - richText = stripInvalidMentions(richText) 149 - 150 - if (list) { 151 - await listMetadataMutation.mutateAsync({ 152 - uri: list.uri, 153 - name: nameTrimmed, 154 - description: richText.text, 155 - descriptionFacets: richText.facets, 156 - avatar: newAvatar, 157 - }) 158 - Toast.show( 159 - isCurateList 160 - ? _(msg({message: 'User list updated', context: 'toast'})) 161 - : _(msg({message: 'Moderation list updated', context: 'toast'})), 162 - ) 163 - onSave?.(list.uri) 164 - } else { 165 - const res = await listCreateMutation.mutateAsync({ 166 - purpose: activePurpose, 167 - name, 168 - description: richText.text, 169 - descriptionFacets: richText.facets, 170 - avatar: newAvatar, 171 - }) 172 - Toast.show( 173 - isCurateList 174 - ? _(msg({message: 'User list created', context: 'toast'})) 175 - : _(msg({message: 'Moderation list created', context: 'toast'})), 176 - ) 177 - onSave?.(res.uri) 178 - } 179 - closeModal() 180 - } catch (e: any) { 181 - if (isNetworkError(e)) { 182 - setError( 183 - _( 184 - msg`Failed to create the list. Check your internet connection and try again.`, 185 - ), 186 - ) 187 - } else { 188 - setError(cleanError(e)) 189 - } 190 - } 191 - setProcessing(false) 192 - }, [ 193 - setProcessing, 194 - setError, 195 - error, 196 - onSave, 197 - closeModal, 198 - activePurpose, 199 - isCurateList, 200 - name, 201 - descriptionRt, 202 - newAvatar, 203 - list, 204 - listMetadataMutation, 205 - listCreateMutation, 206 - _, 207 - agent, 208 - ]) 209 - 210 - return ( 211 - <KeyboardAvoidingView behavior="height"> 212 - <ScrollView 213 - style={[ 214 - pal.view, 215 - { 216 - paddingHorizontal: isMobile ? 16 : 0, 217 - }, 218 - ]} 219 - testID="createOrEditListModal"> 220 - <Text style={[styles.title, pal.text]}> 221 - {isCurateList ? ( 222 - list ? ( 223 - <Trans>Edit User List</Trans> 224 - ) : ( 225 - <Trans>New User List</Trans> 226 - ) 227 - ) : list ? ( 228 - <Trans>Edit Moderation List</Trans> 229 - ) : ( 230 - <Trans>New Moderation List</Trans> 231 - )} 232 - </Text> 233 - {error !== '' && ( 234 - <View style={styles.errorContainer}> 235 - <ErrorMessage message={error} /> 236 - </View> 237 - )} 238 - <Text style={[styles.label, pal.text]}> 239 - <Trans>List Avatar</Trans> 240 - </Text> 241 - <View style={[styles.avi, {borderColor: pal.colors.background}]}> 242 - <EditableUserAvatar 243 - type="list" 244 - size={80} 245 - avatar={avatar} 246 - onSelectNewAvatar={onSelectNewAvatar} 247 - /> 248 - </View> 249 - <View style={styles.form}> 250 - <View> 251 - <View style={styles.labelWrapper}> 252 - <Text style={[styles.label, pal.text]} nativeID="list-name"> 253 - <Trans>List Name</Trans> 254 - </Text> 255 - </View> 256 - <TextInput 257 - testID="editNameInput" 258 - style={[styles.textInput, pal.border, pal.text]} 259 - placeholder={ 260 - isCurateList 261 - ? _(msg`e.g. Great Posters`) 262 - : _(msg`e.g. Spammers`) 263 - } 264 - placeholderTextColor={colors.gray4} 265 - value={name} 266 - onChangeText={v => setName(enforceLen(v, MAX_NAME))} 267 - accessible={true} 268 - accessibilityLabel={_(msg`Name`)} 269 - accessibilityHint="" 270 - accessibilityLabelledBy="list-name" 271 - /> 272 - </View> 273 - <View style={s.pb10}> 274 - <View style={styles.labelWrapper}> 275 - <Text 276 - style={[styles.label, pal.text]} 277 - nativeID="list-description"> 278 - <Trans>Description</Trans> 279 - </Text> 280 - <Text 281 - style={[!isDescriptionOver ? pal.textLight : s.red3, s.f13]}> 282 - {graphemeLength}/{MAX_DESCRIPTION} 283 - </Text> 284 - </View> 285 - <TextInput 286 - testID="editDescriptionInput" 287 - style={[styles.textArea, pal.border, pal.text]} 288 - placeholder={ 289 - isCurateList 290 - ? _(msg`e.g. The posters who never miss.`) 291 - : _(msg`e.g. Users that repeatedly reply with ads.`) 292 - } 293 - placeholderTextColor={colors.gray4} 294 - keyboardAppearance={theme.colorScheme} 295 - multiline 296 - value={descriptionRt.text} 297 - onChangeText={onDescriptionChange} 298 - accessible={true} 299 - accessibilityLabel={_(msg`Description`)} 300 - accessibilityHint="" 301 - accessibilityLabelledBy="list-description" 302 - /> 303 - </View> 304 - {isProcessing ? ( 305 - <View style={[styles.btn, s.mt10, {backgroundColor: colors.gray2}]}> 306 - <ActivityIndicator /> 307 - </View> 308 - ) : ( 309 - <TouchableOpacity 310 - testID="saveBtn" 311 - style={[s.mt10, isDescriptionOver && s.dimmed]} 312 - disabled={isDescriptionOver} 313 - onPress={onPressSave} 314 - accessibilityRole="button" 315 - accessibilityLabel={_(msg`Save`)} 316 - accessibilityHint=""> 317 - <LinearGradient 318 - colors={[gradients.blueLight.start, gradients.blueLight.end]} 319 - start={{x: 0, y: 0}} 320 - end={{x: 1, y: 1}} 321 - style={styles.btn}> 322 - <Text style={[s.white, s.bold]}> 323 - <Trans context="action">Save</Trans> 324 - </Text> 325 - </LinearGradient> 326 - </TouchableOpacity> 327 - )} 328 - <TouchableOpacity 329 - testID="cancelBtn" 330 - style={s.mt5} 331 - onPress={onPressCancel} 332 - accessibilityRole="button" 333 - accessibilityLabel={_(msg`Cancel`)} 334 - accessibilityHint="" 335 - onAccessibilityEscape={onPressCancel}> 336 - <View style={[styles.btn]}> 337 - <Text style={[s.black, s.bold, pal.text]}> 338 - <Trans context="action">Cancel</Trans> 339 - </Text> 340 - </View> 341 - </TouchableOpacity> 342 - </View> 343 - </ScrollView> 344 - </KeyboardAvoidingView> 345 - ) 346 - } 347 - 348 - const styles = StyleSheet.create({ 349 - title: { 350 - textAlign: 'center', 351 - fontWeight: '600', 352 - fontSize: 24, 353 - marginBottom: 18, 354 - }, 355 - labelWrapper: { 356 - flexDirection: 'row', 357 - gap: 8, 358 - alignItems: 'center', 359 - justifyContent: 'space-between', 360 - paddingHorizontal: 4, 361 - paddingBottom: 4, 362 - marginTop: 20, 363 - }, 364 - label: { 365 - fontWeight: '600', 366 - }, 367 - form: { 368 - paddingHorizontal: 6, 369 - }, 370 - textInput: { 371 - borderWidth: 1, 372 - borderRadius: 6, 373 - paddingHorizontal: 14, 374 - paddingVertical: 10, 375 - fontSize: 16, 376 - }, 377 - textArea: { 378 - borderWidth: 1, 379 - borderRadius: 6, 380 - paddingHorizontal: 12, 381 - paddingTop: 10, 382 - fontSize: 16, 383 - height: 100, 384 - textAlignVertical: 'top', 385 - }, 386 - btn: { 387 - flexDirection: 'row', 388 - alignItems: 'center', 389 - justifyContent: 'center', 390 - width: '100%', 391 - borderRadius: 32, 392 - padding: 10, 393 - marginBottom: 10, 394 - }, 395 - avi: { 396 - width: 84, 397 - height: 84, 398 - borderWidth: 2, 399 - borderRadius: 42, 400 - marginTop: 4, 401 - }, 402 - errorContainer: {marginTop: 20}, 403 - })
-145
src/view/com/modals/CropImage.web.tsx
··· 1 - import React from 'react' 2 - import {StyleSheet, TouchableOpacity, View} from 'react-native' 3 - import {manipulateAsync, SaveFormat} from 'expo-image-manipulator' 4 - import {LinearGradient} from 'expo-linear-gradient' 5 - import {msg, Trans} from '@lingui/macro' 6 - import {useLingui} from '@lingui/react' 7 - import ReactCrop, {type PercentCrop} from 'react-image-crop' 8 - 9 - import {usePalette} from '#/lib/hooks/usePalette' 10 - import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' 11 - import {type PickerImage} from '#/lib/media/picker.shared' 12 - import {getDataUriSize} from '#/lib/media/util' 13 - import {gradients, s} from '#/lib/styles' 14 - import {useModalControls} from '#/state/modals' 15 - import {Text} from '#/view/com/util/text/Text' 16 - 17 - export const snapPoints = ['0%'] 18 - 19 - export function Component({ 20 - uri, 21 - aspect, 22 - circular, 23 - onSelect, 24 - }: { 25 - uri: string 26 - aspect?: number 27 - circular?: boolean 28 - onSelect: (img?: PickerImage) => void 29 - }) { 30 - const pal = usePalette('default') 31 - const {_} = useLingui() 32 - 33 - const {closeModal} = useModalControls() 34 - const {isMobile} = useWebMediaQueries() 35 - 36 - const imageRef = React.useRef<HTMLImageElement>(null) 37 - const [crop, setCrop] = React.useState<PercentCrop>() 38 - 39 - const isEmpty = !crop || (crop.width || crop.height) === 0 40 - 41 - const onPressCancel = () => { 42 - onSelect(undefined) 43 - closeModal() 44 - } 45 - const onPressDone = async () => { 46 - const img = imageRef.current! 47 - 48 - const result = await manipulateAsync( 49 - uri, 50 - isEmpty 51 - ? [] 52 - : [ 53 - { 54 - crop: { 55 - originX: (crop.x * img.naturalWidth) / 100, 56 - originY: (crop.y * img.naturalHeight) / 100, 57 - width: (crop.width * img.naturalWidth) / 100, 58 - height: (crop.height * img.naturalHeight) / 100, 59 - }, 60 - }, 61 - ], 62 - { 63 - base64: true, 64 - format: SaveFormat.JPEG, 65 - }, 66 - ) 67 - 68 - onSelect({ 69 - path: result.uri, 70 - mime: 'image/jpeg', 71 - size: result.base64 !== undefined ? getDataUriSize(result.base64) : 0, 72 - width: result.width, 73 - height: result.height, 74 - }) 75 - 76 - closeModal() 77 - } 78 - 79 - return ( 80 - <View> 81 - <View style={[styles.cropper, pal.borderDark]}> 82 - <ReactCrop 83 - aspect={aspect} 84 - crop={crop} 85 - onChange={(_pixelCrop, percentCrop) => setCrop(percentCrop)} 86 - circularCrop={circular}> 87 - <img ref={imageRef} src={uri} style={{maxHeight: '75vh'}} /> 88 - </ReactCrop> 89 - </View> 90 - <View style={[styles.btns, isMobile && {paddingHorizontal: 16}]}> 91 - <TouchableOpacity 92 - onPress={onPressCancel} 93 - accessibilityRole="button" 94 - accessibilityLabel={_(msg`Cancel image crop`)} 95 - accessibilityHint={_(msg`Exits image cropping process`)}> 96 - <Text type="xl" style={pal.link}> 97 - <Trans>Cancel</Trans> 98 - </Text> 99 - </TouchableOpacity> 100 - <View style={s.flex1} /> 101 - <TouchableOpacity 102 - onPress={onPressDone} 103 - accessibilityRole="button" 104 - accessibilityLabel={_(msg`Save image crop`)} 105 - accessibilityHint={_(msg`Saves image crop settings`)}> 106 - <LinearGradient 107 - colors={[gradients.blueLight.start, gradients.blueLight.end]} 108 - start={{x: 0, y: 0}} 109 - end={{x: 1, y: 1}} 110 - style={[styles.btn]}> 111 - <Text type="xl-medium" style={s.white}> 112 - <Trans>Done</Trans> 113 - </Text> 114 - </LinearGradient> 115 - </TouchableOpacity> 116 - </View> 117 - </View> 118 - ) 119 - } 120 - 121 - const styles = StyleSheet.create({ 122 - cropper: { 123 - marginLeft: 'auto', 124 - marginRight: 'auto', 125 - borderWidth: 1, 126 - borderRadius: 4, 127 - overflow: 'hidden', 128 - alignItems: 'center', 129 - }, 130 - ctrls: { 131 - flexDirection: 'row', 132 - alignItems: 'center', 133 - marginTop: 10, 134 - }, 135 - btns: { 136 - flexDirection: 'row', 137 - alignItems: 'center', 138 - marginTop: 10, 139 - }, 140 - btn: { 141 - borderRadius: 4, 142 - paddingVertical: 8, 143 - paddingHorizontal: 24, 144 - }, 145 - })
-287
src/view/com/modals/InviteCodes.tsx
··· 1 - import React from 'react' 2 - import { 3 - ActivityIndicator, 4 - StyleSheet, 5 - TouchableOpacity, 6 - View, 7 - } from 'react-native' 8 - import {setStringAsync} from 'expo-clipboard' 9 - import {type ComAtprotoServerDefs} from '@atproto/api' 10 - import { 11 - FontAwesomeIcon, 12 - type FontAwesomeIconStyle, 13 - } from '@fortawesome/react-native-fontawesome' 14 - import {msg, Trans} from '@lingui/macro' 15 - import {useLingui} from '@lingui/react' 16 - 17 - import {usePalette} from '#/lib/hooks/usePalette' 18 - import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' 19 - import {makeProfileLink} from '#/lib/routes/links' 20 - import {cleanError} from '#/lib/strings/errors' 21 - import {isWeb} from '#/platform/detection' 22 - import {useInvitesAPI, useInvitesState} from '#/state/invites' 23 - import {useModalControls} from '#/state/modals' 24 - import { 25 - type InviteCodesQueryResponse, 26 - useInviteCodesQuery, 27 - } from '#/state/queries/invites' 28 - import {ErrorMessage} from '../util/error/ErrorMessage' 29 - import {Button} from '../util/forms/Button' 30 - import {Link} from '../util/Link' 31 - import {Text} from '../util/text/Text' 32 - import * as Toast from '../util/Toast' 33 - import {UserInfoText} from '../util/UserInfoText' 34 - import {ScrollView} from './util' 35 - 36 - export const snapPoints = ['70%'] 37 - 38 - export function Component() { 39 - const {isLoading, data: invites, error} = useInviteCodesQuery() 40 - 41 - return error ? ( 42 - <ErrorMessage message={cleanError(error)} /> 43 - ) : isLoading || !invites ? ( 44 - <View style={{padding: 18}}> 45 - <ActivityIndicator /> 46 - </View> 47 - ) : ( 48 - <Inner invites={invites} /> 49 - ) 50 - } 51 - 52 - export function Inner({invites}: {invites: InviteCodesQueryResponse}) { 53 - const pal = usePalette('default') 54 - const {_} = useLingui() 55 - const {closeModal} = useModalControls() 56 - const {isTabletOrDesktop} = useWebMediaQueries() 57 - 58 - const onClose = React.useCallback(() => { 59 - closeModal() 60 - }, [closeModal]) 61 - 62 - if (invites.all.length === 0) { 63 - return ( 64 - <View style={[styles.container, pal.view]} testID="inviteCodesModal"> 65 - <View style={[styles.empty, pal.viewLight]}> 66 - <Text type="lg" style={[pal.text, styles.emptyText]}> 67 - <Trans> 68 - You don't have any invite codes yet! We'll send you some when 69 - you've been on Bluesky for a little longer. 70 - </Trans> 71 - </Text> 72 - </View> 73 - <View style={styles.flex1} /> 74 - <View 75 - style={[ 76 - styles.btnContainer, 77 - isTabletOrDesktop && styles.btnContainerDesktop, 78 - ]}> 79 - <Button 80 - type="primary" 81 - label={_(msg`Done`)} 82 - style={styles.btn} 83 - labelStyle={styles.btnLabel} 84 - onPress={onClose} 85 - /> 86 - </View> 87 - </View> 88 - ) 89 - } 90 - 91 - return ( 92 - <View style={[styles.container, pal.view]} testID="inviteCodesModal"> 93 - <Text type="title-xl" style={[styles.title, pal.text]}> 94 - <Trans>Invite a Friend</Trans> 95 - </Text> 96 - <Text type="lg" style={[styles.description, pal.text]}> 97 - <Trans> 98 - Each code works once. You'll receive more invite codes periodically. 99 - </Trans> 100 - </Text> 101 - <ScrollView style={[styles.scrollContainer, pal.border]}> 102 - {invites.available.map((invite, i) => ( 103 - <InviteCode 104 - testID={`inviteCode-${i}`} 105 - key={invite.code} 106 - invite={invite} 107 - invites={invites} 108 - /> 109 - ))} 110 - {invites.used.map((invite, i) => ( 111 - <InviteCode 112 - used 113 - testID={`inviteCode-${i}`} 114 - key={invite.code} 115 - invite={invite} 116 - invites={invites} 117 - /> 118 - ))} 119 - </ScrollView> 120 - <View style={styles.btnContainer}> 121 - <Button 122 - testID="closeBtn" 123 - type="primary" 124 - label={_(msg`Done`)} 125 - style={styles.btn} 126 - labelStyle={styles.btnLabel} 127 - onPress={onClose} 128 - /> 129 - </View> 130 - </View> 131 - ) 132 - } 133 - 134 - function InviteCode({ 135 - testID, 136 - invite, 137 - used, 138 - invites, 139 - }: { 140 - testID: string 141 - invite: ComAtprotoServerDefs.InviteCode 142 - used?: boolean 143 - invites: InviteCodesQueryResponse 144 - }) { 145 - const pal = usePalette('default') 146 - const {_} = useLingui() 147 - const invitesState = useInvitesState() 148 - const {setInviteCopied} = useInvitesAPI() 149 - const uses = invite.uses 150 - 151 - const onPress = React.useCallback(() => { 152 - setStringAsync(invite.code) 153 - Toast.show(_(msg`Copied to clipboard`), 'clipboard-check') 154 - setInviteCopied(invite.code) 155 - }, [setInviteCopied, invite, _]) 156 - 157 - return ( 158 - <View 159 - style={[ 160 - pal.border, 161 - {borderBottomWidth: 1, paddingHorizontal: 20, paddingVertical: 14}, 162 - ]}> 163 - <TouchableOpacity 164 - testID={testID} 165 - style={[styles.inviteCode]} 166 - onPress={onPress} 167 - accessibilityRole="button" 168 - accessibilityLabel={ 169 - invites.available.length === 1 170 - ? _(msg`Invite codes: 1 available`) 171 - : _(msg`Invite codes: ${invites.available.length} available`) 172 - } 173 - accessibilityHint={_(msg`Opens list of invite codes`)}> 174 - <Text 175 - testID={`${testID}-code`} 176 - type={used ? 'md' : 'md-bold'} 177 - style={used ? [pal.textLight, styles.strikeThrough] : pal.text}> 178 - {invite.code} 179 - </Text> 180 - <View style={styles.flex1} /> 181 - {!used && invitesState.copiedInvites.includes(invite.code) && ( 182 - <Text style={[pal.textLight, styles.codeCopied]}> 183 - <Trans>Copied</Trans> 184 - </Text> 185 - )} 186 - {!used && ( 187 - <FontAwesomeIcon 188 - icon={['far', 'clone']} 189 - style={pal.text as FontAwesomeIconStyle} 190 - /> 191 - )} 192 - </TouchableOpacity> 193 - {uses.length > 0 ? ( 194 - <View 195 - style={{ 196 - flexDirection: 'column', 197 - gap: 8, 198 - paddingTop: 6, 199 - }}> 200 - <Text style={pal.text}> 201 - <Trans>Used by:</Trans>{' '} 202 - {uses.map((use, i) => ( 203 - <Link 204 - key={use.usedBy} 205 - href={makeProfileLink({handle: use.usedBy, did: ''})} 206 - style={{ 207 - flexDirection: 'row', 208 - }}> 209 - <UserInfoText did={use.usedBy} style={pal.link} /> 210 - {i !== uses.length - 1 && <Text style={pal.text}>, </Text>} 211 - </Link> 212 - ))} 213 - </Text> 214 - </View> 215 - ) : null} 216 - </View> 217 - ) 218 - } 219 - 220 - const styles = StyleSheet.create({ 221 - container: { 222 - flex: 1, 223 - paddingBottom: isWeb ? 0 : 50, 224 - }, 225 - title: { 226 - textAlign: 'center', 227 - marginTop: 12, 228 - marginBottom: 12, 229 - }, 230 - description: { 231 - textAlign: 'center', 232 - paddingHorizontal: 42, 233 - marginBottom: 14, 234 - }, 235 - 236 - scrollContainer: { 237 - flex: 1, 238 - borderTopWidth: 1, 239 - marginTop: 4, 240 - marginBottom: 16, 241 - }, 242 - 243 - flex1: { 244 - flex: 1, 245 - }, 246 - empty: { 247 - paddingHorizontal: 20, 248 - paddingVertical: 20, 249 - borderRadius: 16, 250 - marginHorizontal: 24, 251 - marginTop: 10, 252 - }, 253 - emptyText: { 254 - textAlign: 'center', 255 - }, 256 - 257 - inviteCode: { 258 - flexDirection: 'row', 259 - alignItems: 'center', 260 - }, 261 - codeCopied: { 262 - marginRight: 8, 263 - }, 264 - strikeThrough: { 265 - textDecorationLine: 'line-through', 266 - textDecorationStyle: 'solid', 267 - }, 268 - 269 - btnContainer: { 270 - flexDirection: 'row', 271 - justifyContent: 'center', 272 - }, 273 - btnContainerDesktop: { 274 - marginTop: 14, 275 - }, 276 - btn: { 277 - flexDirection: 'row', 278 - alignItems: 'center', 279 - justifyContent: 'center', 280 - borderRadius: 32, 281 - paddingHorizontal: 60, 282 - paddingVertical: 14, 283 - }, 284 - btnLabel: { 285 - fontSize: 18, 286 - }, 287 - })
-4
src/view/com/modals/Modal.tsx
··· 8 8 import {FullWindowOverlay} from '#/components/FullWindowOverlay' 9 9 import {createCustomBackdrop} from '../util/BottomSheetCustomBackdrop' 10 10 import * as DeleteAccountModal from './DeleteAccount' 11 - import * as InviteCodesModal from './InviteCodes' 12 11 import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings' 13 12 import * as UserAddRemoveListsModal from './UserAddRemoveLists' 14 13 ··· 49 48 } else if (activeModal?.name === 'delete-account') { 50 49 snapPoints = DeleteAccountModal.snapPoints 51 50 element = <DeleteAccountModal.Component /> 52 - } else if (activeModal?.name === 'invite-codes') { 53 - snapPoints = InviteCodesModal.snapPoints 54 - element = <InviteCodesModal.Component /> 55 51 } else if (activeModal?.name === 'content-languages-settings') { 56 52 snapPoints = ContentLanguagesSettingsModal.snapPoints 57 53 element = <ContentLanguagesSettingsModal.Component />
-3
src/view/com/modals/Modal.web.tsx
··· 7 7 import {type Modal as ModalIface} from '#/state/modals' 8 8 import {useModalControls, useModals} from '#/state/modals' 9 9 import * as DeleteAccountModal from './DeleteAccount' 10 - import * as InviteCodesModal from './InviteCodes' 11 10 import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings' 12 11 import * as UserAddRemoveLists from './UserAddRemoveLists' 13 12 ··· 51 50 element = <UserAddRemoveLists.Component {...modal} /> 52 51 } else if (modal.name === 'delete-account') { 53 52 element = <DeleteAccountModal.Component /> 54 - } else if (modal.name === 'invite-codes') { 55 - element = <InviteCodesModal.Component /> 56 53 } else if (modal.name === 'content-languages-settings') { 57 54 element = <ContentLanguagesSettingsModal.Component /> 58 55 } else {
-8
src/view/com/testing/TestCtrls.e2e.tsx
··· 3 3 import {useQueryClient} from '@tanstack/react-query' 4 4 5 5 import {BLUESKY_PROXY_HEADER} from '#/lib/constants' 6 - import {useModalControls} from '#/state/modals' 7 6 import {useSessionApi, useAgent} from '#/state/session' 8 7 import {useLoggedOutViewControls} from '#/state/shell/logged-out' 9 8 import {useOnboardingDispatch} from '#/state/shell/onboarding' ··· 23 22 const agent = useAgent() 24 23 const queryClient = useQueryClient() 25 24 const {logoutEveryAccount, login} = useSessionApi() 26 - const {openModal} = useModalControls() 27 25 const onboardingDispatch = useOnboardingDispatch() 28 26 const {setShowLoggedOut} = useLoggedOutViewControls() 29 27 const onPressSignInAlice = async () => { ··· 118 116 <Pressable 119 117 testID="e2eRefreshHome" 120 118 onPress={() => queryClient.invalidateQueries({queryKey: ['post-feed']})} 121 - accessibilityRole="button" 122 - style={BTN} 123 - /> 124 - <Pressable 125 - testID="e2eOpenInviteCodesModal" 126 - onPress={() => openModal({name: 'invite-codes'})} 127 119 accessibilityRole="button" 128 120 style={BTN} 129 121 />