Add context to replies when appearing in the feed

zio/stable
Paul Frazee 2022-12-06 12:29:13 -06:00
parent d60de5e214
commit 246b0e19e1
4 changed files with 140 additions and 40 deletions

View File

@ -1,6 +1,8 @@
import {makeAutoObservable, runInAction} from 'mobx' import {makeAutoObservable, runInAction} from 'mobx'
import {Record as PostRecord} from '../../third-party/api/src/client/types/app/bsky/feed/post'
import * as GetTimeline from '../../third-party/api/src/client/types/app/bsky/feed/getTimeline' import * as GetTimeline from '../../third-party/api/src/client/types/app/bsky/feed/getTimeline'
import * as GetAuthorFeed from '../../third-party/api/src/client/types/app/bsky/feed/getAuthorFeed' import * as GetAuthorFeed from '../../third-party/api/src/client/types/app/bsky/feed/getAuthorFeed'
import {PostThreadViewModel} from './post-thread-view'
import {AtUri} from '../../third-party/uri' import {AtUri} from '../../third-party/uri'
import {RootStoreModel} from './root-store' import {RootStoreModel} from './root-store'
import * as apilib from '../lib/api' import * as apilib from '../lib/api'
@ -43,10 +45,6 @@ export class FeedItemModel implements GetTimeline.FeedItem {
repostedBy?: GetTimeline.Actor repostedBy?: GetTimeline.Actor
trendedBy?: GetTimeline.Actor trendedBy?: GetTimeline.Actor
record: Record<string, unknown> = {} record: Record<string, unknown> = {}
embed?:
| GetTimeline.RecordEmbed
| GetTimeline.ExternalEmbed
| GetTimeline.UnknownEmbed
replyCount: number = 0 replyCount: number = 0
repostCount: number = 0 repostCount: number = 0
upvoteCount: number = 0 upvoteCount: number = 0
@ -54,6 +52,9 @@ export class FeedItemModel implements GetTimeline.FeedItem {
indexedAt: string = '' indexedAt: string = ''
myState = new FeedItemMyStateModel() myState = new FeedItemMyStateModel()
// additional data
additionalParentPost?: PostThreadViewModel
constructor( constructor(
public rootStore: RootStoreModel, public rootStore: RootStoreModel,
reactKey: string, reactKey: string,
@ -73,7 +74,6 @@ export class FeedItemModel implements GetTimeline.FeedItem {
this.repostedBy = v.repostedBy this.repostedBy = v.repostedBy
this.trendedBy = v.trendedBy this.trendedBy = v.trendedBy
this.record = v.record this.record = v.record
this.embed = v.embed
this.replyCount = v.replyCount this.replyCount = v.replyCount
this.repostCount = v.repostCount this.repostCount = v.repostCount
this.upvoteCount = v.upvoteCount this.upvoteCount = v.upvoteCount
@ -156,6 +156,29 @@ export class FeedItemModel implements GetTimeline.FeedItem {
rkey: new AtUri(this.uri).rkey, rkey: new AtUri(this.uri).rkey,
}) })
} }
get needsAdditionalData() {
if (
(this.record as PostRecord).reply?.parent?.uri &&
!this._isThreadChild
) {
return !this.additionalParentPost
}
return false
}
async fetchAdditionalData() {
if (!this.needsAdditionalData) {
return
}
this.additionalParentPost = new PostThreadViewModel(this.rootStore, {
uri: (this.record as PostRecord).reply?.parent.uri,
depth: 0,
})
await this.additionalParentPost.setup().catch(e => {
console.error('Failed to load post needed by notification', e)
})
}
} }
export class FeedModel { export class FeedModel {
@ -345,7 +368,7 @@ export class FeedModel {
this._xLoading(isRefreshing) this._xLoading(isRefreshing)
try { try {
const res = await this._getFeed({limit: PAGE_SIZE}) const res = await this._getFeed({limit: PAGE_SIZE})
this._replaceAll(res) await this._replaceAll(res)
this._xIdle() this._xIdle()
} catch (e: any) { } catch (e: any) {
this._xIdle(e.toString()) this._xIdle(e.toString())
@ -356,7 +379,7 @@ export class FeedModel {
this._xLoading() this._xLoading()
try { try {
const res = await this._getFeed({limit: PAGE_SIZE}) const res = await this._getFeed({limit: PAGE_SIZE})
this._prependAll(res) await this._prependAll(res)
this._xIdle() this._xIdle()
} catch (e: any) { } catch (e: any) {
this._xIdle(e.toString()) this._xIdle(e.toString())
@ -373,7 +396,7 @@ export class FeedModel {
before: this.loadMoreCursor, before: this.loadMoreCursor,
limit: PAGE_SIZE, limit: PAGE_SIZE,
}) })
this._appendAll(res) await this._appendAll(res)
this._xIdle() this._xIdle()
} catch (e: any) { } catch (e: any) {
this._xIdle(`Failed to load feed: ${e.toString()}`) this._xIdle(`Failed to load feed: ${e.toString()}`)
@ -407,13 +430,17 @@ export class FeedModel {
} }
} }
private _replaceAll(res: GetTimeline.Response | GetAuthorFeed.Response) { private async _replaceAll(
this.feed.length = 0 res: GetTimeline.Response | GetAuthorFeed.Response,
) {
this.pollCursor = res.data.feed[0]?.uri this.pollCursor = res.data.feed[0]?.uri
this._appendAll(res) return this._appendAll(res, true)
} }
private _appendAll(res: GetTimeline.Response | GetAuthorFeed.Response) { private async _appendAll(
res: GetTimeline.Response | GetAuthorFeed.Response,
replace = false,
) {
this.loadMoreCursor = res.data.cursor this.loadMoreCursor = res.data.cursor
this.hasMore = !!this.loadMoreCursor this.hasMore = !!this.loadMoreCursor
let counter = this.feed.length let counter = this.feed.length
@ -428,40 +455,64 @@ export class FeedModel {
// -prf // -prf
const reorgedFeed = preprocessFeed(res.data.feed, this.feedType === 'home') const reorgedFeed = preprocessFeed(res.data.feed, this.feedType === 'home')
const promises = []
const toAppend: FeedItemModel[] = []
for (const item of reorgedFeed) { for (const item of reorgedFeed) {
this._append(counter++, item) const itemModel = new FeedItemModel(
this.rootStore,
`item-${counter++}`,
item,
)
if (itemModel.needsAdditionalData) {
promises.push(
itemModel.fetchAdditionalData().catch(e => {
console.error('Failure during feed-view _appendAll()', e)
}),
)
}
toAppend.push(itemModel)
} }
await Promise.all(promises)
runInAction(() => {
if (replace) {
this.feed = toAppend
} else {
this.feed = this.feed.concat(toAppend)
}
})
} }
private _append( private async _prependAll(
keyId: number, res: GetTimeline.Response | GetAuthorFeed.Response,
item: GetTimeline.FeedItem | GetAuthorFeed.FeedItem,
) { ) {
// TODO: validate .record
this.feed.push(new FeedItemModel(this.rootStore, `item-${keyId}`, item))
}
private _prependAll(res: GetTimeline.Response | GetAuthorFeed.Response) {
this.pollCursor = res.data.feed[0]?.uri this.pollCursor = res.data.feed[0]?.uri
let counter = this.feed.length let counter = this.feed.length
const toPrepend = []
const promises = []
const toPrepend: FeedItemModel[] = []
for (const item of res.data.feed) { for (const item of res.data.feed) {
if (this.feed.find(item2 => item2.uri === item.uri)) { if (this.feed.find(item2 => item2.uri === item.uri)) {
break // stop here - we've hit a post we already have break // stop here - we've hit a post we already have
} }
toPrepend.unshift(item) // reverse the order
}
for (const item of toPrepend) {
this._prepend(counter++, item)
}
}
private _prepend( const itemModel = new FeedItemModel(
keyId: number, this.rootStore,
item: GetTimeline.FeedItem | GetAuthorFeed.FeedItem, `item-${counter++}`,
) { item,
// TODO: validate .record )
this.feed.unshift(new FeedItemModel(this.rootStore, `item-${keyId}`, item)) if (itemModel.needsAdditionalData) {
promises.push(
itemModel.fetchAdditionalData().catch(e => {
console.error('Failure during feed-view _prependAll()', e)
}),
)
}
toPrepend.push(itemModel)
}
await Promise.all(promises)
runInAction(() => {
this.feed = toPrepend.concat(this.feed)
})
} }
private _updateAll(res: GetTimeline.Response | GetAuthorFeed.Response) { private _updateAll(res: GetTimeline.Response | GetAuthorFeed.Response) {

View File

@ -18,6 +18,7 @@ import {s, colors} from '../../lib/styles'
import {useStores} from '../../../state' import {useStores} from '../../../state'
const TOP_REPLY_LINE_LENGTH = 12 const TOP_REPLY_LINE_LENGTH = 12
const REPLYING_TO_LINE_LENGTH = 8
export const FeedItem = observer(function FeedItem({ export const FeedItem = observer(function FeedItem({
item, item,
@ -129,6 +130,25 @@ export const FeedItem = observer(function FeedItem({
</Text> </Text>
</Link> </Link>
)} )}
{item.additionalParentPost ? (
<View style={styles.replyingTo}>
<View style={styles.replyingToLine} />
<View style={styles.replyingToAvatar}>
<UserAvatar
handle={item.additionalParentPost?.thread?.author.handle}
displayName={
item.additionalParentPost?.thread?.author.displayName
}
size={32}
/>
</View>
<View style={styles.replyingToTextContainer}>
<Text style={styles.replyingToText} numberOfLines={2}>
{item.additionalParentPost?.thread?.record.text}
</Text>
</View>
</View>
) : undefined}
<View style={styles.layout}> <View style={styles.layout}>
<View style={styles.layoutAvi}> <View style={styles.layoutAvi}>
<Link <Link
@ -237,6 +257,35 @@ const styles = StyleSheet.create({
marginRight: 4, marginRight: 4,
color: colors.gray4, color: colors.gray4,
}, },
replyingToLine: {
position: 'absolute',
left: 24,
bottom: -1 * REPLYING_TO_LINE_LENGTH + 6,
height: REPLYING_TO_LINE_LENGTH,
borderLeftWidth: 2,
borderLeftColor: colors.gray2,
},
replyingTo: {
flexDirection: 'row',
backgroundColor: colors.white,
paddingBottom: 8,
paddingRight: 24,
},
replyingToAvatar: {
marginLeft: 9,
marginRight: 19,
marginTop: 1,
},
replyingToTextContainer: {
flex: 1,
flexDirection: 'row',
height: 34,
alignItems: 'center',
},
replyingToText: {
flex: 1,
color: colors.gray5,
},
layout: { layout: {
flexDirection: 'row', flexDirection: 'row',
}, },

View File

@ -21,7 +21,7 @@ export function UserAvatar({
size: number size: number
handle: string handle: string
displayName: string | undefined displayName: string | undefined
userAvatar: string | null userAvatar: string | null | undefined
setUserAvatar?: React.Dispatch<React.SetStateAction<string | null>> setUserAvatar?: React.Dispatch<React.SetStateAction<string | null>>
}) { }) {
const initials = getInitials(displayName || handle) const initials = getInitials(displayName || handle)
@ -92,7 +92,7 @@ export function UserAvatar({
// setUserAvatar is only passed as prop on the EditProfile component // setUserAvatar is only passed as prop on the EditProfile component
return setUserAvatar != null && IMAGES_ENABLED ? ( return setUserAvatar != null && IMAGES_ENABLED ? (
<TouchableOpacity onPress={handleEditAvatar}> <TouchableOpacity onPress={handleEditAvatar}>
{userAvatar != null ? ( {userAvatar ? (
<Image style={styles.avatarImage} source={{uri: userAvatar}} /> <Image style={styles.avatarImage} source={{uri: userAvatar}} />
) : ( ) : (
renderSvg(size, initials) renderSvg(size, initials)
@ -105,7 +105,7 @@ export function UserAvatar({
/> />
</View> </View>
</TouchableOpacity> </TouchableOpacity>
) : userAvatar != null ? ( ) : userAvatar ? (
<Image <Image
style={styles.avatarImage} style={styles.avatarImage}
resizeMode="stretch" resizeMode="stretch"

View File

@ -17,7 +17,7 @@ export function UserBanner({
setUserBanner, setUserBanner,
}: { }: {
handle: string handle: string
userBanner: string | null userBanner: string | null | undefined
setUserBanner?: React.Dispatch<React.SetStateAction<string | null>> setUserBanner?: React.Dispatch<React.SetStateAction<string | null>>
}) { }) {
const gradient = getGradient(handle) const gradient = getGradient(handle)
@ -81,7 +81,7 @@ export function UserBanner({
// setUserBanner is only passed as prop on the EditProfile component // setUserBanner is only passed as prop on the EditProfile component
return setUserBanner != null && IMAGES_ENABLED ? ( return setUserBanner != null && IMAGES_ENABLED ? (
<TouchableOpacity onPress={handleEditBanner}> <TouchableOpacity onPress={handleEditBanner}>
{userBanner != null ? ( {userBanner ? (
<Image style={styles.bannerImage} source={{uri: userBanner}} /> <Image style={styles.bannerImage} source={{uri: userBanner}} />
) : ( ) : (
renderSvg() renderSvg()
@ -94,7 +94,7 @@ export function UserBanner({
/> />
</View> </View>
</TouchableOpacity> </TouchableOpacity>
) : userBanner != null ? ( ) : userBanner ? (
<Image <Image
style={styles.bannerImage} style={styles.bannerImage}
resizeMode="stretch" resizeMode="stretch"