Bluesky app fork with some witchin' additions 馃挮 witchsky.app
bluesky fork client
at main 198 lines 4.6 kB view raw
1import {createContext, useContext, useReducer} from 'react' 2import {type GestureResponderEvent} from 'react-native' 3import {type ExistingContact} from 'expo-contacts' 4 5import {type CountryCode} from '#/lib/international-telephone-codes' 6import type * as bsky from '#/types/bsky' 7 8export type Contact = ExistingContact 9 10export type Match = { 11 profile: bsky.profile.AnyProfileView 12 contact?: Contact 13} 14 15export type State = 16 | { 17 step: '1: phone input' 18 phoneCountryCode?: CountryCode 19 phoneNumber?: string 20 } 21 | { 22 step: '2: verify number' 23 phoneCountryCode: CountryCode 24 phoneNumber: string 25 lastSentAt: Date | null 26 } 27 | { 28 step: '3: get contacts' 29 phoneCountryCode: CountryCode 30 phoneNumber: string 31 token: string 32 contacts?: Contact[] 33 } 34 | { 35 step: '4: view matches' 36 contacts: Contact[] 37 matches: Match[] 38 // rather than mutating `matches`, we keep track of dismissed matches 39 // so we can roll back optimistic updates 40 dismissedMatches: string[] 41 } 42 43export type Action = 44 | { 45 type: 'SUBMIT_PHONE_NUMBER' 46 payload: { 47 phoneCountryCode: CountryCode 48 phoneNumber: string 49 } 50 } 51 | { 52 type: 'RESEND_VERIFICATION_CODE' 53 } 54 | { 55 type: 'VERIFY_PHONE_NUMBER_SUCCESS' 56 payload: { 57 token: string 58 } 59 } 60 | { 61 type: 'GET_CONTACTS_SUCCESS' 62 payload: { 63 contacts: Contact[] 64 } 65 } 66 | { 67 type: 'SYNC_CONTACTS_SUCCESS' 68 payload: { 69 matches: Match[] 70 // filter out matched contacts 71 contacts: Contact[] 72 } 73 } 74 | { 75 type: 'BACK' 76 } 77 | { 78 type: 'DISMISS_MATCH' 79 payload: { 80 did: string 81 } 82 } 83 | { 84 type: 'DISMISS_MATCH_FAILED' 85 payload: { 86 did: string 87 } 88 } 89 90function reducer(state: State, action: Action): State { 91 switch (action.type) { 92 case 'SUBMIT_PHONE_NUMBER': { 93 assertCurrentStep(state, '1: phone input') 94 return { 95 step: '2: verify number', 96 ...action.payload, 97 lastSentAt: null, 98 } 99 } 100 case 'RESEND_VERIFICATION_CODE': { 101 assertCurrentStep(state, '2: verify number') 102 return { 103 ...state, 104 lastSentAt: new Date(), 105 } 106 } 107 case 'VERIFY_PHONE_NUMBER_SUCCESS': { 108 assertCurrentStep(state, '2: verify number') 109 return { 110 step: '3: get contacts', 111 token: action.payload.token, 112 phoneCountryCode: state.phoneCountryCode, 113 phoneNumber: state.phoneNumber, 114 } 115 } 116 case 'BACK': { 117 assertCurrentStep(state, '2: verify number') 118 return { 119 step: '1: phone input', 120 phoneNumber: state.phoneNumber, 121 phoneCountryCode: state.phoneCountryCode, 122 } 123 } 124 case 'GET_CONTACTS_SUCCESS': { 125 assertCurrentStep(state, '3: get contacts') 126 return { 127 ...state, 128 contacts: action.payload.contacts, 129 } 130 } 131 case 'SYNC_CONTACTS_SUCCESS': { 132 assertCurrentStep(state, '3: get contacts') 133 return { 134 step: '4: view matches', 135 contacts: action.payload.contacts, 136 matches: action.payload.matches, 137 dismissedMatches: [], 138 } 139 } 140 case 'DISMISS_MATCH': { 141 assertCurrentStep(state, '4: view matches') 142 return { 143 ...state, 144 dismissedMatches: [ 145 ...new Set(state.dismissedMatches), 146 action.payload.did, 147 ], 148 } 149 } 150 case 'DISMISS_MATCH_FAILED': { 151 assertCurrentStep(state, '4: view matches') 152 return { 153 ...state, 154 dismissedMatches: state.dismissedMatches.filter( 155 did => did !== action.payload.did, 156 ), 157 } 158 } 159 } 160} 161 162class InvalidStateTransitionError extends Error { 163 constructor(message: string) { 164 super(message) 165 this.name = 'InvalidStateTransitionError' 166 } 167} 168 169function assertCurrentStep<S extends State['step']>( 170 state: State, 171 step: S, 172): asserts state is Extract<State, {step: S}> { 173 if (state.step !== step) { 174 throw new InvalidStateTransitionError( 175 `Invalid state transition: expecting ${step}, got ${state.step}`, 176 ) 177 } 178} 179 180export function useFindContactsFlowState( 181 initialState: State = {step: '1: phone input'}, 182) { 183 return useReducer(reducer, initialState) 184} 185 186export const FindContactsGoBackContext = createContext< 187 (() => void) | undefined 188>(undefined) 189export function useOnPressBackButton() { 190 const goBack = useContext(FindContactsGoBackContext) 191 if (!goBack) { 192 return undefined 193 } 194 return (evt: GestureResponderEvent) => { 195 evt.preventDefault() 196 goBack() 197 } 198}