diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 77e58f54..c5b9a324 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -18,8 +18,10 @@ jobs: uses: actions/checkout@v3 - name: Yarn install run: yarn - - name: Typescript & Lint check + - name: Lint check run: yarn lint + - name: Type check + run: yarn typecheck testing: name: Run tests runs-on: ubuntu-latest diff --git a/package.json b/package.json index 0f3151d7..a7bd627f 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "test-ci": "jest --ci --forceExit --reporters=default --reporters=jest-junit", "test-coverage": "jest --coverage", "lint": "eslint ./src --ext .js,.jsx,.ts,.tsx", + "typecheck": "tsc --project ./tsconfig.check.json", "e2e:mock-server": "ts-node __e2e__/mock-server.ts", "e2e:metro": "RN_SRC_EXT=e2e.ts,e2e.tsx expo run:ios", "e2e:build": "detox build -c ios.sim.debug", diff --git a/src/lib/api/api-polyfill.ts b/src/lib/api/api-polyfill.ts index 7c38625a..ea1d9759 100644 --- a/src/lib/api/api-polyfill.ts +++ b/src/lib/api/api-polyfill.ts @@ -11,7 +11,7 @@ export function doPolyfill() { interface FetchHandlerResponse { status: number headers: Record - body: ArrayBuffer | undefined + body: any } async function fetchHandler( diff --git a/src/lib/api/feed-manip.ts b/src/lib/api/feed-manip.ts index 035b3609..f2500c4f 100644 --- a/src/lib/api/feed-manip.ts +++ b/src/lib/api/feed-manip.ts @@ -74,9 +74,12 @@ export class FeedViewPostsSlice { } flattenReplyParent() { - if (this.items[0].reply?.parent) { - this.isFlattenedReply = true - this.items.splice(0, 0, {post: this.items[0].reply?.parent}) + if (this.items[0].reply) { + const reply = this.items[0].reply + if (AppBskyFeedDefs.isPostView(reply.parent)) { + this.isFlattenedReply = true + this.items.splice(0, 0, {post: reply.parent}) + } } } } @@ -130,16 +133,17 @@ export class FeedTuner { // turn non-threads with reply parents into threads for (const slice of slices) { - if ( - !slice.isThread && - !slice.items[0].reason && - slice.items[0].reply?.parent && - !this.seenUris.has(slice.items[0].reply?.parent.uri) && - !soonToBeSeenUris.has(slice.items[0].reply?.parent.uri) - ) { - const uri = slice.items[0].reply?.parent.uri - slice.flattenReplyParent() - soonToBeSeenUris.add(uri) + if (!slice.isThread && !slice.items[0].reason && slice.items[0].reply) { + const reply = slice.items[0].reply + if ( + AppBskyFeedDefs.isPostView(reply.parent) && + !this.seenUris.has(reply.parent.uri) && + !soonToBeSeenUris.has(reply.parent.uri) + ) { + const uri = reply.parent.uri + slice.flattenReplyParent() + soonToBeSeenUris.add(uri) + } } } @@ -231,7 +235,12 @@ export class FeedTuner { } function getSelfReplyUri(item: FeedViewPost): string | undefined { - return item.reply?.parent.author.did === item.post.author.did - ? item.reply?.parent.uri - : undefined + if (item.reply) { + if (AppBskyFeedDefs.isPostView(item.reply.parent)) { + return item.reply.parent.author.did === item.post.author.did + ? item.reply.parent.uri + : undefined + } + } + return undefined } diff --git a/src/lib/hooks/useTimer.ts b/src/lib/hooks/useTimer.ts index bf3ecc07..b14a9f24 100644 --- a/src/lib/hooks/useTimer.ts +++ b/src/lib/hooks/useTimer.ts @@ -4,7 +4,7 @@ import * as React from 'react' * Helper hook to run persistent timers on views */ export function useTimer(time: number, handler: () => void) { - const timer = React.useRef(undefined) + const timer = React.useRef(undefined) // function to restart the timer const reset = React.useCallback(() => { diff --git a/src/state/models/content/list-membership.ts b/src/state/models/content/list-membership.ts index b4af4472..20d9b60a 100644 --- a/src/state/models/content/list-membership.ts +++ b/src/state/models/content/list-membership.ts @@ -9,6 +9,16 @@ interface Membership { value: AppBskyGraphListitem.Record } +interface ListitemRecord { + uri: string + value: AppBskyGraphListitem.Record +} + +interface ListitemListResponse { + cursor?: string + records: ListitemRecord[] +} + export class ListMembershipModel { // data memberships: Membership[] = [] @@ -32,13 +42,14 @@ export class ListMembershipModel { // it needs to be replaced with server side list membership queries // -prf let cursor - let records = [] + let records: ListitemRecord[] = [] for (let i = 0; i < 100; i++) { - const res = await this.rootStore.agent.app.bsky.graph.listitem.list({ - repo: this.rootStore.me.did, - cursor, - limit: PAGE_SIZE, - }) + const res: ListitemListResponse = + await this.rootStore.agent.app.bsky.graph.listitem.list({ + repo: this.rootStore.me.did, + cursor, + limit: PAGE_SIZE, + }) records = records.concat( res.records.filter(record => record.value.subject === this.subject), ) @@ -99,7 +110,7 @@ export class ListMembershipModel { }) } - async updateTo(uris: string) { + async updateTo(uris: string[]) { for (const uri of uris) { await this.add(uri) } diff --git a/src/state/models/content/list.ts b/src/state/models/content/list.ts index 673ee943..3913d3e6 100644 --- a/src/state/models/content/list.ts +++ b/src/state/models/content/list.ts @@ -4,6 +4,7 @@ import { AppBskyGraphGetList as GetList, AppBskyGraphDefs as GraphDefs, AppBskyGraphList, + AppBskyGraphListitem, } from '@atproto/api' import {Image as RNImage} from 'react-native-image-crop-picker' import {RootStoreModel} from '../root-store' @@ -13,6 +14,16 @@ import {bundleAsync} from 'lib/async/bundle' const PAGE_SIZE = 30 +interface ListitemRecord { + uri: string + value: AppBskyGraphListitem.Record +} + +interface ListitemListResponse { + cursor?: string + records: ListitemRecord[] +} + export class ListModel { // state isLoading = false @@ -33,7 +44,7 @@ export class ListModel { name, description, avatar, - }: {name: string; description: string; avatar: RNImage | undefined}, + }: {name: string; description: string; avatar: RNImage | null | undefined}, ) { const record: AppBskyGraphList.Record = { purpose: 'app.bsky.graph.defs#modlist', @@ -124,6 +135,9 @@ export class ListModel { description: string avatar: RNImage | null | undefined }) { + if (!this.list) { + return + } if (!this.isOwner) { throw new Error('Cannot edit this list') } @@ -157,15 +171,20 @@ export class ListModel { } async delete() { + if (!this.list) { + return + } + // fetch all the listitem records that belong to this list let cursor - let records = [] + let records: ListitemRecord[] = [] for (let i = 0; i < 100; i++) { - const res = await this.rootStore.agent.app.bsky.graph.listitem.list({ - repo: this.rootStore.me.did, - cursor, - limit: PAGE_SIZE, - }) + const res: ListitemListResponse = + await this.rootStore.agent.app.bsky.graph.listitem.list({ + repo: this.rootStore.me.did, + cursor, + limit: PAGE_SIZE, + }) records = records.concat( res.records.filter(record => record.value.list === this.uri), ) @@ -193,6 +212,9 @@ export class ListModel { } async subscribe() { + if (!this.list) { + return + } await this.rootStore.agent.app.bsky.graph.muteActorList({ list: this.list.uri, }) @@ -200,6 +222,9 @@ export class ListModel { } async unsubscribe() { + if (!this.list) { + return + } await this.rootStore.agent.app.bsky.graph.unmuteActorList({ list: this.list.uri, }) diff --git a/src/state/models/discovery/foafs.ts b/src/state/models/discovery/foafs.ts index f6e3157b..4bbd3280 100644 --- a/src/state/models/discovery/foafs.ts +++ b/src/state/models/discovery/foafs.ts @@ -1,4 +1,7 @@ -import {AppBskyActorDefs} from '@atproto/api' +import { + AppBskyActorDefs, + AppBskyGraphGetFollows as GetFollows, +} from '@atproto/api' import {makeAutoObservable, runInAction} from 'mobx' import sampleSize from 'lodash.samplesize' import {bundleAsync} from 'lib/async/bundle' @@ -43,11 +46,12 @@ export class FoafsModel { { let cursor for (let i = 0; i < 10; i++) { - const res = await this.rootStore.agent.getFollows({ - actor: this.rootStore.me.did, - cursor, - limit: 100, - }) + const res: GetFollows.Response = + await this.rootStore.agent.getFollows({ + actor: this.rootStore.me.did, + cursor, + limit: 100, + }) this.rootStore.me.follows.hydrateProfiles(res.data.follows) if (!res.data.cursor) { break diff --git a/src/state/models/feeds/post.ts b/src/state/models/feeds/post.ts index 0c411d44..18a90ee8 100644 --- a/src/state/models/feeds/post.ts +++ b/src/state/models/feeds/post.ts @@ -67,7 +67,7 @@ export class PostsFeedItemModel { } get rootUri(): string { - if (this.reply?.root.uri) { + if (typeof this.reply?.root.uri === 'string') { return this.reply.root.uri } return this.post.uri diff --git a/src/state/models/lists/lists-list.ts b/src/state/models/lists/lists-list.ts index 309ab0e0..6618c3bf 100644 --- a/src/state/models/lists/lists-list.ts +++ b/src/state/models/lists/lists-list.ts @@ -61,7 +61,7 @@ export class ListsListModel { } this._xLoading(replace) try { - let res + let res: GetLists.Response if (this.source === 'my-modlists') { res = { success: true, @@ -170,7 +170,7 @@ async function fetchAllUserLists( let cursor for (let i = 0; i < 100; i++) { - const res = await store.agent.app.bsky.graph.getLists({ + const res: GetLists.Response = await store.agent.app.bsky.graph.getLists({ actor: did, cursor, limit: 50, @@ -199,10 +199,11 @@ async function fetchAllMyMuteLists( let cursor for (let i = 0; i < 100; i++) { - const res = await store.agent.app.bsky.graph.getListMutes({ - cursor, - limit: 50, - }) + const res: GetListMutes.Response = + await store.agent.app.bsky.graph.getListMutes({ + cursor, + limit: 50, + }) cursor = res.data.cursor acc.data.lists = acc.data.lists.concat(res.data.lists) if (!cursor) { diff --git a/src/state/models/ui/shell.ts b/src/state/models/ui/shell.ts index a77ffbdf..3853f239 100644 --- a/src/state/models/ui/shell.ts +++ b/src/state/models/ui/shell.ts @@ -8,6 +8,12 @@ import {ImageModel} from '../media/image' import {ListModel} from '../content/list' import {GalleryModel} from '../media/gallery' +export type ColorMode = 'system' | 'light' | 'dark' + +export function isColorMode(v: unknown): v is ColorMode { + return v === 'system' || v === 'light' || v === 'dark' +} + export interface ConfirmModal { name: 'confirm' title: string @@ -189,7 +195,7 @@ export interface ComposerOpts { } export class ShellUiModel { - colorMode = 'system' + colorMode: ColorMode = 'system' minimalShellMode = false isDrawerOpen = false isDrawerSwipeDisabled = false @@ -216,13 +222,13 @@ export class ShellUiModel { hydrate(v: unknown) { if (isObj(v)) { - if (hasProp(v, 'colorMode') && typeof v.colorMode === 'string') { + if (hasProp(v, 'colorMode') && isColorMode(v.colorMode)) { this.colorMode = v.colorMode } } } - setColorMode(mode: string) { + setColorMode(mode: ColorMode) { this.colorMode = mode } diff --git a/src/view/com/composer/useExternalLinkFetch.ts b/src/view/com/composer/useExternalLinkFetch.ts index 8d3b8cac..91f4da05 100644 --- a/src/view/com/composer/useExternalLinkFetch.ts +++ b/src/view/com/composer/useExternalLinkFetch.ts @@ -1,5 +1,6 @@ import {useState, useEffect} from 'react' import {useStores} from 'state/index' +import {ImageModel} from 'state/models/media/image' import * as apilib from 'lib/api/index' import {getLinkMeta} from 'lib/link-meta/link-meta' import {getPostAsQuote, getFeedAsEmbed} from 'lib/link-meta/bsky' @@ -90,7 +91,9 @@ export function useExternalLinkFetch({ setExtLink({ ...extLink, isLoading: false, // done - localThumb, + localThumb: localThumb + ? new ImageModel(store, localThumb) + : undefined, }) }) return cleanup diff --git a/src/view/com/lightbox/Lightbox.tsx b/src/view/com/lightbox/Lightbox.tsx index c4bc88cf..18440c55 100644 --- a/src/view/com/lightbox/Lightbox.tsx +++ b/src/view/com/lightbox/Lightbox.tsx @@ -27,14 +27,14 @@ export const Lightbox = observer(function Lightbox() { } let altText = '' - let uri + let uri = '' if (lightbox.name === 'images') { - const opts = store.shell.activeLightbox as models.ImagesLightbox + const opts = lightbox as models.ImagesLightbox uri = opts.images[imageIndex].uri - altText = opts.images[imageIndex].alt - } else if (store.shell.activeLightbox.name === 'profile-image') { - const opts = store.shell.activeLightbox as models.ProfileImageLightbox - uri = opts.profileView.avatar + altText = opts.images[imageIndex].alt || '' + } else if (lightbox.name === 'profile-image') { + const opts = lightbox as models.ProfileImageLightbox + uri = opts.profileView.avatar || '' } return ( diff --git a/src/view/com/modals/CreateOrEditMuteList.tsx b/src/view/com/modals/CreateOrEditMuteList.tsx index 736deae7..5e5130b3 100644 --- a/src/view/com/modals/CreateOrEditMuteList.tsx +++ b/src/view/com/modals/CreateOrEditMuteList.tsx @@ -44,11 +44,11 @@ export function Component({ const {track} = useAnalytics() const [isProcessing, setProcessing] = useState(false) - const [name, setName] = useState(list?.list.name || '') + const [name, setName] = useState(list?.list?.name || '') const [description, setDescription] = useState( - list?.list.description || '', + list?.list?.description || '', ) - const [avatar, setAvatar] = useState(list?.list.avatar) + const [avatar, setAvatar] = useState(list?.list?.avatar) const [newAvatar, setNewAvatar] = useState() const onPressCancel = useCallback(() => { @@ -59,7 +59,7 @@ export function Component({ async (img: RNImage | null) => { if (!img) { setNewAvatar(null) - setAvatar(null) + setAvatar(undefined) return } track('CreateMuteList:AvatarSelected') diff --git a/src/view/com/modals/ListAddRemoveUser.tsx b/src/view/com/modals/ListAddRemoveUser.tsx index 91fe67c1..e1677d25 100644 --- a/src/view/com/modals/ListAddRemoveUser.tsx +++ b/src/view/com/modals/ListAddRemoveUser.tsx @@ -36,7 +36,7 @@ export const Component = observer( const pal = usePalette('default') const palPrimary = usePalette('primary') const palInverted = usePalette('inverted') - const [selected, setSelected] = React.useState([]) + const [selected, setSelected] = React.useState([]) const listsList: ListsListModel = React.useMemo( () => new ListsListModel(store, store.me.did), diff --git a/src/view/com/profile/ProfileCard.tsx b/src/view/com/profile/ProfileCard.tsx index 42c4edef..50b9f199 100644 --- a/src/view/com/profile/ProfileCard.tsx +++ b/src/view/com/profile/ProfileCard.tsx @@ -1,4 +1,4 @@ -import React from 'react' +import * as React from 'react' import {StyleSheet, View} from 'react-native' import {observer} from 'mobx-react-lite' import {AppBskyActorDefs} from '@atproto/api' @@ -32,7 +32,9 @@ export const ProfileCard = observer( noBorder?: boolean followers?: AppBskyActorDefs.ProfileView[] | undefined overrideModeration?: boolean - renderButton?: (profile: AppBskyActorDefs.ProfileViewBasic) => JSX.Element + renderButton?: ( + profile: AppBskyActorDefs.ProfileViewBasic, + ) => React.ReactNode }) => { const store = useStores() const pal = usePalette('default') diff --git a/src/view/com/profile/ProfileHeader.tsx b/src/view/com/profile/ProfileHeader.tsx index 9b4df098..46a6bb23 100644 --- a/src/view/com/profile/ProfileHeader.tsx +++ b/src/view/com/profile/ProfileHeader.tsx @@ -587,9 +587,9 @@ const styles = StyleSheet.create({ // Word wrapping appears fine on // mobile but overflows on desktop handle: isNative - ? undefined + ? {} : { - // eslint-disable-next-line + // @ts-ignore web only -prf wordBreak: 'break-all', }, diff --git a/src/view/com/util/BlurView.web.tsx b/src/view/com/util/BlurView.web.tsx index 5267e6ad..d1fb4665 100644 --- a/src/view/com/util/BlurView.web.tsx +++ b/src/view/com/util/BlurView.web.tsx @@ -15,6 +15,7 @@ export const BlurView = ({ }: React.PropsWithChildren) => { // @ts-ignore using an RNW-specific attribute here -prf let blur = `blur(${blurAmount || 10}px` + // @ts-ignore using an RNW-specific attribute here -prf style = addStyle(style, {backdropFilter: blur, WebkitBackdropFilter: blur}) if (blurType === 'dark') { style = addStyle(style, styles.dark) diff --git a/src/view/com/util/Html.tsx b/src/view/com/util/Html.tsx index dbf24a83..8d3f29fb 100644 --- a/src/view/com/util/Html.tsx +++ b/src/view/com/util/Html.tsx @@ -1,4 +1,4 @@ -import React from 'react' +import * as React from 'react' import {StyleSheet, View} from 'react-native' import {usePalette} from 'lib/hooks/usePalette' import {Text} from './text/Text' @@ -10,6 +10,15 @@ import {isDesktopWeb} from 'platform/detection' * DSL. See for instance /locale/en/privacy-policy.tsx */ +interface IsChildProps { + isChild?: boolean +} + +// type ReactNodeWithIsChildProp = +// | React.ReactElement +// | React.ReactElement[] +// | React.ReactNode + export function H1({children}: React.PropsWithChildren<{}>) { const pal = usePalette('default') return ( @@ -55,10 +64,7 @@ export function P({children}: React.PropsWithChildren<{}>) { ) } -export function UL({ - children, - isChild, -}: React.PropsWithChildren<{isChild: boolean}>) { +export function UL({children, isChild}: React.PropsWithChildren) { return ( {markChildProps(children)} @@ -66,10 +72,7 @@ export function UL({ ) } -export function OL({ - children, - isChild, -}: React.PropsWithChildren<{isChild: boolean}>) { +export function OL({children, isChild}: React.PropsWithChildren) { return ( {markChildProps(children)} @@ -122,10 +125,13 @@ export function EM({children}: React.PropsWithChildren<{}>) { ) } -function markChildProps(children) { +function markChildProps(children: React.ReactNode) { return React.Children.map(children, child => { if (React.isValidElement(child)) { - return React.cloneElement(child, {isChild: true}) + return React.cloneElement( + child as React.ReactElement, + {isChild: true}, + ) } return child }) diff --git a/src/view/com/util/UserInfoText.tsx b/src/view/com/util/UserInfoText.tsx index b8f6e082..b737b2b1 100644 --- a/src/view/com/util/UserInfoText.tsx +++ b/src/view/com/util/UserInfoText.tsx @@ -70,7 +70,9 @@ export function UserInfoText({ numberOfLines={1} href={`/profile/${profile.handle}`} text={`${prefix || ''}${sanitizeDisplayName( - profile[attr] || profile.handle, + typeof profile[attr] === 'string' && profile[attr] + ? (profile[attr] as string) + : profile.handle, )}`} /> ) diff --git a/src/view/screens/Settings.tsx b/src/view/screens/Settings.tsx index 42882233..79889385 100644 --- a/src/view/screens/Settings.tsx +++ b/src/view/screens/Settings.tsx @@ -38,6 +38,7 @@ import {NavigationProp} from 'lib/routes/types' import {isDesktopWeb} from 'platform/detection' import {pluralize} from 'lib/strings/helpers' import {formatCount} from 'view/com/util/numeric/format' +import {isColorMode} from 'state/models/ui/shell' type Props = NativeStackScreenProps export const SettingsScreen = withAuthRequired( @@ -299,20 +300,26 @@ export const SettingsScreen = withAuthRequired( value="system" label="System" left - onChange={(v: string) => store.shell.setColorMode(v)} + onChange={(v: string) => + store.shell.setColorMode(isColorMode(v) ? v : 'system') + } /> store.shell.setColorMode(v)} + onChange={(v: string) => + store.shell.setColorMode(isColorMode(v) ? v : 'system') + } /> store.shell.setColorMode(v)} + onChange={(v: string) => + store.shell.setColorMode(isColorMode(v) ? v : 'system') + } /> diff --git a/src/view/shell/desktop/LeftNav.tsx b/src/view/shell/desktop/LeftNav.tsx index 8c701ea4..9f047418 100644 --- a/src/view/shell/desktop/LeftNav.tsx +++ b/src/view/shell/desktop/LeftNav.tsx @@ -34,7 +34,7 @@ import { SatelliteDishIconSolid, } from 'lib/icons' import {getCurrentRoute, isTab, isStateAtTabRoot} from 'lib/routes/helpers' -import {NavigationProp} from 'lib/routes/types' +import {NavigationProp, CommonNavigatorParams} from 'lib/routes/types' import {router} from '../../../routes' const ProfileCard = observer(() => { @@ -100,7 +100,8 @@ const NavItem = observer( let isCurrent = currentRouteInfo.name === 'Profile' ? isTab(currentRouteInfo.name, pathName) && - currentRouteInfo.params.name === store.me.handle + (currentRouteInfo.params as CommonNavigatorParams['Profile']).name === + store.me.handle : isTab(currentRouteInfo.name, pathName) const {onPress} = useLinkProps({to: href}) const onPressWrapped = React.useCallback( @@ -122,6 +123,7 @@ const NavItem = observer(