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

Fix a bunch of type errors and add a type-check to the github workflows (#837)

* Add yarn type-check

* Rename to yarn typecheck

* Fix a collection of type errors

* Add typecheck to automated tests

* add `dist` to exluded folders tsconfig

---------

Co-authored-by: Ansh Nanda <anshnanda10@gmail.com>

authored by

Paul Frazee
Ansh Nanda
and committed by
GitHub
e8843ded 46c9de7c

+168 -82
+3 -1
.github/workflows/lint.yml
··· 18 18 uses: actions/checkout@v3 19 19 - name: Yarn install 20 20 run: yarn 21 - - name: Typescript & Lint check 21 + - name: Lint check 22 22 run: yarn lint 23 + - name: Type check 24 + run: yarn typecheck 23 25 testing: 24 26 name: Run tests 25 27 runs-on: ubuntu-latest
+1
package.json
··· 16 16 "test-ci": "jest --ci --forceExit --reporters=default --reporters=jest-junit", 17 17 "test-coverage": "jest --coverage", 18 18 "lint": "eslint ./src --ext .js,.jsx,.ts,.tsx", 19 + "typecheck": "tsc --project ./tsconfig.check.json", 19 20 "e2e:mock-server": "ts-node __e2e__/mock-server.ts", 20 21 "e2e:metro": "RN_SRC_EXT=e2e.ts,e2e.tsx expo run:ios", 21 22 "e2e:build": "detox build -c ios.sim.debug",
+1 -1
src/lib/api/api-polyfill.ts
··· 11 11 interface FetchHandlerResponse { 12 12 status: number 13 13 headers: Record<string, string> 14 - body: ArrayBuffer | undefined 14 + body: any 15 15 } 16 16 17 17 async function fetchHandler(
+25 -16
src/lib/api/feed-manip.ts
··· 74 74 } 75 75 76 76 flattenReplyParent() { 77 - if (this.items[0].reply?.parent) { 78 - this.isFlattenedReply = true 79 - this.items.splice(0, 0, {post: this.items[0].reply?.parent}) 77 + if (this.items[0].reply) { 78 + const reply = this.items[0].reply 79 + if (AppBskyFeedDefs.isPostView(reply.parent)) { 80 + this.isFlattenedReply = true 81 + this.items.splice(0, 0, {post: reply.parent}) 82 + } 80 83 } 81 84 } 82 85 } ··· 130 133 131 134 // turn non-threads with reply parents into threads 132 135 for (const slice of slices) { 133 - if ( 134 - !slice.isThread && 135 - !slice.items[0].reason && 136 - slice.items[0].reply?.parent && 137 - !this.seenUris.has(slice.items[0].reply?.parent.uri) && 138 - !soonToBeSeenUris.has(slice.items[0].reply?.parent.uri) 139 - ) { 140 - const uri = slice.items[0].reply?.parent.uri 141 - slice.flattenReplyParent() 142 - soonToBeSeenUris.add(uri) 136 + if (!slice.isThread && !slice.items[0].reason && slice.items[0].reply) { 137 + const reply = slice.items[0].reply 138 + if ( 139 + AppBskyFeedDefs.isPostView(reply.parent) && 140 + !this.seenUris.has(reply.parent.uri) && 141 + !soonToBeSeenUris.has(reply.parent.uri) 142 + ) { 143 + const uri = reply.parent.uri 144 + slice.flattenReplyParent() 145 + soonToBeSeenUris.add(uri) 146 + } 143 147 } 144 148 } 145 149 ··· 231 235 } 232 236 233 237 function getSelfReplyUri(item: FeedViewPost): string | undefined { 234 - return item.reply?.parent.author.did === item.post.author.did 235 - ? item.reply?.parent.uri 236 - : undefined 238 + if (item.reply) { 239 + if (AppBskyFeedDefs.isPostView(item.reply.parent)) { 240 + return item.reply.parent.author.did === item.post.author.did 241 + ? item.reply.parent.uri 242 + : undefined 243 + } 244 + } 245 + return undefined 237 246 }
+1 -1
src/lib/hooks/useTimer.ts
··· 4 4 * Helper hook to run persistent timers on views 5 5 */ 6 6 export function useTimer(time: number, handler: () => void) { 7 - const timer = React.useRef(undefined) 7 + const timer = React.useRef<undefined | NodeJS.Timeout>(undefined) 8 8 9 9 // function to restart the timer 10 10 const reset = React.useCallback(() => {
+18 -7
src/state/models/content/list-membership.ts
··· 9 9 value: AppBskyGraphListitem.Record 10 10 } 11 11 12 + interface ListitemRecord { 13 + uri: string 14 + value: AppBskyGraphListitem.Record 15 + } 16 + 17 + interface ListitemListResponse { 18 + cursor?: string 19 + records: ListitemRecord[] 20 + } 21 + 12 22 export class ListMembershipModel { 13 23 // data 14 24 memberships: Membership[] = [] ··· 32 42 // it needs to be replaced with server side list membership queries 33 43 // -prf 34 44 let cursor 35 - let records = [] 45 + let records: ListitemRecord[] = [] 36 46 for (let i = 0; i < 100; i++) { 37 - const res = await this.rootStore.agent.app.bsky.graph.listitem.list({ 38 - repo: this.rootStore.me.did, 39 - cursor, 40 - limit: PAGE_SIZE, 41 - }) 47 + const res: ListitemListResponse = 48 + await this.rootStore.agent.app.bsky.graph.listitem.list({ 49 + repo: this.rootStore.me.did, 50 + cursor, 51 + limit: PAGE_SIZE, 52 + }) 42 53 records = records.concat( 43 54 res.records.filter(record => record.value.subject === this.subject), 44 55 ) ··· 99 110 }) 100 111 } 101 112 102 - async updateTo(uris: string) { 113 + async updateTo(uris: string[]) { 103 114 for (const uri of uris) { 104 115 await this.add(uri) 105 116 }
+32 -7
src/state/models/content/list.ts
··· 4 4 AppBskyGraphGetList as GetList, 5 5 AppBskyGraphDefs as GraphDefs, 6 6 AppBskyGraphList, 7 + AppBskyGraphListitem, 7 8 } from '@atproto/api' 8 9 import {Image as RNImage} from 'react-native-image-crop-picker' 9 10 import {RootStoreModel} from '../root-store' ··· 12 13 import {bundleAsync} from 'lib/async/bundle' 13 14 14 15 const PAGE_SIZE = 30 16 + 17 + interface ListitemRecord { 18 + uri: string 19 + value: AppBskyGraphListitem.Record 20 + } 21 + 22 + interface ListitemListResponse { 23 + cursor?: string 24 + records: ListitemRecord[] 25 + } 15 26 16 27 export class ListModel { 17 28 // state ··· 33 44 name, 34 45 description, 35 46 avatar, 36 - }: {name: string; description: string; avatar: RNImage | undefined}, 47 + }: {name: string; description: string; avatar: RNImage | null | undefined}, 37 48 ) { 38 49 const record: AppBskyGraphList.Record = { 39 50 purpose: 'app.bsky.graph.defs#modlist', ··· 124 135 description: string 125 136 avatar: RNImage | null | undefined 126 137 }) { 138 + if (!this.list) { 139 + return 140 + } 127 141 if (!this.isOwner) { 128 142 throw new Error('Cannot edit this list') 129 143 } ··· 157 171 } 158 172 159 173 async delete() { 174 + if (!this.list) { 175 + return 176 + } 177 + 160 178 // fetch all the listitem records that belong to this list 161 179 let cursor 162 - let records = [] 180 + let records: ListitemRecord[] = [] 163 181 for (let i = 0; i < 100; i++) { 164 - const res = await this.rootStore.agent.app.bsky.graph.listitem.list({ 165 - repo: this.rootStore.me.did, 166 - cursor, 167 - limit: PAGE_SIZE, 168 - }) 182 + const res: ListitemListResponse = 183 + await this.rootStore.agent.app.bsky.graph.listitem.list({ 184 + repo: this.rootStore.me.did, 185 + cursor, 186 + limit: PAGE_SIZE, 187 + }) 169 188 records = records.concat( 170 189 res.records.filter(record => record.value.list === this.uri), 171 190 ) ··· 193 212 } 194 213 195 214 async subscribe() { 215 + if (!this.list) { 216 + return 217 + } 196 218 await this.rootStore.agent.app.bsky.graph.muteActorList({ 197 219 list: this.list.uri, 198 220 }) ··· 200 222 } 201 223 202 224 async unsubscribe() { 225 + if (!this.list) { 226 + return 227 + } 203 228 await this.rootStore.agent.app.bsky.graph.unmuteActorList({ 204 229 list: this.list.uri, 205 230 })
+10 -6
src/state/models/discovery/foafs.ts
··· 1 - import {AppBskyActorDefs} from '@atproto/api' 1 + import { 2 + AppBskyActorDefs, 3 + AppBskyGraphGetFollows as GetFollows, 4 + } from '@atproto/api' 2 5 import {makeAutoObservable, runInAction} from 'mobx' 3 6 import sampleSize from 'lodash.samplesize' 4 7 import {bundleAsync} from 'lib/async/bundle' ··· 43 46 { 44 47 let cursor 45 48 for (let i = 0; i < 10; i++) { 46 - const res = await this.rootStore.agent.getFollows({ 47 - actor: this.rootStore.me.did, 48 - cursor, 49 - limit: 100, 50 - }) 49 + const res: GetFollows.Response = 50 + await this.rootStore.agent.getFollows({ 51 + actor: this.rootStore.me.did, 52 + cursor, 53 + limit: 100, 54 + }) 51 55 this.rootStore.me.follows.hydrateProfiles(res.data.follows) 52 56 if (!res.data.cursor) { 53 57 break
+1 -1
src/state/models/feeds/post.ts
··· 67 67 } 68 68 69 69 get rootUri(): string { 70 - if (this.reply?.root.uri) { 70 + if (typeof this.reply?.root.uri === 'string') { 71 71 return this.reply.root.uri 72 72 } 73 73 return this.post.uri
+7 -6
src/state/models/lists/lists-list.ts
··· 61 61 } 62 62 this._xLoading(replace) 63 63 try { 64 - let res 64 + let res: GetLists.Response 65 65 if (this.source === 'my-modlists') { 66 66 res = { 67 67 success: true, ··· 170 170 171 171 let cursor 172 172 for (let i = 0; i < 100; i++) { 173 - const res = await store.agent.app.bsky.graph.getLists({ 173 + const res: GetLists.Response = await store.agent.app.bsky.graph.getLists({ 174 174 actor: did, 175 175 cursor, 176 176 limit: 50, ··· 199 199 200 200 let cursor 201 201 for (let i = 0; i < 100; i++) { 202 - const res = await store.agent.app.bsky.graph.getListMutes({ 203 - cursor, 204 - limit: 50, 205 - }) 202 + const res: GetListMutes.Response = 203 + await store.agent.app.bsky.graph.getListMutes({ 204 + cursor, 205 + limit: 50, 206 + }) 206 207 cursor = res.data.cursor 207 208 acc.data.lists = acc.data.lists.concat(res.data.lists) 208 209 if (!cursor) {
+9 -3
src/state/models/ui/shell.ts
··· 8 8 import {ListModel} from '../content/list' 9 9 import {GalleryModel} from '../media/gallery' 10 10 11 + export type ColorMode = 'system' | 'light' | 'dark' 12 + 13 + export function isColorMode(v: unknown): v is ColorMode { 14 + return v === 'system' || v === 'light' || v === 'dark' 15 + } 16 + 11 17 export interface ConfirmModal { 12 18 name: 'confirm' 13 19 title: string ··· 189 195 } 190 196 191 197 export class ShellUiModel { 192 - colorMode = 'system' 198 + colorMode: ColorMode = 'system' 193 199 minimalShellMode = false 194 200 isDrawerOpen = false 195 201 isDrawerSwipeDisabled = false ··· 216 222 217 223 hydrate(v: unknown) { 218 224 if (isObj(v)) { 219 - if (hasProp(v, 'colorMode') && typeof v.colorMode === 'string') { 225 + if (hasProp(v, 'colorMode') && isColorMode(v.colorMode)) { 220 226 this.colorMode = v.colorMode 221 227 } 222 228 } 223 229 } 224 230 225 - setColorMode(mode: string) { 231 + setColorMode(mode: ColorMode) { 226 232 this.colorMode = mode 227 233 } 228 234
+4 -1
src/view/com/composer/useExternalLinkFetch.ts
··· 1 1 import {useState, useEffect} from 'react' 2 2 import {useStores} from 'state/index' 3 + import {ImageModel} from 'state/models/media/image' 3 4 import * as apilib from 'lib/api/index' 4 5 import {getLinkMeta} from 'lib/link-meta/link-meta' 5 6 import {getPostAsQuote, getFeedAsEmbed} from 'lib/link-meta/bsky' ··· 90 91 setExtLink({ 91 92 ...extLink, 92 93 isLoading: false, // done 93 - localThumb, 94 + localThumb: localThumb 95 + ? new ImageModel(store, localThumb) 96 + : undefined, 94 97 }) 95 98 }) 96 99 return cleanup
+6 -6
src/view/com/lightbox/Lightbox.tsx
··· 27 27 } 28 28 29 29 let altText = '' 30 - let uri 30 + let uri = '' 31 31 if (lightbox.name === 'images') { 32 - const opts = store.shell.activeLightbox as models.ImagesLightbox 32 + const opts = lightbox as models.ImagesLightbox 33 33 uri = opts.images[imageIndex].uri 34 - altText = opts.images[imageIndex].alt 35 - } else if (store.shell.activeLightbox.name === 'profile-image') { 36 - const opts = store.shell.activeLightbox as models.ProfileImageLightbox 37 - uri = opts.profileView.avatar 34 + altText = opts.images[imageIndex].alt || '' 35 + } else if (lightbox.name === 'profile-image') { 36 + const opts = lightbox as models.ProfileImageLightbox 37 + uri = opts.profileView.avatar || '' 38 38 } 39 39 40 40 return (
+4 -4
src/view/com/modals/CreateOrEditMuteList.tsx
··· 44 44 const {track} = useAnalytics() 45 45 46 46 const [isProcessing, setProcessing] = useState<boolean>(false) 47 - const [name, setName] = useState<string>(list?.list.name || '') 47 + const [name, setName] = useState<string>(list?.list?.name || '') 48 48 const [description, setDescription] = useState<string>( 49 - list?.list.description || '', 49 + list?.list?.description || '', 50 50 ) 51 - const [avatar, setAvatar] = useState<string | undefined>(list?.list.avatar) 51 + const [avatar, setAvatar] = useState<string | undefined>(list?.list?.avatar) 52 52 const [newAvatar, setNewAvatar] = useState<RNImage | undefined | null>() 53 53 54 54 const onPressCancel = useCallback(() => { ··· 59 59 async (img: RNImage | null) => { 60 60 if (!img) { 61 61 setNewAvatar(null) 62 - setAvatar(null) 62 + setAvatar(undefined) 63 63 return 64 64 } 65 65 track('CreateMuteList:AvatarSelected')
+1 -1
src/view/com/modals/ListAddRemoveUser.tsx
··· 36 36 const pal = usePalette('default') 37 37 const palPrimary = usePalette('primary') 38 38 const palInverted = usePalette('inverted') 39 - const [selected, setSelected] = React.useState([]) 39 + const [selected, setSelected] = React.useState<string[]>([]) 40 40 41 41 const listsList: ListsListModel = React.useMemo( 42 42 () => new ListsListModel(store, store.me.did),
+4 -2
src/view/com/profile/ProfileCard.tsx
··· 1 - import React from 'react' 1 + import * as React from 'react' 2 2 import {StyleSheet, View} from 'react-native' 3 3 import {observer} from 'mobx-react-lite' 4 4 import {AppBskyActorDefs} from '@atproto/api' ··· 32 32 noBorder?: boolean 33 33 followers?: AppBskyActorDefs.ProfileView[] | undefined 34 34 overrideModeration?: boolean 35 - renderButton?: (profile: AppBskyActorDefs.ProfileViewBasic) => JSX.Element 35 + renderButton?: ( 36 + profile: AppBskyActorDefs.ProfileViewBasic, 37 + ) => React.ReactNode 36 38 }) => { 37 39 const store = useStores() 38 40 const pal = usePalette('default')
+2 -2
src/view/com/profile/ProfileHeader.tsx
··· 587 587 // Word wrapping appears fine on 588 588 // mobile but overflows on desktop 589 589 handle: isNative 590 - ? undefined 590 + ? {} 591 591 : { 592 - // eslint-disable-next-line 592 + // @ts-ignore web only -prf 593 593 wordBreak: 'break-all', 594 594 }, 595 595
+1
src/view/com/util/BlurView.web.tsx
··· 15 15 }: React.PropsWithChildren<BlurViewProps>) => { 16 16 // @ts-ignore using an RNW-specific attribute here -prf 17 17 let blur = `blur(${blurAmount || 10}px` 18 + // @ts-ignore using an RNW-specific attribute here -prf 18 19 style = addStyle(style, {backdropFilter: blur, WebkitBackdropFilter: blur}) 19 20 if (blurType === 'dark') { 20 21 style = addStyle(style, styles.dark)
+17 -11
src/view/com/util/Html.tsx
··· 1 - import React from 'react' 1 + import * as React from 'react' 2 2 import {StyleSheet, View} from 'react-native' 3 3 import {usePalette} from 'lib/hooks/usePalette' 4 4 import {Text} from './text/Text' ··· 9 9 * These utilities are used to define long documents in an html-like 10 10 * DSL. See for instance /locale/en/privacy-policy.tsx 11 11 */ 12 + 13 + interface IsChildProps { 14 + isChild?: boolean 15 + } 16 + 17 + // type ReactNodeWithIsChildProp = 18 + // | React.ReactElement<IsChildProps> 19 + // | React.ReactElement<IsChildProps>[] 20 + // | React.ReactNode 12 21 13 22 export function H1({children}: React.PropsWithChildren<{}>) { 14 23 const pal = usePalette('default') ··· 55 64 ) 56 65 } 57 66 58 - export function UL({ 59 - children, 60 - isChild, 61 - }: React.PropsWithChildren<{isChild: boolean}>) { 67 + export function UL({children, isChild}: React.PropsWithChildren<IsChildProps>) { 62 68 return ( 63 69 <View style={[styles.ul, isChild && styles.ulChild]}> 64 70 {markChildProps(children)} ··· 66 72 ) 67 73 } 68 74 69 - export function OL({ 70 - children, 71 - isChild, 72 - }: React.PropsWithChildren<{isChild: boolean}>) { 75 + export function OL({children, isChild}: React.PropsWithChildren<IsChildProps>) { 73 76 return ( 74 77 <View style={[styles.ol, isChild && styles.olChild]}> 75 78 {markChildProps(children)} ··· 122 125 ) 123 126 } 124 127 125 - function markChildProps(children) { 128 + function markChildProps(children: React.ReactNode) { 126 129 return React.Children.map(children, child => { 127 130 if (React.isValidElement(child)) { 128 - return React.cloneElement(child, {isChild: true}) 131 + return React.cloneElement<IsChildProps>( 132 + child as React.ReactElement<IsChildProps>, 133 + {isChild: true}, 134 + ) 129 135 } 130 136 return child 131 137 })
+3 -1
src/view/com/util/UserInfoText.tsx
··· 70 70 numberOfLines={1} 71 71 href={`/profile/${profile.handle}`} 72 72 text={`${prefix || ''}${sanitizeDisplayName( 73 - profile[attr] || profile.handle, 73 + typeof profile[attr] === 'string' && profile[attr] 74 + ? (profile[attr] as string) 75 + : profile.handle, 74 76 )}`} 75 77 /> 76 78 )
+10 -3
src/view/screens/Settings.tsx
··· 38 38 import {isDesktopWeb} from 'platform/detection' 39 39 import {pluralize} from 'lib/strings/helpers' 40 40 import {formatCount} from 'view/com/util/numeric/format' 41 + import {isColorMode} from 'state/models/ui/shell' 41 42 42 43 type Props = NativeStackScreenProps<CommonNavigatorParams, 'Settings'> 43 44 export const SettingsScreen = withAuthRequired( ··· 299 300 value="system" 300 301 label="System" 301 302 left 302 - onChange={(v: string) => store.shell.setColorMode(v)} 303 + onChange={(v: string) => 304 + store.shell.setColorMode(isColorMode(v) ? v : 'system') 305 + } 303 306 /> 304 307 <SelectableBtn 305 308 current={store.shell.colorMode} 306 309 value="light" 307 310 label="Light" 308 - onChange={(v: string) => store.shell.setColorMode(v)} 311 + onChange={(v: string) => 312 + store.shell.setColorMode(isColorMode(v) ? v : 'system') 313 + } 309 314 /> 310 315 <SelectableBtn 311 316 current={store.shell.colorMode} 312 317 value="dark" 313 318 label="Dark" 314 319 right 315 - onChange={(v: string) => store.shell.setColorMode(v)} 320 + onChange={(v: string) => 321 + store.shell.setColorMode(isColorMode(v) ? v : 'system') 322 + } 316 323 /> 317 324 </View> 318 325 </View>
+4 -2
src/view/shell/desktop/LeftNav.tsx
··· 34 34 SatelliteDishIconSolid, 35 35 } from 'lib/icons' 36 36 import {getCurrentRoute, isTab, isStateAtTabRoot} from 'lib/routes/helpers' 37 - import {NavigationProp} from 'lib/routes/types' 37 + import {NavigationProp, CommonNavigatorParams} from 'lib/routes/types' 38 38 import {router} from '../../../routes' 39 39 40 40 const ProfileCard = observer(() => { ··· 100 100 let isCurrent = 101 101 currentRouteInfo.name === 'Profile' 102 102 ? isTab(currentRouteInfo.name, pathName) && 103 - currentRouteInfo.params.name === store.me.handle 103 + (currentRouteInfo.params as CommonNavigatorParams['Profile']).name === 104 + store.me.handle 104 105 : isTab(currentRouteInfo.name, pathName) 105 106 const {onPress} = useLinkProps({to: href}) 106 107 const onPressWrapped = React.useCallback( ··· 122 123 <PressableWithHover 123 124 style={styles.navItemWrapper} 124 125 hoverStyle={pal.viewLight} 126 + // @ts-ignore the function signature differs on web -prf 125 127 onPress={onPressWrapped} 126 128 // @ts-ignore web only -prf 127 129 href={href}
+4
tsconfig.check.json
··· 1 + { 2 + "extends": "./tsconfig.json", 3 + "exclude": ["__e2e__", "dist"], 4 + }