diff --git a/src/state/models/feed-view.ts b/src/state/models/feed-view.ts index 68738d72..e5a6d73d 100644 --- a/src/state/models/feed-view.ts +++ b/src/state/models/feed-view.ts @@ -33,6 +33,7 @@ export class FeedItemModel { // data post: PostView + postRecord?: AppBskyFeedPost.Record reply?: FeedViewPost['reply'] replyParent?: FeedItemModel reason?: FeedViewPost['reason'] @@ -44,6 +45,22 @@ export class FeedItemModel { ) { this._reactKey = reactKey this.post = v.post + if (AppBskyFeedPost.isRecord(this.post.record)) { + const valid = AppBskyFeedPost.validateRecord(this.post.record) + if (valid.success) { + this.postRecord = this.post.record + } else { + rootStore.log.warn( + 'Received an invalid app.bsky.feed.post record', + valid.error, + ) + } + } else { + rootStore.log.warn( + 'app.bsky.feed.getTimeline or app.bsky.feed.getAuthorFeed served an unexpected record type', + this.post.record, + ) + } this.reply = v.reply if (v.reply?.parent) { this.replyParent = new FeedItemModel(rootStore, '', { diff --git a/src/state/models/notifications-view.ts b/src/state/models/notifications-view.ts index 44f92dd2..c169a995 100644 --- a/src/state/models/notifications-view.ts +++ b/src/state/models/notifications-view.ts @@ -2,11 +2,16 @@ import {makeAutoObservable, runInAction} from 'mobx' import { AppBskyNotificationList as ListNotifications, AppBskyActorRef as ActorRef, + AppBskyFeedPost, + AppBskyFeedRepost, + AppBskyFeedTrend, + AppBskyFeedVote, + AppBskyGraphAssertion, + AppBskyGraphFollow, APP_BSKY_GRAPH, } from '@atproto/api' import {RootStoreModel} from './root-store' import {PostThreadViewModel} from './post-thread-view' -import {hasProp} from '../lib/type-guards' import {cleanError} from '../../lib/strings' const UNGROUPABLE_REASONS = ['trend', 'assertion'] @@ -19,7 +24,15 @@ export interface GroupedNotification extends ListNotifications.Notification { additional?: ListNotifications.Notification[] } -export class NotificationsViewItemModel implements GroupedNotification { +type SupportedRecord = + | AppBskyFeedPost.Record + | AppBskyFeedRepost.Record + | AppBskyFeedTrend.Record + | AppBskyFeedVote.Record + | AppBskyGraphAssertion.Record + | AppBskyGraphFollow.Record + +export class NotificationsViewItemModel { // ui state _reactKey: string = '' @@ -34,7 +47,7 @@ export class NotificationsViewItemModel implements GroupedNotification { } reason: string = '' reasonSubject?: string - record: any = {} + record?: SupportedRecord isRead: boolean = false indexedAt: string = '' additional?: NotificationsViewItemModel[] @@ -58,7 +71,7 @@ export class NotificationsViewItemModel implements GroupedNotification { this.author = v.author this.reason = v.reason this.reasonSubject = v.reasonSubject - this.record = v.record + this.record = this.toSupportedRecord(v.record) this.isRead = v.isRead this.indexedAt = v.indexedAt if (v.additional?.length) { @@ -116,23 +129,55 @@ export class NotificationsViewItemModel implements GroupedNotification { get isInvite() { return ( - this.isAssertion && this.record.assertion === APP_BSKY_GRAPH.AssertMember + this.isAssertion && + AppBskyGraphAssertion.isRecord(this.record) && + this.record.assertion === APP_BSKY_GRAPH.AssertMember ) } - get subjectUri() { + get subjectUri(): string { if (this.reasonSubject) { return this.reasonSubject } + const record = this.record if ( - hasProp(this.record, 'subject') && - typeof this.record.subject === 'string' + AppBskyFeedRepost.isRecord(record) || + AppBskyFeedTrend.isRecord(record) || + AppBskyFeedVote.isRecord(record) ) { - return this.record.subject + return record.subject.uri } return '' } + toSupportedRecord(v: unknown): SupportedRecord | undefined { + for (const ns of [ + AppBskyFeedPost, + AppBskyFeedRepost, + AppBskyFeedTrend, + AppBskyFeedVote, + AppBskyGraphAssertion, + AppBskyGraphFollow, + ]) { + if (ns.isRecord(v)) { + const valid = ns.validateRecord(v) + if (valid.success) { + return v + } else { + this.rootStore.log.warn('Received an invalid record', { + record: v, + error: valid.error, + }) + return + } + } + } + this.rootStore.log.warn( + 'app.bsky.notifications.list served an unsupported record type', + v, + ) + } + async fetchAdditionalData() { if (!this.needsAdditionalData) { return @@ -140,7 +185,7 @@ export class NotificationsViewItemModel implements GroupedNotification { let postUri if (this.isReply || this.isMention) { postUri = this.uri - } else if (this.isUpvote || this.isRead || this.isTrend) { + } else if (this.isUpvote || this.isRepost || this.isTrend) { postUri = this.subjectUri } if (postUri) { diff --git a/src/state/models/post-thread-view.ts b/src/state/models/post-thread-view.ts index b7c33cfb..f1933553 100644 --- a/src/state/models/post-thread-view.ts +++ b/src/state/models/post-thread-view.ts @@ -1,5 +1,8 @@ import {makeAutoObservable, runInAction} from 'mobx' -import {AppBskyFeedGetPostThread as GetPostThread} from '@atproto/api' +import { + AppBskyFeedGetPostThread as GetPostThread, + AppBskyFeedPost as FeedPost, +} from '@atproto/api' import {AtUri} from '../../third-party/uri' import {RootStoreModel} from './root-store' import * as apilib from '../lib/api' @@ -19,7 +22,8 @@ export class PostThreadViewPostModel { _hasMore = false // data - post: GetPostThread.ThreadViewPost['post'] + post: FeedPost.View + postRecord?: FeedPost.Record parent?: PostThreadViewPostModel | GetPostThread.NotFoundPost replies?: (PostThreadViewPostModel | GetPostThread.NotFoundPost)[] @@ -30,6 +34,22 @@ export class PostThreadViewPostModel { ) { this._reactKey = reactKey this.post = v.post + if (FeedPost.isRecord(this.post.record)) { + const valid = FeedPost.validateRecord(this.post.record) + if (valid.success) { + this.postRecord = this.post.record + } else { + rootStore.log.warn( + 'Received an invalid app.bsky.feed.post record', + valid.error, + ) + } + } else { + rootStore.log.warn( + 'app.bsky.feed.getPostThread served an unexpected record type', + this.post.record, + ) + } // replies and parent are handled via assignTreeModels makeAutoObservable(this, {rootStore: false}) } @@ -278,7 +298,6 @@ export class PostThreadViewModel { } private _replaceAll(res: GetPostThread.Response) { - // TODO: validate .record // sortThread(res.data.thread) TODO needed? const keyGen = reactKeyGenerator() const thread = new PostThreadViewPostModel( diff --git a/src/view/com/notifications/FeedItem.tsx b/src/view/com/notifications/FeedItem.tsx index 6eabee70..efb4d610 100644 --- a/src/view/com/notifications/FeedItem.tsx +++ b/src/view/com/notifications/FeedItem.tsx @@ -221,14 +221,14 @@ function AdditionalPostText({ additionalPost?: PostThreadViewModel }) { const pal = usePalette('default') - if (!additionalPost) { + if (!additionalPost || !additionalPost.thread?.postRecord) { return } if (additionalPost.error) { return } return ( - {additionalPost.thread?.post.record.text} + {additionalPost.thread?.postRecord.text} ) } diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx index e8c23d3a..e93f77e3 100644 --- a/src/view/com/post-thread/PostThreadItem.tsx +++ b/src/view/com/post-thread/PostThreadItem.tsx @@ -3,7 +3,6 @@ import {observer} from 'mobx-react-lite' import {StyleSheet, View} from 'react-native' import Clipboard from '@react-native-clipboard/clipboard' import {AtUri} from '../../../third-party/uri' -import {AppBskyFeedPost} from '@atproto/api' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {PostThreadViewPostModel} from '../../../state/models/post-thread-view' import {Link} from '../util/Link' @@ -18,6 +17,7 @@ import {useStores} from '../../../state' import {PostMeta} from '../util/PostMeta' import {PostEmbeds} from '../util/PostEmbeds' import {PostCtrls} from '../util/PostCtrls' +import {ErrorMessage} from '../util/error/ErrorMessage' import {ComposePrompt} from '../composer/Prompt' import {usePalette} from '../../lib/hooks/usePalette' @@ -33,7 +33,7 @@ export const PostThreadItem = observer(function PostThreadItem({ const pal = usePalette('default') const store = useStores() const [deleted, setDeleted] = useState(false) - const record = item.post.record as unknown as AppBskyFeedPost.Record + const record = item.postRecord const hasEngagement = item.post.upvoteCount || item.post.repostCount const itemHref = useMemo(() => { @@ -96,6 +96,10 @@ export const PostThreadItem = observer(function PostThreadItem({ ) } + if (!record) { + return + } + if (deleted) { return ( diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx index 351282df..032d5c14 100644 --- a/src/view/com/post/Post.tsx +++ b/src/view/com/post/Post.tsx @@ -9,7 +9,6 @@ import { import {observer} from 'mobx-react-lite' import Clipboard from '@react-native-clipboard/clipboard' import {AtUri} from '../../../third-party/uri' -import {AppBskyFeedPost} from '@atproto/api' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {PostThreadViewModel} from '../../../state/models/post-thread-view' import {Link} from '../util/Link' @@ -21,6 +20,7 @@ import {Text} from '../util/text/Text' import {RichText} from '../util/text/RichText' import * as Toast from '../util/Toast' import {UserAvatar} from '../util/UserAvatar' +import {ErrorMessage} from '../util/error/ErrorMessage' import {useStores} from '../../../state' import {s, colors} from '../../lib/styles' import {usePalette} from '../../lib/hooks/usePalette' @@ -68,7 +68,7 @@ export const Post = observer(function Post({ // error // = - if (view.hasError || !view.thread) { + if (view.hasError || !view.thread || !view.thread?.postRecord) { return ( {view.error || 'Thread not found'} @@ -79,7 +79,7 @@ export const Post = observer(function Post({ // loaded // = const item = view.thread - const record = view.thread?.post.record as unknown as AppBskyFeedPost.Record + const record = view.thread.postRecord const itemUrip = new AtUri(item.post.uri) const itemHref = `/profile/${item.post.author.handle}/post/${itemUrip.rkey}` diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx index 583d1548..7a1aa5d2 100644 --- a/src/view/com/posts/FeedItem.tsx +++ b/src/view/com/posts/FeedItem.tsx @@ -4,7 +4,6 @@ import {StyleSheet, View} from 'react-native' import Clipboard from '@react-native-clipboard/clipboard' import Svg, {Circle, Line} from 'react-native-svg' import {AtUri} from '../../../third-party/uri' -import {AppBskyFeedPost} from '@atproto/api' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {FeedItemModel} from '../../../state/models/feed-view' import {Link} from '../util/Link' @@ -34,7 +33,7 @@ export const FeedItem = observer(function ({ const theme = useTheme() const pal = usePalette('default') const [deleted, setDeleted] = useState(false) - const record = item.post.record as unknown as AppBskyFeedPost.Record + const record = item.postRecord const itemHref = useMemo(() => { const urip = new AtUri(item.post.uri) return `/profile/${item.post.author.handle}/post/${urip.rkey}` @@ -42,22 +41,22 @@ export const FeedItem = observer(function ({ const itemTitle = `Post by ${item.post.author.handle}` const authorHref = `/profile/${item.post.author.handle}` const replyAuthorDid = useMemo(() => { - if (!record.reply) return '' + if (!record?.reply) return '' const urip = new AtUri(record.reply.parent?.uri || record.reply.root.uri) return urip.hostname - }, [record.reply]) + }, [record?.reply]) const replyHref = useMemo(() => { - if (!record.reply) return '' - const urip = new AtUri(record.reply.parent?.uri || record.reply.root.uri) + if (!record?.reply) return '' + const urip = new AtUri(record?.reply.parent?.uri || record?.reply.root.uri) return `/profile/${urip.hostname}/post/${urip.rkey}` - }, [record.reply]) + }, [record?.reply]) const onPressReply = () => { store.shell.openComposer({ replyTo: { uri: item.post.uri, cid: item.post.cid, - text: record.text as string, + text: record?.text || '', author: { handle: item.post.author.handle, displayName: item.post.author.displayName, @@ -77,7 +76,7 @@ export const FeedItem = observer(function ({ .catch(e => store.log.error('Failed to toggle upvote', e)) } const onCopyPostText = () => { - Clipboard.setString(record.text) + Clipboard.setString(record?.text || '') Toast.show('Copied to clipboard') } const onDeletePost = () => { @@ -93,7 +92,7 @@ export const FeedItem = observer(function ({ ) } - if (deleted) { + if (!record || deleted) { return }