Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Implement validation and proper type detection

+114 -30
+17
src/state/models/feed-view.ts
··· 33 33 34 34 // data 35 35 post: PostView 36 + postRecord?: AppBskyFeedPost.Record 36 37 reply?: FeedViewPost['reply'] 37 38 replyParent?: FeedItemModel 38 39 reason?: FeedViewPost['reason'] ··· 44 45 ) { 45 46 this._reactKey = reactKey 46 47 this.post = v.post 48 + if (AppBskyFeedPost.isRecord(this.post.record)) { 49 + const valid = AppBskyFeedPost.validateRecord(this.post.record) 50 + if (valid.success) { 51 + this.postRecord = this.post.record 52 + } else { 53 + rootStore.log.warn( 54 + 'Received an invalid app.bsky.feed.post record', 55 + valid.error, 56 + ) 57 + } 58 + } else { 59 + rootStore.log.warn( 60 + 'app.bsky.feed.getTimeline or app.bsky.feed.getAuthorFeed served an unexpected record type', 61 + this.post.record, 62 + ) 63 + } 47 64 this.reply = v.reply 48 65 if (v.reply?.parent) { 49 66 this.replyParent = new FeedItemModel(rootStore, '', {
+55 -10
src/state/models/notifications-view.ts
··· 2 2 import { 3 3 AppBskyNotificationList as ListNotifications, 4 4 AppBskyActorRef as ActorRef, 5 + AppBskyFeedPost, 6 + AppBskyFeedRepost, 7 + AppBskyFeedTrend, 8 + AppBskyFeedVote, 9 + AppBskyGraphAssertion, 10 + AppBskyGraphFollow, 5 11 APP_BSKY_GRAPH, 6 12 } from '@atproto/api' 7 13 import {RootStoreModel} from './root-store' 8 14 import {PostThreadViewModel} from './post-thread-view' 9 - import {hasProp} from '../lib/type-guards' 10 15 import {cleanError} from '../../lib/strings' 11 16 12 17 const UNGROUPABLE_REASONS = ['trend', 'assertion'] ··· 19 24 additional?: ListNotifications.Notification[] 20 25 } 21 26 22 - export class NotificationsViewItemModel implements GroupedNotification { 27 + type SupportedRecord = 28 + | AppBskyFeedPost.Record 29 + | AppBskyFeedRepost.Record 30 + | AppBskyFeedTrend.Record 31 + | AppBskyFeedVote.Record 32 + | AppBskyGraphAssertion.Record 33 + | AppBskyGraphFollow.Record 34 + 35 + export class NotificationsViewItemModel { 23 36 // ui state 24 37 _reactKey: string = '' 25 38 ··· 34 47 } 35 48 reason: string = '' 36 49 reasonSubject?: string 37 - record: any = {} 50 + record?: SupportedRecord 38 51 isRead: boolean = false 39 52 indexedAt: string = '' 40 53 additional?: NotificationsViewItemModel[] ··· 58 71 this.author = v.author 59 72 this.reason = v.reason 60 73 this.reasonSubject = v.reasonSubject 61 - this.record = v.record 74 + this.record = this.toSupportedRecord(v.record) 62 75 this.isRead = v.isRead 63 76 this.indexedAt = v.indexedAt 64 77 if (v.additional?.length) { ··· 116 129 117 130 get isInvite() { 118 131 return ( 119 - this.isAssertion && this.record.assertion === APP_BSKY_GRAPH.AssertMember 132 + this.isAssertion && 133 + AppBskyGraphAssertion.isRecord(this.record) && 134 + this.record.assertion === APP_BSKY_GRAPH.AssertMember 120 135 ) 121 136 } 122 137 123 - get subjectUri() { 138 + get subjectUri(): string { 124 139 if (this.reasonSubject) { 125 140 return this.reasonSubject 126 141 } 142 + const record = this.record 127 143 if ( 128 - hasProp(this.record, 'subject') && 129 - typeof this.record.subject === 'string' 144 + AppBskyFeedRepost.isRecord(record) || 145 + AppBskyFeedTrend.isRecord(record) || 146 + AppBskyFeedVote.isRecord(record) 130 147 ) { 131 - return this.record.subject 148 + return record.subject.uri 132 149 } 133 150 return '' 134 151 } 135 152 153 + toSupportedRecord(v: unknown): SupportedRecord | undefined { 154 + for (const ns of [ 155 + AppBskyFeedPost, 156 + AppBskyFeedRepost, 157 + AppBskyFeedTrend, 158 + AppBskyFeedVote, 159 + AppBskyGraphAssertion, 160 + AppBskyGraphFollow, 161 + ]) { 162 + if (ns.isRecord(v)) { 163 + const valid = ns.validateRecord(v) 164 + if (valid.success) { 165 + return v 166 + } else { 167 + this.rootStore.log.warn('Received an invalid record', { 168 + record: v, 169 + error: valid.error, 170 + }) 171 + return 172 + } 173 + } 174 + } 175 + this.rootStore.log.warn( 176 + 'app.bsky.notifications.list served an unsupported record type', 177 + v, 178 + ) 179 + } 180 + 136 181 async fetchAdditionalData() { 137 182 if (!this.needsAdditionalData) { 138 183 return ··· 140 185 let postUri 141 186 if (this.isReply || this.isMention) { 142 187 postUri = this.uri 143 - } else if (this.isUpvote || this.isRead || this.isTrend) { 188 + } else if (this.isUpvote || this.isRepost || this.isTrend) { 144 189 postUri = this.subjectUri 145 190 } 146 191 if (postUri) {
+22 -3
src/state/models/post-thread-view.ts
··· 1 1 import {makeAutoObservable, runInAction} from 'mobx' 2 - import {AppBskyFeedGetPostThread as GetPostThread} from '@atproto/api' 2 + import { 3 + AppBskyFeedGetPostThread as GetPostThread, 4 + AppBskyFeedPost as FeedPost, 5 + } from '@atproto/api' 3 6 import {AtUri} from '../../third-party/uri' 4 7 import {RootStoreModel} from './root-store' 5 8 import * as apilib from '../lib/api' ··· 19 22 _hasMore = false 20 23 21 24 // data 22 - post: GetPostThread.ThreadViewPost['post'] 25 + post: FeedPost.View 26 + postRecord?: FeedPost.Record 23 27 parent?: PostThreadViewPostModel | GetPostThread.NotFoundPost 24 28 replies?: (PostThreadViewPostModel | GetPostThread.NotFoundPost)[] 25 29 ··· 30 34 ) { 31 35 this._reactKey = reactKey 32 36 this.post = v.post 37 + if (FeedPost.isRecord(this.post.record)) { 38 + const valid = FeedPost.validateRecord(this.post.record) 39 + if (valid.success) { 40 + this.postRecord = this.post.record 41 + } else { 42 + rootStore.log.warn( 43 + 'Received an invalid app.bsky.feed.post record', 44 + valid.error, 45 + ) 46 + } 47 + } else { 48 + rootStore.log.warn( 49 + 'app.bsky.feed.getPostThread served an unexpected record type', 50 + this.post.record, 51 + ) 52 + } 33 53 // replies and parent are handled via assignTreeModels 34 54 makeAutoObservable(this, {rootStore: false}) 35 55 } ··· 278 298 } 279 299 280 300 private _replaceAll(res: GetPostThread.Response) { 281 - // TODO: validate .record 282 301 // sortThread(res.data.thread) TODO needed? 283 302 const keyGen = reactKeyGenerator() 284 303 const thread = new PostThreadViewPostModel(
+2 -2
src/view/com/notifications/FeedItem.tsx
··· 221 221 additionalPost?: PostThreadViewModel 222 222 }) { 223 223 const pal = usePalette('default') 224 - if (!additionalPost) { 224 + if (!additionalPost || !additionalPost.thread?.postRecord) { 225 225 return <View /> 226 226 } 227 227 if (additionalPost.error) { 228 228 return <ErrorMessage message={additionalPost.error} /> 229 229 } 230 230 return ( 231 - <Text style={pal.textLight}>{additionalPost.thread?.post.record.text}</Text> 231 + <Text style={pal.textLight}>{additionalPost.thread?.postRecord.text}</Text> 232 232 ) 233 233 } 234 234
+6 -2
src/view/com/post-thread/PostThreadItem.tsx
··· 3 3 import {StyleSheet, View} from 'react-native' 4 4 import Clipboard from '@react-native-clipboard/clipboard' 5 5 import {AtUri} from '../../../third-party/uri' 6 - import {AppBskyFeedPost} from '@atproto/api' 7 6 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 8 7 import {PostThreadViewPostModel} from '../../../state/models/post-thread-view' 9 8 import {Link} from '../util/Link' ··· 18 17 import {PostMeta} from '../util/PostMeta' 19 18 import {PostEmbeds} from '../util/PostEmbeds' 20 19 import {PostCtrls} from '../util/PostCtrls' 20 + import {ErrorMessage} from '../util/error/ErrorMessage' 21 21 import {ComposePrompt} from '../composer/Prompt' 22 22 import {usePalette} from '../../lib/hooks/usePalette' 23 23 ··· 33 33 const pal = usePalette('default') 34 34 const store = useStores() 35 35 const [deleted, setDeleted] = useState(false) 36 - const record = item.post.record as unknown as AppBskyFeedPost.Record 36 + const record = item.postRecord 37 37 const hasEngagement = item.post.upvoteCount || item.post.repostCount 38 38 39 39 const itemHref = useMemo(() => { ··· 94 94 Toast.show('Failed to delete post, please try again') 95 95 }, 96 96 ) 97 + } 98 + 99 + if (!record) { 100 + return <ErrorMessage message="Invalid or unsupported post record" /> 97 101 } 98 102 99 103 if (deleted) {
+3 -3
src/view/com/post/Post.tsx
··· 9 9 import {observer} from 'mobx-react-lite' 10 10 import Clipboard from '@react-native-clipboard/clipboard' 11 11 import {AtUri} from '../../../third-party/uri' 12 - import {AppBskyFeedPost} from '@atproto/api' 13 12 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 14 13 import {PostThreadViewModel} from '../../../state/models/post-thread-view' 15 14 import {Link} from '../util/Link' ··· 21 20 import {RichText} from '../util/text/RichText' 22 21 import * as Toast from '../util/Toast' 23 22 import {UserAvatar} from '../util/UserAvatar' 23 + import {ErrorMessage} from '../util/error/ErrorMessage' 24 24 import {useStores} from '../../../state' 25 25 import {s, colors} from '../../lib/styles' 26 26 import {usePalette} from '../../lib/hooks/usePalette' ··· 68 68 69 69 // error 70 70 // = 71 - if (view.hasError || !view.thread) { 71 + if (view.hasError || !view.thread || !view.thread?.postRecord) { 72 72 return ( 73 73 <View style={pal.view}> 74 74 <Text>{view.error || 'Thread not found'}</Text> ··· 79 79 // loaded 80 80 // = 81 81 const item = view.thread 82 - const record = view.thread?.post.record as unknown as AppBskyFeedPost.Record 82 + const record = view.thread.postRecord 83 83 84 84 const itemUrip = new AtUri(item.post.uri) 85 85 const itemHref = `/profile/${item.post.author.handle}/post/${itemUrip.rkey}`
+9 -10
src/view/com/posts/FeedItem.tsx
··· 4 4 import Clipboard from '@react-native-clipboard/clipboard' 5 5 import Svg, {Circle, Line} from 'react-native-svg' 6 6 import {AtUri} from '../../../third-party/uri' 7 - import {AppBskyFeedPost} from '@atproto/api' 8 7 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 9 8 import {FeedItemModel} from '../../../state/models/feed-view' 10 9 import {Link} from '../util/Link' ··· 34 33 const theme = useTheme() 35 34 const pal = usePalette('default') 36 35 const [deleted, setDeleted] = useState(false) 37 - const record = item.post.record as unknown as AppBskyFeedPost.Record 36 + const record = item.postRecord 38 37 const itemHref = useMemo(() => { 39 38 const urip = new AtUri(item.post.uri) 40 39 return `/profile/${item.post.author.handle}/post/${urip.rkey}` ··· 42 41 const itemTitle = `Post by ${item.post.author.handle}` 43 42 const authorHref = `/profile/${item.post.author.handle}` 44 43 const replyAuthorDid = useMemo(() => { 45 - if (!record.reply) return '' 44 + if (!record?.reply) return '' 46 45 const urip = new AtUri(record.reply.parent?.uri || record.reply.root.uri) 47 46 return urip.hostname 48 - }, [record.reply]) 47 + }, [record?.reply]) 49 48 const replyHref = useMemo(() => { 50 - if (!record.reply) return '' 51 - const urip = new AtUri(record.reply.parent?.uri || record.reply.root.uri) 49 + if (!record?.reply) return '' 50 + const urip = new AtUri(record?.reply.parent?.uri || record?.reply.root.uri) 52 51 return `/profile/${urip.hostname}/post/${urip.rkey}` 53 - }, [record.reply]) 52 + }, [record?.reply]) 54 53 55 54 const onPressReply = () => { 56 55 store.shell.openComposer({ 57 56 replyTo: { 58 57 uri: item.post.uri, 59 58 cid: item.post.cid, 60 - text: record.text as string, 59 + text: record?.text || '', 61 60 author: { 62 61 handle: item.post.author.handle, 63 62 displayName: item.post.author.displayName, ··· 77 76 .catch(e => store.log.error('Failed to toggle upvote', e)) 78 77 } 79 78 const onCopyPostText = () => { 80 - Clipboard.setString(record.text) 79 + Clipboard.setString(record?.text || '') 81 80 Toast.show('Copied to clipboard') 82 81 } 83 82 const onDeletePost = () => { ··· 93 92 ) 94 93 } 95 94 96 - if (deleted) { 95 + if (!record || deleted) { 97 96 return <View /> 98 97 } 99 98