Merge remote-tracking branch 'origin/main' into reply-qp-moderation
* origin/main: (30 commits) Show just-posted replies above OP replies (#4901) Remove client filtering of starter packs (#4753) Remove show_avi_follow_button (#4900) Remove native_pwi_disabled (#4896) Fix overflow on posts (#4899) Move onPressReply into child component (#4898) Remove new_user_progress_guide (#4895) Remove explore_page_profile_card_social_proof (#4894) Remove ungroup_follow_backs gate (#4893) Remove unnecessary state update for reply gate (#4897) Include follow-based suggestions in interstitial (#4889) Cleanup flags (#4891) ALF suggested follows in profile header (#4828) Added trans (#4890) Keep interstitial fresh on refresh (#4888) Include popcluster in suggestion ranking (#4887) Add logging of selected feed preference when displaying the following feed (#4789) [Video] Visibility detection view (#4741) [Videos] Video player - PR #2 - better web support (#4732) [Video] Authed video upload (#4885) ...
This commit is contained in:
@@ -92,14 +92,16 @@ function getRank(seenPost: SeenPost): string {
|
||||
tier = 'a'
|
||||
} else if (seenPost.feedContext?.startsWith('cluster')) {
|
||||
tier = 'b'
|
||||
} else if (seenPost.feedContext?.startsWith('ntpc')) {
|
||||
} else if (seenPost.feedContext === 'popcluster') {
|
||||
tier = 'c'
|
||||
} else if (seenPost.feedContext?.startsWith('t-')) {
|
||||
} else if (seenPost.feedContext?.startsWith('ntpc')) {
|
||||
tier = 'd'
|
||||
} else if (seenPost.feedContext === 'nettop') {
|
||||
} else if (seenPost.feedContext?.startsWith('t-')) {
|
||||
tier = 'e'
|
||||
} else {
|
||||
} else if (seenPost.feedContext === 'nettop') {
|
||||
tier = 'f'
|
||||
} else {
|
||||
tier = 'g'
|
||||
}
|
||||
let score = Math.round(
|
||||
Math.log(
|
||||
@@ -131,16 +133,26 @@ function useExperimentalSuggestedUsersQuery() {
|
||||
const {currentAccount} = useSession()
|
||||
const userActionSnapshot = userActionHistory.useActionHistorySnapshot()
|
||||
const dids = React.useMemo(() => {
|
||||
const {likes, follows, seen} = userActionSnapshot
|
||||
const {likes, follows, followSuggestions, seen} = userActionSnapshot
|
||||
const likeDids = likes
|
||||
.map(l => new AtUri(l))
|
||||
.map(uri => uri.host)
|
||||
.filter(did => !follows.includes(did))
|
||||
let suggestedDids: string[] = []
|
||||
if (followSuggestions.length > 0) {
|
||||
suggestedDids = [
|
||||
// It's ok if these will pick the same item (weighed by its frequency)
|
||||
followSuggestions[Math.floor(Math.random() * followSuggestions.length)],
|
||||
followSuggestions[Math.floor(Math.random() * followSuggestions.length)],
|
||||
followSuggestions[Math.floor(Math.random() * followSuggestions.length)],
|
||||
followSuggestions[Math.floor(Math.random() * followSuggestions.length)],
|
||||
]
|
||||
}
|
||||
const seenDids = seen
|
||||
.sort(sortSeenPosts)
|
||||
.map(l => new AtUri(l.uri))
|
||||
.map(uri => uri.host)
|
||||
return [...new Set([...likeDids, ...seenDids])].filter(
|
||||
return [...new Set([...suggestedDids, ...likeDids, ...seenDids])].filter(
|
||||
did => did !== currentAccount?.did,
|
||||
)
|
||||
}, [userActionSnapshot, currentAccount])
|
||||
|
||||
@@ -122,8 +122,16 @@ export function ListHeaderDesktop({
|
||||
if (!gtTablet) return null
|
||||
|
||||
return (
|
||||
<View style={[a.w_full, a.py_lg, a.px_xl, a.gap_xs]}>
|
||||
<Text style={[a.text_3xl, a.font_bold]}>{title}</Text>
|
||||
<View
|
||||
style={[
|
||||
a.w_full,
|
||||
a.py_sm,
|
||||
a.px_xl,
|
||||
a.gap_xs,
|
||||
a.justify_center,
|
||||
{minHeight: 50},
|
||||
]}>
|
||||
<Text style={[a.text_2xl, a.font_bold]}>{title}</Text>
|
||||
{subtitle ? (
|
||||
<Text style={[a.text_md, t.atoms.text_contrast_medium]}>
|
||||
{subtitle}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const ArrowsDiagonalOut_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M14 5a1 1 0 1 1 0-2h6a1 1 0 0 1 1 1v6a1 1 0 1 1-2 0V6.414l-4.293 4.293a1 1 0 0 1-1.414-1.414L17.586 5H14ZM4 13a1 1 0 0 1 1 1v3.586l4.293-4.293a1 1 0 0 1 1.414 1.414L6.414 19H10a1 1 0 1 1 0 2H4a1 1 0 0 1-1-1v-6a1 1 0 0 1 1-1Z',
|
||||
})
|
||||
|
||||
export const ArrowsDiagonalIn_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M20.957 3.043a1 1 0 0 1 0 1.414L16.414 9H20a1 1 0 1 1 0 2h-6a1 1 0 0 1-1-1V4a1 1 0 1 1 2 0v3.586l4.543-4.543a1 1 0 0 1 1.414 0ZM3 14a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v6a1 1 0 1 1-2 0v-3.586l-4.543 4.543a1 1 0 0 1-1.414-1.414L7.586 15H4a1 1 0 0 1-1-1Z',
|
||||
})
|
||||
|
||||
export const ArrowsDiagonalOut_Stroke2_Corner2_Rounded = createSinglePathSVG({
|
||||
path: 'M13 4a1 1 0 0 1 1-1h5a2 2 0 0 1 2 2v5a1 1 0 1 1-2 0V6.414l-4.293 4.293a1 1 0 0 1-1.414-1.414L17.586 5H14a1 1 0 0 1-1-1Zm-9 9a1 1 0 0 1 1 1v3.586l4.293-4.293a1 1 0 0 1 1.414 1.414L6.414 19H10a1 1 0 1 1 0 2H5a2 2 0 0 1-2-2v-5a1 1 0 0 1 1-1Z',
|
||||
})
|
||||
|
||||
export const ArrowsDiagonalIn_Stroke2_Corner2_Rounded = createSinglePathSVG({
|
||||
path: 'M20.957 3.043a1 1 0 0 1 0 1.414L16.414 9H20a1 1 0 1 1 0 2h-5a2 2 0 0 1-2-2V4a1 1 0 1 1 2 0v3.586l4.543-4.543a1 1 0 0 1 1.414 0ZM3 14a1 1 0 0 1 1-1h5a2 2 0 0 1 2 2v5a1 1 0 1 1-2 0v-3.586l-4.543 4.543a1 1 0 0 1-1.414-1.414L7.586 15H4a1 1 0 0 1-1-1Z',
|
||||
})
|
||||
@@ -0,0 +1,9 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const CC_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M3 4a1 1 0 0 1 1-1h16a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4Zm2 1v14h14V5H5Zm10.957 6.293a1 1 0 1 0 0 1.414 1 1 0 0 1 1.414 1.414 3 3 0 1 1 0-4.242 1 1 0 0 1-1.414 1.414Zm-6.331-.22a1 1 0 1 0 .331 1.634 1 1 0 0 1 1.414 1.414 3 3 0 1 1 0-4.242 1 1 0 0 1-1.414 1.414.994.994 0 0 0-.331-.22Z',
|
||||
})
|
||||
|
||||
export const CC_Filled_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M3 4a1 1 0 0 1 1-1h16a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4Zm11.543 7.293a1 1 0 0 1 1.414 0 1 1 0 0 0 1.414-1.414 3 3 0 1 0 0 4.242 1 1 0 0 0-1.414-1.414 1 1 0 0 1-1.414-1.414Zm-6 0a1 1 0 0 1 1.414 0 1 1 0 0 0 1.414-1.414 3 3 0 1 0 0 4.243 1 1 0 0 0-1.414-1.415 1 1 0 0 1-1.414-1.414Z',
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const Pause_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M4 4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V4Zm2 1v14h2V5H6Zm8-1a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1h-4a1 1 0 0 1-1-1V4Zm2 1v14h2V5h-2Z',
|
||||
})
|
||||
|
||||
export const Pause_Filled_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M4 4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V4ZM14 4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1h-4a1 1 0 0 1-1-1V4Z',
|
||||
})
|
||||
|
||||
export const Pause_Stroke2_Corner2_Rounded = createSinglePathSVG({
|
||||
path: 'M4 6a3 3 0 0 1 6 0v12a3 3 0 1 1-6 0V6Zm3-1a1 1 0 0 0-1 1v12a1 1 0 1 0 2 0V6a1 1 0 0 0-1-1Zm7 1a3 3 0 1 1 6 0v12a3 3 0 1 1-6 0V6Zm3-1a1 1 0 0 0-1 1v12a1 1 0 1 0 2 0V6a1 1 0 0 0-1-1Z',
|
||||
})
|
||||
|
||||
export const Pause_Filled_Corner2_Rounded = createSinglePathSVG({
|
||||
path: 'M4 6a3 3 0 0 1 6 0v12a3 3 0 1 1-6 0V6ZM14 6a3 3 0 1 1 6 0v12a3 3 0 1 1-6 0V6Z',
|
||||
})
|
||||
@@ -1,5 +1,13 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const Play_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M5.507 2.13a1 1 0 0 1 1.008.013l15 9a1 1 0 0 1 0 1.714l-15 9A1 1 0 0 1 5 21V3a1 1 0 0 1 .507-.87ZM7 4.766v14.468L19.056 12 7 4.766Z',
|
||||
})
|
||||
|
||||
export const Play_Filled_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M6.514 2.143A1 1 0 0 0 5 3v18a1 1 0 0 0 1.514.858l15-9a1 1 0 0 0 0-1.716l-15-9Z',
|
||||
})
|
||||
|
||||
export const Play_Stroke2_Corner2_Rounded = createSinglePathSVG({
|
||||
path: 'M5 5.086C5 2.736 7.578 1.3 9.576 2.534L20.77 9.448c1.899 1.172 1.899 3.932 0 5.104L9.576 21.466C7.578 22.701 5 21.263 5 18.914V5.086Zm3.525-.85A1 1 0 0 0 7 5.085v13.828a1 1 0 0 0 1.525.85l11.194-6.913a1 1 0 0 0 0-1.702L8.525 4.235Z',
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react'
|
||||
import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
|
||||
import {StyleProp, View, ViewStyle} from 'react-native'
|
||||
import {ModerationUI} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
@@ -40,7 +40,7 @@ export function ContentHider({
|
||||
|
||||
if (!blur || (ignoreMute && isJustAMute(modui))) {
|
||||
return (
|
||||
<View testID={testID} style={[styles.outer, style]}>
|
||||
<View testID={testID} style={style}>
|
||||
{children}
|
||||
</View>
|
||||
)
|
||||
@@ -163,21 +163,3 @@ export function ContentHider({
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
outer: {},
|
||||
cover: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
borderRadius: 8,
|
||||
marginTop: 4,
|
||||
paddingVertical: 14,
|
||||
paddingLeft: 14,
|
||||
paddingRight: 18,
|
||||
},
|
||||
showBtn: {
|
||||
marginLeft: 'auto',
|
||||
alignSelf: 'center',
|
||||
},
|
||||
})
|
||||
|
||||
+294
-198
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
AppBskyActorDefs,
|
||||
AppBskyEmbedRecord,
|
||||
AppBskyEmbedRecordWithMedia,
|
||||
AppBskyFeedDefs,
|
||||
@@ -6,50 +7,125 @@ import {
|
||||
} from '@atproto/api'
|
||||
|
||||
import {isPostInLanguage} from '../../locale/helpers'
|
||||
import {FALLBACK_MARKER_POST} from './feed/home'
|
||||
import {ReasonFeedSource} from './feed/types'
|
||||
|
||||
type FeedViewPost = AppBskyFeedDefs.FeedViewPost
|
||||
|
||||
export type FeedTunerFn = (
|
||||
tuner: FeedTuner,
|
||||
slices: FeedViewPostsSlice[],
|
||||
dryRun: boolean,
|
||||
) => FeedViewPostsSlice[]
|
||||
|
||||
type FeedSliceItem = {
|
||||
post: AppBskyFeedDefs.PostView
|
||||
reply?: AppBskyFeedDefs.ReplyRef
|
||||
record: AppBskyFeedPost.Record
|
||||
parentAuthor: AppBskyActorDefs.ProfileViewBasic | undefined
|
||||
isParentBlocked: boolean
|
||||
}
|
||||
|
||||
function toSliceItem(feedViewPost: FeedViewPost): FeedSliceItem {
|
||||
return {
|
||||
post: feedViewPost.post,
|
||||
reply: feedViewPost.reply,
|
||||
}
|
||||
type AuthorContext = {
|
||||
author: AppBskyActorDefs.ProfileViewBasic
|
||||
parentAuthor: AppBskyActorDefs.ProfileViewBasic | undefined
|
||||
grandparentAuthor: AppBskyActorDefs.ProfileViewBasic | undefined
|
||||
rootAuthor: AppBskyActorDefs.ProfileViewBasic | undefined
|
||||
}
|
||||
|
||||
export class FeedViewPostsSlice {
|
||||
_reactKey: string
|
||||
_feedPost: FeedViewPost
|
||||
items: FeedSliceItem[]
|
||||
isIncompleteThread: boolean
|
||||
isFallbackMarker: boolean
|
||||
isOrphan: boolean
|
||||
rootUri: string
|
||||
|
||||
constructor(feedPost: FeedViewPost) {
|
||||
const {post, reply, reason} = feedPost
|
||||
this.items = []
|
||||
this.isIncompleteThread = false
|
||||
this.isFallbackMarker = false
|
||||
this.isOrphan = false
|
||||
if (AppBskyFeedDefs.isPostView(reply?.root)) {
|
||||
this.rootUri = reply.root.uri
|
||||
} else {
|
||||
this.rootUri = post.uri
|
||||
}
|
||||
this._feedPost = feedPost
|
||||
this._reactKey = `slice-${feedPost.post.uri}-${
|
||||
feedPost.reason?.indexedAt || feedPost.post.indexedAt
|
||||
this._reactKey = `slice-${post.uri}-${
|
||||
feedPost.reason?.indexedAt || post.indexedAt
|
||||
}`
|
||||
this.items = [toSliceItem(feedPost)]
|
||||
}
|
||||
|
||||
get uri() {
|
||||
return this._feedPost.post.uri
|
||||
}
|
||||
|
||||
get isThread() {
|
||||
return (
|
||||
this.items.length > 1 &&
|
||||
this.items.every(
|
||||
item => item.post.author.did === this.items[0].post.author.did,
|
||||
)
|
||||
if (feedPost.post.uri === FALLBACK_MARKER_POST.post.uri) {
|
||||
this.isFallbackMarker = true
|
||||
return
|
||||
}
|
||||
if (
|
||||
!AppBskyFeedPost.isRecord(post.record) ||
|
||||
!AppBskyFeedPost.validateRecord(post.record).success
|
||||
) {
|
||||
return
|
||||
}
|
||||
const parent = reply?.parent
|
||||
const isParentBlocked = AppBskyFeedDefs.isBlockedPost(parent)
|
||||
let parentAuthor: AppBskyActorDefs.ProfileViewBasic | undefined
|
||||
if (AppBskyFeedDefs.isPostView(parent)) {
|
||||
parentAuthor = parent.author
|
||||
}
|
||||
this.items.push({
|
||||
post,
|
||||
record: post.record,
|
||||
parentAuthor,
|
||||
isParentBlocked,
|
||||
})
|
||||
if (!reply || reason) {
|
||||
return
|
||||
}
|
||||
if (
|
||||
!AppBskyFeedDefs.isPostView(parent) ||
|
||||
!AppBskyFeedPost.isRecord(parent.record) ||
|
||||
!AppBskyFeedPost.validateRecord(parent.record).success
|
||||
) {
|
||||
this.isOrphan = true
|
||||
return
|
||||
}
|
||||
const grandparentAuthor = reply.grandparentAuthor
|
||||
const isGrandparentBlocked = Boolean(
|
||||
grandparentAuthor?.viewer?.blockedBy ||
|
||||
grandparentAuthor?.viewer?.blocking ||
|
||||
grandparentAuthor?.viewer?.blockingByList,
|
||||
)
|
||||
this.items.unshift({
|
||||
post: parent,
|
||||
record: parent.record,
|
||||
parentAuthor: grandparentAuthor,
|
||||
isParentBlocked: isGrandparentBlocked,
|
||||
})
|
||||
if (isGrandparentBlocked) {
|
||||
this.isOrphan = true
|
||||
// Keep going, it might still have a root.
|
||||
}
|
||||
const root = reply.root
|
||||
if (
|
||||
!AppBskyFeedDefs.isPostView(root) ||
|
||||
!AppBskyFeedPost.isRecord(root.record) ||
|
||||
!AppBskyFeedPost.validateRecord(root.record).success
|
||||
) {
|
||||
this.isOrphan = true
|
||||
return
|
||||
}
|
||||
if (root.uri === parent.uri) {
|
||||
return
|
||||
}
|
||||
this.items.unshift({
|
||||
post: root,
|
||||
record: root.record,
|
||||
isParentBlocked: false,
|
||||
parentAuthor: undefined,
|
||||
})
|
||||
if (parent.record.reply?.parent.uri !== root.uri) {
|
||||
this.isIncompleteThread = true
|
||||
}
|
||||
}
|
||||
|
||||
get isQuotePost() {
|
||||
@@ -90,204 +166,171 @@ export class FeedViewPostsSlice {
|
||||
return !!this.items.find(item => item.post.uri === uri)
|
||||
}
|
||||
|
||||
isNextInThread(uri: string) {
|
||||
return this.items[this.items.length - 1].post.uri === uri
|
||||
}
|
||||
|
||||
insert(item: FeedViewPost) {
|
||||
const selfReplyUri = getSelfReplyUri(item)
|
||||
const i = this.items.findIndex(item2 => item2.post.uri === selfReplyUri)
|
||||
if (i !== -1) {
|
||||
this.items.splice(i + 1, 0, item)
|
||||
} else {
|
||||
this.items.push(item)
|
||||
}
|
||||
}
|
||||
|
||||
flattenReplyParent() {
|
||||
if (this.items[0].reply) {
|
||||
const reply = this.items[0].reply
|
||||
if (AppBskyFeedDefs.isPostView(reply.parent)) {
|
||||
this.items.splice(0, 0, {post: reply.parent})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isFollowingAllAuthors(userDid: string) {
|
||||
getAuthors(): AuthorContext {
|
||||
const feedPost = this._feedPost
|
||||
const authors = [feedPost.post.author]
|
||||
let author: AppBskyActorDefs.ProfileViewBasic = feedPost.post.author
|
||||
let parentAuthor: AppBskyActorDefs.ProfileViewBasic | undefined
|
||||
let grandparentAuthor: AppBskyActorDefs.ProfileViewBasic | undefined
|
||||
let rootAuthor: AppBskyActorDefs.ProfileViewBasic | undefined
|
||||
if (feedPost.reply) {
|
||||
if (AppBskyFeedDefs.isPostView(feedPost.reply.parent)) {
|
||||
authors.push(feedPost.reply.parent.author)
|
||||
parentAuthor = feedPost.reply.parent.author
|
||||
}
|
||||
if (feedPost.reply.grandparentAuthor) {
|
||||
authors.push(feedPost.reply.grandparentAuthor)
|
||||
grandparentAuthor = feedPost.reply.grandparentAuthor
|
||||
}
|
||||
if (AppBskyFeedDefs.isPostView(feedPost.reply.root)) {
|
||||
authors.push(feedPost.reply.root.author)
|
||||
rootAuthor = feedPost.reply.root.author
|
||||
}
|
||||
}
|
||||
return authors.every(a => a.did === userDid || a.viewer?.following)
|
||||
return {
|
||||
author,
|
||||
parentAuthor,
|
||||
grandparentAuthor,
|
||||
rootAuthor,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class FeedTuner {
|
||||
seenKeys: Set<string> = new Set()
|
||||
seenUris: Set<string> = new Set()
|
||||
seenRootUris: Set<string> = new Set()
|
||||
|
||||
constructor(public tunerFns: FeedTunerFn[]) {}
|
||||
|
||||
reset() {
|
||||
this.seenKeys.clear()
|
||||
this.seenUris.clear()
|
||||
}
|
||||
|
||||
tune(
|
||||
feed: FeedViewPost[],
|
||||
{dryRun, maintainOrder}: {dryRun: boolean; maintainOrder: boolean} = {
|
||||
{dryRun}: {dryRun: boolean} = {
|
||||
dryRun: false,
|
||||
maintainOrder: false,
|
||||
},
|
||||
): FeedViewPostsSlice[] {
|
||||
let slices: FeedViewPostsSlice[] = []
|
||||
let slices: FeedViewPostsSlice[] = feed
|
||||
.map(item => new FeedViewPostsSlice(item))
|
||||
.filter(s => s.items.length > 0 || s.isFallbackMarker)
|
||||
|
||||
// remove posts that are replies, but which don't have the parent
|
||||
// hydrated. this means the parent was either deleted or blocked
|
||||
feed = feed.filter(item => {
|
||||
if (
|
||||
AppBskyFeedPost.isRecord(item.post.record) &&
|
||||
item.post.record.reply &&
|
||||
!item.reply
|
||||
) {
|
||||
// run the custom tuners
|
||||
for (const tunerFn of this.tunerFns) {
|
||||
slices = tunerFn(this, slices.slice(), dryRun)
|
||||
}
|
||||
|
||||
slices = slices.filter(slice => {
|
||||
if (this.seenKeys.has(slice._reactKey)) {
|
||||
return false
|
||||
}
|
||||
// Some feeds, like Following, dedupe by thread, so you only see the most recent reply.
|
||||
// However, we don't want per-thread dedupe for author feeds (where we need to show every post)
|
||||
// or for feedgens (where we want to let the feed serve multiple replies if it chooses to).
|
||||
// To avoid showing the same context (root and/or parent) more than once, we do last resort
|
||||
// per-post deduplication. It hides already seen posts as long as this doesn't break the thread.
|
||||
for (let i = 0; i < slice.items.length; i++) {
|
||||
const item = slice.items[i]
|
||||
if (this.seenUris.has(item.post.uri)) {
|
||||
if (i === 0) {
|
||||
// Omit contiguous seen leading items.
|
||||
// For example, [A -> B -> C], [A -> D -> E], [A -> D -> F]
|
||||
// would turn into [A -> B -> C], [D -> E], [F].
|
||||
slice.items.splice(0, 1)
|
||||
i--
|
||||
}
|
||||
if (i === slice.items.length - 1) {
|
||||
// If the last item in the slice was already seen, omit the whole slice.
|
||||
// This means we'd miss its parents, but the user can "show more" to see them.
|
||||
// For example, [A ... E -> F], [A ... D -> E], [A ... C -> D], [A -> B -> C]
|
||||
// would get collapsed into [A ... E -> F], with B/C/D considered seen.
|
||||
return false
|
||||
}
|
||||
} else {
|
||||
if (!dryRun) {
|
||||
this.seenUris.add(item.post.uri)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!dryRun) {
|
||||
this.seenKeys.add(slice._reactKey)
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
if (maintainOrder) {
|
||||
slices = feed.map(item => new FeedViewPostsSlice(item))
|
||||
} else {
|
||||
// arrange the posts into thread slices
|
||||
for (let i = feed.length - 1; i >= 0; i--) {
|
||||
const item = feed[i]
|
||||
|
||||
const selfReplyUri = getSelfReplyUri(item)
|
||||
if (selfReplyUri) {
|
||||
const index = slices.findIndex(slice =>
|
||||
slice.isNextInThread(selfReplyUri),
|
||||
)
|
||||
|
||||
if (index !== -1) {
|
||||
const parent = slices[index]
|
||||
|
||||
parent.insert(item)
|
||||
|
||||
// If our slice isn't currently on the top, reinsert it to the top.
|
||||
if (index !== 0) {
|
||||
slices.splice(index, 1)
|
||||
slices.unshift(parent)
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
slices.unshift(new FeedViewPostsSlice(item))
|
||||
}
|
||||
}
|
||||
|
||||
// run the custom tuners
|
||||
for (const tunerFn of this.tunerFns) {
|
||||
slices = tunerFn(this, slices.slice())
|
||||
}
|
||||
|
||||
// remove any items already "seen"
|
||||
const soonToBeSeenUris: Set<string> = new Set()
|
||||
for (let i = slices.length - 1; i >= 0; i--) {
|
||||
if (!slices[i].isThread && this.seenUris.has(slices[i].uri)) {
|
||||
slices.splice(i, 1)
|
||||
} else {
|
||||
for (const item of slices[i].items) {
|
||||
soonToBeSeenUris.add(item.post.uri)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// turn non-threads with reply parents into threads
|
||||
for (const slice of slices) {
|
||||
if (!slice.isThread && !slice.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!dryRun) {
|
||||
slices = slices.filter(slice => {
|
||||
if (this.seenKeys.has(slice._reactKey)) {
|
||||
return false
|
||||
}
|
||||
for (const item of slice.items) {
|
||||
this.seenUris.add(item.post.uri)
|
||||
}
|
||||
this.seenKeys.add(slice._reactKey)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
return slices
|
||||
}
|
||||
|
||||
static removeReplies(tuner: FeedTuner, slices: FeedViewPostsSlice[]) {
|
||||
for (let i = slices.length - 1; i >= 0; i--) {
|
||||
if (slices[i].isReply) {
|
||||
slices.splice(i, 1)
|
||||
}
|
||||
}
|
||||
return slices
|
||||
}
|
||||
|
||||
static removeReposts(tuner: FeedTuner, slices: FeedViewPostsSlice[]) {
|
||||
for (let i = slices.length - 1; i >= 0; i--) {
|
||||
if (slices[i].isRepost) {
|
||||
slices.splice(i, 1)
|
||||
}
|
||||
}
|
||||
return slices
|
||||
}
|
||||
|
||||
static removeQuotePosts(tuner: FeedTuner, slices: FeedViewPostsSlice[]) {
|
||||
for (let i = slices.length - 1; i >= 0; i--) {
|
||||
if (slices[i].isQuotePost) {
|
||||
slices.splice(i, 1)
|
||||
}
|
||||
}
|
||||
return slices
|
||||
}
|
||||
|
||||
static dedupReposts(
|
||||
static removeReplies(
|
||||
tuner: FeedTuner,
|
||||
slices: FeedViewPostsSlice[],
|
||||
): FeedViewPostsSlice[] {
|
||||
// remove duplicates caused by reposts
|
||||
_dryRun: boolean,
|
||||
) {
|
||||
for (let i = 0; i < slices.length; i++) {
|
||||
const item1 = slices[i]
|
||||
for (let j = i + 1; j < slices.length; j++) {
|
||||
const item2 = slices[j]
|
||||
if (item2.isThread) {
|
||||
// dont dedup items that are rendering in a thread as this can cause rendering errors
|
||||
continue
|
||||
}
|
||||
if (item1.containsUri(item2.items[0].post.uri)) {
|
||||
slices.splice(j, 1)
|
||||
j--
|
||||
const slice = slices[i]
|
||||
if (
|
||||
slice.isReply &&
|
||||
!slice.isRepost &&
|
||||
// This is not perfect but it's close as we can get to
|
||||
// detecting threads without having to peek ahead.
|
||||
!areSameAuthor(slice.getAuthors())
|
||||
) {
|
||||
slices.splice(i, 1)
|
||||
i--
|
||||
}
|
||||
}
|
||||
return slices
|
||||
}
|
||||
|
||||
static removeReposts(
|
||||
tuner: FeedTuner,
|
||||
slices: FeedViewPostsSlice[],
|
||||
_dryRun: boolean,
|
||||
) {
|
||||
for (let i = 0; i < slices.length; i++) {
|
||||
if (slices[i].isRepost) {
|
||||
slices.splice(i, 1)
|
||||
i--
|
||||
}
|
||||
}
|
||||
return slices
|
||||
}
|
||||
|
||||
static removeQuotePosts(
|
||||
tuner: FeedTuner,
|
||||
slices: FeedViewPostsSlice[],
|
||||
_dryRun: boolean,
|
||||
) {
|
||||
for (let i = 0; i < slices.length; i++) {
|
||||
if (slices[i].isQuotePost) {
|
||||
slices.splice(i, 1)
|
||||
i--
|
||||
}
|
||||
}
|
||||
return slices
|
||||
}
|
||||
|
||||
static removeOrphans(
|
||||
tuner: FeedTuner,
|
||||
slices: FeedViewPostsSlice[],
|
||||
_dryRun: boolean,
|
||||
) {
|
||||
for (let i = 0; i < slices.length; i++) {
|
||||
if (slices[i].isOrphan) {
|
||||
slices.splice(i, 1)
|
||||
i--
|
||||
}
|
||||
}
|
||||
return slices
|
||||
}
|
||||
|
||||
static dedupThreads(
|
||||
tuner: FeedTuner,
|
||||
slices: FeedViewPostsSlice[],
|
||||
dryRun: boolean,
|
||||
): FeedViewPostsSlice[] {
|
||||
for (let i = 0; i < slices.length; i++) {
|
||||
const rootUri = slices[i].rootUri
|
||||
if (!slices[i].isRepost && tuner.seenRootUris.has(rootUri)) {
|
||||
slices.splice(i, 1)
|
||||
i--
|
||||
} else {
|
||||
if (!dryRun) {
|
||||
tuner.seenRootUris.add(rootUri)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -298,15 +341,17 @@ export class FeedTuner {
|
||||
return (
|
||||
tuner: FeedTuner,
|
||||
slices: FeedViewPostsSlice[],
|
||||
_dryRun: boolean,
|
||||
): FeedViewPostsSlice[] => {
|
||||
for (let i = slices.length - 1; i >= 0; i--) {
|
||||
for (let i = 0; i < slices.length; i++) {
|
||||
const slice = slices[i]
|
||||
if (
|
||||
slice.isReply &&
|
||||
!slice.isRepost &&
|
||||
!slice.isFollowingAllAuthors(userDid)
|
||||
!shouldDisplayReplyInFollowing(slice.getAuthors(), userDid)
|
||||
) {
|
||||
slices.splice(i, 1)
|
||||
i--
|
||||
}
|
||||
}
|
||||
return slices
|
||||
@@ -324,6 +369,7 @@ export class FeedTuner {
|
||||
return (
|
||||
tuner: FeedTuner,
|
||||
slices: FeedViewPostsSlice[],
|
||||
_dryRun: boolean,
|
||||
): FeedViewPostsSlice[] => {
|
||||
const candidateSlices = slices.slice()
|
||||
|
||||
@@ -332,7 +378,7 @@ export class FeedTuner {
|
||||
return slices
|
||||
}
|
||||
|
||||
for (let i = slices.length - 1; i >= 0; i--) {
|
||||
for (let i = 0; i < slices.length; i++) {
|
||||
let hasPreferredLang = false
|
||||
for (const item of slices[i].items) {
|
||||
if (isPostInLanguage(item.post, preferredLangsCode2)) {
|
||||
@@ -358,16 +404,66 @@ export class FeedTuner {
|
||||
}
|
||||
}
|
||||
|
||||
function getSelfReplyUri(item: FeedViewPost): string | undefined {
|
||||
if (item.reply) {
|
||||
if (
|
||||
AppBskyFeedDefs.isPostView(item.reply.parent) &&
|
||||
!AppBskyFeedDefs.isReasonRepost(item.reason) // don't thread reposted self-replies
|
||||
) {
|
||||
return item.reply.parent.author.did === item.post.author.did
|
||||
? item.reply.parent.uri
|
||||
: undefined
|
||||
}
|
||||
function areSameAuthor(authors: AuthorContext): boolean {
|
||||
const {author, parentAuthor, grandparentAuthor, rootAuthor} = authors
|
||||
const authorDid = author.did
|
||||
if (parentAuthor && parentAuthor.did !== authorDid) {
|
||||
return false
|
||||
}
|
||||
return undefined
|
||||
if (grandparentAuthor && grandparentAuthor.did !== authorDid) {
|
||||
return false
|
||||
}
|
||||
if (rootAuthor && rootAuthor.did !== authorDid) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function shouldDisplayReplyInFollowing(
|
||||
authors: AuthorContext,
|
||||
userDid: string,
|
||||
): boolean {
|
||||
const {author, parentAuthor, grandparentAuthor, rootAuthor} = authors
|
||||
if (!isSelfOrFollowing(author, userDid)) {
|
||||
// Only show replies from self or people you follow.
|
||||
return false
|
||||
}
|
||||
if (
|
||||
(!parentAuthor || parentAuthor.did === author.did) &&
|
||||
(!rootAuthor || rootAuthor.did === author.did) &&
|
||||
(!grandparentAuthor || grandparentAuthor.did === author.did)
|
||||
) {
|
||||
// Always show self-threads.
|
||||
return true
|
||||
}
|
||||
// From this point on we need at least one more reason to show it.
|
||||
if (
|
||||
parentAuthor &&
|
||||
parentAuthor.did !== author.did &&
|
||||
isSelfOrFollowing(parentAuthor, userDid)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
if (
|
||||
grandparentAuthor &&
|
||||
grandparentAuthor.did !== author.did &&
|
||||
isSelfOrFollowing(grandparentAuthor, userDid)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
if (
|
||||
rootAuthor &&
|
||||
rootAuthor.did !== author.did &&
|
||||
isSelfOrFollowing(rootAuthor, userDid)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function isSelfOrFollowing(
|
||||
profile: AppBskyActorDefs.ProfileViewBasic,
|
||||
userDid: string,
|
||||
) {
|
||||
return Boolean(profile.did === userDid || profile.viewer?.following)
|
||||
}
|
||||
|
||||
@@ -193,12 +193,6 @@ class MergeFeedSource {
|
||||
return this.hasMore && this.queue.length === 0
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.cursor = undefined
|
||||
this.queue = []
|
||||
this.hasMore = true
|
||||
}
|
||||
|
||||
take(n: number): AppBskyFeedDefs.FeedViewPost[] {
|
||||
return this.queue.splice(0, n)
|
||||
}
|
||||
@@ -232,11 +226,6 @@ class MergeFeedSource {
|
||||
class MergeFeedSource_Following extends MergeFeedSource {
|
||||
tuner = new FeedTuner(this.feedTuners)
|
||||
|
||||
reset() {
|
||||
super.reset()
|
||||
this.tuner.reset()
|
||||
}
|
||||
|
||||
async fetchNext(n: number) {
|
||||
return this._fetchNextInner(n)
|
||||
}
|
||||
@@ -249,7 +238,6 @@ class MergeFeedSource_Following extends MergeFeedSource {
|
||||
// run the tuner pre-emptively to ensure better mixing
|
||||
const slices = this.tuner.tune(res.data.feed, {
|
||||
dryRun: false,
|
||||
maintainOrder: true,
|
||||
})
|
||||
res.data.feed = slices.map(slice => slice._feedPost)
|
||||
return res
|
||||
|
||||
@@ -134,7 +134,7 @@ export function useModerationCauseDescription(
|
||||
}
|
||||
}
|
||||
if (def.identifier === 'porn' || def.identifier === 'sexual') {
|
||||
strings.name = 'Adult Content'
|
||||
strings.name = _(msg`Adult Content`)
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -5,7 +5,7 @@ import {BskyAgent} from '@atproto/api'
|
||||
|
||||
import {logger} from '#/logger'
|
||||
import {SessionAccount, useAgent, useSession} from '#/state/session'
|
||||
import {logEvent, useGate} from 'lib/statsig/statsig'
|
||||
import {logEvent} from 'lib/statsig/statsig'
|
||||
import {devicePlatform, isAndroid, isNative} from 'platform/detection'
|
||||
import BackgroundNotificationHandler from '../../../modules/expo-background-notification-handler'
|
||||
|
||||
@@ -86,7 +86,6 @@ export function useNotificationsRegistration() {
|
||||
}
|
||||
|
||||
export function useRequestNotificationsPermission() {
|
||||
const gate = useGate()
|
||||
const {currentAccount} = useSession()
|
||||
const agent = useAgent()
|
||||
|
||||
@@ -102,16 +101,7 @@ export function useRequestNotificationsPermission() {
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (
|
||||
context === 'StartOnboarding' &&
|
||||
gate('request_notifications_permission_after_onboarding_v2')
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (
|
||||
context === 'AfterOnboarding' &&
|
||||
!gate('request_notifications_permission_after_onboarding_v2')
|
||||
) {
|
||||
if (context === 'AfterOnboarding') {
|
||||
return
|
||||
}
|
||||
if (context === 'Home' && !currentAccount) {
|
||||
|
||||
@@ -159,6 +159,7 @@ export type LogEvents = {
|
||||
| 'AvatarButton'
|
||||
| 'StarterPackProfilesList'
|
||||
| 'FeedInterstitial'
|
||||
| 'ProfileHeaderSuggestedFollows'
|
||||
}
|
||||
'profile:unfollow': {
|
||||
logContext:
|
||||
@@ -173,6 +174,7 @@ export type LogEvents = {
|
||||
| 'AvatarButton'
|
||||
| 'StarterPackProfilesList'
|
||||
| 'FeedInterstitial'
|
||||
| 'ProfileHeaderSuggestedFollows'
|
||||
}
|
||||
'chat:create': {
|
||||
logContext: 'ProfileHeader' | 'NewChatDialog' | 'SendViaChatDialog'
|
||||
@@ -211,6 +213,14 @@ export type LogEvents = {
|
||||
'feed:interstitial:profileCard:press': {}
|
||||
'feed:interstitial:feedCard:press': {}
|
||||
|
||||
'profile:header:suggestedFollowsCard:press': {}
|
||||
|
||||
'debug:followingPrefs': {
|
||||
followingShowRepliesFromPref: 'all' | 'following' | 'off'
|
||||
followingRepliesMinLikePref: number
|
||||
}
|
||||
'debug:followingDisplayed': {}
|
||||
|
||||
'test:all:always': {}
|
||||
'test:all:sometimes': {}
|
||||
'test:all:boosted_by_gate1': {reason: 'base' | 'gate1'}
|
||||
|
||||
@@ -1,17 +1,10 @@
|
||||
export type Gate =
|
||||
// Keep this alphabetic please.
|
||||
| 'debug_show_feedcontext'
|
||||
| 'explore_page_profile_card_social_proof'
|
||||
| 'native_pwi_disabled'
|
||||
| 'new_user_guided_tour'
|
||||
| 'new_user_progress_guide'
|
||||
| 'onboarding_minimum_interests'
|
||||
| 'request_notifications_permission_after_onboarding_v2'
|
||||
| 'session_withproxy_fix'
|
||||
| 'show_avi_follow_button'
|
||||
| 'show_follow_back_label_v2'
|
||||
| 'suggested_feeds_interstitial'
|
||||
| 'suggested_follows_interstitial'
|
||||
| 'ungroup_follow_backs'
|
||||
| 'video_debug'
|
||||
| 'videos'
|
||||
| 'small_avi_thumb'
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {Platform} from 'react-native'
|
||||
import {isReducedMotion} from 'react-native-reanimated'
|
||||
import {getLocales} from 'expo-localization'
|
||||
|
||||
import {fixLegacyLanguageCode} from '#/locale/helpers'
|
||||
@@ -15,11 +14,10 @@ export const isMobileWeb =
|
||||
isWeb &&
|
||||
// @ts-ignore we know window exists -prf
|
||||
global.window.matchMedia(isMobileWebMediaQuery)?.matches
|
||||
export const isIPhoneWeb = isWeb && /iPhone/.test(navigator.userAgent)
|
||||
|
||||
export const deviceLocales = dedupArray(
|
||||
getLocales?.()
|
||||
.map?.(locale => fixLegacyLanguageCode(locale.languageCode))
|
||||
.filter(code => typeof code === 'string'),
|
||||
) as string[]
|
||||
|
||||
export const prefersReducedMotion = isReducedMotion()
|
||||
|
||||
@@ -387,9 +387,6 @@ export function MessagesList({
|
||||
renderItem={renderItem}
|
||||
keyExtractor={keyExtractor}
|
||||
disableFullWindowScroll={true}
|
||||
// Prevents wrong position in Firefox when sending a message
|
||||
// as well as scroll getting stuck on Chome when scrolling upwards.
|
||||
disableContainStyle={true}
|
||||
disableVirtualization={true}
|
||||
style={animatedListStyle}
|
||||
// The extra two items account for the header and the footer components
|
||||
|
||||
@@ -157,7 +157,7 @@ let ProfileHeaderStandard = ({
|
||||
hideBackButton={hideBackButton}
|
||||
isPlaceholderProfile={isPlaceholderProfile}>
|
||||
<View
|
||||
style={[a.px_lg, a.pt_md, a.pb_sm]}
|
||||
style={[a.px_lg, a.pt_md, a.pb_sm, a.overflow_hidden]}
|
||||
pointerEvents={isIOS ? 'auto' : 'box-none'}>
|
||||
<View
|
||||
style={[
|
||||
|
||||
@@ -79,6 +79,7 @@ export const ProfileFeedSection = React.forwardRef<
|
||||
headerOffset={headerHeight}
|
||||
renderEndOfFeed={ProfileEndOfFeed}
|
||||
ignoreFilterFor={ignoreFilterFor}
|
||||
outsideHeaderOffset={headerHeight}
|
||||
/>
|
||||
{(isScrolledDown || hasNew) && (
|
||||
<LoadLatestBtn
|
||||
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
import React from 'react'
|
||||
import {AccessibilityInfo} from 'react-native'
|
||||
import {isReducedMotion} from 'react-native-reanimated'
|
||||
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {PlatformInfo} from '../../modules/expo-bluesky-swiss-army'
|
||||
|
||||
const Context = React.createContext({
|
||||
reduceMotionEnabled: false,
|
||||
@@ -15,7 +15,7 @@ export function useA11y() {
|
||||
|
||||
export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
const [reduceMotionEnabled, setReduceMotionEnabled] = React.useState(() =>
|
||||
isReducedMotion(),
|
||||
PlatformInfo.getIsReducedMotionEnabled(),
|
||||
)
|
||||
const [screenReaderEnabled, setScreenReaderEnabled] = React.useState(false)
|
||||
|
||||
|
||||
@@ -123,7 +123,7 @@ export function useFeedFeedback(feed: FeedDescriptor, hasSession: boolean) {
|
||||
toString({
|
||||
item: postItem.uri,
|
||||
event: 'app.bsky.feed.defs#interactionSeen',
|
||||
feedContext: postItem.feedContext,
|
||||
feedContext: slice.feedContext,
|
||||
}),
|
||||
)
|
||||
sendToFeed()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react'
|
||||
|
||||
import * as persisted from '#/state/persisted'
|
||||
|
||||
type StateContext = persisted.Schema['invites']
|
||||
@@ -35,8 +36,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
return persisted.onUpdate(() => {
|
||||
setState(persisted.get('invites'))
|
||||
return persisted.onUpdate('invites', nextInvites => {
|
||||
setState(nextInvites)
|
||||
})
|
||||
}, [setState])
|
||||
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
import type {LegacySchema} from '#/state/persisted/legacy'
|
||||
|
||||
export const ALICE_DID = 'did:plc:ALICE_DID'
|
||||
export const BOB_DID = 'did:plc:BOB_DID'
|
||||
|
||||
export const LEGACY_DATA_DUMP: LegacySchema = {
|
||||
session: {
|
||||
data: {
|
||||
service: 'https://bsky.social/',
|
||||
did: ALICE_DID,
|
||||
},
|
||||
accounts: [
|
||||
{
|
||||
service: 'https://bsky.social',
|
||||
did: ALICE_DID,
|
||||
refreshJwt: 'refreshJwt',
|
||||
accessJwt: 'accessJwt',
|
||||
handle: 'alice.test',
|
||||
email: 'alice@bsky.test',
|
||||
displayName: 'Alice',
|
||||
aviUrl: 'avi',
|
||||
emailConfirmed: true,
|
||||
},
|
||||
{
|
||||
service: 'https://bsky.social',
|
||||
did: BOB_DID,
|
||||
refreshJwt: 'refreshJwt',
|
||||
accessJwt: 'accessJwt',
|
||||
handle: 'bob.test',
|
||||
email: 'bob@bsky.test',
|
||||
displayName: 'Bob',
|
||||
aviUrl: 'avi',
|
||||
emailConfirmed: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
me: {
|
||||
did: ALICE_DID,
|
||||
handle: 'alice.test',
|
||||
displayName: 'Alice',
|
||||
description: '',
|
||||
avatar: 'avi',
|
||||
},
|
||||
onboarding: {step: 'Home'},
|
||||
shell: {colorMode: 'system'},
|
||||
preferences: {
|
||||
primaryLanguage: 'en',
|
||||
contentLanguages: ['en'],
|
||||
postLanguage: 'en',
|
||||
postLanguageHistory: ['en', 'en', 'ja', 'pt', 'de', 'en'],
|
||||
contentLabels: {
|
||||
nsfw: 'warn',
|
||||
nudity: 'warn',
|
||||
suggestive: 'warn',
|
||||
gore: 'warn',
|
||||
hate: 'hide',
|
||||
spam: 'hide',
|
||||
impersonation: 'warn',
|
||||
},
|
||||
savedFeeds: ['feed_a', 'feed_b', 'feed_c'],
|
||||
pinnedFeeds: ['feed_a', 'feed_b'],
|
||||
requireAltTextEnabled: false,
|
||||
},
|
||||
invitedUsers: {seenDids: [], copiedInvites: []},
|
||||
mutedThreads: {uris: []},
|
||||
reminders: {},
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import {jest, expect, test, afterEach} from '@jest/globals'
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
|
||||
import {defaults} from '#/state/persisted/schema'
|
||||
import {migrate} from '#/state/persisted/legacy'
|
||||
import * as store from '#/state/persisted/store'
|
||||
import * as persisted from '#/state/persisted'
|
||||
|
||||
const write = jest.mocked(store.write)
|
||||
const read = jest.mocked(store.read)
|
||||
|
||||
jest.mock('#/logger')
|
||||
jest.mock('#/state/persisted/legacy', () => ({
|
||||
migrate: jest.fn(),
|
||||
}))
|
||||
jest.mock('#/state/persisted/store', () => ({
|
||||
write: jest.fn(),
|
||||
read: jest.fn(),
|
||||
}))
|
||||
|
||||
afterEach(() => {
|
||||
jest.useFakeTimers()
|
||||
jest.clearAllMocks()
|
||||
AsyncStorage.clear()
|
||||
})
|
||||
|
||||
test('init: fresh install, no migration', async () => {
|
||||
await persisted.init()
|
||||
|
||||
expect(migrate).toHaveBeenCalledTimes(1)
|
||||
expect(read).toHaveBeenCalledTimes(1)
|
||||
expect(write).toHaveBeenCalledWith(defaults)
|
||||
|
||||
// default value
|
||||
expect(persisted.get('colorMode')).toBe('system')
|
||||
})
|
||||
|
||||
test('init: fresh install, migration ran', async () => {
|
||||
read.mockResolvedValueOnce(defaults)
|
||||
|
||||
await persisted.init()
|
||||
|
||||
expect(migrate).toHaveBeenCalledTimes(1)
|
||||
expect(read).toHaveBeenCalledTimes(1)
|
||||
expect(write).not.toHaveBeenCalled()
|
||||
|
||||
// default value
|
||||
expect(persisted.get('colorMode')).toBe('system')
|
||||
})
|
||||
@@ -1,93 +0,0 @@
|
||||
import {jest, expect, test, afterEach} from '@jest/globals'
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
|
||||
import {defaults, schema} from '#/state/persisted/schema'
|
||||
import {transform, migrate} from '#/state/persisted/legacy'
|
||||
import * as store from '#/state/persisted/store'
|
||||
import {logger} from '#/logger'
|
||||
import * as fixtures from '#/state/persisted/__tests__/fixtures'
|
||||
|
||||
const write = jest.mocked(store.write)
|
||||
const read = jest.mocked(store.read)
|
||||
|
||||
jest.mock('#/logger')
|
||||
jest.mock('#/state/persisted/store', () => ({
|
||||
write: jest.fn(),
|
||||
read: jest.fn(),
|
||||
}))
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks()
|
||||
AsyncStorage.clear()
|
||||
})
|
||||
|
||||
test('migrate: fresh install', async () => {
|
||||
await migrate()
|
||||
|
||||
expect(AsyncStorage.getItem).toHaveBeenCalledWith('root')
|
||||
expect(read).toHaveBeenCalledTimes(1)
|
||||
expect(logger.debug).toHaveBeenCalledWith(
|
||||
'persisted state: no migration needed',
|
||||
)
|
||||
})
|
||||
|
||||
test('migrate: fresh install, existing new storage', async () => {
|
||||
read.mockResolvedValueOnce(defaults)
|
||||
|
||||
await migrate()
|
||||
|
||||
expect(AsyncStorage.getItem).toHaveBeenCalledWith('root')
|
||||
expect(read).toHaveBeenCalledTimes(1)
|
||||
expect(logger.debug).toHaveBeenCalledWith(
|
||||
'persisted state: no migration needed',
|
||||
)
|
||||
})
|
||||
|
||||
test('migrate: fresh install, AsyncStorage error', async () => {
|
||||
const prevGetItem = AsyncStorage.getItem
|
||||
|
||||
const error = new Error('test error')
|
||||
|
||||
AsyncStorage.getItem = jest.fn(() => {
|
||||
throw error
|
||||
})
|
||||
|
||||
await migrate()
|
||||
|
||||
expect(AsyncStorage.getItem).toHaveBeenCalledWith('root')
|
||||
expect(logger.error).toHaveBeenCalledWith(error, {
|
||||
message: 'persisted state: error migrating legacy storage',
|
||||
})
|
||||
|
||||
AsyncStorage.getItem = prevGetItem
|
||||
})
|
||||
|
||||
test('migrate: has legacy data', async () => {
|
||||
await AsyncStorage.setItem('root', JSON.stringify(fixtures.LEGACY_DATA_DUMP))
|
||||
|
||||
await migrate()
|
||||
|
||||
expect(write).toHaveBeenCalledWith(transform(fixtures.LEGACY_DATA_DUMP))
|
||||
expect(logger.debug).toHaveBeenCalledWith(
|
||||
'persisted state: migrated legacy storage',
|
||||
)
|
||||
})
|
||||
|
||||
test('migrate: has legacy data, fails validation', async () => {
|
||||
const legacy = fixtures.LEGACY_DATA_DUMP
|
||||
// @ts-ignore
|
||||
legacy.shell.colorMode = 'invalid'
|
||||
await AsyncStorage.setItem('root', JSON.stringify(legacy))
|
||||
|
||||
await migrate()
|
||||
|
||||
const transformed = transform(legacy)
|
||||
const validate = schema.safeParse(transformed)
|
||||
|
||||
expect(write).not.toHaveBeenCalled()
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
'persisted state: legacy data failed validation',
|
||||
// @ts-ignore
|
||||
{message: validate.error},
|
||||
)
|
||||
})
|
||||
@@ -1,21 +0,0 @@
|
||||
import {expect, test} from '@jest/globals'
|
||||
|
||||
import {transform} from '#/state/persisted/legacy'
|
||||
import {defaults, schema} from '#/state/persisted/schema'
|
||||
import * as fixtures from '#/state/persisted/__tests__/fixtures'
|
||||
|
||||
test('defaults', () => {
|
||||
expect(() => schema.parse(defaults)).not.toThrow()
|
||||
})
|
||||
|
||||
test('transform', () => {
|
||||
const data = transform({})
|
||||
expect(() => schema.parse(data)).not.toThrow()
|
||||
})
|
||||
|
||||
test('transform: legacy fixture', () => {
|
||||
const data = transform(fixtures.LEGACY_DATA_DUMP)
|
||||
expect(() => schema.parse(data)).not.toThrow()
|
||||
expect(data.session.currentAccount?.did).toEqual(fixtures.ALICE_DID)
|
||||
expect(data.session.accounts.length).toEqual(2)
|
||||
})
|
||||
@@ -1,97 +1,86 @@
|
||||
import EventEmitter from 'eventemitter3'
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
|
||||
import BroadcastChannel from '#/lib/broadcast'
|
||||
import {logger} from '#/logger'
|
||||
import {migrate} from '#/state/persisted/legacy'
|
||||
import {defaults, Schema} from '#/state/persisted/schema'
|
||||
import * as store from '#/state/persisted/store'
|
||||
import {
|
||||
defaults,
|
||||
Schema,
|
||||
tryParse,
|
||||
tryStringify,
|
||||
} from '#/state/persisted/schema'
|
||||
import {PersistedApi} from './types'
|
||||
|
||||
export type {PersistedAccount, Schema} from '#/state/persisted/schema'
|
||||
export {defaults} from '#/state/persisted/schema'
|
||||
|
||||
const broadcast = new BroadcastChannel('BSKY_BROADCAST_CHANNEL')
|
||||
const UPDATE_EVENT = 'BSKY_UPDATE'
|
||||
const BSKY_STORAGE = 'BSKY_STORAGE'
|
||||
|
||||
let _state: Schema = defaults
|
||||
const _emitter = new EventEmitter()
|
||||
|
||||
/**
|
||||
* Initializes and returns persisted data state, so that it can be passed to
|
||||
* the Provider.
|
||||
*/
|
||||
export async function init() {
|
||||
logger.debug('persisted state: initializing')
|
||||
|
||||
broadcast.onmessage = onBroadcastMessage
|
||||
|
||||
try {
|
||||
await migrate() // migrate old store
|
||||
const stored = await store.read() // check for new store
|
||||
if (!stored) {
|
||||
logger.debug('persisted state: initializing default storage')
|
||||
await store.write(defaults) // opt: init new store
|
||||
}
|
||||
_state = stored || defaults // return new store
|
||||
logger.debug('persisted state: initialized')
|
||||
} catch (e) {
|
||||
logger.error('persisted state: failed to load root state from storage', {
|
||||
message: e,
|
||||
})
|
||||
// AsyncStorage failure, but we can still continue in memory
|
||||
return defaults
|
||||
const stored = await readFromStorage()
|
||||
if (stored) {
|
||||
_state = stored
|
||||
}
|
||||
}
|
||||
init satisfies PersistedApi['init']
|
||||
|
||||
export function get<K extends keyof Schema>(key: K): Schema[K] {
|
||||
return _state[key]
|
||||
}
|
||||
get satisfies PersistedApi['get']
|
||||
|
||||
export async function write<K extends keyof Schema>(
|
||||
key: K,
|
||||
value: Schema[K],
|
||||
): Promise<void> {
|
||||
_state = {
|
||||
..._state,
|
||||
[key]: value,
|
||||
}
|
||||
await writeToStorage(_state)
|
||||
}
|
||||
write satisfies PersistedApi['write']
|
||||
|
||||
export function onUpdate<K extends keyof Schema>(
|
||||
_key: K,
|
||||
_cb: (v: Schema[K]) => void,
|
||||
): () => void {
|
||||
return () => {}
|
||||
}
|
||||
onUpdate satisfies PersistedApi['onUpdate']
|
||||
|
||||
export async function clearStorage() {
|
||||
try {
|
||||
_state[key] = value
|
||||
await store.write(_state)
|
||||
// must happen on next tick, otherwise the tab will read stale storage data
|
||||
setTimeout(() => broadcast.postMessage({event: UPDATE_EVENT}), 0)
|
||||
logger.debug(`persisted state: wrote root state to storage`, {
|
||||
updatedKey: key,
|
||||
})
|
||||
} catch (e) {
|
||||
logger.error(`persisted state: failed writing root state to storage`, {
|
||||
message: e,
|
||||
})
|
||||
await AsyncStorage.removeItem(BSKY_STORAGE)
|
||||
} catch (e: any) {
|
||||
logger.error(`persisted store: failed to clear`, {message: e.toString()})
|
||||
}
|
||||
}
|
||||
clearStorage satisfies PersistedApi['clearStorage']
|
||||
|
||||
export function onUpdate(cb: () => void): () => void {
|
||||
_emitter.addListener('update', cb)
|
||||
return () => _emitter.removeListener('update', cb)
|
||||
}
|
||||
|
||||
async function onBroadcastMessage({data}: MessageEvent) {
|
||||
// validate event
|
||||
if (typeof data === 'object' && data.event === UPDATE_EVENT) {
|
||||
async function writeToStorage(value: Schema) {
|
||||
const rawData = tryStringify(value)
|
||||
if (rawData) {
|
||||
try {
|
||||
// read next state, possibly updated by another tab
|
||||
const next = await store.read()
|
||||
|
||||
if (next) {
|
||||
logger.debug(`persisted state: handling update from broadcast channel`)
|
||||
_state = next
|
||||
_emitter.emit('update')
|
||||
} else {
|
||||
logger.error(
|
||||
`persisted state: handled update update from broadcast channel, but found no data`,
|
||||
)
|
||||
}
|
||||
await AsyncStorage.setItem(BSKY_STORAGE, rawData)
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
`persisted state: failed handling update from broadcast channel`,
|
||||
{
|
||||
message: e,
|
||||
},
|
||||
)
|
||||
logger.error(`persisted state: failed writing root state to storage`, {
|
||||
message: e,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function readFromStorage(): Promise<Schema | undefined> {
|
||||
let rawData: string | null = null
|
||||
try {
|
||||
rawData = await AsyncStorage.getItem(BSKY_STORAGE)
|
||||
} catch (e) {
|
||||
logger.error(`persisted state: failed reading root state from storage`, {
|
||||
message: e,
|
||||
})
|
||||
}
|
||||
if (rawData) {
|
||||
return tryParse(rawData)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import EventEmitter from 'eventemitter3'
|
||||
|
||||
import BroadcastChannel from '#/lib/broadcast'
|
||||
import {logger} from '#/logger'
|
||||
import {
|
||||
defaults,
|
||||
Schema,
|
||||
tryParse,
|
||||
tryStringify,
|
||||
} from '#/state/persisted/schema'
|
||||
import {PersistedApi} from './types'
|
||||
|
||||
export type {PersistedAccount, Schema} from '#/state/persisted/schema'
|
||||
export {defaults} from '#/state/persisted/schema'
|
||||
|
||||
const BSKY_STORAGE = 'BSKY_STORAGE'
|
||||
|
||||
const broadcast = new BroadcastChannel('BSKY_BROADCAST_CHANNEL')
|
||||
const UPDATE_EVENT = 'BSKY_UPDATE'
|
||||
|
||||
let _state: Schema = defaults
|
||||
const _emitter = new EventEmitter()
|
||||
|
||||
export async function init() {
|
||||
broadcast.onmessage = onBroadcastMessage
|
||||
const stored = readFromStorage()
|
||||
if (stored) {
|
||||
_state = stored
|
||||
}
|
||||
}
|
||||
init satisfies PersistedApi['init']
|
||||
|
||||
export function get<K extends keyof Schema>(key: K): Schema[K] {
|
||||
return _state[key]
|
||||
}
|
||||
get satisfies PersistedApi['get']
|
||||
|
||||
export async function write<K extends keyof Schema>(
|
||||
key: K,
|
||||
value: Schema[K],
|
||||
): Promise<void> {
|
||||
const next = readFromStorage()
|
||||
if (next) {
|
||||
// The storage could have been updated by a different tab before this tab is notified.
|
||||
// Make sure this write is applied on top of the latest data in the storage as long as it's valid.
|
||||
_state = next
|
||||
// Don't fire the update listeners yet to avoid a loop.
|
||||
// If there was a change, we'll receive the broadcast event soon enough which will do that.
|
||||
}
|
||||
try {
|
||||
if (JSON.stringify({v: _state[key]}) === JSON.stringify({v: value})) {
|
||||
// Fast path for updates that are guaranteed to be noops.
|
||||
// This is good mostly because it avoids useless broadcasts to other tabs.
|
||||
return
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore and go through the normal path.
|
||||
}
|
||||
_state = {
|
||||
..._state,
|
||||
[key]: value,
|
||||
}
|
||||
writeToStorage(_state)
|
||||
broadcast.postMessage({event: {type: UPDATE_EVENT, key}})
|
||||
broadcast.postMessage({event: UPDATE_EVENT}) // Backcompat while upgrading
|
||||
}
|
||||
write satisfies PersistedApi['write']
|
||||
|
||||
export function onUpdate<K extends keyof Schema>(
|
||||
key: K,
|
||||
cb: (v: Schema[K]) => void,
|
||||
): () => void {
|
||||
const listener = () => cb(get(key))
|
||||
_emitter.addListener('update', listener) // Backcompat while upgrading
|
||||
_emitter.addListener('update:' + key, listener)
|
||||
return () => {
|
||||
_emitter.removeListener('update', listener) // Backcompat while upgrading
|
||||
_emitter.removeListener('update:' + key, listener)
|
||||
}
|
||||
}
|
||||
onUpdate satisfies PersistedApi['onUpdate']
|
||||
|
||||
export async function clearStorage() {
|
||||
try {
|
||||
localStorage.removeItem(BSKY_STORAGE)
|
||||
} catch (e: any) {
|
||||
// Expected on the web in private mode.
|
||||
}
|
||||
}
|
||||
clearStorage satisfies PersistedApi['clearStorage']
|
||||
|
||||
async function onBroadcastMessage({data}: MessageEvent) {
|
||||
if (
|
||||
typeof data === 'object' &&
|
||||
(data.event === UPDATE_EVENT || // Backcompat while upgrading
|
||||
data.event?.type === UPDATE_EVENT)
|
||||
) {
|
||||
// read next state, possibly updated by another tab
|
||||
const next = readFromStorage()
|
||||
if (next === _state) {
|
||||
return
|
||||
}
|
||||
if (next) {
|
||||
_state = next
|
||||
if (typeof data.event.key === 'string') {
|
||||
_emitter.emit('update:' + data.event.key)
|
||||
} else {
|
||||
_emitter.emit('update') // Backcompat while upgrading
|
||||
}
|
||||
} else {
|
||||
logger.error(
|
||||
`persisted state: handled update update from broadcast channel, but found no data`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function writeToStorage(value: Schema) {
|
||||
const rawData = tryStringify(value)
|
||||
if (rawData) {
|
||||
try {
|
||||
localStorage.setItem(BSKY_STORAGE, rawData)
|
||||
} catch (e) {
|
||||
// Expected on the web in private mode.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let lastRawData: string | undefined
|
||||
let lastResult: Schema | undefined
|
||||
function readFromStorage(): Schema | undefined {
|
||||
let rawData: string | null = null
|
||||
try {
|
||||
rawData = localStorage.getItem(BSKY_STORAGE)
|
||||
} catch (e) {
|
||||
// Expected on the web in private mode.
|
||||
}
|
||||
if (rawData) {
|
||||
if (rawData === lastRawData) {
|
||||
return lastResult
|
||||
} else {
|
||||
const result = tryParse(rawData)
|
||||
lastRawData = rawData
|
||||
lastResult = result
|
||||
return result
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
|
||||
import {logger} from '#/logger'
|
||||
import {defaults, Schema, schema} from '#/state/persisted/schema'
|
||||
import {read, write} from '#/state/persisted/store'
|
||||
|
||||
/**
|
||||
* The shape of the serialized data from our legacy Mobx store.
|
||||
*/
|
||||
export type LegacySchema = {
|
||||
shell: {
|
||||
colorMode: 'system' | 'light' | 'dark'
|
||||
}
|
||||
session: {
|
||||
data: {
|
||||
service: string
|
||||
did: `did:plc:${string}`
|
||||
} | null
|
||||
accounts: {
|
||||
service: string
|
||||
did: `did:plc:${string}`
|
||||
refreshJwt: string
|
||||
accessJwt: string
|
||||
handle: string
|
||||
email: string
|
||||
displayName: string
|
||||
aviUrl: string
|
||||
emailConfirmed: boolean
|
||||
}[]
|
||||
}
|
||||
me: {
|
||||
did: `did:plc:${string}`
|
||||
handle: string
|
||||
displayName: string
|
||||
description: string
|
||||
avatar: string
|
||||
}
|
||||
onboarding: {
|
||||
step: string
|
||||
}
|
||||
preferences: {
|
||||
primaryLanguage: string
|
||||
contentLanguages: string[]
|
||||
postLanguage: string
|
||||
postLanguageHistory: string[]
|
||||
contentLabels: {
|
||||
nsfw: string
|
||||
nudity: string
|
||||
suggestive: string
|
||||
gore: string
|
||||
hate: string
|
||||
spam: string
|
||||
impersonation: string
|
||||
}
|
||||
savedFeeds: string[]
|
||||
pinnedFeeds: string[]
|
||||
requireAltTextEnabled: boolean
|
||||
}
|
||||
invitedUsers: {
|
||||
seenDids: string[]
|
||||
copiedInvites: string[]
|
||||
}
|
||||
mutedThreads: {uris: string[]}
|
||||
reminders: {lastEmailConfirm?: string}
|
||||
}
|
||||
|
||||
const DEPRECATED_ROOT_STATE_STORAGE_KEY = 'root'
|
||||
|
||||
export function transform(legacy: Partial<LegacySchema>): Schema {
|
||||
return {
|
||||
colorMode: legacy.shell?.colorMode || defaults.colorMode,
|
||||
darkTheme: defaults.darkTheme,
|
||||
session: {
|
||||
accounts: legacy.session?.accounts || defaults.session.accounts,
|
||||
currentAccount:
|
||||
legacy.session?.accounts?.find(
|
||||
a => a.did === legacy.session?.data?.did,
|
||||
) || defaults.session.currentAccount,
|
||||
},
|
||||
reminders: {
|
||||
lastEmailConfirm:
|
||||
legacy.reminders?.lastEmailConfirm ||
|
||||
defaults.reminders.lastEmailConfirm,
|
||||
},
|
||||
languagePrefs: {
|
||||
primaryLanguage:
|
||||
legacy.preferences?.primaryLanguage ||
|
||||
defaults.languagePrefs.primaryLanguage,
|
||||
contentLanguages:
|
||||
legacy.preferences?.contentLanguages ||
|
||||
defaults.languagePrefs.contentLanguages,
|
||||
postLanguage:
|
||||
legacy.preferences?.postLanguage || defaults.languagePrefs.postLanguage,
|
||||
postLanguageHistory:
|
||||
legacy.preferences?.postLanguageHistory ||
|
||||
defaults.languagePrefs.postLanguageHistory,
|
||||
appLanguage:
|
||||
legacy.preferences?.primaryLanguage ||
|
||||
defaults.languagePrefs.appLanguage,
|
||||
},
|
||||
requireAltTextEnabled:
|
||||
legacy.preferences?.requireAltTextEnabled ||
|
||||
defaults.requireAltTextEnabled,
|
||||
mutedThreads: legacy.mutedThreads?.uris || defaults.mutedThreads,
|
||||
invites: {
|
||||
copiedInvites:
|
||||
legacy.invitedUsers?.copiedInvites || defaults.invites.copiedInvites,
|
||||
},
|
||||
onboarding: {
|
||||
step: legacy.onboarding?.step || defaults.onboarding.step,
|
||||
},
|
||||
hiddenPosts: defaults.hiddenPosts,
|
||||
externalEmbeds: defaults.externalEmbeds,
|
||||
lastSelectedHomeFeed: defaults.lastSelectedHomeFeed,
|
||||
pdsAddressHistory: defaults.pdsAddressHistory,
|
||||
disableHaptics: defaults.disableHaptics,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrates legacy persisted state to new store if new store doesn't exist in
|
||||
* local storage AND old storage exists.
|
||||
*/
|
||||
export async function migrate() {
|
||||
logger.debug('persisted state: check need to migrate')
|
||||
|
||||
try {
|
||||
const rawLegacyData = await AsyncStorage.getItem(
|
||||
DEPRECATED_ROOT_STATE_STORAGE_KEY,
|
||||
)
|
||||
const newData = await read()
|
||||
const alreadyMigrated = Boolean(newData)
|
||||
|
||||
if (!alreadyMigrated && rawLegacyData) {
|
||||
logger.debug('persisted state: migrating legacy storage')
|
||||
|
||||
const legacyData = JSON.parse(rawLegacyData)
|
||||
const newData = transform(legacyData)
|
||||
const validate = schema.safeParse(newData)
|
||||
|
||||
if (validate.success) {
|
||||
await write(newData)
|
||||
logger.debug('persisted state: migrated legacy storage')
|
||||
} else {
|
||||
logger.error('persisted state: legacy data failed validation', {
|
||||
message: validate.error,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
logger.debug('persisted state: no migration needed')
|
||||
}
|
||||
} catch (e: any) {
|
||||
logger.error(e, {
|
||||
message: 'persisted state: error migrating legacy storage',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearLegacyStorage() {
|
||||
try {
|
||||
await AsyncStorage.removeItem(DEPRECATED_ROOT_STATE_STORAGE_KEY)
|
||||
} catch (e: any) {
|
||||
logger.error(`persisted legacy store: failed to clear`, {
|
||||
message: e.toString(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import {z} from 'zod'
|
||||
|
||||
import {deviceLocales, prefersReducedMotion} from '#/platform/detection'
|
||||
import {logger} from '#/logger'
|
||||
import {deviceLocales} from '#/platform/detection'
|
||||
import {PlatformInfo} from '../../../modules/expo-bluesky-swiss-army'
|
||||
|
||||
const externalEmbedOptions = ['show', 'hide'] as const
|
||||
|
||||
@@ -42,7 +44,7 @@ const currentAccountSchema = accountSchema.extend({
|
||||
})
|
||||
export type PersistedCurrentAccount = z.infer<typeof currentAccountSchema>
|
||||
|
||||
export const schema = z.object({
|
||||
const schema = z.object({
|
||||
colorMode: z.enum(['system', 'light', 'dark']),
|
||||
darkTheme: z.enum(['dim', 'dark']).optional(),
|
||||
session: z.object({
|
||||
@@ -89,6 +91,7 @@ export const schema = z.object({
|
||||
disableAutoplay: z.boolean().optional(),
|
||||
kawaii: z.boolean().optional(),
|
||||
hasCheckedForStarterPack: z.boolean().optional(),
|
||||
subtitlesEnabled: z.boolean().optional(),
|
||||
/** @deprecated */
|
||||
mutedThreads: z.array(z.string()),
|
||||
})
|
||||
@@ -128,7 +131,48 @@ export const defaults: Schema = {
|
||||
lastSelectedHomeFeed: undefined,
|
||||
pdsAddressHistory: [],
|
||||
disableHaptics: false,
|
||||
disableAutoplay: prefersReducedMotion,
|
||||
disableAutoplay: PlatformInfo.getIsReducedMotionEnabled(),
|
||||
kawaii: false,
|
||||
hasCheckedForStarterPack: false,
|
||||
subtitlesEnabled: true,
|
||||
}
|
||||
|
||||
export function tryParse(rawData: string): Schema | undefined {
|
||||
let objData
|
||||
try {
|
||||
objData = JSON.parse(rawData)
|
||||
} catch (e) {
|
||||
logger.error('persisted state: failed to parse root state from storage', {
|
||||
message: e,
|
||||
})
|
||||
}
|
||||
if (!objData) {
|
||||
return undefined
|
||||
}
|
||||
const parsed = schema.safeParse(objData)
|
||||
if (parsed.success) {
|
||||
return objData
|
||||
} else {
|
||||
const errors =
|
||||
parsed.error?.errors?.map(e => ({
|
||||
code: e.code,
|
||||
// @ts-ignore exists on some types
|
||||
expected: e?.expected,
|
||||
path: e.path?.join('.'),
|
||||
})) || []
|
||||
logger.error(`persisted store: data failed validation on read`, {errors})
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export function tryStringify(value: Schema): string | undefined {
|
||||
try {
|
||||
schema.parse(value)
|
||||
return JSON.stringify(value)
|
||||
} catch (e) {
|
||||
logger.error(`persisted state: failed stringifying root state`, {
|
||||
message: e,
|
||||
})
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
|
||||
import {logger} from '#/logger'
|
||||
import {Schema, schema} from '#/state/persisted/schema'
|
||||
|
||||
const BSKY_STORAGE = 'BSKY_STORAGE'
|
||||
|
||||
export async function write(value: Schema) {
|
||||
schema.parse(value)
|
||||
await AsyncStorage.setItem(BSKY_STORAGE, JSON.stringify(value))
|
||||
}
|
||||
|
||||
export async function read(): Promise<Schema | undefined> {
|
||||
const rawData = await AsyncStorage.getItem(BSKY_STORAGE)
|
||||
const objData = rawData ? JSON.parse(rawData) : undefined
|
||||
|
||||
// new user
|
||||
if (!objData) return undefined
|
||||
|
||||
// existing user, validate
|
||||
const parsed = schema.safeParse(objData)
|
||||
|
||||
if (parsed.success) {
|
||||
return objData
|
||||
} else {
|
||||
const errors =
|
||||
parsed.error?.errors?.map(e => ({
|
||||
code: e.code,
|
||||
// @ts-ignore exists on some types
|
||||
expected: e?.expected,
|
||||
path: e.path?.join('.'),
|
||||
})) || []
|
||||
logger.error(`persisted store: data failed validation on read`, {errors})
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export async function clear() {
|
||||
try {
|
||||
await AsyncStorage.removeItem(BSKY_STORAGE)
|
||||
} catch (e: any) {
|
||||
logger.error(`persisted store: failed to clear`, {message: e.toString()})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type {Schema} from './schema'
|
||||
|
||||
export type PersistedApi = {
|
||||
init(): Promise<void>
|
||||
get<K extends keyof Schema>(key: K): Schema[K]
|
||||
write<K extends keyof Schema>(key: K, value: Schema[K]): Promise<void>
|
||||
onUpdate<K extends keyof Schema>(
|
||||
key: K,
|
||||
cb: (v: Schema[K]) => void,
|
||||
): () => void
|
||||
clearStorage: () => Promise<void>
|
||||
}
|
||||
@@ -26,9 +26,12 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
return persisted.onUpdate(() => {
|
||||
setState(persisted.get('requireAltTextEnabled'))
|
||||
})
|
||||
return persisted.onUpdate(
|
||||
'requireAltTextEnabled',
|
||||
nextRequireAltTextEnabled => {
|
||||
setState(nextRequireAltTextEnabled)
|
||||
},
|
||||
)
|
||||
}, [setStateWrapped])
|
||||
|
||||
return (
|
||||
|
||||
@@ -24,8 +24,8 @@ export function Provider({children}: {children: React.ReactNode}) {
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
return persisted.onUpdate(() => {
|
||||
setState(Boolean(persisted.get('disableAutoplay')))
|
||||
return persisted.onUpdate('disableAutoplay', nextDisableAutoplay => {
|
||||
setState(Boolean(nextDisableAutoplay))
|
||||
})
|
||||
}, [setStateWrapped])
|
||||
|
||||
|
||||
@@ -24,8 +24,8 @@ export function Provider({children}: {children: React.ReactNode}) {
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
return persisted.onUpdate(() => {
|
||||
setState(Boolean(persisted.get('disableHaptics')))
|
||||
return persisted.onUpdate('disableHaptics', nextDisableHaptics => {
|
||||
setState(Boolean(nextDisableHaptics))
|
||||
})
|
||||
}, [setStateWrapped])
|
||||
|
||||
|
||||
@@ -35,8 +35,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
return persisted.onUpdate(() => {
|
||||
setState(persisted.get('externalEmbeds'))
|
||||
return persisted.onUpdate('externalEmbeds', nextExternalEmbeds => {
|
||||
setState(nextExternalEmbeds)
|
||||
})
|
||||
}, [setStateWrapped])
|
||||
|
||||
|
||||
@@ -19,20 +19,15 @@ export function useFeedTuners(feedDesc: FeedDescriptor) {
|
||||
}
|
||||
}
|
||||
if (feedDesc.startsWith('feedgen')) {
|
||||
return [
|
||||
FeedTuner.dedupReposts,
|
||||
FeedTuner.preferredLangOnly(langPrefs.contentLanguages),
|
||||
]
|
||||
return [FeedTuner.preferredLangOnly(langPrefs.contentLanguages)]
|
||||
}
|
||||
if (feedDesc.startsWith('list')) {
|
||||
const feedTuners = []
|
||||
|
||||
let feedTuners = []
|
||||
if (feedDesc.endsWith('|as_following')) {
|
||||
// Same as Following tuners below, copypaste for now.
|
||||
feedTuners.push(FeedTuner.removeOrphans)
|
||||
if (preferences?.feedViewPrefs.hideReposts) {
|
||||
feedTuners.push(FeedTuner.removeReposts)
|
||||
} else {
|
||||
feedTuners.push(FeedTuner.dedupReposts)
|
||||
}
|
||||
if (preferences?.feedViewPrefs.hideReplies) {
|
||||
feedTuners.push(FeedTuner.removeReplies)
|
||||
@@ -46,18 +41,15 @@ export function useFeedTuners(feedDesc: FeedDescriptor) {
|
||||
if (preferences?.feedViewPrefs.hideQuotePosts) {
|
||||
feedTuners.push(FeedTuner.removeQuotePosts)
|
||||
}
|
||||
} else {
|
||||
feedTuners.push(FeedTuner.dedupReposts)
|
||||
feedTuners.push(FeedTuner.dedupThreads)
|
||||
}
|
||||
return feedTuners
|
||||
}
|
||||
if (feedDesc === 'following') {
|
||||
const feedTuners = []
|
||||
const feedTuners = [FeedTuner.removeOrphans]
|
||||
|
||||
if (preferences?.feedViewPrefs.hideReposts) {
|
||||
feedTuners.push(FeedTuner.removeReposts)
|
||||
} else {
|
||||
feedTuners.push(FeedTuner.dedupReposts)
|
||||
}
|
||||
if (preferences?.feedViewPrefs.hideReplies) {
|
||||
feedTuners.push(FeedTuner.removeReplies)
|
||||
@@ -71,6 +63,7 @@ export function useFeedTuners(feedDesc: FeedDescriptor) {
|
||||
if (preferences?.feedViewPrefs.hideQuotePosts) {
|
||||
feedTuners.push(FeedTuner.removeQuotePosts)
|
||||
}
|
||||
feedTuners.push(FeedTuner.dedupThreads)
|
||||
|
||||
return feedTuners
|
||||
}
|
||||
|
||||
@@ -44,8 +44,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
return persisted.onUpdate(() => {
|
||||
setState(persisted.get('hiddenPosts'))
|
||||
return persisted.onUpdate('hiddenPosts', nextHiddenPosts => {
|
||||
setState(nextHiddenPosts)
|
||||
})
|
||||
}, [setStateWrapped])
|
||||
|
||||
|
||||
@@ -34,8 +34,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
return persisted.onUpdate(() => {
|
||||
setState(persisted.get('useInAppBrowser'))
|
||||
return persisted.onUpdate('useInAppBrowser', nextUseInAppBrowser => {
|
||||
setState(nextUseInAppBrowser)
|
||||
})
|
||||
}, [setStateWrapped])
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {Provider as InAppBrowserProvider} from './in-app-browser'
|
||||
import {Provider as KawaiiProvider} from './kawaii'
|
||||
import {Provider as LanguagesProvider} from './languages'
|
||||
import {Provider as LargeAltBadgeProvider} from './large-alt-badge'
|
||||
import {Provider as SubtitlesProvider} from './subtitles'
|
||||
import {Provider as UsedStarterPacksProvider} from './used-starter-packs'
|
||||
|
||||
export {
|
||||
@@ -24,6 +25,7 @@ export {
|
||||
export * from './hidden-posts'
|
||||
export {useLabelDefinitions} from './label-defs'
|
||||
export {useLanguagePrefs, useLanguagePrefsApi} from './languages'
|
||||
export {useSetSubtitlesEnabled, useSubtitlesEnabled} from './subtitles'
|
||||
|
||||
export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
return (
|
||||
@@ -36,7 +38,9 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
<DisableHapticsProvider>
|
||||
<AutoplayProvider>
|
||||
<UsedStarterPacksProvider>
|
||||
<KawaiiProvider>{children}</KawaiiProvider>
|
||||
<SubtitlesProvider>
|
||||
<KawaiiProvider>{children}</KawaiiProvider>
|
||||
</SubtitlesProvider>
|
||||
</UsedStarterPacksProvider>
|
||||
</AutoplayProvider>
|
||||
</DisableHapticsProvider>
|
||||
|
||||
@@ -21,8 +21,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
return persisted.onUpdate(() => {
|
||||
setState(persisted.get('kawaii'))
|
||||
return persisted.onUpdate('kawaii', nextKawaii => {
|
||||
setState(nextKawaii)
|
||||
})
|
||||
}, [setStateWrapped])
|
||||
|
||||
|
||||
@@ -43,8 +43,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
return persisted.onUpdate(() => {
|
||||
setState(persisted.get('languagePrefs'))
|
||||
return persisted.onUpdate('languagePrefs', nextLanguagePrefs => {
|
||||
setState(nextLanguagePrefs)
|
||||
})
|
||||
}, [setStateWrapped])
|
||||
|
||||
|
||||
@@ -26,9 +26,12 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
return persisted.onUpdate(() => {
|
||||
setState(persisted.get('largeAltBadgeEnabled'))
|
||||
})
|
||||
return persisted.onUpdate(
|
||||
'largeAltBadgeEnabled',
|
||||
nextLargeAltBadgeEnabled => {
|
||||
setState(nextLargeAltBadgeEnabled)
|
||||
},
|
||||
)
|
||||
}, [setStateWrapped])
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import React from 'react'
|
||||
|
||||
import * as persisted from '#/state/persisted'
|
||||
|
||||
type StateContext = boolean
|
||||
type SetContext = (v: boolean) => void
|
||||
|
||||
const stateContext = React.createContext<StateContext>(
|
||||
Boolean(persisted.defaults.subtitlesEnabled),
|
||||
)
|
||||
const setContext = React.createContext<SetContext>((_: boolean) => {})
|
||||
|
||||
export function Provider({children}: {children: React.ReactNode}) {
|
||||
const [state, setState] = React.useState(
|
||||
Boolean(persisted.get('subtitlesEnabled')),
|
||||
)
|
||||
|
||||
const setStateWrapped = React.useCallback(
|
||||
(subtitlesEnabled: persisted.Schema['subtitlesEnabled']) => {
|
||||
setState(Boolean(subtitlesEnabled))
|
||||
persisted.write('subtitlesEnabled', subtitlesEnabled)
|
||||
},
|
||||
[setState],
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
return persisted.onUpdate('subtitlesEnabled', nextSubtitlesEnabled => {
|
||||
setState(Boolean(nextSubtitlesEnabled))
|
||||
})
|
||||
}, [setStateWrapped])
|
||||
|
||||
return (
|
||||
<stateContext.Provider value={state}>
|
||||
<setContext.Provider value={setStateWrapped}>
|
||||
{children}
|
||||
</setContext.Provider>
|
||||
</stateContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const useSubtitlesEnabled = () => React.useContext(stateContext)
|
||||
export const useSetSubtitlesEnabled = () => React.useContext(setContext)
|
||||
@@ -19,9 +19,12 @@ export function Provider({children}: {children: React.ReactNode}) {
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
return persisted.onUpdate(() => {
|
||||
setState(persisted.get('hasCheckedForStarterPack'))
|
||||
})
|
||||
return persisted.onUpdate(
|
||||
'hasCheckedForStarterPack',
|
||||
nextHasCheckedForStarterPack => {
|
||||
setState(nextHasCheckedForStarterPack)
|
||||
},
|
||||
)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
|
||||
@@ -26,7 +26,6 @@ import {
|
||||
useQueryClient,
|
||||
} from '@tanstack/react-query'
|
||||
|
||||
import {useGate} from '#/lib/statsig/statsig'
|
||||
import {useAgent} from '#/state/session'
|
||||
import {useModerationOpts} from '../../preferences/moderation-opts'
|
||||
import {STALE} from '..'
|
||||
@@ -59,7 +58,6 @@ export function useNotificationFeedQuery(opts?: {
|
||||
const moderationOpts = useModerationOpts()
|
||||
const unreads = useUnreadNotificationsApi()
|
||||
const enabled = opts?.enabled !== false
|
||||
const gate = useGate()
|
||||
|
||||
// false: force showing all notifications
|
||||
// undefined: let the server decide
|
||||
@@ -88,7 +86,6 @@ export function useNotificationFeedQuery(opts?: {
|
||||
queryClient,
|
||||
moderationOpts,
|
||||
fetchAdditionalData: true,
|
||||
shouldUngroupFollowBacks: () => gate('ungroup_follow_backs'),
|
||||
priority,
|
||||
})
|
||||
page = fetchedPage
|
||||
|
||||
@@ -8,7 +8,6 @@ import {useQueryClient} from '@tanstack/react-query'
|
||||
import EventEmitter from 'eventemitter3'
|
||||
|
||||
import BroadcastChannel from '#/lib/broadcast'
|
||||
import {useGate} from '#/lib/statsig/statsig'
|
||||
import {logger} from '#/logger'
|
||||
import {useAgent, useSession} from '#/state/session'
|
||||
import {resetBadgeCount} from 'lib/notifications/notifications'
|
||||
@@ -48,7 +47,6 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
const agent = useAgent()
|
||||
const queryClient = useQueryClient()
|
||||
const moderationOpts = useModerationOpts()
|
||||
const gate = useGate()
|
||||
|
||||
const [numUnread, setNumUnread] = React.useState('')
|
||||
|
||||
@@ -151,7 +149,6 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
// only fetch subjects when the page is going to be used
|
||||
// in the notifications query, otherwise skip it
|
||||
fetchAdditionalData: !!invalidate,
|
||||
shouldUngroupFollowBacks: () => gate('ungroup_follow_backs'),
|
||||
})
|
||||
const unreadCount = countUnread(page)
|
||||
const unreadCountStr =
|
||||
@@ -192,7 +189,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
}
|
||||
},
|
||||
}
|
||||
}, [setNumUnread, queryClient, moderationOpts, agent, gate])
|
||||
}, [setNumUnread, queryClient, moderationOpts, agent])
|
||||
checkUnreadRef.current = api.checkUnread
|
||||
|
||||
return (
|
||||
|
||||
@@ -30,7 +30,6 @@ export async function fetchPage({
|
||||
queryClient,
|
||||
moderationOpts,
|
||||
fetchAdditionalData,
|
||||
shouldUngroupFollowBacks,
|
||||
}: {
|
||||
agent: BskyAgent
|
||||
cursor: string | undefined
|
||||
@@ -38,7 +37,6 @@ export async function fetchPage({
|
||||
queryClient: QueryClient
|
||||
moderationOpts: ModerationOpts | undefined
|
||||
fetchAdditionalData: boolean
|
||||
shouldUngroupFollowBacks?: () => boolean
|
||||
priority?: boolean
|
||||
}): Promise<{
|
||||
page: FeedPage
|
||||
@@ -58,7 +56,7 @@ export async function fetchPage({
|
||||
)
|
||||
|
||||
// group notifications which are essentially similar (follows, likes on a post)
|
||||
let notifsGrouped = groupNotifications(notifs, {shouldUngroupFollowBacks})
|
||||
let notifsGrouped = groupNotifications(notifs)
|
||||
|
||||
// we fetch subjects of notifications (usually posts) now instead of lazily
|
||||
// in the UI to avoid relayouts
|
||||
@@ -117,7 +115,6 @@ export function shouldFilterNotif(
|
||||
|
||||
export function groupNotifications(
|
||||
notifs: AppBskyNotificationListNotifications.Notification[],
|
||||
options?: {shouldUngroupFollowBacks?: () => boolean},
|
||||
): FeedNotification[] {
|
||||
const groupedNotifs: FeedNotification[] = []
|
||||
for (const notif of notifs) {
|
||||
@@ -137,9 +134,7 @@ export function groupNotifications(
|
||||
const prevIsFollowBack =
|
||||
groupedNotif.notification.reason === 'follow' &&
|
||||
groupedNotif.notification.author.viewer?.following
|
||||
const shouldUngroup =
|
||||
(nextIsFollowBack || prevIsFollowBack) &&
|
||||
options?.shouldUngroupFollowBacks?.()
|
||||
const shouldUngroup = nextIsFollowBack || prevIsFollowBack
|
||||
if (!shouldUngroup) {
|
||||
groupedNotif.additional = groupedNotif.additional || []
|
||||
groupedNotif.additional.push(notif)
|
||||
|
||||
@@ -77,11 +77,6 @@ export interface FeedPostSliceItem {
|
||||
uri: string
|
||||
post: AppBskyFeedDefs.PostView
|
||||
record: AppBskyFeedPost.Record
|
||||
reason?:
|
||||
| AppBskyFeedDefs.ReasonRepost
|
||||
| ReasonFeedSource
|
||||
| {[k: string]: unknown; $type: string}
|
||||
feedContext: string | undefined
|
||||
moderation: ModerationDecision
|
||||
parentAuthor?: AppBskyActorDefs.ProfileViewBasic
|
||||
isParentBlocked?: boolean
|
||||
@@ -90,9 +85,14 @@ export interface FeedPostSliceItem {
|
||||
export interface FeedPostSlice {
|
||||
_isFeedPostSlice: boolean
|
||||
_reactKey: string
|
||||
rootUri: string
|
||||
isThread: boolean
|
||||
items: FeedPostSliceItem[]
|
||||
isIncompleteThread: boolean
|
||||
isFallbackMarker: boolean
|
||||
feedContext: string | undefined
|
||||
reason?:
|
||||
| AppBskyFeedDefs.ReasonRepost
|
||||
| ReasonFeedSource
|
||||
| {[k: string]: unknown; $type: string}
|
||||
}
|
||||
|
||||
export interface FeedPageUnselected {
|
||||
@@ -313,53 +313,22 @@ export function usePostFeedQuery(
|
||||
const feedPostSlice: FeedPostSlice = {
|
||||
_reactKey: slice._reactKey,
|
||||
_isFeedPostSlice: true,
|
||||
rootUri: slice.uri,
|
||||
isThread:
|
||||
slice.items.length > 1 &&
|
||||
slice.items.every(
|
||||
item =>
|
||||
item.post.author.did ===
|
||||
slice.items[0].post.author.did,
|
||||
),
|
||||
items: slice.items
|
||||
.map((item, i) => {
|
||||
if (
|
||||
AppBskyFeedPost.isRecord(item.post.record) &&
|
||||
AppBskyFeedPost.validateRecord(item.post.record)
|
||||
.success
|
||||
) {
|
||||
const parent = item.reply?.parent
|
||||
let parentAuthor:
|
||||
| AppBskyActorDefs.ProfileViewBasic
|
||||
| undefined
|
||||
if (AppBskyFeedDefs.isPostView(parent)) {
|
||||
parentAuthor = parent.author
|
||||
}
|
||||
if (!parentAuthor) {
|
||||
parentAuthor =
|
||||
slice.items[i + 1]?.reply?.grandparentAuthor
|
||||
}
|
||||
const replyRef = item.reply
|
||||
const isParentBlocked = AppBskyFeedDefs.isBlockedPost(
|
||||
replyRef?.parent,
|
||||
)
|
||||
|
||||
const feedPostSliceItem: FeedPostSliceItem = {
|
||||
_reactKey: `${slice._reactKey}-${i}-${item.post.uri}`,
|
||||
uri: item.post.uri,
|
||||
post: item.post,
|
||||
record: item.post.record,
|
||||
reason: slice.reason,
|
||||
feedContext: slice.feedContext,
|
||||
moderation: moderations[i],
|
||||
parentAuthor,
|
||||
isParentBlocked,
|
||||
}
|
||||
return feedPostSliceItem
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
.filter(n => !!n),
|
||||
isIncompleteThread: slice.isIncompleteThread,
|
||||
isFallbackMarker: slice.isFallbackMarker,
|
||||
feedContext: slice.feedContext,
|
||||
reason: slice.reason,
|
||||
items: slice.items.map((item, i) => {
|
||||
const feedPostSliceItem: FeedPostSliceItem = {
|
||||
_reactKey: `${slice._reactKey}-${i}-${item.post.uri}`,
|
||||
uri: item.post.uri,
|
||||
post: item.post,
|
||||
record: item.record,
|
||||
moderation: moderations[i],
|
||||
parentAuthor: item.parentAuthor,
|
||||
isParentBlocked: item.isParentBlocked,
|
||||
}
|
||||
return feedPostSliceItem
|
||||
}),
|
||||
}
|
||||
return feedPostSlice
|
||||
})
|
||||
@@ -442,7 +411,6 @@ export async function pollLatest(page: FeedPage | undefined) {
|
||||
if (post) {
|
||||
const slices = page.tuner.tune([post], {
|
||||
dryRun: true,
|
||||
maintainOrder: true,
|
||||
})
|
||||
if (slices[0]) {
|
||||
return true
|
||||
|
||||
@@ -137,6 +137,8 @@ export function sortThread(
|
||||
node: ThreadNode,
|
||||
opts: UsePreferencesQueryResponse['threadViewPrefs'],
|
||||
modCache: ThreadModerationCache,
|
||||
currentDid: string | undefined,
|
||||
justPostedUris: Set<string>,
|
||||
threadgateRecord?: AppBskyFeedThreadgate.Record,
|
||||
): ThreadNode {
|
||||
if (node.type !== 'post') {
|
||||
@@ -164,10 +166,20 @@ export function sortThread(
|
||||
return -1
|
||||
}
|
||||
|
||||
/*
|
||||
* Here, OP is actually whatever node is highlighted in the thread view,
|
||||
* NOT necessarily the root post of the thread, though it can be.
|
||||
*/
|
||||
if (node.ctx.isHighlightedPost || opts.lab_treeViewEnabled) {
|
||||
const aIsJustPosted =
|
||||
a.post.author.did === currentDid && justPostedUris.has(a.post.uri)
|
||||
const bIsJustPosted =
|
||||
b.post.author.did === currentDid && justPostedUris.has(b.post.uri)
|
||||
if (aIsJustPosted && bIsJustPosted) {
|
||||
return a.post.indexedAt.localeCompare(b.post.indexedAt) // oldest
|
||||
} else if (aIsJustPosted) {
|
||||
return -1 // reply while onscreen
|
||||
} else if (bIsJustPosted) {
|
||||
return 1 // reply while onscreen
|
||||
}
|
||||
}
|
||||
|
||||
const aIsByOp = a.post.author.did === node.post?.author.did
|
||||
const bIsByOp = b.post.author.did === node.post?.author.did
|
||||
if (aIsByOp && bIsByOp) {
|
||||
@@ -178,6 +190,16 @@ export function sortThread(
|
||||
return 1 // op's own reply
|
||||
}
|
||||
|
||||
const aIsBySelf = a.post.author.did === currentDid
|
||||
const bIsBySelf = b.post.author.did === currentDid
|
||||
if (aIsBySelf && bIsBySelf) {
|
||||
return a.post.indexedAt.localeCompare(b.post.indexedAt) // oldest
|
||||
} else if (aIsBySelf) {
|
||||
return -1 // current account's reply
|
||||
} else if (bIsBySelf) {
|
||||
return 1 // current account's reply
|
||||
}
|
||||
|
||||
const aBlur = Boolean(modCache.get(a)?.ui('contentList').blur)
|
||||
const bBlur = Boolean(modCache.get(b)?.ui('contentList').blur)
|
||||
if (aBlur !== bBlur) {
|
||||
@@ -215,7 +237,14 @@ export function sortThread(
|
||||
return b.post.indexedAt.localeCompare(a.post.indexedAt)
|
||||
})
|
||||
node.replies.forEach(reply =>
|
||||
sortThread(reply, opts, modCache, threadgateRecord),
|
||||
sortThread(
|
||||
reply,
|
||||
opts,
|
||||
modCache,
|
||||
currentDid,
|
||||
justPostedUris,
|
||||
threadgateRecord,
|
||||
),
|
||||
)
|
||||
}
|
||||
return node
|
||||
|
||||
@@ -40,18 +40,10 @@ export function useProfileListsQuery(did: string, opts?: {enabled?: boolean}) {
|
||||
pages: data.pages.map(page => {
|
||||
return {
|
||||
...page,
|
||||
lists: page.lists
|
||||
/*
|
||||
* Starter packs use a reference list, which we do not want to
|
||||
* show on profiles. At some point we could probably just filter
|
||||
* this out on the backend instead of in the client.
|
||||
*/
|
||||
.filter(l => l.purpose !== 'app.bsky.graph.defs#referencelist')
|
||||
// filter by labels
|
||||
.filter(list => {
|
||||
const decision = moderateUserList(list, moderationOpts!)
|
||||
return !decision.ui('contentList').filter
|
||||
}),
|
||||
lists: page.lists.filter(list => {
|
||||
const decision = moderateUserList(list, moderationOpts!)
|
||||
return !decision.ui('contentList').filter
|
||||
}),
|
||||
}
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -222,6 +222,7 @@ export function useProfileFollowMutationQueue(
|
||||
logContext: LogEvents['profile:follow']['logContext'] &
|
||||
LogEvents['profile:unfollow']['logContext'],
|
||||
) {
|
||||
const agent = useAgent()
|
||||
const queryClient = useQueryClient()
|
||||
const did = profile.did
|
||||
const initialFollowingUri = profile.viewer?.following
|
||||
@@ -253,6 +254,20 @@ export function useProfileFollowMutationQueue(
|
||||
updateProfileShadow(queryClient, did, {
|
||||
followingUri: finalFollowingUri,
|
||||
})
|
||||
|
||||
if (finalFollowingUri) {
|
||||
agent.app.bsky.graph
|
||||
.getSuggestedFollowsByActor({
|
||||
actor: did,
|
||||
})
|
||||
.then(res => {
|
||||
const dids = res.data.suggestions
|
||||
.filter(a => !a.viewer?.following)
|
||||
.map(a => a.did)
|
||||
.slice(0, 8)
|
||||
userActionHistory.followSuggestion(dids)
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -106,6 +106,7 @@ export function useSuggestedFollowsQuery(options?: SuggestedFollowsOptions) {
|
||||
export function useSuggestedFollowsByActorQuery({did}: {did: string}) {
|
||||
const agent = useAgent()
|
||||
return useQuery<AppBskyGraphGetSuggestedFollowsByActor.OutputSchema, Error>({
|
||||
gcTime: 0,
|
||||
queryKey: suggestedFollowsByActorQueryKey(did),
|
||||
queryFn: async () => {
|
||||
const res = await agent.app.bsky.graph.getSuggestedFollowsByActor({
|
||||
|
||||
@@ -2,10 +2,11 @@ import {createUploadTask, FileSystemUploadType} from 'expo-file-system'
|
||||
import {useMutation} from '@tanstack/react-query'
|
||||
import {nanoid} from 'nanoid/non-secure'
|
||||
|
||||
import {CompressedVideo} from 'lib/media/video/compress'
|
||||
import {UploadVideoResponse} from 'lib/media/video/types'
|
||||
import {createVideoEndpointUrl} from 'state/queries/video/util'
|
||||
import {useSession} from 'state/session'
|
||||
import {CompressedVideo} from '#/lib/media/video/compress'
|
||||
import {UploadVideoResponse} from '#/lib/media/video/types'
|
||||
import {createVideoEndpointUrl} from '#/state/queries/video/util'
|
||||
import {useAgent, useSession} from '#/state/session'
|
||||
|
||||
const UPLOAD_HEADER = process.env.EXPO_PUBLIC_VIDEO_HEADER ?? ''
|
||||
|
||||
export const useUploadVideoMutation = ({
|
||||
@@ -18,6 +19,7 @@ export const useUploadVideoMutation = ({
|
||||
setProgress: (progress: number) => void
|
||||
}) => {
|
||||
const {currentAccount} = useSession()
|
||||
const agent = useAgent()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (video: CompressedVideo) => {
|
||||
@@ -26,6 +28,17 @@ export const useUploadVideoMutation = ({
|
||||
name: `${nanoid(12)}.mp4`, // @TODO what are we limiting this to?
|
||||
})
|
||||
|
||||
// a logged-in agent should have this set, but we'll check just in case
|
||||
if (!agent.pdsUrl) {
|
||||
throw new Error('Agent does not have a PDS URL')
|
||||
}
|
||||
|
||||
const {data: serviceAuth} =
|
||||
await agent.api.com.atproto.server.getServiceAuth({
|
||||
aud: `did:web:${agent.pdsUrl.hostname}`,
|
||||
lxm: 'com.atproto.repo.uploadBlob',
|
||||
})
|
||||
|
||||
const uploadTask = createUploadTask(
|
||||
uri,
|
||||
video.uri,
|
||||
@@ -33,13 +46,12 @@ export const useUploadVideoMutation = ({
|
||||
headers: {
|
||||
'dev-key': UPLOAD_HEADER,
|
||||
'content-type': 'video/mp4', // @TODO same question here. does the compression step always output mp4?
|
||||
Authorization: `Bearer ${serviceAuth.token}`,
|
||||
},
|
||||
httpMethod: 'POST',
|
||||
uploadType: FileSystemUploadType.BINARY_CONTENT,
|
||||
},
|
||||
p => {
|
||||
setProgress(p.totalBytesSent / p.totalBytesExpectedToSend)
|
||||
},
|
||||
p => setProgress(p.totalBytesSent / p.totalBytesExpectedToSend),
|
||||
)
|
||||
const res = await uploadTask.uploadAsync()
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import {useMutation} from '@tanstack/react-query'
|
||||
import {nanoid} from 'nanoid/non-secure'
|
||||
|
||||
import {CompressedVideo} from 'lib/media/video/compress'
|
||||
import {UploadVideoResponse} from 'lib/media/video/types'
|
||||
import {createVideoEndpointUrl} from 'state/queries/video/util'
|
||||
import {useSession} from 'state/session'
|
||||
import {CompressedVideo} from '#/lib/media/video/compress'
|
||||
import {UploadVideoResponse} from '#/lib/media/video/types'
|
||||
import {createVideoEndpointUrl} from '#/state/queries/video/util'
|
||||
import {useAgent, useSession} from '#/state/session'
|
||||
|
||||
const UPLOAD_HEADER = process.env.EXPO_PUBLIC_VIDEO_HEADER ?? ''
|
||||
|
||||
export const useUploadVideoMutation = ({
|
||||
@@ -17,6 +18,7 @@ export const useUploadVideoMutation = ({
|
||||
setProgress: (progress: number) => void
|
||||
}) => {
|
||||
const {currentAccount} = useSession()
|
||||
const agent = useAgent()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (video: CompressedVideo) => {
|
||||
@@ -25,6 +27,17 @@ export const useUploadVideoMutation = ({
|
||||
name: `${nanoid(12)}.mp4`, // @TODO what are we limiting this to?
|
||||
})
|
||||
|
||||
// a logged-in agent should have this set, but we'll check just in case
|
||||
if (!agent.pdsUrl) {
|
||||
throw new Error('Agent does not have a PDS URL')
|
||||
}
|
||||
|
||||
const {data: serviceAuth} =
|
||||
await agent.api.com.atproto.server.getServiceAuth({
|
||||
aud: `did:web:${agent.pdsUrl.hostname}`,
|
||||
lxm: 'com.atproto.repo.uploadBlob',
|
||||
})
|
||||
|
||||
const bytes = await fetch(video.uri).then(res => res.arrayBuffer())
|
||||
|
||||
const xhr = new XMLHttpRequest()
|
||||
@@ -53,6 +66,7 @@ export const useUploadVideoMutation = ({
|
||||
xhr.setRequestHeader('Content-Type', 'video/mp4') // @TODO how we we set the proper content type?
|
||||
// @TODO remove this header for prod
|
||||
xhr.setRequestHeader('dev-key', UPLOAD_HEADER)
|
||||
xhr.setRequestHeader('Authorization', `Bearer ${serviceAuth.token}`)
|
||||
xhr.send(bytes)
|
||||
})) as UploadVideoResponse
|
||||
|
||||
|
||||
@@ -185,8 +185,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
}, [state])
|
||||
|
||||
React.useEffect(() => {
|
||||
return persisted.onUpdate(() => {
|
||||
const synced = persisted.get('session')
|
||||
return persisted.onUpdate('session', nextSession => {
|
||||
const synced = nextSession
|
||||
addSessionDebugLog({type: 'persisted:receive', data: synced})
|
||||
dispatch({
|
||||
type: 'synced-accounts',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react'
|
||||
|
||||
import * as persisted from '#/state/persisted'
|
||||
|
||||
type StateContext = {
|
||||
@@ -43,10 +44,16 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
return persisted.onUpdate(() => {
|
||||
setColorMode(persisted.get('colorMode'))
|
||||
setDarkTheme(persisted.get('darkTheme'))
|
||||
const unsub1 = persisted.onUpdate('darkTheme', nextDarkTheme => {
|
||||
setDarkTheme(nextDarkTheme)
|
||||
})
|
||||
const unsub2 = persisted.onUpdate('colorMode', nextColorMode => {
|
||||
setColorMode(nextColorMode)
|
||||
})
|
||||
return () => {
|
||||
unsub1()
|
||||
unsub2()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import React from 'react'
|
||||
import {
|
||||
AppBskyActorDefs,
|
||||
AppBskyEmbedRecord,
|
||||
AppBskyRichtextFacet,
|
||||
ModerationDecision,
|
||||
AppBskyActorDefs,
|
||||
} from '@atproto/api'
|
||||
|
||||
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
||||
|
||||
export interface ComposerOptsPostRef {
|
||||
@@ -31,7 +32,7 @@ export interface ComposerOptsQuote {
|
||||
}
|
||||
export interface ComposerOpts {
|
||||
replyTo?: ComposerOptsPostRef
|
||||
onPost?: () => void
|
||||
onPost?: (postUri: string | undefined) => void
|
||||
quote?: ComposerOptsQuote
|
||||
mention?: string // handle of user to mention
|
||||
openPicker?: (pos: DOMRect | undefined) => void
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react'
|
||||
import * as persisted from '#/state/persisted'
|
||||
|
||||
import {track} from '#/lib/analytics/analytics'
|
||||
import * as persisted from '#/state/persisted'
|
||||
|
||||
export const OnboardingScreenSteps = {
|
||||
Welcome: 'Welcome',
|
||||
@@ -81,13 +82,13 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
return persisted.onUpdate(() => {
|
||||
const next = persisted.get('onboarding').step
|
||||
return persisted.onUpdate('onboarding', nextOnboarding => {
|
||||
const next = nextOnboarding.step
|
||||
// TODO we've introduced a footgun
|
||||
if (state.step !== next) {
|
||||
dispatch({
|
||||
type: 'set',
|
||||
step: persisted.get('onboarding').step as OnboardingStep,
|
||||
step: nextOnboarding.step as OnboardingStep,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -2,7 +2,6 @@ import React from 'react'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useGate} from '#/lib/statsig/statsig'
|
||||
import {
|
||||
ProgressGuideToast,
|
||||
ProgressGuideToastRef,
|
||||
@@ -61,7 +60,6 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
const {data: preferences} = usePreferencesQuery()
|
||||
const {mutateAsync, variables, isPending} =
|
||||
useSetActiveProgressGuideMutation()
|
||||
const gate = useGate()
|
||||
|
||||
const activeProgressGuide = (
|
||||
isPending ? variables : preferences?.bskyAppState?.activeProgressGuide
|
||||
@@ -89,9 +87,6 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
const controls = React.useMemo(() => {
|
||||
return {
|
||||
startProgressGuide(guide: ProgressGuideName) {
|
||||
if (!gate('new_user_progress_guide')) {
|
||||
return
|
||||
}
|
||||
if (guide === 'like-10-and-follow-7') {
|
||||
const guideObj = {
|
||||
guide: 'like-10-and-follow-7',
|
||||
@@ -148,7 +143,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
mutateAsync(guide?.isComplete ? undefined : guide)
|
||||
},
|
||||
}
|
||||
}, [activeProgressGuide, mutateAsync, gate, setLocalGuideState])
|
||||
}, [activeProgressGuide, mutateAsync, setLocalGuideState])
|
||||
|
||||
return (
|
||||
<ProgressGuideContext.Provider value={localGuideState}>
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from 'react'
|
||||
|
||||
const LIKE_WINDOW = 100
|
||||
const FOLLOW_WINDOW = 100
|
||||
const FOLLOW_SUGGESTION_WINDOW = 100
|
||||
const SEEN_WINDOW = 100
|
||||
|
||||
export type SeenPost = {
|
||||
@@ -22,6 +23,10 @@ export type UserActionHistory = {
|
||||
* The last 100 DIDs the user has followed
|
||||
*/
|
||||
follows: string[]
|
||||
/*
|
||||
* The last 100 DIDs of suggested follows based on last follows
|
||||
*/
|
||||
followSuggestions: string[]
|
||||
/**
|
||||
* The last 100 post URIs the user has seen from the Discover feed only
|
||||
*/
|
||||
@@ -31,6 +36,7 @@ export type UserActionHistory = {
|
||||
const userActionHistory: UserActionHistory = {
|
||||
likes: [],
|
||||
follows: [],
|
||||
followSuggestions: [],
|
||||
seen: [],
|
||||
}
|
||||
|
||||
@@ -58,6 +64,13 @@ export function follow(dids: string[]) {
|
||||
.concat(dids)
|
||||
.slice(-FOLLOW_WINDOW)
|
||||
}
|
||||
|
||||
export function followSuggestion(dids: string[]) {
|
||||
userActionHistory.followSuggestions = userActionHistory.followSuggestions
|
||||
.concat(dids)
|
||||
.slice(-FOLLOW_SUGGESTION_WINDOW)
|
||||
}
|
||||
|
||||
export function unfollow(dids: string[]) {
|
||||
userActionHistory.follows = userActionHistory.follows.filter(
|
||||
uri => !dids.includes(uri),
|
||||
|
||||
@@ -1,25 +1,20 @@
|
||||
import React from 'react'
|
||||
import {Pressable, View} from 'react-native'
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
|
||||
import {useAnalytics} from '#/lib/analytics/analytics'
|
||||
import {usePalette} from '#/lib/hooks/usePalette'
|
||||
import {logEvent} from '#/lib/statsig/statsig'
|
||||
import {s} from '#/lib/styles'
|
||||
import {isIOS, isNative} from '#/platform/detection'
|
||||
import {useSession} from '#/state/session'
|
||||
import {isIOS} from '#/platform/detection'
|
||||
import {
|
||||
useLoggedOutView,
|
||||
useLoggedOutViewControls,
|
||||
} from '#/state/shell/logged-out'
|
||||
import {useSetMinimalShellMode} from '#/state/shell/minimal-mode'
|
||||
import {NavigationProp} from 'lib/routes/types'
|
||||
import {useGate} from 'lib/statsig/statsig'
|
||||
import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
|
||||
import {Text} from '#/view/com/util/text/Text'
|
||||
import {Login} from '#/screens/Login'
|
||||
import {Signup} from '#/screens/Signup'
|
||||
import {LandingScreen} from '#/screens/StarterPack/StarterPackLandingScreen'
|
||||
@@ -34,7 +29,6 @@ enum ScreenState {
|
||||
export {ScreenState as LoggedOutScreenState}
|
||||
|
||||
export function LoggedOut({onDismiss}: {onDismiss?: () => void}) {
|
||||
const {hasSession} = useSession()
|
||||
const {_} = useLingui()
|
||||
const pal = usePalette('default')
|
||||
const setMinimalShellMode = useSetMinimalShellMode()
|
||||
@@ -52,10 +46,7 @@ export function LoggedOut({onDismiss}: {onDismiss?: () => void}) {
|
||||
}
|
||||
})
|
||||
const {clearRequestedAccount} = useLoggedOutViewControls()
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
const gate = useGate()
|
||||
|
||||
const isFirstScreen = screenState === ScreenState.S_LoginOrCreateAccount
|
||||
React.useEffect(() => {
|
||||
screen('Login')
|
||||
setMinimalShellMode(true)
|
||||
@@ -68,10 +59,6 @@ export function LoggedOut({onDismiss}: {onDismiss?: () => void}) {
|
||||
clearRequestedAccount()
|
||||
}, [clearRequestedAccount, onDismiss])
|
||||
|
||||
const onPressSearch = React.useCallback(() => {
|
||||
navigation.navigate(`SearchTab`)
|
||||
}, [navigation])
|
||||
|
||||
return (
|
||||
<View testID="noSessionView" style={[s.hContentRegion, pal.view]}>
|
||||
<ErrorBoundary>
|
||||
@@ -98,39 +85,6 @@ export function LoggedOut({onDismiss}: {onDismiss?: () => void}) {
|
||||
}}
|
||||
/>
|
||||
</Pressable>
|
||||
) : isNative &&
|
||||
!hasSession &&
|
||||
isFirstScreen &&
|
||||
!gate('native_pwi_disabled') ? (
|
||||
<Pressable
|
||||
accessibilityHint={_(msg`Search for users`)}
|
||||
accessibilityLabel={_(msg`Search for users`)}
|
||||
accessibilityRole="button"
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
position: 'absolute',
|
||||
top: 20,
|
||||
right: 20,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 8,
|
||||
zIndex: 100,
|
||||
backgroundColor: pal.btn.backgroundColor,
|
||||
borderRadius: 100,
|
||||
}}
|
||||
onPress={onPressSearch}>
|
||||
<Text type="lg-bold" style={[pal.text]}>
|
||||
<Trans>Search</Trans>{' '}
|
||||
</Text>
|
||||
<FontAwesomeIcon
|
||||
icon="search"
|
||||
size={16}
|
||||
style={{
|
||||
color: String(pal.text.color),
|
||||
}}
|
||||
/>
|
||||
</Pressable>
|
||||
) : null}
|
||||
|
||||
{screenState === ScreenState.S_StarterPack ? (
|
||||
|
||||
@@ -392,7 +392,7 @@ export const ComposePost = observer(function ComposePost({
|
||||
emitPostCreated()
|
||||
}
|
||||
setLangPrefs.savePostLanguageToHistory()
|
||||
onPost?.()
|
||||
onPost?.(postUri)
|
||||
onClose()
|
||||
Toast.show(
|
||||
replyTo
|
||||
|
||||
@@ -91,7 +91,7 @@ export function ComposerReplyTo({replyTo}: {replyTo: ComposerOptsPostRef}) {
|
||||
type={replyTo.author.associated?.labeler ? 'labeler' : 'user'}
|
||||
/>
|
||||
<View style={styles.replyToPost}>
|
||||
<Text type="xl-medium" style={t.atoms.text}>
|
||||
<Text type="xl-medium" style={t.atoms.text} numberOfLines={1}>
|
||||
{sanitizeDisplayName(
|
||||
replyTo.author.displayName || sanitizeHandle(replyTo.author.handle),
|
||||
)}
|
||||
|
||||
@@ -194,6 +194,7 @@ export function Feed({
|
||||
initialNumToRender={initialNumToRender}
|
||||
windowSize={11}
|
||||
sideBorders={false}
|
||||
removeClippedSubviews={true}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
|
||||
@@ -25,7 +25,6 @@ import {useLingui} from '@lingui/react'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {useGate} from '#/lib/statsig/statsig'
|
||||
import {parseTenorGif} from '#/lib/strings/embed-player'
|
||||
import {logger} from '#/logger'
|
||||
import {FeedNotification} from '#/state/queries/notifications/feed'
|
||||
@@ -87,7 +86,6 @@ let FeedItem = ({
|
||||
const pal = usePalette('default')
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const gate = useGate()
|
||||
const [isAuthorsExpanded, setAuthorsExpanded] = useState<boolean>(false)
|
||||
const itemHref = useMemo(() => {
|
||||
if (item.type === 'post-like' || item.type === 'repost') {
|
||||
@@ -207,7 +205,7 @@ let FeedItem = ({
|
||||
}
|
||||
}
|
||||
|
||||
if (isFollowBack && gate('ungroup_follow_backs')) {
|
||||
if (isFollowBack) {
|
||||
action = _(msg`followed you back`)
|
||||
} else {
|
||||
action = _(msg`followed you`)
|
||||
@@ -255,6 +253,7 @@ let FeedItem = ({
|
||||
borderColor: pal.colors.unreadNotifBorder,
|
||||
},
|
||||
{borderTopWidth: hideTopBorder ? 0 : StyleSheet.hairlineWidth},
|
||||
a.overflow_hidden,
|
||||
]}
|
||||
href={itemHref}
|
||||
noFeedback
|
||||
@@ -547,7 +546,7 @@ function ExpandedAuthorsList({
|
||||
}, [heightInterp, visible])
|
||||
|
||||
return (
|
||||
<Animated.View style={[heightStyle, styles.overflowHidden]}>
|
||||
<Animated.View style={[a.overflow_hidden, heightStyle]}>
|
||||
{visible &&
|
||||
authors.map(author => (
|
||||
<NewLink
|
||||
@@ -643,9 +642,6 @@ function AdditionalPostText({post}: {post?: AppBskyFeedDefs.PostView}) {
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overflowHidden: {
|
||||
overflow: 'hidden',
|
||||
},
|
||||
pointer: isWeb
|
||||
? {
|
||||
// @ts-ignore web only
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React, {useEffect, useRef} from 'react'
|
||||
import {useWindowDimensions, View} from 'react-native'
|
||||
import React, {useRef} from 'react'
|
||||
import {StyleSheet, useWindowDimensions, View} from 'react-native'
|
||||
import {runOnJS} from 'react-native-reanimated'
|
||||
import Animated from 'react-native-reanimated'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import {
|
||||
AppBskyFeedDefs,
|
||||
AppBskyFeedPost,
|
||||
@@ -10,6 +12,7 @@ import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped'
|
||||
import {clamp} from '#/lib/numbers'
|
||||
import {ScrollProvider} from '#/lib/ScrollContext'
|
||||
import {isAndroid, isNative, isWeb} from '#/platform/detection'
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
@@ -26,7 +29,9 @@ import {
|
||||
import {usePreferencesQuery} from '#/state/queries/preferences'
|
||||
import {useThreadgateRecordQuery} from '#/state/queries/threadgate'
|
||||
import {useSession} from '#/state/session'
|
||||
import {useComposerControls} from '#/state/shell'
|
||||
import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender'
|
||||
import {useMinimalShellFabTransform} from 'lib/hooks/useMinimalShellTransform'
|
||||
import {useSetTitle} from 'lib/hooks/useSetTitle'
|
||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||
import {sanitizeDisplayName} from 'lib/strings/display-names'
|
||||
@@ -35,9 +40,9 @@ import {CenteredView} from 'view/com/util/Views'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {ListFooter, ListMaybePlaceholder} from '#/components/Lists'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {ComposePrompt} from '../composer/Prompt'
|
||||
import {List, ListMethods} from '../util/List'
|
||||
import {ViewHeader} from '../util/ViewHeader'
|
||||
import {PostThreadComposePrompt} from './PostThreadComposePrompt'
|
||||
import {PostThreadItem} from './PostThreadItem'
|
||||
import {PostThreadLoadMore} from './PostThreadLoadMore'
|
||||
import {PostThreadShowHiddenReplies} from './PostThreadShowHiddenReplies'
|
||||
@@ -85,16 +90,8 @@ const keyExtractor = (item: RowItem) => {
|
||||
return item._reactKey
|
||||
}
|
||||
|
||||
export function PostThread({
|
||||
uri,
|
||||
onCanReply,
|
||||
onPressReply,
|
||||
}: {
|
||||
uri: string | undefined
|
||||
onCanReply: (canReply: boolean) => void
|
||||
onPressReply: () => unknown
|
||||
}) {
|
||||
const {hasSession} = useSession()
|
||||
export function PostThread({uri}: {uri: string | undefined}) {
|
||||
const {hasSession, currentAccount} = useSession()
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const {isMobile, isTabletOrMobile} = useWebMediaQueries()
|
||||
@@ -169,6 +166,7 @@ export function PostThread({
|
||||
// On the web this is not necessary because we can synchronously adjust the scroll in onContentSizeChange instead.
|
||||
const [deferParents, setDeferParents] = React.useState(isNative)
|
||||
|
||||
const currentDid = currentAccount?.did
|
||||
const threadModerationCache = React.useMemo(() => {
|
||||
const cache: ThreadModerationCache = new WeakMap()
|
||||
if (thread && moderationOpts) {
|
||||
@@ -177,6 +175,10 @@ export function PostThread({
|
||||
return cache
|
||||
}, [thread, moderationOpts])
|
||||
|
||||
const [justPostedUris, setJustPostedUris] = React.useState(
|
||||
() => new Set<string>(),
|
||||
)
|
||||
|
||||
const skeleton = React.useMemo(() => {
|
||||
const threadViewPrefs = preferences?.threadViewPrefs
|
||||
if (!threadViewPrefs || !thread) return null
|
||||
@@ -186,21 +188,24 @@ export function PostThread({
|
||||
thread,
|
||||
threadViewPrefs,
|
||||
threadModerationCache,
|
||||
currentDid,
|
||||
justPostedUris,
|
||||
threadgateRecord ?? undefined,
|
||||
),
|
||||
hasSession,
|
||||
!!currentDid,
|
||||
treeView,
|
||||
threadModerationCache,
|
||||
hiddenRepliesState !== HiddenRepliesState.Hide,
|
||||
threadgateRecord || undefined,
|
||||
threadgateRecord ?? undefined,
|
||||
)
|
||||
}, [
|
||||
thread,
|
||||
preferences?.threadViewPrefs,
|
||||
hasSession,
|
||||
currentDid,
|
||||
treeView,
|
||||
threadModerationCache,
|
||||
hiddenRepliesState,
|
||||
justPostedUris,
|
||||
threadgateRecord,
|
||||
])
|
||||
|
||||
@@ -231,14 +236,6 @@ export function PostThread({
|
||||
return null
|
||||
}, [thread, skeleton?.highlightedPost, isThreadError, _, threadError])
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
onCanReply(false)
|
||||
} else if (rootPost) {
|
||||
onCanReply(!rootPost.viewer?.replyDisabled)
|
||||
}
|
||||
}, [rootPost, onCanReply, error])
|
||||
|
||||
// construct content
|
||||
const posts = React.useMemo(() => {
|
||||
if (!skeleton) return []
|
||||
@@ -334,6 +331,38 @@ export function PostThread({
|
||||
setMaxReplies(prev => prev + 50)
|
||||
}, [isFetching, maxReplies, posts.length])
|
||||
|
||||
const onPostReply = React.useCallback(
|
||||
(postUri: string | undefined) => {
|
||||
refetch()
|
||||
if (postUri) {
|
||||
setJustPostedUris(set => {
|
||||
const nextSet = new Set(set)
|
||||
nextSet.add(postUri)
|
||||
return nextSet
|
||||
})
|
||||
}
|
||||
},
|
||||
[refetch],
|
||||
)
|
||||
|
||||
const {openComposer} = useComposerControls()
|
||||
const onPressReply = React.useCallback(() => {
|
||||
if (thread?.type !== 'post') {
|
||||
return
|
||||
}
|
||||
openComposer({
|
||||
replyTo: {
|
||||
uri: thread.post.uri,
|
||||
cid: thread.post.cid,
|
||||
text: thread.record.text,
|
||||
author: thread.post.author,
|
||||
embed: thread.post.embed,
|
||||
},
|
||||
onPost: onPostReply,
|
||||
})
|
||||
}, [openComposer, thread, onPostReply])
|
||||
|
||||
const canReply = !error && rootPost && !rootPost.viewer?.replyDisabled
|
||||
const hasParents =
|
||||
skeleton?.highlightedPost?.type === 'post' &&
|
||||
(skeleton.highlightedPost.ctx.isParentLoading ||
|
||||
@@ -345,7 +374,9 @@ export function PostThread({
|
||||
if (item === REPLY_PROMPT && hasSession) {
|
||||
return (
|
||||
<View>
|
||||
{!isMobile && <ComposePrompt onPressCompose={onPressReply} />}
|
||||
{!isMobile && (
|
||||
<PostThreadComposePrompt onPressCompose={onPressReply} />
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
} else if (item === SHOW_HIDDEN_REPLIES || item === SHOW_MUTED_REPLIES) {
|
||||
@@ -427,7 +458,7 @@ export function PostThread({
|
||||
HiddenRepliesState.ShowAndOverridePostHider &&
|
||||
item.ctx.depth > 0
|
||||
}
|
||||
onPostReply={refetch}
|
||||
onPostReply={onPostReply}
|
||||
hideTopBorder={index === 0 && !item.ctx.isParentLoading}
|
||||
/>
|
||||
</View>
|
||||
@@ -494,10 +525,30 @@ export function PostThread({
|
||||
sideBorders={false}
|
||||
/>
|
||||
</ScrollProvider>
|
||||
{isMobile && canReply && hasSession && (
|
||||
<MobileComposePrompt onPressReply={onPressReply} />
|
||||
)}
|
||||
</CenteredView>
|
||||
)
|
||||
}
|
||||
|
||||
function MobileComposePrompt({onPressReply}: {onPressReply: () => unknown}) {
|
||||
const safeAreaInsets = useSafeAreaInsets()
|
||||
const fabMinimalShellTransform = useMinimalShellFabTransform()
|
||||
return (
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.prompt,
|
||||
fabMinimalShellTransform,
|
||||
{
|
||||
bottom: clamp(safeAreaInsets.bottom, 15, 30),
|
||||
},
|
||||
]}>
|
||||
<PostThreadComposePrompt onPressCompose={onPressReply} />
|
||||
</Animated.View>
|
||||
)
|
||||
}
|
||||
|
||||
function isThreadPost(v: unknown): v is ThreadPost {
|
||||
return !!v && typeof v === 'object' && 'type' in v && v.type === 'post'
|
||||
}
|
||||
@@ -516,7 +567,7 @@ function createThreadSkeleton(
|
||||
treeView: boolean,
|
||||
modCache: ThreadModerationCache,
|
||||
showHiddenReplies: boolean,
|
||||
threadgateRecord: AppBskyFeedThreadgate.Record | undefined,
|
||||
threadgateRecord?: AppBskyFeedThreadgate.Record,
|
||||
): ThreadSkeletonParts | null {
|
||||
if (!node) return null
|
||||
|
||||
@@ -567,7 +618,7 @@ function* flattenThreadReplies(
|
||||
treeView: boolean,
|
||||
modCache: ThreadModerationCache,
|
||||
showHiddenReplies: boolean,
|
||||
threadgateRecord: AppBskyFeedThreadgate.Record | undefined,
|
||||
threadgateRecord?: AppBskyFeedThreadgate.Record,
|
||||
): Generator<YieldedItem, HiddenReplyType> {
|
||||
if (node.type === 'post') {
|
||||
// dont show pwi-opted-out posts to logged out users
|
||||
@@ -654,3 +705,12 @@ function hasBranchingReplies(node?: ThreadNode) {
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
prompt: {
|
||||
// @ts-ignore web-only
|
||||
position: isWeb ? 'fixed' : 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
},
|
||||
})
|
||||
|
||||
+5
-1
@@ -10,7 +10,11 @@ import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||
import {Text} from '../util/text/Text'
|
||||
import {UserAvatar} from '../util/UserAvatar'
|
||||
|
||||
export function ComposePrompt({onPressCompose}: {onPressCompose: () => void}) {
|
||||
export function PostThreadComposePrompt({
|
||||
onPressCompose,
|
||||
}: {
|
||||
onPressCompose: () => void
|
||||
}) {
|
||||
const {currentAccount} = useSession()
|
||||
const {data: profile} = useProfileQuery({did: currentAccount?.did})
|
||||
const pal = usePalette('default')
|
||||
@@ -77,7 +77,7 @@ export function PostThreadItem({
|
||||
showParentReplyLine?: boolean
|
||||
hasPrecedingItem: boolean
|
||||
overrideBlur: boolean
|
||||
onPostReply: () => void
|
||||
onPostReply: (postUri: string | undefined) => void
|
||||
hideTopBorder?: boolean
|
||||
}) {
|
||||
const postShadowed = usePostShadow(post)
|
||||
@@ -171,7 +171,7 @@ let PostThreadItemLoaded = ({
|
||||
showParentReplyLine?: boolean
|
||||
hasPrecedingItem: boolean
|
||||
overrideBlur: boolean
|
||||
onPostReply: () => void
|
||||
onPostReply: (postUri: string | undefined) => void
|
||||
hideTopBorder?: boolean
|
||||
}): React.ReactNode => {
|
||||
const pal = usePalette('default')
|
||||
@@ -765,6 +765,7 @@ const styles = StyleSheet.create({
|
||||
flexWrap: 'wrap',
|
||||
paddingBottom: 4,
|
||||
paddingRight: 10,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
postTextLargeContainer: {
|
||||
paddingHorizontal: 0,
|
||||
|
||||
@@ -12,17 +12,17 @@ import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {MAX_POST_LINES} from '#/lib/constants'
|
||||
import {usePalette} from '#/lib/hooks/usePalette'
|
||||
import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped'
|
||||
import {makeProfileLink} from '#/lib/routes/links'
|
||||
import {countLines} from '#/lib/strings/helpers'
|
||||
import {colors, s} from '#/lib/styles'
|
||||
import {POST_TOMBSTONE, Shadow, usePostShadow} from '#/state/cache/post-shadow'
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {precacheProfile} from '#/state/queries/profile'
|
||||
import {useSession} from '#/state/session'
|
||||
import {useComposerControls} from '#/state/shell/composer'
|
||||
import {MAX_POST_LINES} from 'lib/constants'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {makeProfileLink} from 'lib/routes/links'
|
||||
import {countLines} from 'lib/strings/helpers'
|
||||
import {colors, s} from 'lib/styles'
|
||||
import {precacheProfile} from 'state/queries/profile'
|
||||
import {AviFollowButton} from '#/view/com/posts/AviFollowButton'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {ProfileHoverCard} from '#/components/ProfileHoverCard'
|
||||
@@ -280,6 +280,7 @@ const styles = StyleSheet.create({
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
replyLine: {
|
||||
position: 'absolute',
|
||||
|
||||
@@ -7,7 +7,6 @@ import {useNavigation} from '@react-navigation/native'
|
||||
|
||||
import {createHitslop} from '#/lib/constants'
|
||||
import {NavigationProp} from '#/lib/routes/types'
|
||||
import {useGate} from '#/lib/statsig/statsig'
|
||||
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||
import {useProfileShadow} from '#/state/cache/profile-shadow'
|
||||
import {useSession} from '#/state/session'
|
||||
@@ -37,7 +36,6 @@ export function AviFollowButton({
|
||||
profile: profile,
|
||||
logContext: 'AvatarButton',
|
||||
})
|
||||
const gate = useGate()
|
||||
const {currentAccount, hasSession} = useSession()
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
|
||||
@@ -80,7 +78,7 @@ export function AviFollowButton({
|
||||
},
|
||||
]
|
||||
|
||||
return hasSession && gate('show_avi_follow_button') ? (
|
||||
return hasSession ? (
|
||||
<View style={a.relative}>
|
||||
{children}
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {FALLBACK_MARKER_POST} from '#/lib/api/feed/home'
|
||||
import {DISCOVER_FEED_URI, KNOWN_SHUTDOWN_FEEDS} from '#/lib/constants'
|
||||
import {logEvent, useGate} from '#/lib/statsig/statsig'
|
||||
import {logger} from '#/logger'
|
||||
@@ -181,6 +180,7 @@ let Feed = ({
|
||||
ListHeaderComponent?: () => JSX.Element
|
||||
extraData?: any
|
||||
savedFeedConfig?: AppBskyActorDefs.SavedFeed
|
||||
outsideHeaderOffset?: number
|
||||
}): React.ReactNode => {
|
||||
const theme = useTheme()
|
||||
const {track} = useAnalytics()
|
||||
@@ -212,8 +212,9 @@ let Feed = ({
|
||||
isFetchingNextPage,
|
||||
fetchNextPage,
|
||||
} = usePostFeedQuery(feed, feedParams, opts)
|
||||
if (data?.pages[0]) {
|
||||
lastFetchRef.current = data?.pages[0].fetchedAt
|
||||
const lastFetchedAt = data?.pages[0].fetchedAt
|
||||
if (lastFetchedAt) {
|
||||
lastFetchRef.current = lastFetchedAt
|
||||
}
|
||||
const isEmpty = React.useMemo(
|
||||
() => !isFetching && !data?.pages?.some(page => page.slices.length),
|
||||
@@ -348,8 +349,7 @@ let Feed = ({
|
||||
const shouldShow =
|
||||
(interstitial.type === feedInterstitialType &&
|
||||
gate('suggested_feeds_interstitial')) ||
|
||||
(interstitial.type === followInterstitialType &&
|
||||
gate('suggested_follows_interstitial')) ||
|
||||
interstitial.type === followInterstitialType ||
|
||||
interstitial.type === progressGuideInterstitialType
|
||||
|
||||
if (shouldShow) {
|
||||
@@ -358,7 +358,7 @@ let Feed = ({
|
||||
...interstitial,
|
||||
params: {variant},
|
||||
// overwrite key with unique value
|
||||
key: [interstitial.type, variant].join(':'),
|
||||
key: [interstitial.type, variant, lastFetchedAt].join(':'),
|
||||
}
|
||||
|
||||
if (arr.length > interstitial.slot) {
|
||||
@@ -374,6 +374,7 @@ let Feed = ({
|
||||
isFetched,
|
||||
isError,
|
||||
isEmpty,
|
||||
lastFetchedAt,
|
||||
data,
|
||||
feedUri,
|
||||
feedIsDiscover,
|
||||
@@ -472,7 +473,7 @@ let Feed = ({
|
||||
} else if (item.type === progressGuideInterstitialType) {
|
||||
return <ProgressGuide />
|
||||
} else if (item.type === 'slice') {
|
||||
if (item.slice.rootUri === FALLBACK_MARKER_POST.post.uri) {
|
||||
if (item.slice.isFallbackMarker) {
|
||||
// HACK
|
||||
// tell the user we fell back to discover
|
||||
// see home.ts (feed api) for more info
|
||||
|
||||
@@ -345,11 +345,9 @@ let FeedItemInner = ({
|
||||
postHref={href}
|
||||
onOpenAuthor={onOpenAuthor}
|
||||
/>
|
||||
{!isThreadChild &&
|
||||
showReplyTo &&
|
||||
(parentAuthor || isParentBlocked) && (
|
||||
<ReplyToLabel blocked={isParentBlocked} profile={parentAuthor} />
|
||||
)}
|
||||
{showReplyTo && (parentAuthor || isParentBlocked) && (
|
||||
<ReplyToLabel blocked={isParentBlocked} profile={parentAuthor} />
|
||||
)}
|
||||
<LabelsOnMyPost post={post} />
|
||||
<PostContent
|
||||
moderation={moderation}
|
||||
@@ -358,7 +356,7 @@ let FeedItemInner = ({
|
||||
postAuthor={post.author}
|
||||
onOpenEmbed={onOpenEmbed}
|
||||
/>
|
||||
{__DEV__ && gate('videos') && (
|
||||
{gate('video_debug') && (
|
||||
<VideoEmbed source="https://lumi.jazco.dev/watch/did:plc:q6gjnaw2blty4crticxkmujt/Qmc8w93UpTa2adJHg4ZhnDPrBs1EsbzrekzPcqF5SwusuZ/playlist.m3u8" />
|
||||
)}
|
||||
<PostCtrls
|
||||
@@ -509,7 +507,6 @@ const styles = StyleSheet.create({
|
||||
paddingRight: 15,
|
||||
// @ts-ignore web only -prf
|
||||
cursor: 'pointer',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
replyLine: {
|
||||
width: 2,
|
||||
@@ -547,6 +544,7 @@ const styles = StyleSheet.create({
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
paddingBottom: 2,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
contentHiderChild: {
|
||||
marginTop: 6,
|
||||
|
||||
@@ -18,7 +18,7 @@ let FeedSlice = ({
|
||||
slice: FeedPostSlice
|
||||
hideTopBorder?: boolean
|
||||
}): React.ReactNode => {
|
||||
if (slice.isThread && slice.items.length > 3) {
|
||||
if (slice.isIncompleteThread && slice.items.length >= 3) {
|
||||
const beforeLast = slice.items.length - 2
|
||||
const last = slice.items.length - 1
|
||||
return (
|
||||
@@ -27,25 +27,28 @@ let FeedSlice = ({
|
||||
key={slice.items[0]._reactKey}
|
||||
post={slice.items[0].post}
|
||||
record={slice.items[0].record}
|
||||
reason={slice.items[0].reason}
|
||||
feedContext={slice.items[0].feedContext}
|
||||
reason={slice.reason}
|
||||
feedContext={slice.feedContext}
|
||||
parentAuthor={slice.items[0].parentAuthor}
|
||||
showReplyTo={true}
|
||||
showReplyTo={false}
|
||||
moderation={slice.items[0].moderation}
|
||||
isThreadParent={isThreadParentAt(slice.items, 0)}
|
||||
isThreadChild={isThreadChildAt(slice.items, 0)}
|
||||
hideTopBorder={hideTopBorder}
|
||||
isParentBlocked={slice.items[0].isParentBlocked}
|
||||
/>
|
||||
<ViewFullThread slice={slice} />
|
||||
<ViewFullThread uri={slice.items[0].uri} />
|
||||
<FeedItem
|
||||
key={slice.items[beforeLast]._reactKey}
|
||||
post={slice.items[beforeLast].post}
|
||||
record={slice.items[beforeLast].record}
|
||||
reason={slice.items[beforeLast].reason}
|
||||
feedContext={slice.items[beforeLast].feedContext}
|
||||
reason={undefined}
|
||||
feedContext={slice.feedContext}
|
||||
parentAuthor={slice.items[beforeLast].parentAuthor}
|
||||
showReplyTo={false}
|
||||
showReplyTo={
|
||||
slice.items[beforeLast].parentAuthor?.did !==
|
||||
slice.items[beforeLast].post.author.did
|
||||
}
|
||||
moderation={slice.items[beforeLast].moderation}
|
||||
isThreadParent={isThreadParentAt(slice.items, beforeLast)}
|
||||
isThreadChild={isThreadChildAt(slice.items, beforeLast)}
|
||||
@@ -55,8 +58,8 @@ let FeedSlice = ({
|
||||
key={slice.items[last]._reactKey}
|
||||
post={slice.items[last].post}
|
||||
record={slice.items[last].record}
|
||||
reason={slice.items[last].reason}
|
||||
feedContext={slice.items[last].feedContext}
|
||||
reason={undefined}
|
||||
feedContext={slice.feedContext}
|
||||
parentAuthor={slice.items[last].parentAuthor}
|
||||
showReplyTo={false}
|
||||
moderation={slice.items[last].moderation}
|
||||
@@ -76,8 +79,8 @@ let FeedSlice = ({
|
||||
key={item._reactKey}
|
||||
post={slice.items[i].post}
|
||||
record={slice.items[i].record}
|
||||
reason={slice.items[i].reason}
|
||||
feedContext={slice.items[i].feedContext}
|
||||
reason={i === 0 ? slice.reason : undefined}
|
||||
feedContext={slice.feedContext}
|
||||
moderation={slice.items[i].moderation}
|
||||
parentAuthor={slice.items[i].parentAuthor}
|
||||
showReplyTo={i === 0}
|
||||
@@ -96,12 +99,12 @@ let FeedSlice = ({
|
||||
FeedSlice = memo(FeedSlice)
|
||||
export {FeedSlice}
|
||||
|
||||
function ViewFullThread({slice}: {slice: FeedPostSlice}) {
|
||||
function ViewFullThread({uri}: {uri: string}) {
|
||||
const pal = usePalette('default')
|
||||
const itemHref = React.useMemo(() => {
|
||||
const urip = new AtUri(slice.rootUri)
|
||||
const urip = new AtUri(uri)
|
||||
return makeProfileLink({did: urip.hostname, handle: ''}, 'post', urip.rkey)
|
||||
}, [slice.rootUri])
|
||||
}, [uri])
|
||||
|
||||
return (
|
||||
<Link style={[styles.viewFullThread]} href={itemHref} asAnchor noFeedback>
|
||||
|
||||
@@ -1,32 +1,60 @@
|
||||
import React from 'react'
|
||||
import {Pressable, ScrollView, StyleSheet, View} from 'react-native'
|
||||
import {AppBskyActorDefs, moderateProfile} from '@atproto/api'
|
||||
import {
|
||||
FontAwesomeIcon,
|
||||
FontAwesomeIconStyle,
|
||||
} from '@fortawesome/react-native-fontawesome'
|
||||
import {ScrollView, View} from 'react-native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useProfileShadow} from '#/state/cache/profile-shadow'
|
||||
import {logEvent} from '#/lib/statsig/statsig'
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {useProfileFollowMutationQueue} from '#/state/queries/profile'
|
||||
import {useSuggestedFollowsByActorQuery} from '#/state/queries/suggested-follows'
|
||||
import {useAnalytics} from 'lib/analytics/analytics'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {makeProfileLink} from 'lib/routes/links'
|
||||
import {sanitizeDisplayName} from 'lib/strings/display-names'
|
||||
import {sanitizeHandle} from 'lib/strings/handles'
|
||||
import {isWeb} from 'platform/detection'
|
||||
import {Button} from 'view/com/util/forms/Button'
|
||||
import {Link} from 'view/com/util/Link'
|
||||
import {Text} from 'view/com/util/text/Text'
|
||||
import {PreviewableUserAvatar} from 'view/com/util/UserAvatar'
|
||||
import * as Toast from '../util/Toast'
|
||||
import {atoms as a, useTheme, ViewStyleProp} from '#/alf'
|
||||
import {Button, ButtonIcon} from '#/components/Button'
|
||||
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
|
||||
import * as ProfileCard from '#/components/ProfileCard'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
const OUTER_PADDING = 10
|
||||
const INNER_PADDING = 14
|
||||
const TOTAL_HEIGHT = 250
|
||||
const OUTER_PADDING = a.p_md.padding
|
||||
const INNER_PADDING = a.p_lg.padding
|
||||
const TOTAL_HEIGHT = 232
|
||||
const MOBILE_CARD_WIDTH = 300
|
||||
|
||||
function CardOuter({
|
||||
children,
|
||||
style,
|
||||
}: {children: React.ReactNode | React.ReactNode[]} & ViewStyleProp) {
|
||||
const t = useTheme()
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.w_full,
|
||||
a.p_lg,
|
||||
a.rounded_md,
|
||||
a.border,
|
||||
t.atoms.bg,
|
||||
t.atoms.border_contrast_low,
|
||||
{
|
||||
width: MOBILE_CARD_WIDTH,
|
||||
},
|
||||
style,
|
||||
]}>
|
||||
{children}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export function SuggestedFollowPlaceholder() {
|
||||
const t = useTheme()
|
||||
return (
|
||||
<CardOuter style={[a.gap_sm, t.atoms.border_contrast_low]}>
|
||||
<ProfileCard.Header>
|
||||
<ProfileCard.AvatarPlaceholder />
|
||||
<ProfileCard.NameAndHandlePlaceholder />
|
||||
</ProfileCard.Header>
|
||||
|
||||
<ProfileCard.DescriptionPlaceholder />
|
||||
</CardOuter>
|
||||
)
|
||||
}
|
||||
|
||||
export function ProfileHeaderSuggestedFollows({
|
||||
actorDid,
|
||||
@@ -35,47 +63,55 @@ export function ProfileHeaderSuggestedFollows({
|
||||
actorDid: string
|
||||
requestDismiss: () => void
|
||||
}) {
|
||||
const pal = usePalette('default')
|
||||
const {isLoading, data} = useSuggestedFollowsByActorQuery({
|
||||
did: actorDid,
|
||||
})
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {isLoading: isSuggestionsLoading, data} =
|
||||
useSuggestedFollowsByActorQuery({
|
||||
did: actorDid,
|
||||
})
|
||||
const moderationOpts = useModerationOpts()
|
||||
const isLoading = isSuggestionsLoading || !moderationOpts
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{paddingVertical: OUTER_PADDING, height: TOTAL_HEIGHT}}
|
||||
pointerEvents="box-none">
|
||||
<View
|
||||
pointerEvents="box-none"
|
||||
style={{
|
||||
backgroundColor: pal.viewLight.backgroundColor,
|
||||
height: '100%',
|
||||
paddingTop: INNER_PADDING / 2,
|
||||
}}>
|
||||
style={[
|
||||
t.atoms.bg_contrast_25,
|
||||
{
|
||||
height: '100%',
|
||||
paddingTop: INNER_PADDING / 2,
|
||||
},
|
||||
]}>
|
||||
<View
|
||||
pointerEvents="box-none"
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingTop: 4,
|
||||
paddingBottom: INNER_PADDING / 2,
|
||||
paddingLeft: INNER_PADDING,
|
||||
paddingRight: INNER_PADDING / 2,
|
||||
}}>
|
||||
<Text type="sm-bold" style={[pal.textLight]}>
|
||||
<Trans>Suggested for you</Trans>
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.justify_between,
|
||||
a.align_center,
|
||||
a.pt_xs,
|
||||
{
|
||||
paddingBottom: INNER_PADDING / 2,
|
||||
paddingLeft: INNER_PADDING,
|
||||
paddingRight: INNER_PADDING / 2,
|
||||
},
|
||||
]}>
|
||||
<Text style={[a.text_md, a.font_bold, t.atoms.text_contrast_medium]}>
|
||||
<Trans>Similar accounts</Trans>
|
||||
</Text>
|
||||
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
<Button
|
||||
onPress={requestDismiss}
|
||||
hitSlop={10}
|
||||
style={{padding: INNER_PADDING / 2}}>
|
||||
<FontAwesomeIcon
|
||||
icon="x"
|
||||
size={12}
|
||||
style={pal.textLight as FontAwesomeIconStyle}
|
||||
/>
|
||||
</Pressable>
|
||||
label={_(msg`Dismiss`)}
|
||||
size="xsmall"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
shape="round">
|
||||
<ButtonIcon icon={X} size="sm" />
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
<ScrollView
|
||||
@@ -83,187 +119,72 @@ export function ProfileHeaderSuggestedFollows({
|
||||
showsHorizontalScrollIndicator={isWeb}
|
||||
persistentScrollbar={true}
|
||||
scrollIndicatorInsets={{bottom: 0}}
|
||||
scrollEnabled={true}
|
||||
contentContainerStyle={{
|
||||
alignItems: 'flex-start',
|
||||
paddingLeft: INNER_PADDING / 2,
|
||||
paddingBottom: INNER_PADDING,
|
||||
}}>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<SuggestedFollowSkeleton />
|
||||
<SuggestedFollowSkeleton />
|
||||
<SuggestedFollowSkeleton />
|
||||
<SuggestedFollowSkeleton />
|
||||
<SuggestedFollowSkeleton />
|
||||
<SuggestedFollowSkeleton />
|
||||
</>
|
||||
) : data ? (
|
||||
data.suggestions
|
||||
.filter(s => (s.associated?.labeler ? false : true))
|
||||
.map(profile => (
|
||||
<SuggestedFollow key={profile.did} profile={profile} />
|
||||
))
|
||||
) : (
|
||||
<View />
|
||||
)}
|
||||
snapToInterval={MOBILE_CARD_WIDTH + a.gap_sm.gap}
|
||||
decelerationRate="fast">
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.gap_sm,
|
||||
{
|
||||
paddingHorizontal: INNER_PADDING,
|
||||
paddingBottom: INNER_PADDING,
|
||||
},
|
||||
]}>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<SuggestedFollowPlaceholder />
|
||||
<SuggestedFollowPlaceholder />
|
||||
<SuggestedFollowPlaceholder />
|
||||
<SuggestedFollowPlaceholder />
|
||||
<SuggestedFollowPlaceholder />
|
||||
</>
|
||||
) : data ? (
|
||||
data.suggestions
|
||||
.filter(s => (s.associated?.labeler ? false : true))
|
||||
.map(profile => (
|
||||
<ProfileCard.Link
|
||||
key={profile.did}
|
||||
profile={profile}
|
||||
onPress={() => {
|
||||
logEvent('profile:header:suggestedFollowsCard:press', {})
|
||||
}}
|
||||
style={[a.flex_1]}>
|
||||
{({hovered, pressed}) => (
|
||||
<CardOuter
|
||||
style={[
|
||||
a.flex_1,
|
||||
(hovered || pressed) && t.atoms.border_contrast_high,
|
||||
]}>
|
||||
<ProfileCard.Outer>
|
||||
<ProfileCard.Header>
|
||||
<ProfileCard.Avatar
|
||||
profile={profile}
|
||||
moderationOpts={moderationOpts}
|
||||
/>
|
||||
<ProfileCard.NameAndHandle
|
||||
profile={profile}
|
||||
moderationOpts={moderationOpts}
|
||||
/>
|
||||
<ProfileCard.FollowButton
|
||||
profile={profile}
|
||||
moderationOpts={moderationOpts}
|
||||
logContext="ProfileHeaderSuggestedFollows"
|
||||
color="secondary_inverted"
|
||||
shape="round"
|
||||
/>
|
||||
</ProfileCard.Header>
|
||||
<ProfileCard.Description profile={profile} />
|
||||
</ProfileCard.Outer>
|
||||
</CardOuter>
|
||||
)}
|
||||
</ProfileCard.Link>
|
||||
))
|
||||
) : (
|
||||
<View />
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function SuggestedFollowSkeleton() {
|
||||
const pal = usePalette('default')
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.suggestedFollowCardOuter,
|
||||
{
|
||||
backgroundColor: pal.view.backgroundColor,
|
||||
},
|
||||
]}>
|
||||
<View
|
||||
style={{
|
||||
height: 60,
|
||||
width: 60,
|
||||
borderRadius: 60,
|
||||
backgroundColor: pal.viewLight.backgroundColor,
|
||||
opacity: 0.6,
|
||||
}}
|
||||
/>
|
||||
<View
|
||||
style={{
|
||||
height: 17,
|
||||
width: 70,
|
||||
borderRadius: 4,
|
||||
backgroundColor: pal.viewLight.backgroundColor,
|
||||
marginTop: 12,
|
||||
marginBottom: 4,
|
||||
}}
|
||||
/>
|
||||
<View
|
||||
style={{
|
||||
height: 12,
|
||||
width: 70,
|
||||
borderRadius: 4,
|
||||
backgroundColor: pal.viewLight.backgroundColor,
|
||||
marginBottom: 12,
|
||||
opacity: 0.6,
|
||||
}}
|
||||
/>
|
||||
<View
|
||||
style={{
|
||||
height: 32,
|
||||
borderRadius: 32,
|
||||
width: '100%',
|
||||
backgroundColor: pal.viewLight.backgroundColor,
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function SuggestedFollow({
|
||||
profile: profileUnshadowed,
|
||||
}: {
|
||||
profile: AppBskyActorDefs.ProfileView
|
||||
}) {
|
||||
const {track} = useAnalytics()
|
||||
const pal = usePalette('default')
|
||||
const {_} = useLingui()
|
||||
const moderationOpts = useModerationOpts()
|
||||
const profile = useProfileShadow(profileUnshadowed)
|
||||
const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(
|
||||
profile,
|
||||
'ProfileHeaderSuggestedFollows',
|
||||
)
|
||||
|
||||
const onPressFollow = React.useCallback(async () => {
|
||||
try {
|
||||
track('ProfileHeader:SuggestedFollowFollowed')
|
||||
await queueFollow()
|
||||
} catch (e: any) {
|
||||
if (e?.name !== 'AbortError') {
|
||||
Toast.show(_(msg`An issue occurred, please try again.`), 'xmark')
|
||||
}
|
||||
}
|
||||
}, [queueFollow, track, _])
|
||||
|
||||
const onPressUnfollow = React.useCallback(async () => {
|
||||
try {
|
||||
await queueUnfollow()
|
||||
} catch (e: any) {
|
||||
if (e?.name !== 'AbortError') {
|
||||
Toast.show(_(msg`An issue occurred, please try again.`), 'xmark')
|
||||
}
|
||||
}
|
||||
}, [queueUnfollow, _])
|
||||
|
||||
if (!moderationOpts) {
|
||||
return null
|
||||
}
|
||||
const moderation = moderateProfile(profile, moderationOpts)
|
||||
const following = profile.viewer?.following
|
||||
return (
|
||||
<Link
|
||||
href={makeProfileLink(profile)}
|
||||
title={profile.handle}
|
||||
asAnchor
|
||||
anchorNoUnderline>
|
||||
<View
|
||||
style={[
|
||||
styles.suggestedFollowCardOuter,
|
||||
{
|
||||
backgroundColor: pal.view.backgroundColor,
|
||||
},
|
||||
]}>
|
||||
<PreviewableUserAvatar
|
||||
size={60}
|
||||
profile={profile}
|
||||
avatar={profile.avatar}
|
||||
moderation={moderation.ui('avatar')}
|
||||
/>
|
||||
|
||||
<View style={{width: '100%', paddingVertical: 12}}>
|
||||
<Text
|
||||
type="xs-medium"
|
||||
style={[pal.text, {textAlign: 'center'}]}
|
||||
numberOfLines={1}>
|
||||
{sanitizeDisplayName(
|
||||
profile.displayName || sanitizeHandle(profile.handle),
|
||||
moderation.ui('displayName'),
|
||||
)}
|
||||
</Text>
|
||||
<Text
|
||||
type="xs-medium"
|
||||
style={[pal.textLight, {textAlign: 'center'}]}
|
||||
numberOfLines={1}>
|
||||
{sanitizeHandle(profile.handle, '@')}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Button
|
||||
label={following ? _(msg`Unfollow`) : _(msg`Follow`)}
|
||||
type="inverted"
|
||||
labelStyle={{textAlign: 'center'}}
|
||||
onPress={following ? onPressUnfollow : onPressFollow}
|
||||
/>
|
||||
</View>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
suggestedFollowCardOuter: {
|
||||
marginHorizontal: INNER_PADDING / 2,
|
||||
paddingTop: 10,
|
||||
paddingBottom: 12,
|
||||
paddingHorizontal: 10,
|
||||
borderRadius: 8,
|
||||
width: 130,
|
||||
alignItems: 'center',
|
||||
overflow: 'hidden',
|
||||
flexShrink: 1,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -5,7 +5,9 @@ import {runOnJS, useSharedValue} from 'react-native-reanimated'
|
||||
import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED'
|
||||
import {usePalette} from '#/lib/hooks/usePalette'
|
||||
import {useScrollHandlers} from '#/lib/ScrollContext'
|
||||
import {useDedupe} from 'lib/hooks/useDedupe'
|
||||
import {addStyle} from 'lib/styles'
|
||||
import {updateActiveViewAsync} from '../../../../modules/expo-bluesky-swiss-army/src/VisibilityView'
|
||||
import {FlatList_INTERNAL} from './Views'
|
||||
|
||||
export type ListMethods = FlatList_INTERNAL
|
||||
@@ -28,8 +30,6 @@ export type ListProps<ItemT> = Omit<
|
||||
// Web only prop to contain the scroll to the container rather than the window
|
||||
disableFullWindowScroll?: boolean
|
||||
sideBorders?: boolean
|
||||
// Web only prop to disable a perf optimization (which would otherwise be on).
|
||||
disableContainStyle?: boolean
|
||||
}
|
||||
export type ListRef = React.MutableRefObject<FlatList_INTERNAL | null>
|
||||
|
||||
@@ -49,6 +49,7 @@ function ListImpl<ItemT>(
|
||||
) {
|
||||
const isScrolledDown = useSharedValue(false)
|
||||
const pal = usePalette('default')
|
||||
const dedupe = useDedupe()
|
||||
|
||||
function handleScrolledDownChange(didScrollDown: boolean) {
|
||||
onScrolledDownChange?.(didScrollDown)
|
||||
@@ -79,6 +80,8 @@ function ListImpl<ItemT>(
|
||||
runOnJS(handleScrolledDownChange)(didScrollDown)
|
||||
}
|
||||
}
|
||||
|
||||
runOnJS(dedupe)(updateActiveViewAsync)
|
||||
},
|
||||
// Note: adding onMomentumBegin here makes simulator scroll
|
||||
// lag on Android. So either don't add it, or figure out why.
|
||||
|
||||
@@ -4,11 +4,10 @@ import {ReanimatedScrollEvent} from 'react-native-reanimated/lib/typescript/rean
|
||||
|
||||
import {batchedUpdates} from '#/lib/batchedUpdates'
|
||||
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
||||
import {usePalette} from '#/lib/hooks/usePalette'
|
||||
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
|
||||
import {useScrollHandlers} from '#/lib/ScrollContext'
|
||||
import {isSafari} from 'lib/browser'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||
import {addStyle} from 'lib/styles'
|
||||
import {addStyle} from '#/lib/styles'
|
||||
|
||||
export type ListMethods = any // TODO: Better types.
|
||||
export type ListProps<ItemT> = Omit<
|
||||
@@ -26,8 +25,6 @@ export type ListProps<ItemT> = Omit<
|
||||
// Web only prop to contain the scroll to the container rather than the window
|
||||
disableFullWindowScroll?: boolean
|
||||
sideBorders?: boolean
|
||||
// Web only prop to disable a perf optimization (which would otherwise be on).
|
||||
disableContainStyle?: boolean
|
||||
}
|
||||
export type ListRef = React.MutableRefObject<any | null> // TODO: Better types.
|
||||
|
||||
@@ -60,7 +57,6 @@ function ListImpl<ItemT>(
|
||||
extraData,
|
||||
style,
|
||||
sideBorders = true,
|
||||
disableContainStyle,
|
||||
...props
|
||||
}: ListProps<ItemT>,
|
||||
ref: React.Ref<ListMethods>,
|
||||
@@ -364,7 +360,6 @@ function ListImpl<ItemT>(
|
||||
renderItem={renderItem}
|
||||
extraData={extraData}
|
||||
onItemSeen={onItemSeen}
|
||||
disableContainStyle={disableContainStyle}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
@@ -442,7 +437,6 @@ let Row = function RowImpl<ItemT>({
|
||||
renderItem,
|
||||
extraData: _unused,
|
||||
onItemSeen,
|
||||
disableContainStyle,
|
||||
}: {
|
||||
item: ItemT
|
||||
index: number
|
||||
@@ -452,7 +446,6 @@ let Row = function RowImpl<ItemT>({
|
||||
| ((data: {index: number; item: any; separators: any}) => React.ReactNode)
|
||||
extraData: any
|
||||
onItemSeen: ((item: any) => void) | undefined
|
||||
disableContainStyle?: boolean
|
||||
}): React.ReactNode {
|
||||
const rowRef = React.useRef(null)
|
||||
const intersectionTimeout = React.useRef<NodeJS.Timer | undefined>(undefined)
|
||||
@@ -501,11 +494,8 @@ let Row = function RowImpl<ItemT>({
|
||||
return null
|
||||
}
|
||||
|
||||
const shouldDisableContainStyle = disableContainStyle || isSafari
|
||||
return (
|
||||
<View
|
||||
style={shouldDisableContainStyle ? undefined : styles.contain}
|
||||
ref={rowRef}>
|
||||
<View ref={rowRef}>
|
||||
{renderItem({item, index, separators: null as any})}
|
||||
</View>
|
||||
)
|
||||
@@ -576,10 +566,6 @@ const styles = StyleSheet.create({
|
||||
marginLeft: 'auto',
|
||||
marginRight: 'auto',
|
||||
},
|
||||
contain: {
|
||||
// @ts-ignore web only
|
||||
contain: 'layout paint',
|
||||
},
|
||||
minHeightViewport: {
|
||||
// @ts-ignore web only
|
||||
minHeight: '100vh',
|
||||
|
||||
@@ -8,7 +8,6 @@ import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {useGate} from '#/lib/statsig/statsig'
|
||||
import {logger} from '#/logger'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {
|
||||
@@ -179,7 +178,6 @@ let UserAvatar = ({
|
||||
const pal = usePalette('default')
|
||||
const backgroundColor = pal.colors.backgroundLight
|
||||
const finalShape = overrideShape ?? (type === 'user' ? 'circle' : 'square')
|
||||
const gate = useGate()
|
||||
|
||||
const aviStyle = useMemo(() => {
|
||||
if (finalShape === 'square') {
|
||||
@@ -223,10 +221,7 @@ let UserAvatar = ({
|
||||
style={aviStyle}
|
||||
resizeMode="cover"
|
||||
source={{
|
||||
uri: hackModifyThumbnailPath(
|
||||
avatar,
|
||||
size < 90 && gate('small_avi_thumb'),
|
||||
),
|
||||
uri: hackModifyThumbnailPath(avatar, size < 90),
|
||||
}}
|
||||
blurRadius={moderation?.blur ? BLUR_AMOUNT : 0}
|
||||
/>
|
||||
@@ -236,10 +231,7 @@ let UserAvatar = ({
|
||||
style={aviStyle}
|
||||
contentFit="cover"
|
||||
source={{
|
||||
uri: hackModifyThumbnailPath(
|
||||
avatar,
|
||||
size < 90 && gate('small_avi_thumb'),
|
||||
),
|
||||
uri: hackModifyThumbnailPath(avatar, size < 90),
|
||||
}}
|
||||
blurRadius={moderation?.blur ? BLUR_AMOUNT : 0}
|
||||
/>
|
||||
|
||||
@@ -1,37 +1,103 @@
|
||||
import React, {useCallback, useId, useMemo, useState} from 'react'
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useId,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import {useWindowDimensions} from 'react-native'
|
||||
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {VideoPlayerProvider} from './VideoPlayerContext'
|
||||
|
||||
const ActiveVideoContext = React.createContext<{
|
||||
activeViewId: string | null
|
||||
setActiveView: (viewId: string, src: string) => void
|
||||
sendViewPosition: (viewId: string, y: number) => void
|
||||
} | null>(null)
|
||||
|
||||
export function ActiveVideoProvider({children}: {children: React.ReactNode}) {
|
||||
const [activeViewId, setActiveViewId] = useState<string | null>(null)
|
||||
const activeViewLocationRef = useRef(Infinity)
|
||||
const [source, setSource] = useState<string | null>(null)
|
||||
const {height: windowHeight} = useWindowDimensions()
|
||||
|
||||
// minimising re-renders by using refs
|
||||
const manuallySetRef = useRef(false)
|
||||
const activeViewIdRef = useRef(activeViewId)
|
||||
useEffect(() => {
|
||||
activeViewIdRef.current = activeViewId
|
||||
}, [activeViewId])
|
||||
|
||||
const setActiveView = useCallback(
|
||||
(viewId: string, src: string) => {
|
||||
setActiveViewId(viewId)
|
||||
setSource(src)
|
||||
manuallySetRef.current = true
|
||||
// we don't know the exact position, but it's definitely on screen
|
||||
// so just guess that it's in the middle. Any value is fine
|
||||
// so long as it's not offscreen
|
||||
activeViewLocationRef.current = windowHeight / 2
|
||||
},
|
||||
[windowHeight],
|
||||
)
|
||||
|
||||
const sendViewPosition = useCallback(
|
||||
(viewId: string, y: number) => {
|
||||
if (isNative) return
|
||||
|
||||
if (viewId === activeViewIdRef.current) {
|
||||
activeViewLocationRef.current = y
|
||||
} else {
|
||||
if (
|
||||
distanceToIdealPosition(y) <
|
||||
distanceToIdealPosition(activeViewLocationRef.current)
|
||||
) {
|
||||
// if the old view was manually set, only usurp if the old view is offscreen
|
||||
if (
|
||||
manuallySetRef.current &&
|
||||
withinViewport(activeViewLocationRef.current)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
setActiveViewId(viewId)
|
||||
activeViewLocationRef.current = y
|
||||
manuallySetRef.current = false
|
||||
}
|
||||
}
|
||||
|
||||
function distanceToIdealPosition(yPos: number) {
|
||||
return Math.abs(yPos - windowHeight / 2.5)
|
||||
}
|
||||
|
||||
function withinViewport(yPos: number) {
|
||||
return yPos > 0 && yPos < windowHeight
|
||||
}
|
||||
},
|
||||
[windowHeight],
|
||||
)
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
activeViewId,
|
||||
setActiveView: (viewId: string, src: string) => {
|
||||
setActiveViewId(viewId)
|
||||
setSource(src)
|
||||
},
|
||||
setActiveView,
|
||||
sendViewPosition,
|
||||
}),
|
||||
[activeViewId],
|
||||
[activeViewId, setActiveView, sendViewPosition],
|
||||
)
|
||||
|
||||
return (
|
||||
<ActiveVideoContext.Provider value={value}>
|
||||
<VideoPlayerProvider source={source ?? ''} viewId={activeViewId}>
|
||||
<VideoPlayerProvider source={source ?? ''}>
|
||||
{children}
|
||||
</VideoPlayerProvider>
|
||||
</ActiveVideoContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useActiveVideoView() {
|
||||
export function useActiveVideoView({source}: {source: string}) {
|
||||
const context = React.useContext(ActiveVideoContext)
|
||||
if (!context) {
|
||||
throw new Error('useActiveVideo must be used within a ActiveVideoProvider')
|
||||
@@ -41,7 +107,12 @@ export function useActiveVideoView() {
|
||||
return {
|
||||
active: context.activeViewId === id,
|
||||
setActive: useCallback(
|
||||
(source: string) => context.setActiveView(id, source),
|
||||
() => context.setActiveView(id, source),
|
||||
[context, id, source],
|
||||
),
|
||||
currentActiveView: context.activeViewId,
|
||||
sendPosition: useCallback(
|
||||
(y: number) => context.sendViewPosition(id, y),
|
||||
[context, id],
|
||||
),
|
||||
}
|
||||
|
||||
@@ -1,21 +1,20 @@
|
||||
import React, {useCallback} from 'react'
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {VideoEmbedInnerNative} from 'view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button, ButtonIcon} from '#/components/Button'
|
||||
import {Play_Filled_Corner2_Rounded as PlayIcon} from '#/components/icons/Play'
|
||||
import {VisibilityView} from '../../../../../modules/expo-bluesky-swiss-army'
|
||||
import {useActiveVideoView} from './ActiveVideoContext'
|
||||
import {VideoEmbedInner} from './VideoEmbedInner'
|
||||
|
||||
export function VideoEmbed({source}: {source: string}) {
|
||||
const t = useTheme()
|
||||
const {active, setActive} = useActiveVideoView()
|
||||
const {active, setActive} = useActiveVideoView({source})
|
||||
const {_} = useLingui()
|
||||
|
||||
const onPress = useCallback(() => setActive(source), [setActive, source])
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
@@ -26,19 +25,27 @@ export function VideoEmbed({source}: {source: string}) {
|
||||
t.atoms.bg_contrast_25,
|
||||
a.my_xs,
|
||||
]}>
|
||||
{active ? (
|
||||
<VideoEmbedInner source={source} />
|
||||
) : (
|
||||
<Button
|
||||
style={[a.flex_1, t.atoms.bg_contrast_25]}
|
||||
onPress={onPress}
|
||||
label={_(msg`Play video`)}
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="large">
|
||||
<ButtonIcon icon={PlayIcon} />
|
||||
</Button>
|
||||
)}
|
||||
<VisibilityView
|
||||
enabled={true}
|
||||
onChangeStatus={isActive => {
|
||||
if (isActive) {
|
||||
setActive()
|
||||
}
|
||||
}}>
|
||||
{active ? (
|
||||
<VideoEmbedInnerNative />
|
||||
) : (
|
||||
<Button
|
||||
style={[a.flex_1, t.atoms.bg_contrast_25]}
|
||||
onPress={setActive}
|
||||
label={_(msg`Play video`)}
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="large">
|
||||
<ButtonIcon icon={PlayIcon} />
|
||||
</Button>
|
||||
)}
|
||||
</VisibilityView>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import React, {useCallback, useEffect, useRef, useState} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {
|
||||
HLSUnsupportedError,
|
||||
VideoEmbedInnerWeb,
|
||||
} from 'view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {ErrorBoundary} from '../ErrorBoundary'
|
||||
import {useActiveVideoView} from './ActiveVideoContext'
|
||||
|
||||
export function VideoEmbed({source}: {source: string}) {
|
||||
const t = useTheme()
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const {active, setActive, sendPosition, currentActiveView} =
|
||||
useActiveVideoView({source})
|
||||
const [onScreen, setOnScreen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current) return
|
||||
const observer = new IntersectionObserver(
|
||||
entries => {
|
||||
const entry = entries[0]
|
||||
if (!entry) return
|
||||
setOnScreen(entry.isIntersecting)
|
||||
sendPosition(
|
||||
entry.boundingClientRect.y + entry.boundingClientRect.height / 2,
|
||||
)
|
||||
},
|
||||
{threshold: 0.5},
|
||||
)
|
||||
observer.observe(ref.current)
|
||||
return () => observer.disconnect()
|
||||
}, [sendPosition])
|
||||
|
||||
const [key, setKey] = useState(0)
|
||||
const renderError = useCallback(
|
||||
(error: unknown) => (
|
||||
<VideoError error={error} retry={() => setKey(key + 1)} />
|
||||
),
|
||||
[key],
|
||||
)
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.w_full,
|
||||
{aspectRatio: 16 / 9},
|
||||
t.atoms.bg_contrast_25,
|
||||
a.rounded_sm,
|
||||
a.my_xs,
|
||||
]}>
|
||||
<div
|
||||
ref={ref}
|
||||
style={{display: 'flex', flex: 1, cursor: 'default'}}
|
||||
onClick={evt => evt.stopPropagation()}>
|
||||
<ErrorBoundary renderError={renderError} key={key}>
|
||||
<ViewportObserver
|
||||
sendPosition={sendPosition}
|
||||
isAnyViewActive={currentActiveView !== null}>
|
||||
<VideoEmbedInnerWeb
|
||||
source={source}
|
||||
active={active}
|
||||
setActive={setActive}
|
||||
onScreen={onScreen}
|
||||
/>
|
||||
</ViewportObserver>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a 100vh tall div and watches it with an IntersectionObserver to
|
||||
* send the position of the div when it's near the screen.
|
||||
*/
|
||||
function ViewportObserver({
|
||||
children,
|
||||
sendPosition,
|
||||
isAnyViewActive,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
sendPosition: (position: number) => void
|
||||
isAnyViewActive?: boolean
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const [nearScreen, setNearScreen] = useState(false)
|
||||
|
||||
// Send position when scrolling. This is done with an IntersectionObserver
|
||||
// observing a div of 100vh height
|
||||
useEffect(() => {
|
||||
if (!ref.current) return
|
||||
const observer = new IntersectionObserver(
|
||||
entries => {
|
||||
const entry = entries[0]
|
||||
if (!entry) return
|
||||
const position =
|
||||
entry.boundingClientRect.y + entry.boundingClientRect.height / 2
|
||||
sendPosition(position)
|
||||
setNearScreen(entry.isIntersecting)
|
||||
},
|
||||
{threshold: Array.from({length: 101}, (_, i) => i / 100)},
|
||||
)
|
||||
observer.observe(ref.current)
|
||||
return () => observer.disconnect()
|
||||
}, [sendPosition])
|
||||
|
||||
// In case scrolling hasn't started yet, send up the position
|
||||
useEffect(() => {
|
||||
if (ref.current && !isAnyViewActive) {
|
||||
const rect = ref.current.getBoundingClientRect()
|
||||
const position = rect.y + rect.height / 2
|
||||
sendPosition(position)
|
||||
}
|
||||
}, [isAnyViewActive, sendPosition])
|
||||
|
||||
return (
|
||||
<View style={[a.flex_1, a.flex_row]}>
|
||||
{nearScreen && children}
|
||||
<div
|
||||
ref={ref}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 'calc(50% - 50vh)',
|
||||
left: '50%',
|
||||
height: '100vh',
|
||||
width: 1,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function VideoError({error, retry}: {error: unknown; retry: () => void}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
|
||||
const isHLS = error instanceof HLSUnsupportedError
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
t.atoms.bg_contrast_25,
|
||||
a.justify_center,
|
||||
a.align_center,
|
||||
a.px_lg,
|
||||
a.border,
|
||||
t.atoms.border_contrast_low,
|
||||
a.rounded_sm,
|
||||
a.gap_lg,
|
||||
]}>
|
||||
<Text
|
||||
style={[
|
||||
a.text_center,
|
||||
t.atoms.text_contrast_high,
|
||||
a.text_md,
|
||||
a.leading_snug,
|
||||
{maxWidth: 300},
|
||||
]}>
|
||||
{isHLS ? (
|
||||
<Trans>
|
||||
Your browser does not support the video format. Please try a
|
||||
different browser.
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
An error occurred while loading the video. Please try again later.
|
||||
</Trans>
|
||||
)}
|
||||
</Text>
|
||||
{!isHLS && (
|
||||
<Button
|
||||
onPress={retry}
|
||||
size="small"
|
||||
color="secondary_inverted"
|
||||
variant="solid"
|
||||
label={_(msg`Retry`)}>
|
||||
<ButtonText>
|
||||
<Trans>Retry</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
import React, {useCallback, useEffect, useRef, useState} from 'react'
|
||||
import {Pressable, StyleSheet, useWindowDimensions, View} from 'react-native'
|
||||
import Animated, {
|
||||
measure,
|
||||
runOnJS,
|
||||
useAnimatedRef,
|
||||
useFrameCallback,
|
||||
useSharedValue,
|
||||
} from 'react-native-reanimated'
|
||||
import {VideoPlayer, VideoView} from 'expo-video'
|
||||
|
||||
import {atoms as a} from '#/alf'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {useVideoPlayer} from './VideoPlayerContext'
|
||||
|
||||
export const VideoEmbedInner = ({}: {source: string}) => {
|
||||
const player = useVideoPlayer()
|
||||
const aref = useAnimatedRef<Animated.View>()
|
||||
const {height: windowHeight} = useWindowDimensions()
|
||||
const hasLeftView = useSharedValue(false)
|
||||
const ref = useRef<VideoView>(null)
|
||||
|
||||
const onEnterView = useCallback(() => {
|
||||
if (player.status === 'readyToPlay') {
|
||||
player.play()
|
||||
}
|
||||
}, [player])
|
||||
|
||||
const onLeaveView = useCallback(() => {
|
||||
player.pause()
|
||||
}, [player])
|
||||
|
||||
const enterFullscreen = useCallback(() => {
|
||||
if (ref.current) {
|
||||
ref.current.enterFullscreen()
|
||||
}
|
||||
}, [])
|
||||
|
||||
useFrameCallback(() => {
|
||||
const measurement = measure(aref)
|
||||
|
||||
if (measurement) {
|
||||
if (hasLeftView.value) {
|
||||
// Check if the video is in view
|
||||
if (
|
||||
measurement.pageY >= 0 &&
|
||||
measurement.pageY + measurement.height <= windowHeight
|
||||
) {
|
||||
runOnJS(onEnterView)()
|
||||
hasLeftView.value = false
|
||||
}
|
||||
} else {
|
||||
// Check if the video is out of view
|
||||
if (
|
||||
measurement.pageY + measurement.height < 0 ||
|
||||
measurement.pageY > windowHeight
|
||||
) {
|
||||
runOnJS(onLeaveView)()
|
||||
hasLeftView.value = true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={[a.flex_1, a.relative]}
|
||||
ref={aref}
|
||||
collapsable={false}>
|
||||
<VideoView
|
||||
ref={ref}
|
||||
player={player}
|
||||
style={a.flex_1}
|
||||
nativeControls={true}
|
||||
/>
|
||||
<VideoControls player={player} enterFullscreen={enterFullscreen} />
|
||||
</Animated.View>
|
||||
)
|
||||
}
|
||||
|
||||
function VideoControls({
|
||||
player,
|
||||
enterFullscreen,
|
||||
}: {
|
||||
player: VideoPlayer
|
||||
enterFullscreen: () => void
|
||||
}) {
|
||||
const [currentTime, setCurrentTime] = useState(Math.floor(player.currentTime))
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setCurrentTime(Math.floor(player.duration - player.currentTime))
|
||||
// how often should we update the time?
|
||||
// 1000 gets out of sync with the video time
|
||||
}, 250)
|
||||
|
||||
return () => {
|
||||
clearInterval(interval)
|
||||
}
|
||||
}, [player])
|
||||
|
||||
const minutes = Math.floor(currentTime / 60)
|
||||
const seconds = String(currentTime % 60).padStart(2, '0')
|
||||
|
||||
return (
|
||||
<View style={[a.absolute, a.inset_0]}>
|
||||
<View style={styles.timeContainer} pointerEvents="none">
|
||||
<Text style={styles.timeElapsed}>
|
||||
{minutes}:{seconds}
|
||||
</Text>
|
||||
</View>
|
||||
<Pressable
|
||||
onPress={enterFullscreen}
|
||||
style={a.flex_1}
|
||||
accessibilityLabel="Video"
|
||||
accessibilityHint="Tap to enter full screen"
|
||||
accessibilityRole="button"
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
timeContainer: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.75)',
|
||||
borderRadius: 6,
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 3,
|
||||
position: 'absolute',
|
||||
left: 5,
|
||||
bottom: 5,
|
||||
},
|
||||
timeElapsed: {
|
||||
color: 'white',
|
||||
fontSize: 12,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
})
|
||||
@@ -1,52 +0,0 @@
|
||||
import React, {useEffect, useRef} from 'react'
|
||||
import Hls from 'hls.js'
|
||||
|
||||
import {atoms as a} from '#/alf'
|
||||
|
||||
export const VideoEmbedInner = ({source}: {source: string}) => {
|
||||
const ref = useRef<HTMLVideoElement>(null)
|
||||
|
||||
// Use HLS.js to play HLS video
|
||||
useEffect(() => {
|
||||
if (ref.current) {
|
||||
if (ref.current.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
ref.current.src = source
|
||||
} else if (Hls.isSupported()) {
|
||||
var hls = new Hls()
|
||||
hls.loadSource(source)
|
||||
hls.attachMedia(ref.current)
|
||||
} else {
|
||||
// TODO: fallback
|
||||
}
|
||||
}
|
||||
}, [source])
|
||||
|
||||
useEffect(() => {
|
||||
if (ref.current) {
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (ref.current) {
|
||||
if (entry.isIntersecting) {
|
||||
if (ref.current.paused) {
|
||||
ref.current.play()
|
||||
}
|
||||
} else {
|
||||
if (!ref.current.paused) {
|
||||
ref.current.pause()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{threshold: 0},
|
||||
)
|
||||
|
||||
observer.observe(ref.current)
|
||||
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
return <video ref={ref} style={a.flex_1} controls playsInline autoPlay loop />
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import React, {useEffect, useRef, useState} from 'react'
|
||||
import {Pressable, View} from 'react-native'
|
||||
import {VideoPlayer, VideoView} from 'expo-video'
|
||||
|
||||
import {useVideoPlayer} from 'view/com/util/post-embeds/VideoPlayerContext'
|
||||
import {android, atoms as a} from '#/alf'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export function VideoEmbedInnerNative() {
|
||||
const player = useVideoPlayer()
|
||||
const ref = useRef<VideoView>(null)
|
||||
|
||||
return (
|
||||
<View style={[a.flex_1, a.relative]} collapsable={false}>
|
||||
<VideoView
|
||||
ref={ref}
|
||||
player={player}
|
||||
style={a.flex_1}
|
||||
nativeControls={true}
|
||||
/>
|
||||
<Controls
|
||||
player={player}
|
||||
enterFullscreen={() => ref.current?.enterFullscreen()}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function Controls({
|
||||
player,
|
||||
enterFullscreen,
|
||||
}: {
|
||||
player: VideoPlayer
|
||||
enterFullscreen: () => void
|
||||
}) {
|
||||
const [duration, setDuration] = useState(() => Math.floor(player.duration))
|
||||
const [currentTime, setCurrentTime] = useState(() =>
|
||||
Math.floor(player.currentTime),
|
||||
)
|
||||
|
||||
const timeRemaining = duration - currentTime
|
||||
const minutes = Math.floor(timeRemaining / 60)
|
||||
const seconds = String(timeRemaining % 60).padStart(2, '0')
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
// duration gets reset to 0 on loop
|
||||
if (player.duration) setDuration(Math.floor(player.duration))
|
||||
setCurrentTime(Math.floor(player.currentTime))
|
||||
// how often should we update the time?
|
||||
// 1000 gets out of sync with the video time
|
||||
}, 250)
|
||||
|
||||
return () => {
|
||||
clearInterval(interval)
|
||||
}
|
||||
}, [player])
|
||||
|
||||
if (isNaN(timeRemaining)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[a.absolute, a.inset_0]}>
|
||||
<View
|
||||
style={[
|
||||
{
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.75',
|
||||
borderRadius: 6,
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 3,
|
||||
position: 'absolute',
|
||||
left: 5,
|
||||
bottom: 5,
|
||||
},
|
||||
]}
|
||||
pointerEvents="none">
|
||||
<Text
|
||||
style={[
|
||||
{color: 'white', fontSize: 12},
|
||||
a.font_bold,
|
||||
android({lineHeight: 1.25}),
|
||||
]}>
|
||||
{minutes}:{seconds}
|
||||
</Text>
|
||||
</View>
|
||||
<Pressable
|
||||
onPress={enterFullscreen}
|
||||
style={a.flex_1}
|
||||
accessibilityLabel="Video"
|
||||
accessibilityHint="Tap to enter full screen"
|
||||
accessibilityRole="button"
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function VideoEmbedInnerNative() {
|
||||
throw new Error('VideoEmbedInnerNative may not be used on native.')
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function VideoEmbedInnerWeb() {
|
||||
throw new Error('VideoEmbedInnerWeb may not be used on native.')
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import React, {useEffect, useRef, useState} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import Hls from 'hls.js'
|
||||
|
||||
import {atoms as a} from '#/alf'
|
||||
import {Controls} from './VideoWebControls'
|
||||
|
||||
export function VideoEmbedInnerWeb({
|
||||
source,
|
||||
active,
|
||||
setActive,
|
||||
onScreen,
|
||||
}: {
|
||||
source: string
|
||||
active?: boolean
|
||||
setActive?: () => void
|
||||
onScreen?: boolean
|
||||
}) {
|
||||
if (active == null || setActive == null || onScreen == null) {
|
||||
throw new Error(
|
||||
'active, setActive, and onScreen are required VideoEmbedInner props on web.',
|
||||
)
|
||||
}
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const ref = useRef<HTMLVideoElement>(null)
|
||||
const [focused, setFocused] = useState(false)
|
||||
const [hasSubtitleTrack, setHasSubtitleTrack] = useState(false)
|
||||
|
||||
const hlsRef = useRef<Hls | undefined>(undefined)
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current) return
|
||||
if (!Hls.isSupported()) throw new HLSUnsupportedError()
|
||||
|
||||
const hls = new Hls({capLevelToPlayerSize: true})
|
||||
hlsRef.current = hls
|
||||
|
||||
hls.attachMedia(ref.current)
|
||||
hls.loadSource(source)
|
||||
|
||||
// initial value, later on it's managed by Controls
|
||||
hls.autoLevelCapping = 0
|
||||
|
||||
hls.on(Hls.Events.SUBTITLE_TRACKS_UPDATED, (event, data) => {
|
||||
if (data.subtitleTracks.length > 0) {
|
||||
setHasSubtitleTrack(true)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
hlsRef.current = undefined
|
||||
hls.detachMedia()
|
||||
hls.destroy()
|
||||
}
|
||||
}, [source])
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.w_full,
|
||||
a.rounded_sm,
|
||||
// TODO: get from embed metadata
|
||||
// max should be 1 / 1
|
||||
{aspectRatio: 16 / 9},
|
||||
a.overflow_hidden,
|
||||
]}>
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={{width: '100%', height: '100%', display: 'flex'}}>
|
||||
<video
|
||||
ref={ref}
|
||||
style={{width: '100%', height: '100%', objectFit: 'contain'}}
|
||||
playsInline
|
||||
preload="none"
|
||||
loop
|
||||
muted={!focused}
|
||||
/>
|
||||
<Controls
|
||||
videoRef={ref}
|
||||
hlsRef={hlsRef}
|
||||
active={active}
|
||||
setActive={setActive}
|
||||
focused={focused}
|
||||
setFocused={setFocused}
|
||||
onScreen={onScreen}
|
||||
fullscreenRef={containerRef}
|
||||
hasSubtitleTrack={hasSubtitleTrack}
|
||||
/>
|
||||
</div>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export class HLSUnsupportedError extends Error {
|
||||
constructor() {
|
||||
super('HLS is not supported')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import React from 'react'
|
||||
import type Hls from 'hls.js'
|
||||
|
||||
export function Controls({}: {
|
||||
videoRef: React.RefObject<HTMLVideoElement>
|
||||
hlsRef: React.RefObject<Hls | undefined>
|
||||
active: boolean
|
||||
setActive: () => void
|
||||
focused: boolean
|
||||
setFocused: (focused: boolean) => void
|
||||
onScreen: boolean
|
||||
fullscreenRef: React.RefObject<HTMLDivElement>
|
||||
hasSubtitleTrack: boolean
|
||||
}): React.ReactElement {
|
||||
throw new Error('Web-only component')
|
||||
}
|
||||
@@ -0,0 +1,587 @@
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
} from 'react'
|
||||
import {Pressable, View} from 'react-native'
|
||||
import Animated, {FadeIn, FadeOut} from 'react-native-reanimated'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import type Hls from 'hls.js'
|
||||
|
||||
import {isIPhoneWeb} from 'platform/detection'
|
||||
import {
|
||||
useAutoplayDisabled,
|
||||
useSetSubtitlesEnabled,
|
||||
useSubtitlesEnabled,
|
||||
} from 'state/preferences'
|
||||
import {atoms as a, useTheme, web} from '#/alf'
|
||||
import {Button} from '#/components/Button'
|
||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||
import {
|
||||
ArrowsDiagonalIn_Stroke2_Corner0_Rounded as ArrowsInIcon,
|
||||
ArrowsDiagonalOut_Stroke2_Corner0_Rounded as ArrowsOutIcon,
|
||||
} from '#/components/icons/ArrowsDiagonal'
|
||||
import {
|
||||
CC_Filled_Corner0_Rounded as CCActiveIcon,
|
||||
CC_Stroke2_Corner0_Rounded as CCInactiveIcon,
|
||||
} from '#/components/icons/CC'
|
||||
import {Mute_Stroke2_Corner0_Rounded as MuteIcon} from '#/components/icons/Mute'
|
||||
import {Pause_Filled_Corner0_Rounded as PauseIcon} from '#/components/icons/Pause'
|
||||
import {Play_Filled_Corner0_Rounded as PlayIcon} from '#/components/icons/Play'
|
||||
import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as UnmuteIcon} from '#/components/icons/Speaker'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export function Controls({
|
||||
videoRef,
|
||||
hlsRef,
|
||||
active,
|
||||
setActive,
|
||||
focused,
|
||||
setFocused,
|
||||
onScreen,
|
||||
fullscreenRef,
|
||||
hasSubtitleTrack,
|
||||
}: {
|
||||
videoRef: React.RefObject<HTMLVideoElement>
|
||||
hlsRef: React.RefObject<Hls | undefined>
|
||||
active: boolean
|
||||
setActive: () => void
|
||||
focused: boolean
|
||||
setFocused: (focused: boolean) => void
|
||||
onScreen: boolean
|
||||
fullscreenRef: React.RefObject<HTMLDivElement>
|
||||
hasSubtitleTrack: boolean
|
||||
}) {
|
||||
const {
|
||||
play,
|
||||
pause,
|
||||
playing,
|
||||
muted,
|
||||
toggleMute,
|
||||
togglePlayPause,
|
||||
currentTime,
|
||||
duration,
|
||||
buffering,
|
||||
error,
|
||||
canPlay,
|
||||
} = useVideoUtils(videoRef)
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const subtitlesEnabled = useSubtitlesEnabled()
|
||||
const setSubtitlesEnabled = useSetSubtitlesEnabled()
|
||||
const {
|
||||
state: hovered,
|
||||
onIn: onMouseEnter,
|
||||
onOut: onMouseLeave,
|
||||
} = useInteractionState()
|
||||
const [isFullscreen, toggleFullscreen] = useFullscreen(fullscreenRef)
|
||||
const {state: hasFocus, onIn: onFocus, onOut: onBlur} = useInteractionState()
|
||||
const [interactingViaKeypress, setInteractingViaKeypress] = useState(false)
|
||||
|
||||
const onKeyDown = useCallback(() => {
|
||||
setInteractingViaKeypress(true)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (interactingViaKeypress) {
|
||||
document.addEventListener('click', () => setInteractingViaKeypress(false))
|
||||
return () => {
|
||||
document.removeEventListener('click', () =>
|
||||
setInteractingViaKeypress(false),
|
||||
)
|
||||
}
|
||||
}
|
||||
}, [interactingViaKeypress])
|
||||
|
||||
// pause + unfocus when another video is active
|
||||
useEffect(() => {
|
||||
if (!active) {
|
||||
pause()
|
||||
setFocused(false)
|
||||
}
|
||||
}, [active, pause, setFocused])
|
||||
|
||||
// autoplay/pause based on visibility
|
||||
const autoplayDisabled = useAutoplayDisabled()
|
||||
useEffect(() => {
|
||||
if (active && !autoplayDisabled) {
|
||||
if (onScreen) {
|
||||
play()
|
||||
} else {
|
||||
pause()
|
||||
}
|
||||
}
|
||||
}, [onScreen, pause, active, play, autoplayDisabled])
|
||||
|
||||
// use minimal quality when not focused
|
||||
useEffect(() => {
|
||||
if (!hlsRef.current) return
|
||||
if (focused) {
|
||||
// auto decide quality based on network conditions
|
||||
hlsRef.current.autoLevelCapping = -1
|
||||
} else {
|
||||
hlsRef.current.autoLevelCapping = 0
|
||||
}
|
||||
}, [hlsRef, focused])
|
||||
|
||||
useEffect(() => {
|
||||
if (!hlsRef.current) return
|
||||
if (hasSubtitleTrack && subtitlesEnabled && canPlay) {
|
||||
hlsRef.current.subtitleTrack = 0
|
||||
} else {
|
||||
hlsRef.current.subtitleTrack = -1
|
||||
}
|
||||
}, [hasSubtitleTrack, subtitlesEnabled, hlsRef, canPlay])
|
||||
|
||||
// clicking on any button should focus the player, if it's not already focused
|
||||
const drawFocus = useCallback(() => {
|
||||
if (!active) {
|
||||
setActive()
|
||||
}
|
||||
setFocused(true)
|
||||
}, [active, setActive, setFocused])
|
||||
|
||||
const onPressEmptySpace = useCallback(() => {
|
||||
if (!focused) {
|
||||
drawFocus()
|
||||
} else {
|
||||
togglePlayPause()
|
||||
}
|
||||
}, [togglePlayPause, drawFocus, focused])
|
||||
|
||||
const onPressPlayPause = useCallback(() => {
|
||||
drawFocus()
|
||||
togglePlayPause()
|
||||
}, [drawFocus, togglePlayPause])
|
||||
|
||||
const onPressSubtitles = useCallback(() => {
|
||||
drawFocus()
|
||||
setSubtitlesEnabled(!subtitlesEnabled)
|
||||
}, [drawFocus, setSubtitlesEnabled, subtitlesEnabled])
|
||||
|
||||
const onPressMute = useCallback(() => {
|
||||
drawFocus()
|
||||
toggleMute()
|
||||
}, [drawFocus, toggleMute])
|
||||
|
||||
const onPressFullscreen = useCallback(() => {
|
||||
drawFocus()
|
||||
toggleFullscreen()
|
||||
}, [drawFocus, toggleFullscreen])
|
||||
|
||||
const showControls =
|
||||
(focused && !playing) || (interactingViaKeypress ? hasFocus : hovered)
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
onClick={evt => {
|
||||
evt.stopPropagation()
|
||||
setInteractingViaKeypress(false)
|
||||
}}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
onKeyDown={onKeyDown}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityHint={_(
|
||||
focused
|
||||
? msg`Unmute video`
|
||||
: playing
|
||||
? msg`Pause video`
|
||||
: msg`Play video`,
|
||||
)}
|
||||
style={a.flex_1}
|
||||
onPress={onPressEmptySpace}
|
||||
/>
|
||||
<View
|
||||
style={[
|
||||
a.flex_shrink_0,
|
||||
a.w_full,
|
||||
a.px_sm,
|
||||
a.pt_sm,
|
||||
a.pb_md,
|
||||
a.gap_md,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
web({
|
||||
background:
|
||||
'linear-gradient(rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.4), rgba(0, 0, 0, 0.7))',
|
||||
}),
|
||||
showControls ? {opacity: 1} : {opacity: 0},
|
||||
]}>
|
||||
<Button
|
||||
label={_(playing ? msg`Pause` : msg`Play`)}
|
||||
onPress={onPressPlayPause}
|
||||
{...btnProps}>
|
||||
{playing ? (
|
||||
<PauseIcon fill={t.palette.white} width={20} />
|
||||
) : (
|
||||
<PlayIcon fill={t.palette.white} width={20} />
|
||||
)}
|
||||
</Button>
|
||||
<View style={a.flex_1} />
|
||||
<Text style={{color: t.palette.white}}>
|
||||
{formatTime(currentTime)} / {formatTime(duration)}
|
||||
</Text>
|
||||
{hasSubtitleTrack && (
|
||||
<Button
|
||||
label={_(
|
||||
subtitlesEnabled ? msg`Disable subtitles` : msg`Enable subtitles`,
|
||||
)}
|
||||
onPress={onPressSubtitles}
|
||||
{...btnProps}>
|
||||
{subtitlesEnabled ? (
|
||||
<CCActiveIcon fill={t.palette.white} width={20} />
|
||||
) : (
|
||||
<CCInactiveIcon fill={t.palette.white} width={20} />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
label={_(muted ? msg`Unmute` : msg`Mute`)}
|
||||
onPress={onPressMute}
|
||||
{...btnProps}>
|
||||
{muted ? (
|
||||
<MuteIcon fill={t.palette.white} width={20} />
|
||||
) : (
|
||||
<UnmuteIcon fill={t.palette.white} width={20} />
|
||||
)}
|
||||
</Button>
|
||||
{!isIPhoneWeb && (
|
||||
<Button
|
||||
label={_(muted ? msg`Unmute` : msg`Mute`)}
|
||||
onPress={onPressFullscreen}
|
||||
{...btnProps}>
|
||||
{isFullscreen ? (
|
||||
<ArrowsInIcon fill={t.palette.white} width={20} />
|
||||
) : (
|
||||
<ArrowsOutIcon fill={t.palette.white} width={20} />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</View>
|
||||
{(showControls || !focused) && (
|
||||
<Animated.View
|
||||
entering={FadeIn.duration(200)}
|
||||
exiting={FadeOut.duration(200)}
|
||||
style={[
|
||||
a.absolute,
|
||||
{
|
||||
height: 5,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
backgroundColor: 'rgba(255,255,255,0.4)',
|
||||
},
|
||||
]}>
|
||||
{duration > 0 && (
|
||||
<View
|
||||
style={[
|
||||
a.h_full,
|
||||
a.mr_auto,
|
||||
{
|
||||
backgroundColor: t.palette.white,
|
||||
width: `${(currentTime / duration) * 100}%`,
|
||||
opacity: 0.8,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</Animated.View>
|
||||
)}
|
||||
{(buffering || error) && (
|
||||
<Animated.View
|
||||
pointerEvents="none"
|
||||
entering={FadeIn.delay(1000).duration(200)}
|
||||
exiting={FadeOut.duration(200)}
|
||||
style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}>
|
||||
{buffering && <Loader fill={t.palette.white} size="lg" />}
|
||||
{error && (
|
||||
<Text style={{color: t.palette.white}}>
|
||||
<Trans>An error occurred</Trans>
|
||||
</Text>
|
||||
)}
|
||||
</Animated.View>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const btnProps = {
|
||||
variant: 'ghost',
|
||||
shape: 'round',
|
||||
size: 'medium',
|
||||
style: a.p_2xs,
|
||||
hoverStyle: {backgroundColor: 'rgba(255, 255, 255, 0.1)'},
|
||||
} as const
|
||||
|
||||
function formatTime(time: number) {
|
||||
if (isNaN(time)) {
|
||||
return '--'
|
||||
}
|
||||
|
||||
time = Math.round(time)
|
||||
|
||||
const minutes = Math.floor(time / 60)
|
||||
const seconds = String(time % 60).padStart(2, '0')
|
||||
|
||||
return `${minutes}:${seconds}`
|
||||
}
|
||||
|
||||
function useVideoUtils(ref: React.RefObject<HTMLVideoElement>) {
|
||||
const [playing, setPlaying] = useState(false)
|
||||
const [muted, setMuted] = useState(true)
|
||||
const [currentTime, setCurrentTime] = useState(0)
|
||||
const [duration, setDuration] = useState(0)
|
||||
const [buffering, setBuffering] = useState(false)
|
||||
const [error, setError] = useState(false)
|
||||
const [canPlay, setCanPlay] = useState(false)
|
||||
const playWhenReadyRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current) return
|
||||
|
||||
let bufferingTimeout: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
function round(num: number) {
|
||||
return Math.round(num * 100) / 100
|
||||
}
|
||||
|
||||
// Initial values
|
||||
setCurrentTime(round(ref.current.currentTime) || 0)
|
||||
setDuration(round(ref.current.duration) || 0)
|
||||
setMuted(ref.current.muted)
|
||||
setPlaying(!ref.current.paused)
|
||||
|
||||
const handleTimeUpdate = () => {
|
||||
if (!ref.current) return
|
||||
setCurrentTime(round(ref.current.currentTime) || 0)
|
||||
}
|
||||
|
||||
const handleDurationChange = () => {
|
||||
if (!ref.current) return
|
||||
setDuration(round(ref.current.duration) || 0)
|
||||
}
|
||||
|
||||
const handlePlay = () => {
|
||||
setPlaying(true)
|
||||
}
|
||||
|
||||
const handlePause = () => {
|
||||
setPlaying(false)
|
||||
}
|
||||
|
||||
const handleVolumeChange = () => {
|
||||
if (!ref.current) return
|
||||
setMuted(ref.current.muted)
|
||||
}
|
||||
|
||||
const handleError = () => {
|
||||
setError(true)
|
||||
}
|
||||
|
||||
const handleCanPlay = () => {
|
||||
setBuffering(false)
|
||||
setCanPlay(true)
|
||||
|
||||
if (!ref.current) return
|
||||
if (playWhenReadyRef.current) {
|
||||
ref.current.play()
|
||||
playWhenReadyRef.current = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleCanPlayThrough = () => {
|
||||
setBuffering(false)
|
||||
}
|
||||
|
||||
const handleWaiting = () => {
|
||||
if (bufferingTimeout) clearTimeout(bufferingTimeout)
|
||||
bufferingTimeout = setTimeout(() => {
|
||||
setBuffering(true)
|
||||
}, 200) // Delay to avoid frequent buffering state changes
|
||||
}
|
||||
|
||||
const handlePlaying = () => {
|
||||
if (bufferingTimeout) clearTimeout(bufferingTimeout)
|
||||
setBuffering(false)
|
||||
setError(false)
|
||||
}
|
||||
|
||||
const handleSeeking = () => {
|
||||
setBuffering(true)
|
||||
}
|
||||
|
||||
const handleSeeked = () => {
|
||||
setBuffering(false)
|
||||
}
|
||||
|
||||
const handleStalled = () => {
|
||||
if (bufferingTimeout) clearTimeout(bufferingTimeout)
|
||||
bufferingTimeout = setTimeout(() => {
|
||||
setBuffering(true)
|
||||
}, 200) // Delay to avoid frequent buffering state changes
|
||||
}
|
||||
|
||||
const handleEnded = () => {
|
||||
setPlaying(false)
|
||||
setBuffering(false)
|
||||
setError(false)
|
||||
}
|
||||
|
||||
const abortController = new AbortController()
|
||||
|
||||
ref.current.addEventListener('timeupdate', handleTimeUpdate, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
ref.current.addEventListener('durationchange', handleDurationChange, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
ref.current.addEventListener('play', handlePlay, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
ref.current.addEventListener('pause', handlePause, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
ref.current.addEventListener('volumechange', handleVolumeChange, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
ref.current.addEventListener('error', handleError, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
ref.current.addEventListener('canplay', handleCanPlay, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
ref.current.addEventListener('canplaythrough', handleCanPlayThrough, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
ref.current.addEventListener('waiting', handleWaiting, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
ref.current.addEventListener('playing', handlePlaying, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
ref.current.addEventListener('seeking', handleSeeking, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
ref.current.addEventListener('seeked', handleSeeked, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
ref.current.addEventListener('stalled', handleStalled, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
ref.current.addEventListener('ended', handleEnded, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
|
||||
return () => {
|
||||
abortController.abort()
|
||||
clearTimeout(bufferingTimeout)
|
||||
}
|
||||
}, [ref])
|
||||
|
||||
const play = useCallback(() => {
|
||||
if (!ref.current) return
|
||||
|
||||
if (ref.current.ended) {
|
||||
ref.current.currentTime = 0
|
||||
}
|
||||
|
||||
if (ref.current.readyState < HTMLMediaElement.HAVE_FUTURE_DATA) {
|
||||
playWhenReadyRef.current = true
|
||||
} else {
|
||||
const promise = ref.current.play()
|
||||
if (promise !== undefined) {
|
||||
promise.catch(err => {
|
||||
console.error('Error playing video:', err)
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [ref])
|
||||
|
||||
const pause = useCallback(() => {
|
||||
if (!ref.current) return
|
||||
|
||||
ref.current.pause()
|
||||
playWhenReadyRef.current = false
|
||||
}, [ref])
|
||||
|
||||
const togglePlayPause = useCallback(() => {
|
||||
if (!ref.current) return
|
||||
|
||||
if (ref.current.paused) {
|
||||
play()
|
||||
} else {
|
||||
pause()
|
||||
}
|
||||
}, [ref, play, pause])
|
||||
|
||||
const mute = useCallback(() => {
|
||||
if (!ref.current) return
|
||||
|
||||
ref.current.muted = true
|
||||
}, [ref])
|
||||
|
||||
const unmute = useCallback(() => {
|
||||
if (!ref.current) return
|
||||
|
||||
ref.current.muted = false
|
||||
}, [ref])
|
||||
|
||||
const toggleMute = useCallback(() => {
|
||||
if (!ref.current) return
|
||||
|
||||
ref.current.muted = !ref.current.muted
|
||||
}, [ref])
|
||||
|
||||
return {
|
||||
play,
|
||||
pause,
|
||||
togglePlayPause,
|
||||
duration,
|
||||
currentTime,
|
||||
playing,
|
||||
muted,
|
||||
mute,
|
||||
unmute,
|
||||
toggleMute,
|
||||
buffering,
|
||||
error,
|
||||
canPlay,
|
||||
}
|
||||
}
|
||||
|
||||
function fullscreenSubscribe(onChange: () => void) {
|
||||
document.addEventListener('fullscreenchange', onChange)
|
||||
return () => document.removeEventListener('fullscreenchange', onChange)
|
||||
}
|
||||
|
||||
function useFullscreen(ref: React.RefObject<HTMLElement>) {
|
||||
const isFullscreen = useSyncExternalStore(fullscreenSubscribe, () =>
|
||||
Boolean(document.fullscreenElement),
|
||||
)
|
||||
|
||||
const toggleFullscreen = useCallback(() => {
|
||||
if (isFullscreen) {
|
||||
document.exitFullscreen()
|
||||
} else {
|
||||
if (!ref.current) return
|
||||
ref.current.requestFullscreen()
|
||||
}
|
||||
}, [isFullscreen, ref])
|
||||
|
||||
return [isFullscreen, toggleFullscreen] as const
|
||||
}
|
||||
@@ -1,15 +1,13 @@
|
||||
import React, {useContext, useEffect} from 'react'
|
||||
import React, {useContext} from 'react'
|
||||
import type {VideoPlayer} from 'expo-video'
|
||||
import {useVideoPlayer as useExpoVideoPlayer} from 'expo-video'
|
||||
|
||||
const VideoPlayerContext = React.createContext<VideoPlayer | null>(null)
|
||||
|
||||
export function VideoPlayerProvider({
|
||||
viewId,
|
||||
source,
|
||||
children,
|
||||
}: {
|
||||
viewId: string | null
|
||||
source: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
@@ -19,12 +17,6 @@ export function VideoPlayerProvider({
|
||||
player.play()
|
||||
})
|
||||
|
||||
// make sure we're playing every time the viewId changes
|
||||
// this means the video is different
|
||||
useEffect(() => {
|
||||
player.play()
|
||||
}, [viewId, player])
|
||||
|
||||
return (
|
||||
<VideoPlayerContext.Provider value={player}>
|
||||
{children}
|
||||
|
||||
@@ -268,7 +268,7 @@ function AppPassword({
|
||||
size={14}
|
||||
/>
|
||||
<Text type="md" style={pal.textLight}>
|
||||
Allows access to direct messages
|
||||
<Trans>Allows access to direct messages</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
@@ -9,7 +9,7 @@ import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
|
||||
import {logEvent, LogEvents} from '#/lib/statsig/statsig'
|
||||
import {emitSoftReset} from '#/state/events'
|
||||
import {SavedFeedSourceInfo, usePinnedFeedsInfos} from '#/state/queries/feed'
|
||||
import {FeedParams} from '#/state/queries/post-feed'
|
||||
import {FeedDescriptor, FeedParams} from '#/state/queries/post-feed'
|
||||
import {usePreferencesQuery} from '#/state/queries/preferences'
|
||||
import {UsePreferencesQueryResponse} from '#/state/queries/preferences/types'
|
||||
import {useSession} from '#/state/session'
|
||||
@@ -108,6 +108,30 @@ function HomeScreenReady({
|
||||
}
|
||||
}, [selectedIndex])
|
||||
|
||||
// Temporary, remove when finished debugging
|
||||
const debugHasLoggedFollowingPrefs = React.useRef(false)
|
||||
const debugLogFollowingPrefs = React.useCallback(
|
||||
(feed: FeedDescriptor) => {
|
||||
if (debugHasLoggedFollowingPrefs.current) return
|
||||
if (feed !== 'following') return
|
||||
logEvent('debug:followingPrefs', {
|
||||
followingShowRepliesFromPref: preferences.feedViewPrefs.hideReplies
|
||||
? 'off'
|
||||
: preferences.feedViewPrefs.hideRepliesByUnfollowed
|
||||
? 'following'
|
||||
: 'all',
|
||||
followingRepliesMinLikePref:
|
||||
preferences.feedViewPrefs.hideRepliesByLikeCount,
|
||||
})
|
||||
debugHasLoggedFollowingPrefs.current = true
|
||||
},
|
||||
[
|
||||
preferences.feedViewPrefs.hideReplies,
|
||||
preferences.feedViewPrefs.hideRepliesByLikeCount,
|
||||
preferences.feedViewPrefs.hideRepliesByUnfollowed,
|
||||
],
|
||||
)
|
||||
|
||||
const {hasSession} = useSession()
|
||||
const setMinimalShellMode = useSetMinimalShellMode()
|
||||
const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled()
|
||||
@@ -136,6 +160,7 @@ function HomeScreenReady({
|
||||
feedUrl: selectedFeed,
|
||||
reason: 'focus',
|
||||
})
|
||||
debugLogFollowingPrefs(selectedFeed)
|
||||
}
|
||||
}),
|
||||
)
|
||||
@@ -182,8 +207,9 @@ function HomeScreenReady({
|
||||
feedUrl: feed,
|
||||
reason,
|
||||
})
|
||||
debugLogFollowingPrefs(feed)
|
||||
},
|
||||
[allFeeds],
|
||||
[allFeeds, debugLogFollowingPrefs],
|
||||
)
|
||||
|
||||
const onPressSelected = React.useCallback(() => {
|
||||
|
||||
@@ -1,39 +1,19 @@
|
||||
import React from 'react'
|
||||
import {StyleSheet, View} from 'react-native'
|
||||
import Animated from 'react-native-reanimated'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import {View} from 'react-native'
|
||||
import {useFocusEffect} from '@react-navigation/native'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
import {clamp} from 'lodash'
|
||||
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {
|
||||
RQKEY as POST_THREAD_RQKEY,
|
||||
ThreadNode,
|
||||
} from '#/state/queries/post-thread'
|
||||
import {useSession} from '#/state/session'
|
||||
import {useSetMinimalShellMode} from '#/state/shell'
|
||||
import {useComposerControls} from '#/state/shell/composer'
|
||||
import {useMinimalShellFabTransform} from 'lib/hooks/useMinimalShellTransform'
|
||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||
import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types'
|
||||
import {makeRecordUri} from 'lib/strings/url-helpers'
|
||||
import {s} from 'lib/styles'
|
||||
import {ComposePrompt} from 'view/com/composer/Prompt'
|
||||
import {PostThread as PostThreadComponent} from '../com/post-thread/PostThread'
|
||||
|
||||
type Props = NativeStackScreenProps<CommonNavigatorParams, 'PostThread'>
|
||||
export function PostThreadScreen({route}: Props) {
|
||||
const queryClient = useQueryClient()
|
||||
const {hasSession} = useSession()
|
||||
const fabMinimalShellTransform = useMinimalShellFabTransform()
|
||||
const setMinimalShellMode = useSetMinimalShellMode()
|
||||
const {openComposer} = useComposerControls()
|
||||
const safeAreaInsets = useSafeAreaInsets()
|
||||
|
||||
const {name, rkey} = route.params
|
||||
const {isMobile} = useWebMediaQueries()
|
||||
const uri = makeRecordUri(name, 'app.bsky.feed.post', rkey)
|
||||
const [canReply, setCanReply] = React.useState(false)
|
||||
|
||||
useFocusEffect(
|
||||
React.useCallback(() => {
|
||||
@@ -41,59 +21,11 @@ export function PostThreadScreen({route}: Props) {
|
||||
}, [setMinimalShellMode]),
|
||||
)
|
||||
|
||||
const onPressReply = React.useCallback(() => {
|
||||
if (!uri) {
|
||||
return
|
||||
}
|
||||
const thread = queryClient.getQueryData<ThreadNode>(POST_THREAD_RQKEY(uri))
|
||||
if (thread?.type !== 'post') {
|
||||
return
|
||||
}
|
||||
openComposer({
|
||||
replyTo: {
|
||||
uri: thread.post.uri,
|
||||
cid: thread.post.cid,
|
||||
text: thread.record.text,
|
||||
author: thread.post.author,
|
||||
embed: thread.post.embed,
|
||||
},
|
||||
onPost: () =>
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: POST_THREAD_RQKEY(uri),
|
||||
}),
|
||||
})
|
||||
}, [openComposer, queryClient, uri])
|
||||
|
||||
return (
|
||||
<View style={s.hContentRegion}>
|
||||
<View style={s.flex1}>
|
||||
<PostThreadComponent
|
||||
uri={uri}
|
||||
onPressReply={onPressReply}
|
||||
onCanReply={setCanReply}
|
||||
/>
|
||||
<PostThreadComponent uri={uri} />
|
||||
</View>
|
||||
{isMobile && canReply && hasSession && (
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.prompt,
|
||||
fabMinimalShellTransform,
|
||||
{
|
||||
bottom: clamp(safeAreaInsets.bottom, 15, 30),
|
||||
},
|
||||
]}>
|
||||
<ComposePrompt onPressCompose={onPressReply} />
|
||||
</Animated.View>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
prompt: {
|
||||
// @ts-ignore web-only
|
||||
position: isWeb ? 'fixed' : 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useGate} from '#/lib/statsig/statsig'
|
||||
import {logger} from '#/logger'
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
@@ -293,7 +292,6 @@ export function Explore() {
|
||||
error: feedsError,
|
||||
fetchNextPage: fetchNextFeedsPage,
|
||||
} = useGetPopularFeedsQuery({limit: 10})
|
||||
const gate = useGate()
|
||||
|
||||
const isLoadingMoreProfiles = isFetchingNextProfilesPage && !isLoadingProfiles
|
||||
const onLoadMoreProfiles = React.useCallback(async () => {
|
||||
@@ -499,9 +497,7 @@ export function Explore() {
|
||||
profile={item.profile}
|
||||
noBg
|
||||
noBorder
|
||||
showKnownFollowers={gate(
|
||||
'explore_page_profile_card_social_proof',
|
||||
)}
|
||||
showKnownFollowers
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
@@ -565,7 +561,7 @@ export function Explore() {
|
||||
}
|
||||
}
|
||||
},
|
||||
[t, moderationOpts, gate],
|
||||
[t, moderationOpts],
|
||||
)
|
||||
|
||||
return (
|
||||
|
||||
@@ -20,8 +20,7 @@ import {useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {useModalControls} from '#/state/modals'
|
||||
import {clearLegacyStorage} from '#/state/persisted/legacy'
|
||||
import {clear as clearStorage} from '#/state/persisted/store'
|
||||
import {clearStorage} from '#/state/persisted'
|
||||
import {
|
||||
useInAppBrowser,
|
||||
useSetInAppBrowser,
|
||||
@@ -299,10 +298,6 @@ export function SettingsScreen({}: Props) {
|
||||
await clearStorage()
|
||||
Toast.show(_(msg`Storage cleared, you need to restart the app now.`))
|
||||
}, [_])
|
||||
const clearAllLegacyStorage = React.useCallback(async () => {
|
||||
await clearLegacyStorage()
|
||||
Toast.show(_(msg`Legacy storage cleared, you need to restart the app now.`))
|
||||
}, [_])
|
||||
|
||||
const deactivateAccountControl = useDialogControl()
|
||||
const onPressDeactivateAccount = React.useCallback(() => {
|
||||
@@ -863,18 +858,6 @@ export function SettingsScreen({}: Props) {
|
||||
<Trans>Reset onboarding state</Trans>
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[pal.view, styles.linkCardNoIcon]}
|
||||
onPress={clearAllLegacyStorage}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Clear all legacy storage data`)}
|
||||
accessibilityHint={_(msg`Clears all legacy storage data`)}>
|
||||
<Text type="lg" style={pal.text}>
|
||||
<Trans>
|
||||
Clear all legacy storage data (restart after this)
|
||||
</Trans>
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[pal.view, styles.linkCardNoIcon]}
|
||||
onPress={clearAllStorage}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {Button, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
import {H3, P, Text} from '#/components/Typography'
|
||||
import {PlatformInfo} from '../../../../modules/expo-bluesky-swiss-army'
|
||||
|
||||
export function Dialogs() {
|
||||
const scrollable = Dialog.useDialogControl()
|
||||
@@ -17,6 +18,8 @@ export function Dialogs() {
|
||||
const testDialog = Dialog.useDialogControl()
|
||||
const {closeAllDialogs} = useDialogStateControlContext()
|
||||
const unmountTestDialog = Dialog.useDialogControl()
|
||||
const [reducedMotionEnabled, setReducedMotionEnabled] =
|
||||
React.useState<boolean>()
|
||||
const [shouldRenderUnmountTest, setShouldRenderUnmountTest] =
|
||||
React.useState(false)
|
||||
const unmountTestInterval = React.useRef<number>()
|
||||
@@ -147,6 +150,22 @@ export function Dialogs() {
|
||||
<ButtonText>Open Shared Prefs Tester</ButtonText>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="small"
|
||||
onPress={() => {
|
||||
const isReducedMotionEnabled =
|
||||
PlatformInfo.getIsReducedMotionEnabled()
|
||||
setReducedMotionEnabled(isReducedMotionEnabled)
|
||||
}}
|
||||
label="two">
|
||||
<ButtonText>
|
||||
Is reduced motion enabled?: (
|
||||
{reducedMotionEnabled?.toString() || 'undefined'})
|
||||
</ButtonText>
|
||||
</Button>
|
||||
|
||||
<Prompt.Outer control={prompt}>
|
||||
<Prompt.TitleText>This is a prompt</Prompt.TitleText>
|
||||
<Prompt.DescriptionText>
|
||||
|
||||
@@ -29,7 +29,6 @@ import {
|
||||
useLoggedOutView,
|
||||
useLoggedOutViewControls,
|
||||
} from '#/state/shell/logged-out'
|
||||
import {useGate} from 'lib/statsig/statsig'
|
||||
import {isNative, isWeb} from 'platform/detection'
|
||||
import {Deactivated} from '#/screens/Deactivated'
|
||||
import {Onboarding} from '#/screens/Onboarding'
|
||||
@@ -51,7 +50,6 @@ function NativeStackNavigator({
|
||||
screenOptions,
|
||||
...rest
|
||||
}: NativeStackNavigatorProps) {
|
||||
const gate = useGate()
|
||||
// --- this is copy and pasted from the original native stack navigator ---
|
||||
const {state, descriptors, navigation, NavigationContent} =
|
||||
useNavigationBuilder<
|
||||
@@ -102,12 +100,7 @@ function NativeStackNavigator({
|
||||
const {showLoggedOut} = useLoggedOutView()
|
||||
const {setShowLoggedOut} = useLoggedOutViewControls()
|
||||
const {isMobile, isTabletOrMobile} = useWebMediaQueries()
|
||||
if (
|
||||
!hasSession &&
|
||||
(!PWI_ENABLED ||
|
||||
activeRouteRequiresAuth ||
|
||||
(isNative && gate('native_pwi_disabled')))
|
||||
) {
|
||||
if (!hasSession && (!PWI_ENABLED || activeRouteRequiresAuth || isNative)) {
|
||||
return <LoggedOut />
|
||||
}
|
||||
if (hasSession && currentAccount?.signupQueued) {
|
||||
|
||||
Reference in New Issue
Block a user