Bluesky app fork with some witchin' additions 馃挮
witchsky.app
bluesky
fork
client
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}