Add context to replies when appearing in the feed
parent
d60de5e214
commit
246b0e19e1
|
@ -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) {
|
||||||
|
|
|
@ -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',
|
||||||
},
|
},
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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"
|
||||||
|
|
Loading…
Reference in New Issue