Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bae356a2d2 | |||
| faa9473032 | |||
| 743f2ea497 | |||
| e1a0fb80f9 | |||
| 3ba894a851 |
+4
-5
@@ -119,7 +119,6 @@ module.exports = function (_config) {
|
||||
'com.apple.developer.kernel.increased-memory-limit': true,
|
||||
'com.apple.developer.kernel.extended-virtual-addressing': true,
|
||||
'com.apple.security.application-groups': 'group.app.bsky',
|
||||
// 'com.apple.developer.device-information.user-assigned-device-name': true,
|
||||
},
|
||||
privacyManifests: {
|
||||
NSPrivacyCollectedDataTypes: [
|
||||
@@ -313,22 +312,22 @@ module.exports = function (_config) {
|
||||
{
|
||||
ios: {
|
||||
enableFullScreenImage_legacy: true, // iOS only
|
||||
backgroundColor: '#006AFF', // primary_500
|
||||
backgroundColor: '#A8CCFF', // primary_200
|
||||
image: './assets/splash/splash.png',
|
||||
resizeMode: 'cover',
|
||||
dark: {
|
||||
enableFullScreenImage_legacy: true, // iOS only
|
||||
backgroundColor: '#002861', // primary_900
|
||||
backgroundColor: '#00398A', // primary_800
|
||||
image: './assets/splash/splash-dark.png',
|
||||
resizeMode: 'cover',
|
||||
},
|
||||
},
|
||||
android: {
|
||||
backgroundColor: '#006AFF', // primary_500
|
||||
backgroundColor: '#A8CCFF', // primary_200
|
||||
image: './assets/splash/android-splash-logo-white.png',
|
||||
imageWidth: 102, // even division of 306px
|
||||
dark: {
|
||||
backgroundColor: '#002861', // primary_900
|
||||
backgroundColor: '#00398A', // primary_800
|
||||
image: './assets/splash/android-splash-logo-white.png',
|
||||
imageWidth: 102,
|
||||
},
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import '@expo/metro-runtime'
|
||||
import '#/platform/markBundleStartTime'
|
||||
import '#/platform/polyfills'
|
||||
|
||||
|
||||
+3
-2
@@ -73,7 +73,7 @@
|
||||
"icons:optimize": "svgo -f ./assets/icons"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atproto/api": "^0.18.20",
|
||||
"@atproto/api": "^0.18.18",
|
||||
"@bitdrift/react-native": "^0.6.8",
|
||||
"@braintree/sanitize-url": "^6.0.2",
|
||||
"@bsky.app/alf": "^0.1.6",
|
||||
@@ -229,12 +229,13 @@
|
||||
"zod": "^3.20.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@atproto/dev-env": "^0.3.208",
|
||||
"@atproto/dev-env": "^0.3.206",
|
||||
"@babel/core": "^7.26.0",
|
||||
"@babel/preset-env": "^7.26.0",
|
||||
"@babel/runtime": "^7.26.0",
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@expo/config-plugins": "~54.0.1",
|
||||
"@expo/metro-runtime": "~6.1.2",
|
||||
"@lingui/cli": "^4.14.1",
|
||||
"@lingui/macro": "^4.14.1",
|
||||
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.15",
|
||||
|
||||
+1
-2
@@ -146,8 +146,7 @@ export function Splash(props: React.PropsWithChildren<Props>) {
|
||||
withTiming(
|
||||
1,
|
||||
{duration: 400, easing: Easing.out(Easing.cubic)},
|
||||
() => {
|
||||
'worklet'
|
||||
async () => {
|
||||
// set these values to check animation at specific point
|
||||
outroLogo.set(() =>
|
||||
withTiming(
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
AtpAgent,
|
||||
getAgeAssuranceRegionConfig,
|
||||
} from '@atproto/api'
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import {createAsyncStoragePersister} from '@tanstack/query-async-storage-persister'
|
||||
import {focusManager, QueryClient, useQuery} from '@tanstack/react-query'
|
||||
import {persistQueryClient} from '@tanstack/react-query-persist-client'
|
||||
@@ -13,7 +14,6 @@ import debounce from 'lodash.debounce'
|
||||
|
||||
import {networkRetry} from '#/lib/async/retry'
|
||||
import {PUBLIC_BSKY_SERVICE} from '#/lib/constants'
|
||||
import {createPersistedQueryStorage} from '#/lib/persisted-query-storage'
|
||||
import {getAge} from '#/lib/strings/time'
|
||||
import {
|
||||
hasSnoozedBirthdateUpdateForDid,
|
||||
@@ -45,7 +45,7 @@ const qc = new QueryClient({
|
||||
},
|
||||
})
|
||||
const persister = createAsyncStoragePersister({
|
||||
storage: createPersistedQueryStorage('age-assurance'),
|
||||
storage: AsyncStorage,
|
||||
key: 'age-assurance-query-client',
|
||||
})
|
||||
const [, cacheHydrationPromise] = persistQueryClient({
|
||||
|
||||
@@ -467,8 +467,8 @@ export type Events = {
|
||||
| 'InterstitialProfile'
|
||||
| 'Profile'
|
||||
| 'Onboarding'
|
||||
location: 'Card' | 'Profile' | 'FollowAll'
|
||||
recId?: number | string
|
||||
location: 'Card' | 'Profile'
|
||||
recId?: number
|
||||
position: number
|
||||
suggestedDid: string
|
||||
category: string | null
|
||||
@@ -479,7 +479,7 @@ export type Events = {
|
||||
| 'InterstitialDiscover'
|
||||
| 'InterstitialProfile'
|
||||
| 'Onboarding'
|
||||
recId?: number | string
|
||||
recId?: number
|
||||
position: number
|
||||
suggestedDid: string
|
||||
category: string | null
|
||||
@@ -492,7 +492,7 @@ export type Events = {
|
||||
| 'Profile'
|
||||
| 'Onboarding'
|
||||
| 'ProgressGuide'
|
||||
recId?: number | string
|
||||
recId?: number
|
||||
position: number
|
||||
suggestedDid: string
|
||||
category: string | null
|
||||
@@ -507,7 +507,7 @@ export type Events = {
|
||||
}
|
||||
'suggestedUser:dismiss': {
|
||||
logContext: 'InterstitialDiscover' | 'InterstitialProfile'
|
||||
recId?: number | string
|
||||
recId?: number
|
||||
position: number
|
||||
suggestedDid: string
|
||||
}
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
import {useCallback, useEffect, useMemo, useRef, useState} from 'react'
|
||||
import React, {useCallback, useEffect, useRef} from 'react'
|
||||
import {ScrollView, View} from 'react-native'
|
||||
import Animated, {
|
||||
Easing,
|
||||
FadeIn,
|
||||
FadeOut,
|
||||
LayoutAnimationConfig,
|
||||
LinearTransition,
|
||||
} from 'react-native-reanimated'
|
||||
import Animated, {LinearTransition} from 'react-native-reanimated'
|
||||
import {type AppBskyFeedDefs, AtUri} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
@@ -27,7 +21,6 @@ import {type SeenPost} from '#/state/userActionHistory'
|
||||
import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture'
|
||||
import {
|
||||
atoms as a,
|
||||
native,
|
||||
useBreakpoints,
|
||||
useTheme,
|
||||
type ViewStyleProp,
|
||||
@@ -159,7 +152,7 @@ function sortSeenPosts(postA: SeenPost, postB: SeenPost): 0 | 1 | -1 {
|
||||
function useExperimentalSuggestedUsersQuery() {
|
||||
const {currentAccount} = useSession()
|
||||
const userActionSnapshot = userActionHistory.useActionHistorySnapshot()
|
||||
const dids = useMemo(() => {
|
||||
const dids = React.useMemo(() => {
|
||||
const {likes, follows, followSuggestions, seen} = userActionSnapshot
|
||||
const likeDids = likes
|
||||
.map(l => new AtUri(l))
|
||||
@@ -232,54 +225,67 @@ export function SuggestedFollowsProfile({did}: {did: string}) {
|
||||
isFetchingNextPage,
|
||||
} = useSuggestedFollowsQuery({limit: 25})
|
||||
|
||||
const [dismissedDids, setDismissedDids] = useState<Set<string>>(new Set())
|
||||
const [dismissedDids, setDismissedDids] = React.useState<Set<string>>(
|
||||
new Set(),
|
||||
)
|
||||
const [dismissingDids, setDismissingDids] = React.useState<Set<string>>(
|
||||
new Set(),
|
||||
)
|
||||
|
||||
const onDismiss = useCallback((dismissedDid: string) => {
|
||||
setDismissedDids(prev => new Set(prev).add(dismissedDid))
|
||||
const onDismiss = React.useCallback((dismissedDid: string) => {
|
||||
// Start the fade animation
|
||||
setDismissingDids(prev => new Set(prev).add(dismissedDid))
|
||||
// After animation completes, actually remove from list
|
||||
setTimeout(() => {
|
||||
setDismissedDids(prev => new Set(prev).add(dismissedDid))
|
||||
setDismissingDids(prev => {
|
||||
const next = new Set(prev)
|
||||
next.delete(dismissedDid)
|
||||
return next
|
||||
})
|
||||
}, DISMISS_ANIMATION_DURATION)
|
||||
}, [])
|
||||
|
||||
// Combine profiles from the actor-specific query with fallback suggestions
|
||||
const allProfiles = useMemo(() => {
|
||||
const allProfiles = React.useMemo(() => {
|
||||
const actorProfiles = data?.suggestions ?? []
|
||||
const fallbackProfiles =
|
||||
moreSuggestions?.pages.flatMap(page =>
|
||||
page.actors.map(actor => ({actor, recId: page.recId})),
|
||||
) ?? []
|
||||
moreSuggestions?.pages.flatMap(page => page.actors) ?? []
|
||||
|
||||
// Dedupe by did, preferring actor-specific profiles
|
||||
const seen = new Set<string>()
|
||||
const combined: {actor: bsky.profile.AnyProfileView; recId?: number}[] = []
|
||||
const combined: bsky.profile.AnyProfileView[] = []
|
||||
|
||||
for (const profile of actorProfiles) {
|
||||
if (!seen.has(profile.did)) {
|
||||
seen.add(profile.did)
|
||||
combined.push({actor: profile, recId: data?.recId})
|
||||
combined.push(profile)
|
||||
}
|
||||
}
|
||||
|
||||
for (const profile of fallbackProfiles) {
|
||||
if (!seen.has(profile.actor.did) && profile.actor.did !== did) {
|
||||
seen.add(profile.actor.did)
|
||||
if (!seen.has(profile.did) && profile.did !== did) {
|
||||
seen.add(profile.did)
|
||||
combined.push(profile)
|
||||
}
|
||||
}
|
||||
|
||||
return combined
|
||||
}, [data?.suggestions, moreSuggestions?.pages, did, data?.recId])
|
||||
}, [data?.suggestions, moreSuggestions?.pages, did])
|
||||
|
||||
const filteredProfiles = useMemo(() => {
|
||||
return allProfiles.filter(p => !dismissedDids.has(p.actor.did))
|
||||
const filteredProfiles = React.useMemo(() => {
|
||||
return allProfiles.filter(p => !dismissedDids.has(p.did))
|
||||
}, [allProfiles, dismissedDids])
|
||||
|
||||
// Fetch more when running low
|
||||
useEffect(() => {
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
moderationOpts &&
|
||||
filteredProfiles.length < maxLength &&
|
||||
hasNextPage &&
|
||||
!isFetchingNextPage
|
||||
) {
|
||||
void fetchNextPage()
|
||||
fetchNextPage()
|
||||
}
|
||||
}, [
|
||||
filteredProfiles.length,
|
||||
@@ -295,9 +301,11 @@ export function SuggestedFollowsProfile({did}: {did: string}) {
|
||||
isSuggestionsLoading={isSuggestionsLoading}
|
||||
profiles={filteredProfiles}
|
||||
totalProfileCount={allProfiles.length}
|
||||
recId={data?.recId}
|
||||
error={error}
|
||||
viewContext="profile"
|
||||
onDismiss={onDismiss}
|
||||
dismissingDids={dismissingDids}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -319,36 +327,46 @@ export function SuggestedFollowsHome() {
|
||||
error: suggestionsError,
|
||||
} = useSuggestedFollowsQuery({limit: 25})
|
||||
|
||||
const [dismissedDids, setDismissedDids] = useState<Set<string>>(new Set())
|
||||
const [dismissedDids, setDismissedDids] = React.useState<Set<string>>(
|
||||
new Set(),
|
||||
)
|
||||
const [dismissingDids, setDismissingDids] = React.useState<Set<string>>(
|
||||
new Set(),
|
||||
)
|
||||
|
||||
const onDismiss = useCallback((did: string) => {
|
||||
setDismissedDids(prev => new Set(prev).add(did))
|
||||
const onDismiss = React.useCallback((did: string) => {
|
||||
// Start the fade animation
|
||||
setDismissingDids(prev => new Set(prev).add(did))
|
||||
// After animation completes, actually remove from list
|
||||
setTimeout(() => {
|
||||
setDismissedDids(prev => new Set(prev).add(did))
|
||||
setDismissingDids(prev => {
|
||||
const next = new Set(prev)
|
||||
next.delete(did)
|
||||
return next
|
||||
})
|
||||
}, DISMISS_ANIMATION_DURATION)
|
||||
}, [])
|
||||
|
||||
// Combine profiles from experimental query with paginated suggestions
|
||||
const allProfiles = useMemo(() => {
|
||||
const allProfiles = React.useMemo(() => {
|
||||
const fallbackProfiles =
|
||||
moreSuggestions?.pages.flatMap(page =>
|
||||
page.actors.map(actor => ({actor, recId: page.recId})),
|
||||
) ?? []
|
||||
moreSuggestions?.pages.flatMap(page => page.actors) ?? []
|
||||
|
||||
// Dedupe by did, preferring experimental profiles
|
||||
const seen = new Set<string>()
|
||||
const combined: Array<{
|
||||
actor: bsky.profile.AnyProfileView
|
||||
recId?: number
|
||||
}> = []
|
||||
const combined: bsky.profile.AnyProfileView[] = []
|
||||
|
||||
for (const profile of experimentalProfiles) {
|
||||
if (!seen.has(profile.did)) {
|
||||
seen.add(profile.did)
|
||||
combined.push({actor: profile, recId: undefined})
|
||||
combined.push(profile)
|
||||
}
|
||||
}
|
||||
|
||||
for (const profile of fallbackProfiles) {
|
||||
if (!seen.has(profile.actor.did)) {
|
||||
seen.add(profile.actor.did)
|
||||
if (!seen.has(profile.did)) {
|
||||
seen.add(profile.did)
|
||||
combined.push(profile)
|
||||
}
|
||||
}
|
||||
@@ -356,19 +374,19 @@ export function SuggestedFollowsHome() {
|
||||
return combined
|
||||
}, [experimentalProfiles, moreSuggestions?.pages])
|
||||
|
||||
const filteredProfiles = useMemo(() => {
|
||||
return allProfiles.filter(p => !dismissedDids.has(p.actor.did))
|
||||
const filteredProfiles = React.useMemo(() => {
|
||||
return allProfiles.filter(p => !dismissedDids.has(p.did))
|
||||
}, [allProfiles, dismissedDids])
|
||||
|
||||
// Fetch more when running low
|
||||
useEffect(() => {
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
moderationOpts &&
|
||||
filteredProfiles.length < maxLength &&
|
||||
hasNextPage &&
|
||||
!isFetchingNextPage
|
||||
) {
|
||||
void fetchNextPage()
|
||||
fetchNextPage()
|
||||
}
|
||||
}, [
|
||||
filteredProfiles.length,
|
||||
@@ -387,6 +405,7 @@ export function SuggestedFollowsHome() {
|
||||
error={experimentalError || suggestionsError}
|
||||
viewContext="feed"
|
||||
onDismiss={onDismiss}
|
||||
dismissingDids={dismissingDids}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -396,14 +415,18 @@ export function ProfileGrid({
|
||||
error,
|
||||
profiles,
|
||||
totalProfileCount,
|
||||
recId,
|
||||
viewContext = 'feed',
|
||||
onDismiss,
|
||||
dismissingDids,
|
||||
isVisible = true,
|
||||
}: {
|
||||
isSuggestionsLoading: boolean
|
||||
profiles: {actor: bsky.profile.AnyProfileView; recId?: number}[]
|
||||
profiles: bsky.profile.AnyProfileView[]
|
||||
totalProfileCount?: number
|
||||
recId?: number
|
||||
error: Error | null
|
||||
dismissingDids?: Set<string>
|
||||
viewContext: 'profile' | 'profileHeader' | 'feed'
|
||||
onDismiss?: (did: string) => void
|
||||
isVisible?: boolean
|
||||
@@ -440,18 +463,18 @@ export function ProfileGrid({
|
||||
|
||||
const profilesToShow = profiles.slice(0, maxLength)
|
||||
profilesToShow.forEach((profile, index) => {
|
||||
if (!seenProfilesRef.current.has(profile.actor.did)) {
|
||||
seenProfilesRef.current.add(profile.actor.did)
|
||||
if (!seenProfilesRef.current.has(profile.did)) {
|
||||
seenProfilesRef.current.add(profile.did)
|
||||
ax.metric('suggestedUser:seen', {
|
||||
logContext,
|
||||
recId: profile.recId,
|
||||
recId,
|
||||
position: index,
|
||||
suggestedDid: profile.actor.did,
|
||||
suggestedDid: profile.did,
|
||||
category: null,
|
||||
})
|
||||
}
|
||||
})
|
||||
}, [ax, isLoading, error, profiles, maxLength, logContext])
|
||||
}, [ax, isLoading, error, profiles, maxLength, logContext, recId])
|
||||
|
||||
// For profile header, fire when isVisible becomes true
|
||||
useEffect(() => {
|
||||
@@ -517,15 +540,8 @@ export function ProfileGrid({
|
||||
? null
|
||||
: profiles.slice(0, maxLength).map((profile, index) => (
|
||||
<Animated.View
|
||||
key={profile.actor.did}
|
||||
layout={native(
|
||||
LinearTransition.delay(DISMISS_ANIMATION_DURATION).easing(
|
||||
Easing.out(Easing.exp),
|
||||
),
|
||||
)}
|
||||
exiting={FadeOut.duration(DISMISS_ANIMATION_DURATION)}
|
||||
// for web, as the cards are static, not in a list
|
||||
entering={web(FadeIn.delay(DISMISS_ANIMATION_DURATION * 2))}
|
||||
key={profile.did}
|
||||
layout={LinearTransition.duration(DISMISS_ANIMATION_DURATION)}
|
||||
style={[
|
||||
a.flex_1,
|
||||
gtMobile &&
|
||||
@@ -534,17 +550,22 @@ export function ProfileGrid({
|
||||
a.flex_grow,
|
||||
{width: `calc(30% - ${a.gap_md.gap / 2}px)`},
|
||||
]),
|
||||
{
|
||||
opacity: dismissingDids?.has(profile.did) ? 0 : 1,
|
||||
transitionProperty: 'opacity',
|
||||
transitionDuration: `${DISMISS_ANIMATION_DURATION}ms`,
|
||||
},
|
||||
]}>
|
||||
<ProfileCard.Link
|
||||
profile={profile.actor}
|
||||
profile={profile}
|
||||
onPress={() => {
|
||||
ax.metric('suggestedUser:press', {
|
||||
logContext: isFeedContext
|
||||
? 'InterstitialDiscover'
|
||||
: 'InterstitialProfile',
|
||||
recId: profile.recId,
|
||||
recId,
|
||||
position: index,
|
||||
suggestedDid: profile.actor.did,
|
||||
suggestedDid: profile.did,
|
||||
category: null,
|
||||
})
|
||||
}}
|
||||
@@ -560,14 +581,14 @@ export function ProfileGrid({
|
||||
label={_(msg`Dismiss this suggestion`)}
|
||||
onPress={e => {
|
||||
e.preventDefault()
|
||||
onDismiss(profile.actor.did)
|
||||
onDismiss(profile.did)
|
||||
ax.metric('suggestedUser:dismiss', {
|
||||
logContext: isFeedContext
|
||||
? 'InterstitialDiscover'
|
||||
: 'InterstitialProfile',
|
||||
position: index,
|
||||
suggestedDid: profile.actor.did,
|
||||
recId: profile.recId,
|
||||
suggestedDid: profile.did,
|
||||
recId,
|
||||
})
|
||||
}}
|
||||
style={[
|
||||
@@ -600,18 +621,18 @@ export function ProfileGrid({
|
||||
a.mb_auto,
|
||||
]}>
|
||||
<ProfileCard.Avatar
|
||||
profile={profile.actor}
|
||||
profile={profile}
|
||||
moderationOpts={moderationOpts}
|
||||
disabledPreview
|
||||
size={88}
|
||||
/>
|
||||
<View style={[a.flex_col, a.align_center, a.max_w_full]}>
|
||||
<ProfileCard.Name
|
||||
profile={profile.actor}
|
||||
profile={profile}
|
||||
moderationOpts={moderationOpts}
|
||||
/>
|
||||
<ProfileCard.Description
|
||||
profile={profile.actor}
|
||||
profile={profile}
|
||||
numberOfLines={2}
|
||||
style={[
|
||||
t.atoms.text_contrast_medium,
|
||||
@@ -623,7 +644,7 @@ export function ProfileGrid({
|
||||
</View>
|
||||
|
||||
<ProfileCard.FollowButton
|
||||
profile={profile.actor}
|
||||
profile={profile}
|
||||
moderationOpts={moderationOpts}
|
||||
logContext="FeedInterstitial"
|
||||
withIcon={false}
|
||||
@@ -634,9 +655,9 @@ export function ProfileGrid({
|
||||
? 'InterstitialDiscover'
|
||||
: 'InterstitialProfile',
|
||||
location: 'Card',
|
||||
recId: profile.recId,
|
||||
recId,
|
||||
position: index,
|
||||
suggestedDid: profile.actor.did,
|
||||
suggestedDid: profile.did,
|
||||
category: null,
|
||||
})
|
||||
}}
|
||||
@@ -705,37 +726,35 @@ export function ProfileGrid({
|
||||
|
||||
<FollowDialogWithoutGuide control={followDialogControl} />
|
||||
|
||||
<LayoutAnimationConfig skipExiting skipEntering>
|
||||
{gtMobile ? (
|
||||
<View style={[a.p_lg, a.pt_md]}>
|
||||
<View style={[a.flex_1, a.flex_row, a.flex_wrap, a.gap_md]}>
|
||||
{content}
|
||||
</View>
|
||||
{gtMobile ? (
|
||||
<View style={[a.p_lg, a.pt_md]}>
|
||||
<View style={[a.flex_1, a.flex_row, a.flex_wrap, a.gap_md]}>
|
||||
{content}
|
||||
</View>
|
||||
) : (
|
||||
<BlockDrawerGesture>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={[a.p_lg, a.pt_md, a.flex_row, a.gap_md]}
|
||||
snapToInterval={MOBILE_CARD_WIDTH + a.gap_md.gap}
|
||||
decelerationRate="fast">
|
||||
{content}
|
||||
</View>
|
||||
) : (
|
||||
<BlockDrawerGesture>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={[a.p_lg, a.pt_md, a.flex_row, a.gap_md]}
|
||||
snapToInterval={MOBILE_CARD_WIDTH + a.gap_md.gap}
|
||||
decelerationRate="fast">
|
||||
{content}
|
||||
|
||||
{!isProfileHeaderContext && (
|
||||
<SeeMoreSuggestedProfilesCard
|
||||
onPress={() => {
|
||||
followDialogControl.open()
|
||||
ax.metric('suggestedUser:seeMore', {
|
||||
logContext: 'Explore',
|
||||
})
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</ScrollView>
|
||||
</BlockDrawerGesture>
|
||||
)}
|
||||
</LayoutAnimationConfig>
|
||||
{!isProfileHeaderContext && (
|
||||
<SeeMoreSuggestedProfilesCard
|
||||
onPress={() => {
|
||||
followDialogControl.open()
|
||||
ax.metric('suggestedUser:seeMore', {
|
||||
logContext: 'Explore',
|
||||
})
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</ScrollView>
|
||||
</BlockDrawerGesture>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -776,7 +795,7 @@ export function SuggestedFeeds() {
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
|
||||
const feeds = useMemo(() => {
|
||||
const feeds = React.useMemo(() => {
|
||||
const items: AppBskyFeedDefs.GeneratorView[] = []
|
||||
|
||||
if (!data) return items
|
||||
|
||||
@@ -50,11 +50,7 @@ export function Embed({
|
||||
} else if (e.type === 'video') {
|
||||
return (
|
||||
<Outer style={style}>
|
||||
{e.view.presentation === 'gif' ? (
|
||||
<GifItem thumbnail={e.view.thumbnail} alt={e.view.alt} />
|
||||
) : (
|
||||
<VideoItem thumbnail={e.view.thumbnail} alt={e.view.alt} />
|
||||
)}
|
||||
<VideoItem thumbnail={e.view.thumbnail} alt={e.view.alt} />
|
||||
</Outer>
|
||||
)
|
||||
} else if (
|
||||
@@ -85,29 +81,11 @@ export function ImageItem({
|
||||
alt,
|
||||
children,
|
||||
}: {
|
||||
thumbnail?: string
|
||||
thumbnail: string
|
||||
alt?: string
|
||||
children?: React.ReactNode
|
||||
}) {
|
||||
const t = useTheme()
|
||||
|
||||
if (!thumbnail) {
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
{backgroundColor: 'black'},
|
||||
a.flex_1,
|
||||
a.aspect_square,
|
||||
{maxWidth: 100},
|
||||
a.rounded_xs,
|
||||
]}
|
||||
accessibilityLabel={alt}
|
||||
accessibilityHint="">
|
||||
{children}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[a.relative, a.flex_1, a.aspect_square, {maxWidth: 100}]}>
|
||||
<Image
|
||||
@@ -125,7 +103,7 @@ export function ImageItem({
|
||||
)
|
||||
}
|
||||
|
||||
export function GifItem({thumbnail, alt}: {thumbnail?: string; alt?: string}) {
|
||||
export function GifItem({thumbnail, alt}: {thumbnail: string; alt?: string}) {
|
||||
return (
|
||||
<ImageItem thumbnail={thumbnail} alt={alt}>
|
||||
<View style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}>
|
||||
@@ -147,6 +125,22 @@ export function VideoItem({
|
||||
thumbnail?: string
|
||||
alt?: string
|
||||
}) {
|
||||
if (!thumbnail) {
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
{backgroundColor: 'black'},
|
||||
a.flex_1,
|
||||
a.aspect_square,
|
||||
{maxWidth: 100},
|
||||
a.justify_center,
|
||||
a.align_center,
|
||||
a.rounded_xs,
|
||||
]}>
|
||||
<PlayButtonIcon size={24} />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<ImageItem thumbnail={thumbnail} alt={alt}>
|
||||
<View style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}>
|
||||
@@ -163,7 +157,7 @@ const styles = StyleSheet.create({
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 3,
|
||||
position: 'absolute',
|
||||
left: 5,
|
||||
right: 5,
|
||||
bottom: 5,
|
||||
zIndex: 2,
|
||||
},
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {useRef, useState} from 'react'
|
||||
import {
|
||||
Pressable,
|
||||
type StyleProp,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
@@ -16,13 +17,60 @@ import {useAutoplayDisabled} from '#/state/preferences'
|
||||
import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Fill} from '#/components/Fill'
|
||||
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
|
||||
import {IS_WEB} from '#/env'
|
||||
import {GifView} from '../../../../../modules/expo-bluesky-gif-view'
|
||||
import {type GifViewStateChangeEvent} from '../../../../../modules/expo-bluesky-gif-view/src/GifView.types'
|
||||
import {GifPresentationControls} from '../VideoEmbed/GifPresentationControls'
|
||||
|
||||
function PlaybackControls({
|
||||
onPress,
|
||||
isPlaying,
|
||||
isLoaded,
|
||||
}: {
|
||||
onPress: () => void
|
||||
isPlaying: boolean
|
||||
isLoaded: boolean
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityHint={_(msg`Plays or pauses the GIF`)}
|
||||
accessibilityLabel={isPlaying ? _(msg`Pause`) : _(msg`Play`)}
|
||||
style={[
|
||||
a.absolute,
|
||||
a.align_center,
|
||||
a.justify_center,
|
||||
!isLoaded && a.border,
|
||||
t.atoms.border_contrast_medium,
|
||||
a.inset_0,
|
||||
a.w_full,
|
||||
a.h_full,
|
||||
{
|
||||
zIndex: 2,
|
||||
backgroundColor: !isLoaded
|
||||
? t.atoms.bg_contrast_25.backgroundColor
|
||||
: undefined,
|
||||
},
|
||||
]}
|
||||
onPress={onPress}>
|
||||
{!isLoaded ? (
|
||||
<View>
|
||||
<View style={[a.align_center, a.justify_center]}>
|
||||
<Loader size="xl" />
|
||||
</View>
|
||||
</View>
|
||||
) : !isPlaying ? (
|
||||
<PlayButtonIcon />
|
||||
) : undefined}
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
|
||||
export function GifEmbed({
|
||||
params,
|
||||
@@ -72,6 +120,8 @@ export function GifEmbed({
|
||||
style={[
|
||||
a.rounded_md,
|
||||
a.overflow_hidden,
|
||||
a.border,
|
||||
t.atoms.border_contrast_low,
|
||||
{backgroundColor: t.palette.black},
|
||||
{aspectRatio},
|
||||
style,
|
||||
@@ -89,11 +139,10 @@ export function GifEmbed({
|
||||
right: -2,
|
||||
},
|
||||
]}>
|
||||
<MediaInsetBorder />
|
||||
<GifPresentationControls
|
||||
<PlaybackControls
|
||||
onPress={onPress}
|
||||
isPlaying={playerState.isPlaying}
|
||||
isLoading={!playerState.isLoaded}
|
||||
isLoaded={playerState.isLoaded}
|
||||
/>
|
||||
<GifView
|
||||
source={params.playerUri}
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
import {Pressable, StyleSheet, View} from 'react-native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Fill} from '#/components/Fill'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
|
||||
|
||||
export function GifPresentationControls({
|
||||
onPress,
|
||||
isPlaying,
|
||||
isLoading,
|
||||
}: {
|
||||
onPress: () => void
|
||||
isPlaying: boolean
|
||||
isLoading?: boolean
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
|
||||
return (
|
||||
<>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityHint={_(msg`Plays or pauses the GIF`)}
|
||||
accessibilityLabel={isPlaying ? _(msg`Pause`) : _(msg`Play`)}
|
||||
style={[
|
||||
a.absolute,
|
||||
a.align_center,
|
||||
a.justify_center,
|
||||
a.inset_0,
|
||||
a.w_full,
|
||||
a.h_full,
|
||||
{zIndex: 2},
|
||||
]}
|
||||
onPress={onPress}>
|
||||
{isLoading ? (
|
||||
<View style={[a.align_center, a.justify_center]}>
|
||||
<Loader size="xl" />
|
||||
</View>
|
||||
) : !isPlaying ? (
|
||||
<PlayButtonIcon />
|
||||
) : undefined}
|
||||
</Pressable>
|
||||
{!isPlaying && (
|
||||
<Fill
|
||||
style={[
|
||||
t.name === 'light' ? t.atoms.bg_contrast_975 : t.atoms.bg,
|
||||
{
|
||||
opacity: 0.2,
|
||||
zIndex: 1,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
<View style={styles.gifBadgeContainer}>
|
||||
<Text style={[{color: 'white'}, a.font_bold, a.text_xs]}>
|
||||
<Trans>GIF</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
gifBadgeContainer: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.75)',
|
||||
borderRadius: 6,
|
||||
paddingHorizontal: 4,
|
||||
paddingVertical: 3,
|
||||
position: 'absolute',
|
||||
left: 6,
|
||||
bottom: 6,
|
||||
zIndex: 2,
|
||||
},
|
||||
})
|
||||
@@ -15,7 +15,6 @@ import {Play_Filled_Corner0_Rounded as PlayIcon} from '#/components/icons/Play'
|
||||
import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as UnmuteIcon} from '#/components/icons/Speaker'
|
||||
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
|
||||
import {useVideoMuteState} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext'
|
||||
import {GifPresentationControls} from '../GifPresentationControls'
|
||||
import {TimeIndicator} from './TimeIndicator'
|
||||
|
||||
export function VideoEmbedInnerNative({
|
||||
@@ -51,14 +50,12 @@ export function VideoEmbedInnerNative({
|
||||
throw new Error(error)
|
||||
}
|
||||
|
||||
const isGif = embed.presentation === 'gif'
|
||||
|
||||
return (
|
||||
<View style={[a.flex_1, a.relative]}>
|
||||
<BlueskyVideoView
|
||||
url={embed.playlist}
|
||||
autoplay={!autoplayDisabled && !isWithinMessage}
|
||||
beginMuted={isGif || autoplayDisabled ? false : muted}
|
||||
beginMuted={autoplayDisabled ? false : muted}
|
||||
style={[a.rounded_sm]}
|
||||
onActiveChange={e => {
|
||||
setIsActive(e.nativeEvent.isActive)
|
||||
@@ -85,35 +82,25 @@ export function VideoEmbedInnerNative({
|
||||
}
|
||||
accessibilityHint=""
|
||||
/>
|
||||
{isGif ? (
|
||||
<GifPresentationControls
|
||||
onPress={() => {
|
||||
videoRef.current?.togglePlayback()
|
||||
}}
|
||||
isPlaying={isPlaying}
|
||||
isLoading={false}
|
||||
/>
|
||||
) : (
|
||||
<VideoPresentationControls
|
||||
enterFullscreen={() => {
|
||||
videoRef.current?.enterFullscreen(true)
|
||||
}}
|
||||
toggleMuted={() => {
|
||||
videoRef.current?.toggleMuted()
|
||||
}}
|
||||
togglePlayback={() => {
|
||||
videoRef.current?.togglePlayback()
|
||||
}}
|
||||
isPlaying={isPlaying}
|
||||
timeRemaining={timeRemaining}
|
||||
/>
|
||||
)}
|
||||
<VideoControls
|
||||
enterFullscreen={() => {
|
||||
videoRef.current?.enterFullscreen(true)
|
||||
}}
|
||||
toggleMuted={() => {
|
||||
videoRef.current?.toggleMuted()
|
||||
}}
|
||||
togglePlayback={() => {
|
||||
videoRef.current?.togglePlayback()
|
||||
}}
|
||||
isPlaying={isPlaying}
|
||||
timeRemaining={timeRemaining}
|
||||
/>
|
||||
<MediaInsetBorder />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function VideoPresentationControls({
|
||||
function VideoControls({
|
||||
enterFullscreen,
|
||||
toggleMuted,
|
||||
togglePlayback,
|
||||
|
||||
@@ -21,7 +21,7 @@ export function VideoEmbedInnerWeb({
|
||||
active: boolean
|
||||
setActive: () => void
|
||||
onScreen: boolean
|
||||
lastKnownTime: React.RefObject<number | undefined>
|
||||
lastKnownTime: React.MutableRefObject<number | undefined>
|
||||
}) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
@@ -37,7 +37,7 @@ export function VideoEmbedInnerWeb({
|
||||
throw error
|
||||
}
|
||||
|
||||
const {hlsRef, loop} = useHLS({
|
||||
const hlsRef = useHLS({
|
||||
playlist: embed.playlist,
|
||||
setHasSubtitleTrack,
|
||||
setError,
|
||||
@@ -64,12 +64,11 @@ export function VideoEmbedInnerWeb({
|
||||
style={{width: '100%', height: '100%', objectFit: 'contain'}}
|
||||
playsInline
|
||||
preload="none"
|
||||
muted={embed.presentation === 'gif' || !focused}
|
||||
muted={!focused}
|
||||
aria-labelledby={embed.alt ? figId : undefined}
|
||||
onTimeUpdate={e => {
|
||||
lastKnownTime.current = e.currentTarget.currentTime
|
||||
}}
|
||||
loop={loop}
|
||||
/>
|
||||
{embed.alt && (
|
||||
<figcaption
|
||||
@@ -100,7 +99,6 @@ export function VideoEmbedInnerWeb({
|
||||
onScreen={onScreen}
|
||||
fullscreenRef={containerRef}
|
||||
hasSubtitleTrack={hasSubtitleTrack}
|
||||
isGif={embed.presentation === 'gif'}
|
||||
/>
|
||||
</div>
|
||||
</View>
|
||||
@@ -194,6 +192,29 @@ function useHLS({
|
||||
},
|
||||
)
|
||||
|
||||
const flushOnLoop = useNonReactiveCallback(() => {
|
||||
if (!Hls) return
|
||||
if (!hlsRef.current) return
|
||||
const hls = hlsRef.current
|
||||
// the above callback will catch most stale frags, but there's a corner case -
|
||||
// if there's only one segment in the video, it won't get flushed because it avoids
|
||||
// flushing the currently active segment. Therefore, we have to catch it when we loop
|
||||
if (
|
||||
hls.nextAutoLevel > 0 &&
|
||||
lowQualityFragments.length === 1 &&
|
||||
lowQualityFragments[0].start === 0
|
||||
) {
|
||||
const lowQualFrag = lowQualityFragments[0]
|
||||
|
||||
hls.trigger(Hls.Events.BUFFER_FLUSHING, {
|
||||
startOffset: lowQualFrag.start,
|
||||
endOffset: lowQualFrag.end,
|
||||
type: 'video',
|
||||
})
|
||||
setLowQualityFragments([])
|
||||
}
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!videoRef.current) return
|
||||
if (!Hls) return
|
||||
@@ -221,6 +242,20 @@ function useHLS({
|
||||
hls.attachMedia(videoRef.current)
|
||||
hls.loadSource(playlist)
|
||||
|
||||
// manually loop, so if we've flushed the first buffer it doesn't get confused
|
||||
const abortController = new AbortController()
|
||||
const {signal} = abortController
|
||||
const videoNode = videoRef.current
|
||||
videoNode.addEventListener(
|
||||
'ended',
|
||||
() => {
|
||||
flushOnLoop()
|
||||
videoNode.currentTime = 0
|
||||
videoNode.play()
|
||||
},
|
||||
{signal},
|
||||
)
|
||||
|
||||
hls.on(Hls.Events.FRAG_LOADED, () => {
|
||||
BandwidthEstimate.set(hls.bandwidthEstimate)
|
||||
})
|
||||
@@ -258,65 +293,17 @@ function useHLS({
|
||||
hlsRef.current = undefined
|
||||
hls.detachMedia()
|
||||
hls.destroy()
|
||||
}
|
||||
}, [playlist, setError, setHasSubtitleTrack, videoRef, handleFragChange, Hls])
|
||||
|
||||
const flushOnLoop = useNonReactiveCallback(() => {
|
||||
if (!Hls) return
|
||||
if (!hlsRef.current) return
|
||||
const hls = hlsRef.current
|
||||
// `handleFragChange` will catch most stale frags, but there's a corner case -
|
||||
// if there's only one segment in the video, it won't get flushed because it avoids
|
||||
// flushing the currently active segment. Therefore, we have to catch it when we loop
|
||||
if (
|
||||
hls.nextAutoLevel > 0 &&
|
||||
lowQualityFragments.length === 1 &&
|
||||
lowQualityFragments[0].start === 0
|
||||
) {
|
||||
const lowQualFrag = lowQualityFragments[0]
|
||||
|
||||
hls.trigger(Hls.Events.BUFFER_FLUSHING, {
|
||||
startOffset: lowQualFrag.start,
|
||||
endOffset: lowQualFrag.end,
|
||||
type: 'video',
|
||||
})
|
||||
setLowQualityFragments([])
|
||||
}
|
||||
})
|
||||
|
||||
// manually loop, so if we've flushed the first buffer it doesn't get confused
|
||||
const hasLowQualityFragmentAtStart = lowQualityFragments.some(
|
||||
frag => frag.start === 0,
|
||||
)
|
||||
useEffect(() => {
|
||||
if (!videoRef.current) return
|
||||
|
||||
// use `loop` prop on `<video>` element if the starting frag is high quality.
|
||||
// otherwise, we need to do it with an event listener as we may need to manually flush the frag
|
||||
if (!hasLowQualityFragmentAtStart) return
|
||||
|
||||
const abortController = new AbortController()
|
||||
const {signal} = abortController
|
||||
const videoNode = videoRef.current
|
||||
videoNode.addEventListener(
|
||||
'ended',
|
||||
() => {
|
||||
flushOnLoop()
|
||||
videoNode.currentTime = 0
|
||||
const maybePromise = videoNode.play() as Promise<void> | undefined
|
||||
if (maybePromise) {
|
||||
maybePromise.catch(() => {})
|
||||
}
|
||||
},
|
||||
{signal},
|
||||
)
|
||||
return () => {
|
||||
abortController.abort()
|
||||
}
|
||||
}, [videoRef, flushOnLoop, hasLowQualityFragmentAtStart])
|
||||
}, [
|
||||
playlist,
|
||||
setError,
|
||||
setHasSubtitleTrack,
|
||||
videoRef,
|
||||
handleFragChange,
|
||||
flushOnLoop,
|
||||
Hls,
|
||||
])
|
||||
|
||||
return {
|
||||
hlsRef,
|
||||
loop: !hasLowQualityFragmentAtStart,
|
||||
}
|
||||
return hlsRef
|
||||
}
|
||||
|
||||
@@ -27,7 +27,6 @@ import {Play_Filled_Corner0_Rounded as PlayIcon} from '#/components/icons/Play'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {IS_WEB_MOBILE_IOS, IS_WEB_TOUCH_DEVICE} from '#/env'
|
||||
import {GifPresentationControls} from '../../GifPresentationControls'
|
||||
import {TimeIndicator} from '../TimeIndicator'
|
||||
import {ControlButton} from './ControlButton'
|
||||
import {Scrubber} from './Scrubber'
|
||||
@@ -45,7 +44,6 @@ export function Controls({
|
||||
fullscreenRef,
|
||||
hlsLoading,
|
||||
hasSubtitleTrack,
|
||||
isGif,
|
||||
}: {
|
||||
videoRef: React.RefObject<HTMLVideoElement | null>
|
||||
hlsRef: React.RefObject<Hls | undefined | null>
|
||||
@@ -57,7 +55,6 @@ export function Controls({
|
||||
fullscreenRef: React.RefObject<HTMLDivElement | null>
|
||||
hlsLoading: boolean
|
||||
hasSubtitleTrack: boolean
|
||||
isGif: boolean
|
||||
}) {
|
||||
const {
|
||||
play,
|
||||
@@ -290,16 +287,6 @@ export function Controls({
|
||||
((focused || autoplayDisabled) && !playing) ||
|
||||
(interactingViaKeypress ? hasFocus : hovered)
|
||||
|
||||
if (isGif) {
|
||||
return (
|
||||
<GifPresentationControls
|
||||
isPlaying={playing}
|
||||
isLoading={showSpinner}
|
||||
onPress={onPressPlayPause}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
|
||||
@@ -9,7 +9,6 @@ import {type ModerationOpts} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
||||
import {popularInterests, useInterestsDisplayNames} from '#/lib/interests'
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {useActorSearch} from '#/state/queries/actor-search'
|
||||
@@ -208,15 +207,6 @@ function DialogInner({guide}: {guide?: Follow10ProgressGuide}) {
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
hasSearchText &&
|
||||
!isFetchingSearchResults &&
|
||||
!_items.length &&
|
||||
!isSearchResultsError
|
||||
) {
|
||||
_items.push({type: 'empty', key: 'empty', message: _(msg`No results`)})
|
||||
}
|
||||
|
||||
return _items
|
||||
}, [
|
||||
_,
|
||||
@@ -229,9 +219,17 @@ function DialogInner({guide}: {guide?: Follow10ProgressGuide}) {
|
||||
currentAccount?.did,
|
||||
hasSearchText,
|
||||
resultsKey,
|
||||
isSearchResultsError,
|
||||
])
|
||||
|
||||
if (
|
||||
searchText &&
|
||||
!isFetchingSearchResults &&
|
||||
!items.length &&
|
||||
!isSearchResultsError
|
||||
) {
|
||||
items.push({type: 'empty', key: 'empty', message: _(msg`No results`)})
|
||||
}
|
||||
|
||||
const renderItems = useCallback(
|
||||
({item, index}: {item: Item; index: number}) => {
|
||||
switch (item.type) {
|
||||
@@ -264,7 +262,7 @@ function DialogInner({guide}: {guide?: Follow10ProgressGuide}) {
|
||||
const selectedInterestRef = useRef(selectedInterest)
|
||||
selectedInterestRef.current = selectedInterest
|
||||
|
||||
const onViewableItemsChanged = useNonReactiveCallback(
|
||||
const onViewableItemsChanged = useRef(
|
||||
({viewableItems}: {viewableItems: ViewToken[]}) => {
|
||||
for (const viewableItem of viewableItems) {
|
||||
const item = viewableItem.item as Item
|
||||
@@ -276,7 +274,7 @@ function DialogInner({guide}: {guide?: Follow10ProgressGuide}) {
|
||||
)
|
||||
ax.metric('suggestedUser:seen', {
|
||||
logContext: 'ProgressGuide',
|
||||
recId: hasSearchText ? undefined : suggestions?.recId,
|
||||
recId: undefined,
|
||||
position: position !== -1 ? position : 0,
|
||||
suggestedDid: item.profile.did,
|
||||
category: selectedInterestRef.current,
|
||||
@@ -285,13 +283,10 @@ function DialogInner({guide}: {guide?: Follow10ProgressGuide}) {
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
const viewabilityConfig = useMemo(
|
||||
() => ({
|
||||
itemVisiblePercentThreshold: 50,
|
||||
}),
|
||||
[],
|
||||
)
|
||||
).current
|
||||
const viewabilityConfig = useRef({
|
||||
itemVisiblePercentThreshold: 50,
|
||||
}).current
|
||||
|
||||
const onSelectTab = useCallback(
|
||||
(interest: string) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {useMemo} from 'react'
|
||||
import React from 'react'
|
||||
import {type StyleProp, type TextStyle} from 'react-native'
|
||||
import {AppBskyRichtextFacet, RichText as RichTextAPI} from '@atproto/api'
|
||||
|
||||
@@ -27,16 +27,6 @@ export type RichTextProps = TextStyleProp &
|
||||
interactiveStyle?: StyleProp<TextStyle>
|
||||
emojiMultiplier?: number
|
||||
shouldProxyLinks?: boolean
|
||||
/**
|
||||
* DANGEROUS: Disable facet lexicon validation
|
||||
*
|
||||
* `detectFacetsWithoutResolution()` generates technically invalid facets,
|
||||
* with a handle in place of the DID. This means that RichText that uses it
|
||||
* won't be able to render links.
|
||||
*
|
||||
* Use with care - only use if you're rendering facets you're generating yourself.
|
||||
*/
|
||||
disableMentionFacetValidation?: true
|
||||
}
|
||||
|
||||
export function RichText({
|
||||
@@ -54,17 +44,12 @@ export function RichText({
|
||||
onLayout,
|
||||
onTextLayout,
|
||||
shouldProxyLinks,
|
||||
disableMentionFacetValidation,
|
||||
}: RichTextProps) {
|
||||
const richText = useMemo(() => {
|
||||
if (value instanceof RichTextAPI) {
|
||||
return value
|
||||
} else {
|
||||
const rt = new RichTextAPI({text: value})
|
||||
rt.detectFacetsWithoutResolution()
|
||||
return rt
|
||||
}
|
||||
}, [value])
|
||||
const richText = React.useMemo(
|
||||
() =>
|
||||
value instanceof RichTextAPI ? value : new RichTextAPI({text: value}),
|
||||
[value],
|
||||
)
|
||||
|
||||
const plainStyles = [a.leading_snug, style]
|
||||
const interactiveStyles = [plainStyles, interactiveStyle]
|
||||
@@ -113,11 +98,9 @@ export function RichText({
|
||||
const link = segment.link
|
||||
const mention = segment.mention
|
||||
const tag = segment.tag
|
||||
|
||||
if (
|
||||
mention &&
|
||||
(disableMentionFacetValidation ||
|
||||
AppBskyRichtextFacet.validateMention(mention).success) &&
|
||||
AppBskyRichtextFacet.validateMention(mention).success &&
|
||||
!disableLinks
|
||||
) {
|
||||
els.push(
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import {useMemo, useState} from 'react'
|
||||
import {
|
||||
LayoutAnimation,
|
||||
type StyleProp,
|
||||
View,
|
||||
type ViewStyle,
|
||||
} from 'react-native'
|
||||
import React from 'react'
|
||||
import {type StyleProp, View, type ViewStyle} from 'react-native'
|
||||
import {type ModerationUI} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
@@ -69,17 +64,16 @@ function ContentHiderActive({
|
||||
style,
|
||||
childContainerStyle,
|
||||
children,
|
||||
}: {
|
||||
}: React.PropsWithChildren<{
|
||||
testID?: string
|
||||
modui: ModerationUI
|
||||
style?: StyleProp<ViewStyle>
|
||||
childContainerStyle?: StyleProp<ViewStyle>
|
||||
children?: React.ReactNode
|
||||
}) {
|
||||
}>) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const [override, setOverride] = useState(false)
|
||||
const [override, setOverride] = React.useState(false)
|
||||
const control = useModerationDetailsDialogControl()
|
||||
const {labelDefs} = useLabelDefinitions()
|
||||
const globalLabelStrings = useGlobalLabelStrings()
|
||||
@@ -87,7 +81,7 @@ function ContentHiderActive({
|
||||
const blur = modui?.blurs[0]
|
||||
const desc = useModerationCauseDescription(blur)
|
||||
|
||||
const labelName = useMemo(() => {
|
||||
const labelName = React.useMemo(() => {
|
||||
if (!modui?.blurs || !blur) {
|
||||
return undefined
|
||||
}
|
||||
@@ -156,7 +150,6 @@ function ContentHiderActive({
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (!modui.noOverride) {
|
||||
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
|
||||
setOverride(v => !v)
|
||||
} else {
|
||||
control.open()
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {useCallback, useState} from 'react'
|
||||
import React, {type ComponentProps} from 'react'
|
||||
import {
|
||||
LayoutAnimation,
|
||||
Pressable,
|
||||
type StyleProp,
|
||||
StyleSheet,
|
||||
@@ -18,7 +17,7 @@ import {useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription'
|
||||
import {addStyle} from '#/lib/styles'
|
||||
import {unstableCacheProfileView} from '#/state/queries/unstable-profile-cache'
|
||||
import {precacheProfile} from '#/state/queries/profile'
|
||||
// import {Link} from '#/components/Link' TODO this imposes some styles that screw things up
|
||||
import {Link} from '#/view/com/util/Link'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
@@ -28,7 +27,7 @@ import {
|
||||
} from '#/components/moderation/ModerationDetailsDialog'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
interface Props extends React.ComponentProps<typeof Link> {
|
||||
interface Props extends ComponentProps<typeof Link> {
|
||||
disabled: boolean
|
||||
iconSize: number
|
||||
iconStyles: StyleProp<ViewStyle>
|
||||
@@ -55,15 +54,15 @@ export function PostHider({
|
||||
const queryClient = useQueryClient()
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const [override, setOverride] = useState(false)
|
||||
const [override, setOverride] = React.useState(false)
|
||||
const control = useModerationDetailsDialogControl()
|
||||
const blur =
|
||||
modui.blurs[0] ||
|
||||
(interpretFilterAsBlur ? getBlurrableFilter(modui) : undefined)
|
||||
const desc = useModerationCauseDescription(blur)
|
||||
|
||||
const onBeforePress = useCallback(() => {
|
||||
unstableCacheProfileView(queryClient, profile)
|
||||
const onBeforePress = React.useCallback(() => {
|
||||
precacheProfile(queryClient, profile)
|
||||
}, [queryClient, profile])
|
||||
|
||||
if (!blur || (disabled && !modui.noOverride)) {
|
||||
@@ -84,15 +83,14 @@ export function PostHider({
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
if (!modui.noOverride) {
|
||||
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
|
||||
setOverride(v => !v)
|
||||
}
|
||||
}}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={
|
||||
accessibilityHint={
|
||||
override ? _(msg`Hides the content`) : _(msg`Shows the content`)
|
||||
}
|
||||
accessibilityHint=""
|
||||
accessibilityLabel=""
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
import {beforeEach, describe, expect, it, jest} from '@jest/globals'
|
||||
|
||||
jest.mock('@bsky.app/react-native-mmkv', () => ({
|
||||
MMKV: class MMKVMock {
|
||||
_store = new Map<string, string>()
|
||||
|
||||
getString(key: string) {
|
||||
return this._store.get(key)
|
||||
}
|
||||
|
||||
set(key: string, value: string) {
|
||||
this._store.set(key, value)
|
||||
}
|
||||
|
||||
delete(key: string) {
|
||||
this._store.delete(key)
|
||||
}
|
||||
|
||||
clearAll() {
|
||||
this._store.clear()
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
import {createPersistedQueryStorage} from '../persisted-query-storage'
|
||||
|
||||
describe('createPersistedQueryStorage', () => {
|
||||
it('should create isolated storage instances', async () => {
|
||||
const storage1 = createPersistedQueryStorage('store1')
|
||||
const storage2 = createPersistedQueryStorage('store2')
|
||||
|
||||
await storage1.setItem('key', 'value1')
|
||||
await storage2.setItem('key', 'value2')
|
||||
|
||||
expect(await storage1.getItem('key')).toBe('value1')
|
||||
expect(await storage2.getItem('key')).toBe('value2')
|
||||
})
|
||||
|
||||
describe('storage operations', () => {
|
||||
let storage: ReturnType<typeof createPersistedQueryStorage>
|
||||
|
||||
beforeEach(() => {
|
||||
storage = createPersistedQueryStorage('test_store')
|
||||
})
|
||||
|
||||
it('should return null for non-existent keys', async () => {
|
||||
const result = await storage.getItem('non-existent-key')
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('should store and retrieve a value', async () => {
|
||||
const testValue = JSON.stringify({data: 'test'})
|
||||
await storage.setItem('test-key', testValue)
|
||||
const result = await storage.getItem('test-key')
|
||||
expect(result).toBe(testValue)
|
||||
})
|
||||
|
||||
it('should remove a value', async () => {
|
||||
const testValue = JSON.stringify({data: 'test'})
|
||||
await storage.setItem('test-key', testValue)
|
||||
await storage.removeItem('test-key')
|
||||
const result = await storage.getItem('test-key')
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('should handle complex JSON data', async () => {
|
||||
const complexData = JSON.stringify({
|
||||
queries: [
|
||||
{key: 'query1', data: {nested: {value: 123}}},
|
||||
{key: 'query2', data: {array: [1, 2, 3]}},
|
||||
],
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
await storage.setItem('complex-key', complexData)
|
||||
const result = await storage.getItem('complex-key')
|
||||
expect(result).toBe(complexData)
|
||||
expect(JSON.parse(result!)).toEqual(JSON.parse(complexData))
|
||||
})
|
||||
|
||||
it('should overwrite existing values', async () => {
|
||||
await storage.setItem('test-key', 'value1')
|
||||
await storage.setItem('test-key', 'value2')
|
||||
const result = await storage.getItem('test-key')
|
||||
expect(result).toBe('value2')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -354,8 +354,6 @@ async function resolveMedia(
|
||||
alt: videoDraft.altText || undefined,
|
||||
captions: captions.length === 0 ? undefined : captions,
|
||||
aspectRatio,
|
||||
presentation:
|
||||
videoDraft.video.mimeType === 'image/gif' ? 'gif' : 'default',
|
||||
}
|
||||
}
|
||||
if (embedDraft.media?.type === 'gif') {
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import * as Device from 'expo-device'
|
||||
|
||||
import * as env from '#/env'
|
||||
|
||||
export const FALLBACK_ANDROID = 'Android'
|
||||
export const FALLBACK_IOS = 'iOS'
|
||||
export const FALLBACK_WEB = 'Web'
|
||||
|
||||
export function getDeviceName(): string {
|
||||
const deviceName = Device.deviceName
|
||||
if (env.IS_ANDROID) {
|
||||
return deviceName || FALLBACK_ANDROID
|
||||
} else if (env.IS_IOS) {
|
||||
return deviceName || FALLBACK_IOS
|
||||
} else {
|
||||
return FALLBACK_WEB // could append browser info here
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import {create as createArchiveDB} from '#/storage/archive/db'
|
||||
|
||||
/**
|
||||
* Interface for async storage compatible with @tanstack/query-async-storage-persister
|
||||
*/
|
||||
export interface PersistedQueryStorage {
|
||||
getItem: (key: string) => Promise<string | null>
|
||||
setItem: (key: string, value: string) => Promise<void>
|
||||
removeItem: (key: string) => Promise<void>
|
||||
}
|
||||
|
||||
function createId(id: string) {
|
||||
return `react-query-cache-${id}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an MMKV-based storage adapter for persisting react-query cache on native platforms.
|
||||
* Each storage instance uses a separate MMKV store identified by the provided id.
|
||||
* MMKV provides synchronous access but we wrap it in Promises for API compatibility.
|
||||
*
|
||||
* @param id - Unique identifier for this storage instance (used as MMKV store id)
|
||||
*/
|
||||
export function createPersistedQueryStorage(id: string): PersistedQueryStorage {
|
||||
const store = createArchiveDB({id: createId(id)})
|
||||
return {
|
||||
getItem: async (key: string): Promise<string | null> => {
|
||||
return (await store.get(key)) ?? null
|
||||
},
|
||||
setItem: async (key: string, value: string): Promise<void> => {
|
||||
await store.set(key, value)
|
||||
},
|
||||
removeItem: async (key: string): Promise<void> => {
|
||||
await store.delete(key)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearPersistedQueryStorage(id: string) {
|
||||
const store = createArchiveDB({id: createId(id)})
|
||||
await store.clear()
|
||||
}
|
||||
+9
-11
@@ -1,26 +1,27 @@
|
||||
import {useEffect, useRef, useState} from 'react'
|
||||
import {AppState, type AppStateStatus} from 'react-native'
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import {createAsyncStoragePersister} from '@tanstack/query-async-storage-persister'
|
||||
import {focusManager, onlineManager, QueryClient} from '@tanstack/react-query'
|
||||
import {
|
||||
type PersistQueryClientOptions,
|
||||
PersistQueryClientProvider,
|
||||
type PersistQueryClientProviderProps,
|
||||
} from '@tanstack/react-query-persist-client'
|
||||
import type React from 'react'
|
||||
|
||||
import {createPersistedQueryStorage} from '#/lib/persisted-query-storage'
|
||||
import {listenNetworkConfirmed, listenNetworkLost} from '#/state/events'
|
||||
import {PERSISTED_QUERY_ROOT} from '#/state/queries'
|
||||
import * as env from '#/env'
|
||||
import {IS_NATIVE, IS_WEB} from '#/env'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
||||
__TANSTACK_QUERY_CLIENT__: import('@tanstack/query-core').QueryClient
|
||||
}
|
||||
}
|
||||
|
||||
// any query keys in this array will be persisted to AsyncStorage
|
||||
export const labelersDetailedInfoQueryKeyRoot = 'labelers-detailed-info'
|
||||
const STORED_CACHE_QUERY_KEY_ROOTS = [labelersDetailedInfoQueryKeyRoot]
|
||||
|
||||
async function checkIsOnline(): Promise<boolean> {
|
||||
try {
|
||||
const controller = new AbortController()
|
||||
@@ -137,8 +138,7 @@ const dehydrateOptions: PersistQueryClientProviderProps['persistOptions']['dehyd
|
||||
{
|
||||
shouldDehydrateMutation: (_: any) => false,
|
||||
shouldDehydrateQuery: query => {
|
||||
const root = String(query.queryKey[0])
|
||||
return root === PERSISTED_QUERY_ROOT
|
||||
return STORED_CACHE_QUERY_KEY_ROOTS.includes(String(query.queryKey[0]))
|
||||
},
|
||||
}
|
||||
|
||||
@@ -177,16 +177,14 @@ function QueryProviderInner({
|
||||
// Do not move the query client creation outside of this component.
|
||||
const [queryClient, _setQueryClient] = useState(() => createQueryClient())
|
||||
const [persistOptions, _setPersistOptions] = useState(() => {
|
||||
const storage = createPersistedQueryStorage(currentDid ?? 'logged-out')
|
||||
const asyncPersister = createAsyncStoragePersister({
|
||||
storage,
|
||||
storage: AsyncStorage,
|
||||
key: 'queryClient-' + (currentDid ?? 'logged-out'),
|
||||
})
|
||||
return {
|
||||
persister: asyncPersister,
|
||||
dehydrateOptions,
|
||||
buster: env.APP_VERSION,
|
||||
} satisfies Omit<PersistQueryClientOptions, 'queryClient'>
|
||||
}
|
||||
})
|
||||
useEffect(() => {
|
||||
if (IS_WEB) {
|
||||
|
||||
+372
-492
File diff suppressed because it is too large
Load Diff
+1423
-1526
File diff suppressed because it is too large
Load Diff
+372
-492
File diff suppressed because it is too large
Load Diff
+1423
-1526
File diff suppressed because it is too large
Load Diff
+1423
-1526
File diff suppressed because it is too large
Load Diff
+371
-491
File diff suppressed because it is too large
Load Diff
+390
-510
File diff suppressed because it is too large
Load Diff
+375
-495
File diff suppressed because it is too large
Load Diff
+390
-510
File diff suppressed because it is too large
Load Diff
+372
-492
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+182
-213
File diff suppressed because it is too large
Load Diff
+390
-510
File diff suppressed because it is too large
Load Diff
+372
-492
File diff suppressed because it is too large
Load Diff
+371
-491
File diff suppressed because it is too large
Load Diff
+372
-492
File diff suppressed because it is too large
Load Diff
+372
-492
File diff suppressed because it is too large
Load Diff
+372
-492
File diff suppressed because it is too large
Load Diff
+372
-492
File diff suppressed because it is too large
Load Diff
+372
-492
File diff suppressed because it is too large
Load Diff
+372
-492
File diff suppressed because it is too large
Load Diff
+372
-492
File diff suppressed because it is too large
Load Diff
+375
-495
File diff suppressed because it is too large
Load Diff
+372
-492
File diff suppressed because it is too large
Load Diff
+372
-492
File diff suppressed because it is too large
Load Diff
+372
-492
File diff suppressed because it is too large
Load Diff
+372
-492
File diff suppressed because it is too large
Load Diff
+1423
-1526
File diff suppressed because it is too large
Load Diff
+372
-492
File diff suppressed because it is too large
Load Diff
+374
-494
File diff suppressed because it is too large
Load Diff
+1423
-1526
File diff suppressed because it is too large
Load Diff
+372
-492
File diff suppressed because it is too large
Load Diff
+372
-492
File diff suppressed because it is too large
Load Diff
+372
-492
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+371
-491
File diff suppressed because it is too large
Load Diff
+372
-492
File diff suppressed because it is too large
Load Diff
+373
-493
File diff suppressed because it is too large
Load Diff
+372
-492
File diff suppressed because it is too large
Load Diff
+381
-501
File diff suppressed because it is too large
Load Diff
+372
-492
File diff suppressed because it is too large
Load Diff
+372
-492
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -97,17 +97,6 @@ export function StepSuggestedAccounts() {
|
||||
tab: selectedInterest ?? 'all',
|
||||
numAccounts: followableDids.length,
|
||||
})
|
||||
for (let i = 0; i < followableDids.length; i++) {
|
||||
const did = followableDids[i]
|
||||
ax.metric('suggestedUser:follow', {
|
||||
logContext: 'Onboarding',
|
||||
location: 'FollowAll',
|
||||
recId: suggestedUsers?.recId,
|
||||
position: i,
|
||||
suggestedDid: did,
|
||||
category: selectedInterest,
|
||||
})
|
||||
}
|
||||
},
|
||||
mutationFn: async () => {
|
||||
for (const did of followableDids) {
|
||||
@@ -146,14 +135,14 @@ export function StepSuggestedAccounts() {
|
||||
seenProfilesRef.current.add(did)
|
||||
ax.metric('suggestedUser:seen', {
|
||||
logContext: 'Onboarding',
|
||||
recId: suggestedUsers?.recId,
|
||||
recId: undefined,
|
||||
position,
|
||||
suggestedDid: did,
|
||||
category: selectedInterest,
|
||||
})
|
||||
}
|
||||
},
|
||||
[ax, selectedInterest, suggestedUsers?.recId],
|
||||
[ax, selectedInterest],
|
||||
)
|
||||
|
||||
return (
|
||||
@@ -231,7 +220,6 @@ export function StepSuggestedAccounts() {
|
||||
position={index}
|
||||
category={selectedInterest}
|
||||
onSeen={onProfileSeen}
|
||||
recId={suggestedUsers.recId}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
@@ -246,7 +234,7 @@ export function StepSuggestedAccounts() {
|
||||
color="secondary"
|
||||
size="large"
|
||||
label={_(msg`Retry`)}
|
||||
onPress={() => void refetch()}>
|
||||
onPress={() => refetch()}>
|
||||
<ButtonText>
|
||||
<Trans>Retry</Trans>
|
||||
</ButtonText>
|
||||
@@ -341,14 +329,12 @@ function SuggestedProfileCard({
|
||||
position,
|
||||
category,
|
||||
onSeen,
|
||||
recId,
|
||||
}: {
|
||||
profile: bsky.profile.AnyProfileView
|
||||
moderationOpts: ModerationOpts
|
||||
position: number
|
||||
category: string | null
|
||||
onSeen: (did: string, position: number) => void
|
||||
recId?: number | string
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const ax = useAnalytics()
|
||||
@@ -415,7 +401,7 @@ function SuggestedProfileCard({
|
||||
ax.metric('suggestedUser:follow', {
|
||||
logContext: 'Onboarding',
|
||||
location: 'Card',
|
||||
recId,
|
||||
recId: undefined,
|
||||
position,
|
||||
suggestedDid: profile.did,
|
||||
category,
|
||||
|
||||
@@ -303,7 +303,7 @@ const ThreadItemPostInner = memo(function ThreadItemPostInner({
|
||||
additionalCauses={additionalPostAlerts}
|
||||
/>
|
||||
{richText?.text ? (
|
||||
<View style={[a.mb_2xs]}>
|
||||
<>
|
||||
<RichText
|
||||
enableTags
|
||||
value={richText}
|
||||
@@ -318,7 +318,7 @@ const ThreadItemPostInner = memo(function ThreadItemPostInner({
|
||||
onPress={onPressShowMore}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</>
|
||||
) : undefined}
|
||||
{post.embed && (
|
||||
<View style={[a.pb_xs]}>
|
||||
|
||||
@@ -343,7 +343,7 @@ const ThreadItemTreePostInner = memo(function ThreadItemTreePostInner({
|
||||
additionalCauses={additionalPostAlerts}
|
||||
/>
|
||||
{richText?.text ? (
|
||||
<View style={[a.mb_2xs]}>
|
||||
<>
|
||||
<RichText
|
||||
enableTags
|
||||
value={richText}
|
||||
@@ -358,7 +358,7 @@ const ThreadItemTreePostInner = memo(function ThreadItemTreePostInner({
|
||||
onPress={onPressShowMore}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</>
|
||||
) : null}
|
||||
{post.embed && (
|
||||
<View style={[a.pb_xs]}>
|
||||
|
||||
@@ -43,7 +43,7 @@ import {EditProfileDialog} from './EditProfileDialog'
|
||||
import {ProfileHeaderHandle} from './Handle'
|
||||
import {ProfileHeaderMetrics} from './Metrics'
|
||||
import {ProfileHeaderShell} from './Shell'
|
||||
import {ProfileHeaderSuggestedFollows} from './SuggestedFollows'
|
||||
import {AnimatedProfileHeaderSuggestedFollows} from './SuggestedFollows'
|
||||
|
||||
interface Props {
|
||||
profile: AppBskyActorDefs.ProfileViewDetailed
|
||||
@@ -193,7 +193,7 @@ let ProfileHeaderStandard = ({
|
||||
/>
|
||||
</ProfileHeaderShell>
|
||||
|
||||
<ProfileHeaderSuggestedFollows
|
||||
<AnimatedProfileHeaderSuggestedFollows
|
||||
isExpanded={showSuggestedFollows}
|
||||
actorDid={profile.did}
|
||||
/>
|
||||
@@ -317,10 +317,7 @@ export function HeaderStandardButtons({
|
||||
testID="profileHeaderEditProfileButton"
|
||||
size="small"
|
||||
color="secondary"
|
||||
onPress={() => {
|
||||
playHaptic('Light')
|
||||
editProfileControl.open()
|
||||
}}
|
||||
onPress={editProfileControl.open}
|
||||
label={_(msg`Edit profile`)}>
|
||||
<ButtonText>
|
||||
<Trans>Edit Profile</Trans>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {useCallback, useEffect, useMemo, useState} from 'react'
|
||||
import React from 'react'
|
||||
import {type AppBskyActorDefs} from '@atproto/api'
|
||||
|
||||
import {AccordionAnimation} from '#/lib/custom-animations/AccordionAnimation'
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
@@ -9,17 +10,198 @@ import {
|
||||
import {useBreakpoints} from '#/alf'
|
||||
import {ProfileGrid} from '#/components/FeedInterstitials'
|
||||
import {IS_ANDROID} from '#/env'
|
||||
import type * as bsky from '#/types/bsky'
|
||||
|
||||
export function ProfileHeaderSuggestedFollows({
|
||||
const DISMISS_ANIMATION_DURATION = 200
|
||||
|
||||
export function ProfileHeaderSuggestedFollows({actorDid}: {actorDid: string}) {
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const moderationOpts = useModerationOpts()
|
||||
const maxLength = gtMobile ? 4 : 12
|
||||
const {isLoading, data, error} = useSuggestedFollowsByActorQuery({
|
||||
did: actorDid,
|
||||
})
|
||||
const {
|
||||
data: moreSuggestions,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
} = useSuggestedFollowsQuery({limit: 25})
|
||||
|
||||
const [dismissedDids, setDismissedDids] = React.useState<Set<string>>(
|
||||
new Set(),
|
||||
)
|
||||
const [dismissingDids, setDismissingDids] = React.useState<Set<string>>(
|
||||
new Set(),
|
||||
)
|
||||
|
||||
const onDismiss = React.useCallback((did: string) => {
|
||||
// Start the fade animation
|
||||
setDismissingDids(prev => new Set(prev).add(did))
|
||||
// After animation completes, actually remove from list
|
||||
setTimeout(() => {
|
||||
setDismissedDids(prev => new Set(prev).add(did))
|
||||
setDismissingDids(prev => {
|
||||
const next = new Set(prev)
|
||||
next.delete(did)
|
||||
return next
|
||||
})
|
||||
}, DISMISS_ANIMATION_DURATION)
|
||||
}, [])
|
||||
|
||||
// Combine profiles from the actor-specific query with fallback suggestions
|
||||
const allProfiles = React.useMemo(() => {
|
||||
const actorProfiles = data?.suggestions ?? []
|
||||
const fallbackProfiles =
|
||||
moreSuggestions?.pages.flatMap(page => page.actors) ?? []
|
||||
|
||||
// Dedupe by did, preferring actor-specific profiles
|
||||
const seen = new Set<string>()
|
||||
const combined: AppBskyActorDefs.ProfileView[] = []
|
||||
|
||||
for (const profile of actorProfiles) {
|
||||
if (!seen.has(profile.did)) {
|
||||
seen.add(profile.did)
|
||||
combined.push(profile)
|
||||
}
|
||||
}
|
||||
|
||||
for (const profile of fallbackProfiles) {
|
||||
if (!seen.has(profile.did) && profile.did !== actorDid) {
|
||||
seen.add(profile.did)
|
||||
combined.push(profile)
|
||||
}
|
||||
}
|
||||
|
||||
return combined
|
||||
}, [data?.suggestions, moreSuggestions?.pages, actorDid])
|
||||
|
||||
const filteredProfiles = React.useMemo(() => {
|
||||
return allProfiles.filter(p => !dismissedDids.has(p.did))
|
||||
}, [allProfiles, dismissedDids])
|
||||
|
||||
// Fetch more when running low
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
moderationOpts &&
|
||||
filteredProfiles.length < maxLength &&
|
||||
hasNextPage &&
|
||||
!isFetchingNextPage
|
||||
) {
|
||||
fetchNextPage()
|
||||
}
|
||||
}, [
|
||||
filteredProfiles.length,
|
||||
maxLength,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
fetchNextPage,
|
||||
moderationOpts,
|
||||
])
|
||||
|
||||
return (
|
||||
<ProfileGrid
|
||||
isSuggestionsLoading={isLoading}
|
||||
profiles={filteredProfiles}
|
||||
totalProfileCount={allProfiles.length}
|
||||
recId={data?.recId}
|
||||
error={error}
|
||||
viewContext="profileHeader"
|
||||
onDismiss={onDismiss}
|
||||
dismissingDids={dismissingDids}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function AnimatedProfileHeaderSuggestedFollows({
|
||||
isExpanded,
|
||||
actorDid,
|
||||
}: {
|
||||
isExpanded: boolean
|
||||
actorDid: string
|
||||
}) {
|
||||
const {allProfiles, filteredProfiles, onDismiss, isLoading, error} =
|
||||
useProfileHeaderSuggestions(actorDid)
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const moderationOpts = useModerationOpts()
|
||||
const maxLength = gtMobile ? 4 : 12
|
||||
const {isLoading, data, error} = useSuggestedFollowsByActorQuery({
|
||||
did: actorDid,
|
||||
})
|
||||
const {
|
||||
data: moreSuggestions,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
} = useSuggestedFollowsQuery({limit: 25})
|
||||
|
||||
const [dismissedDids, setDismissedDids] = React.useState<Set<string>>(
|
||||
new Set(),
|
||||
)
|
||||
const [dismissingDids, setDismissingDids] = React.useState<Set<string>>(
|
||||
new Set(),
|
||||
)
|
||||
|
||||
const onDismiss = React.useCallback((did: string) => {
|
||||
// Start the fade animation
|
||||
setDismissingDids(prev => new Set(prev).add(did))
|
||||
// After animation completes, actually remove from list
|
||||
setTimeout(() => {
|
||||
setDismissedDids(prev => new Set(prev).add(did))
|
||||
setDismissingDids(prev => {
|
||||
const next = new Set(prev)
|
||||
next.delete(did)
|
||||
return next
|
||||
})
|
||||
}, DISMISS_ANIMATION_DURATION)
|
||||
}, [])
|
||||
|
||||
// Combine profiles from the actor-specific query with fallback suggestions
|
||||
const allProfiles = React.useMemo(() => {
|
||||
const actorProfiles = data?.suggestions ?? []
|
||||
const fallbackProfiles =
|
||||
moreSuggestions?.pages.flatMap(page => page.actors) ?? []
|
||||
|
||||
// Dedupe by did, preferring actor-specific profiles
|
||||
const seen = new Set<string>()
|
||||
const combined: AppBskyActorDefs.ProfileView[] = []
|
||||
|
||||
for (const profile of actorProfiles) {
|
||||
if (!seen.has(profile.did)) {
|
||||
seen.add(profile.did)
|
||||
combined.push(profile)
|
||||
}
|
||||
}
|
||||
|
||||
for (const profile of fallbackProfiles) {
|
||||
if (!seen.has(profile.did) && profile.did !== actorDid) {
|
||||
seen.add(profile.did)
|
||||
combined.push(profile)
|
||||
}
|
||||
}
|
||||
|
||||
return combined
|
||||
}, [data?.suggestions, moreSuggestions?.pages, actorDid])
|
||||
|
||||
const filteredProfiles = React.useMemo(() => {
|
||||
return allProfiles.filter(p => !dismissedDids.has(p.did))
|
||||
}, [allProfiles, dismissedDids])
|
||||
|
||||
// Fetch more when running low
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
moderationOpts &&
|
||||
filteredProfiles.length < maxLength &&
|
||||
hasNextPage &&
|
||||
!isFetchingNextPage
|
||||
) {
|
||||
fetchNextPage()
|
||||
}
|
||||
}, [
|
||||
filteredProfiles.length,
|
||||
maxLength,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
fetchNextPage,
|
||||
moderationOpts,
|
||||
])
|
||||
|
||||
if (!allProfiles.length && !isLoading) return null
|
||||
|
||||
@@ -36,92 +218,13 @@ export function ProfileHeaderSuggestedFollows({
|
||||
isSuggestionsLoading={isLoading}
|
||||
profiles={filteredProfiles}
|
||||
totalProfileCount={allProfiles.length}
|
||||
recId={data?.recId}
|
||||
error={error}
|
||||
viewContext="profileHeader"
|
||||
onDismiss={onDismiss}
|
||||
dismissingDids={dismissingDids}
|
||||
isVisible={isExpanded}
|
||||
/>
|
||||
</AccordionAnimation>
|
||||
)
|
||||
}
|
||||
|
||||
function useProfileHeaderSuggestions(actorDid: string) {
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const moderationOpts = useModerationOpts()
|
||||
const maxLength = gtMobile ? 4 : 12
|
||||
const {isLoading, data, error} = useSuggestedFollowsByActorQuery({
|
||||
did: actorDid,
|
||||
})
|
||||
const {
|
||||
data: moreSuggestions,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
} = useSuggestedFollowsQuery({limit: 25})
|
||||
|
||||
const [dismissedDids, setDismissedDids] = useState<Set<string>>(new Set())
|
||||
|
||||
const onDismiss = useCallback((did: string) => {
|
||||
setDismissedDids(prev => new Set(prev).add(did))
|
||||
}, [])
|
||||
|
||||
// Combine profiles from the actor-specific query with fallback suggestions
|
||||
const allProfiles = useMemo(() => {
|
||||
const actorProfiles = data?.suggestions ?? []
|
||||
const fallbackProfiles =
|
||||
moreSuggestions?.pages.flatMap(page =>
|
||||
page.actors.map(actor => ({actor, recId: page.recId})),
|
||||
) ?? []
|
||||
|
||||
// Dedupe by did, preferring actor-specific profiles
|
||||
const seen = new Set<string>()
|
||||
const combined: {actor: bsky.profile.AnyProfileView; recId?: number}[] = []
|
||||
|
||||
for (const profile of actorProfiles) {
|
||||
if (!seen.has(profile.did)) {
|
||||
seen.add(profile.did)
|
||||
combined.push({actor: profile, recId: data?.recId})
|
||||
}
|
||||
}
|
||||
|
||||
for (const profile of fallbackProfiles) {
|
||||
if (!seen.has(profile.actor.did) && profile.actor.did !== actorDid) {
|
||||
seen.add(profile.actor.did)
|
||||
combined.push(profile)
|
||||
}
|
||||
}
|
||||
|
||||
return combined
|
||||
}, [data?.suggestions, moreSuggestions?.pages, actorDid, data?.recId])
|
||||
|
||||
const filteredProfiles = useMemo(() => {
|
||||
return allProfiles.filter(p => !dismissedDids.has(p.actor.did))
|
||||
}, [allProfiles, dismissedDids])
|
||||
|
||||
// Fetch more when running low
|
||||
useEffect(() => {
|
||||
if (
|
||||
moderationOpts &&
|
||||
filteredProfiles.length < maxLength &&
|
||||
hasNextPage &&
|
||||
!isFetchingNextPage
|
||||
) {
|
||||
void fetchNextPage()
|
||||
}
|
||||
}, [
|
||||
filteredProfiles.length,
|
||||
maxLength,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
fetchNextPage,
|
||||
moderationOpts,
|
||||
])
|
||||
|
||||
return {
|
||||
allProfiles,
|
||||
filteredProfiles,
|
||||
onDismiss,
|
||||
isLoading,
|
||||
error,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,7 +45,6 @@ export function useSuggestedUsers({
|
||||
data: searched?.data
|
||||
? {
|
||||
actors: searched.data.pages.flatMap(p => p.actors) ?? [],
|
||||
recId: undefined,
|
||||
}
|
||||
: undefined,
|
||||
isLoading: searched.isLoading,
|
||||
|
||||
+16
-17
@@ -270,29 +270,27 @@ async function moveIfNecessary(from: string) {
|
||||
* On web, converts blob URLs to data URIs immediately to prevent revocation issues.
|
||||
*/
|
||||
async function copyToCache(from: string): Promise<string> {
|
||||
// Handle web blob URLs - convert to data URI immediately before they can be revoked
|
||||
if (IS_WEB && from.startsWith('blob:')) {
|
||||
try {
|
||||
const response = await fetch(from)
|
||||
const blob = await response.blob()
|
||||
return await blobToDataUri(blob)
|
||||
} catch (e) {
|
||||
// If fetch fails, the blob URL was likely already revoked
|
||||
// Return as-is and let downstream code handle the error
|
||||
return from
|
||||
}
|
||||
}
|
||||
|
||||
// Data URIs don't need any conversion
|
||||
if (from.startsWith('data:')) {
|
||||
return from
|
||||
}
|
||||
|
||||
if (IS_WEB) {
|
||||
// Web: convert blob URLs to data URIs before they can be revoked
|
||||
if (from.startsWith('blob:')) {
|
||||
try {
|
||||
const response = await fetch(from)
|
||||
const blob = await response.blob()
|
||||
return await blobToDataUri(blob)
|
||||
} catch (e) {
|
||||
// Blob URL was likely revoked, return as-is for downstream error handling
|
||||
return from
|
||||
}
|
||||
}
|
||||
// Other URLs on web don't need conversion
|
||||
return from
|
||||
}
|
||||
const cacheDir = IS_WEB && getImageCacheDirectory()
|
||||
|
||||
// Native: copy to cache directory to survive OS temp file cleanup
|
||||
const cacheDir = getImageCacheDirectory()
|
||||
// On web (non-blob URLs) or if already in cache dir, no need to copy
|
||||
if (!cacheDir || from.startsWith(cacheDir)) {
|
||||
return from
|
||||
}
|
||||
@@ -300,6 +298,7 @@ async function copyToCache(from: string): Promise<string> {
|
||||
const to = joinPath(cacheDir, nanoid(36))
|
||||
await makeDirectoryAsync(cacheDir, {intermediates: true})
|
||||
|
||||
// Normalize the source path for expo-file-system
|
||||
let normalizedFrom = from
|
||||
if (!from.startsWith('file://') && from.startsWith('/')) {
|
||||
normalizedFrom = `file://${from}`
|
||||
|
||||
+19
-24
@@ -8,11 +8,11 @@ import {
|
||||
moderateFeedGenerator,
|
||||
RichText,
|
||||
} from '@atproto/api'
|
||||
import {t} from '@lingui/macro'
|
||||
import {
|
||||
type InfiniteData,
|
||||
keepPreviousData,
|
||||
type QueryClient,
|
||||
type QueryKey,
|
||||
useInfiniteQuery,
|
||||
useMutation,
|
||||
useQuery,
|
||||
@@ -22,11 +22,7 @@ import {
|
||||
import {DISCOVER_FEED_URI, DISCOVER_SAVED_FEED} from '#/lib/constants'
|
||||
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||
import {
|
||||
PERSISTED_QUERY_GCTIME,
|
||||
PERSISTED_QUERY_ROOT,
|
||||
STALE,
|
||||
} from '#/state/queries'
|
||||
import {STALE} from '#/state/queries'
|
||||
import {RQKEY as listQueryKey} from '#/state/queries/list'
|
||||
import {usePreferencesQuery} from '#/state/queries/preferences'
|
||||
import {useAgent, useSession} from '#/state/session'
|
||||
@@ -118,7 +114,7 @@ export function hydrateFeedGenerator(
|
||||
avatar: view.avatar,
|
||||
displayName: view.displayName
|
||||
? sanitizeDisplayName(view.displayName)
|
||||
: t`Feed by ${sanitizeHandle(view.creator.handle, '@')}`,
|
||||
: `Feed by ${sanitizeHandle(view.creator.handle, '@')}`,
|
||||
description: new RichText({
|
||||
text: view.description || '',
|
||||
facets: (view.descriptionFacets || [])?.slice(),
|
||||
@@ -159,7 +155,7 @@ export function hydrateList(view: AppBskyGraphDefs.ListView): FeedSourceInfo {
|
||||
creatorHandle: view.creator.handle,
|
||||
displayName: view.name
|
||||
? sanitizeDisplayName(view.name)
|
||||
: t`User List by ${sanitizeHandle(view.creator.handle, '@')}`,
|
||||
: `User List by ${sanitizeHandle(view.creator.handle, '@')}`,
|
||||
contentMode: undefined,
|
||||
}
|
||||
}
|
||||
@@ -242,7 +238,13 @@ export function useGetPopularFeedsQuery(options?: GetPopularFeedsOptions) {
|
||||
)
|
||||
const lastPageCountRef = useRef(0)
|
||||
|
||||
const query = useInfiniteQuery({
|
||||
const query = useInfiniteQuery<
|
||||
AppBskyUnspeccedGetPopularFeedGenerators.OutputSchema,
|
||||
Error,
|
||||
InfiniteData<AppBskyUnspeccedGetPopularFeedGenerators.OutputSchema>,
|
||||
QueryKey,
|
||||
string | undefined
|
||||
>({
|
||||
enabled: Boolean(moderationOpts) && options?.enabled !== false,
|
||||
queryKey: createGetPopularFeedsQueryKey(options),
|
||||
queryFn: async ({pageParam}) => {
|
||||
@@ -259,7 +261,7 @@ export function useGetPopularFeedsQuery(options?: GetPopularFeedsOptions) {
|
||||
|
||||
return res.data
|
||||
},
|
||||
initialPageParam: undefined as string | undefined,
|
||||
initialPageParam: undefined,
|
||||
getNextPageParam: lastPage => lastPage.cursor,
|
||||
select: useCallback(
|
||||
(
|
||||
@@ -416,10 +418,7 @@ const PWI_DISCOVER_FEED_STUB: SavedFeedSourceInfo = {
|
||||
contentMode: undefined,
|
||||
}
|
||||
|
||||
const createPinnedFeedInfosQueryKeyRoot = (
|
||||
kind: 'pinned' | 'saved',
|
||||
feedUris: string[],
|
||||
) => [PERSISTED_QUERY_ROOT, 'feed-info', kind, feedUris]
|
||||
const pinnedFeedInfosQueryKeyRoot = 'pinnedFeedsInfos'
|
||||
|
||||
export function usePinnedFeedsInfos() {
|
||||
const {hasSession} = useSession()
|
||||
@@ -428,13 +427,13 @@ export function usePinnedFeedsInfos() {
|
||||
const pinnedItems = preferences?.savedFeeds.filter(feed => feed.pinned) ?? []
|
||||
|
||||
return useQuery({
|
||||
queryKey: createPinnedFeedInfosQueryKeyRoot(
|
||||
'pinned',
|
||||
pinnedItems.map(f => f.value),
|
||||
),
|
||||
gcTime: PERSISTED_QUERY_GCTIME,
|
||||
staleTime: STALE.INFINITY,
|
||||
enabled: !isLoadingPrefs,
|
||||
queryKey: [
|
||||
pinnedFeedInfosQueryKeyRoot,
|
||||
(hasSession ? 'authed:' : 'unauthed:') +
|
||||
pinnedItems.map(f => f.value).join(','),
|
||||
],
|
||||
queryFn: async () => {
|
||||
if (!hasSession) {
|
||||
return [PWI_DISCOVER_FEED_STUB]
|
||||
@@ -536,13 +535,9 @@ export function useSavedFeeds() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useQuery({
|
||||
queryKey: createPinnedFeedInfosQueryKeyRoot(
|
||||
'saved',
|
||||
savedItems.map(f => f.value),
|
||||
),
|
||||
gcTime: PERSISTED_QUERY_GCTIME,
|
||||
staleTime: STALE.INFINITY,
|
||||
enabled: !isLoadingPrefs,
|
||||
queryKey: [pinnedFeedInfosQueryKeyRoot, ...savedItems],
|
||||
placeholderData: previousData => {
|
||||
return (
|
||||
previousData || {
|
||||
|
||||
@@ -18,23 +18,3 @@ export const STALE = {
|
||||
},
|
||||
INFINITY: Infinity,
|
||||
}
|
||||
|
||||
/**
|
||||
* Root key for persisted queries.
|
||||
*
|
||||
* If the `querykey` of your query uses this at index 0, it will be
|
||||
* persisted automatically by the `PersistQueryClientProvider` in
|
||||
* `#/lib/react-query.tsx`.
|
||||
*
|
||||
* Be careful when using this, since it will change the query key and may
|
||||
* break any cases where we call `invalidateQueries` or `refetchQueries`
|
||||
* with the old key.
|
||||
*
|
||||
* Also, only use this for queries that are safe to persist between
|
||||
* app launches (like user preferences).
|
||||
*
|
||||
* Note that for queries that are persisted, it is recommended to extend
|
||||
* the `gcTime` to a longer duration, otherwise it'll get busted
|
||||
*/
|
||||
export const PERSISTED_QUERY_ROOT = 'PERSISTED'
|
||||
export const PERSISTED_QUERY_GCTIME = Infinity
|
||||
|
||||
@@ -3,11 +3,8 @@ import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||
import {z} from 'zod'
|
||||
|
||||
import {MAX_LABELERS} from '#/lib/constants'
|
||||
import {
|
||||
PERSISTED_QUERY_GCTIME,
|
||||
PERSISTED_QUERY_ROOT,
|
||||
STALE,
|
||||
} from '#/state/queries'
|
||||
import {labelersDetailedInfoQueryKeyRoot} from '#/lib/react-query'
|
||||
import {STALE} from '#/state/queries'
|
||||
import {
|
||||
preferencesQueryKey,
|
||||
usePreferencesQuery,
|
||||
@@ -26,9 +23,8 @@ export const labelersInfoQueryKey = (dids: string[]) => [
|
||||
dids.slice().sort(),
|
||||
]
|
||||
|
||||
const persistedLabelersDetailedInfoQueryKey = (dids: string[]) => [
|
||||
PERSISTED_QUERY_ROOT,
|
||||
'labelers-detailed-info',
|
||||
export const labelersDetailedInfoQueryKey = (dids: string[]) => [
|
||||
labelersDetailedInfoQueryKeyRoot,
|
||||
dids,
|
||||
]
|
||||
|
||||
@@ -69,8 +65,8 @@ export function useLabelersDetailedInfoQuery({dids}: {dids: string[]}) {
|
||||
const agent = useAgent()
|
||||
return useQuery({
|
||||
enabled: !!dids.length,
|
||||
queryKey: persistedLabelersDetailedInfoQueryKey(dids),
|
||||
gcTime: PERSISTED_QUERY_GCTIME,
|
||||
queryKey: labelersDetailedInfoQueryKey(dids),
|
||||
gcTime: 1000 * 60 * 60 * 6, // 6 hours
|
||||
staleTime: STALE.MINUTES.ONE,
|
||||
queryFn: async () => {
|
||||
const res = await agent.app.bsky.labeler.getServices({
|
||||
|
||||
@@ -9,11 +9,7 @@ import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||
import {PROD_DEFAULT_FEED} from '#/lib/constants'
|
||||
import {replaceEqualDeep} from '#/lib/functions'
|
||||
import {getAge} from '#/lib/strings/time'
|
||||
import {
|
||||
PERSISTED_QUERY_GCTIME,
|
||||
PERSISTED_QUERY_ROOT,
|
||||
STALE,
|
||||
} from '#/state/queries'
|
||||
import {STALE} from '#/state/queries'
|
||||
import {
|
||||
DEFAULT_HOME_FEED_PREFS,
|
||||
DEFAULT_LOGGED_OUT_PREFERENCES,
|
||||
@@ -33,18 +29,18 @@ export * from '#/state/queries/preferences/const'
|
||||
export * from '#/state/queries/preferences/moderation'
|
||||
export * from '#/state/queries/preferences/types'
|
||||
|
||||
export const preferencesQueryKey = [PERSISTED_QUERY_ROOT, 'getPreferences']
|
||||
const preferencesQueryKeyRoot = 'getPreferences'
|
||||
export const preferencesQueryKey = [preferencesQueryKeyRoot]
|
||||
|
||||
export function usePreferencesQuery() {
|
||||
const agent = useAgent()
|
||||
const aa = useAgeAssurance()
|
||||
|
||||
const query = useQuery({
|
||||
return useQuery({
|
||||
staleTime: STALE.SECONDS.FIFTEEN,
|
||||
structuralSharing: replaceEqualDeep,
|
||||
refetchOnWindowFocus: true,
|
||||
queryKey: preferencesQueryKey,
|
||||
gcTime: PERSISTED_QUERY_GCTIME,
|
||||
queryFn: async () => {
|
||||
if (!agent.did) {
|
||||
return DEFAULT_LOGGED_OUT_PREFERENCES
|
||||
@@ -96,15 +92,6 @@ export function usePreferencesQuery() {
|
||||
[aa],
|
||||
),
|
||||
})
|
||||
|
||||
if (query.data?.birthDate) {
|
||||
/**
|
||||
* The persisted query cache stores dates as strings, but our code expects a `Date`.
|
||||
*/
|
||||
query.data.birthDate = new Date(query.data.birthDate)
|
||||
}
|
||||
|
||||
return query
|
||||
}
|
||||
|
||||
export function useClearPreferencesMutation() {
|
||||
|
||||
+28
-43
@@ -1,13 +1,4 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
} from 'react'
|
||||
import React from 'react'
|
||||
import {type AtpSessionEvent, type BskyAgent} from '@atproto/api'
|
||||
|
||||
import * as persisted from '#/state/persisted'
|
||||
@@ -28,8 +19,6 @@ import {type Action, getInitialState, reducer, type State} from './reducer'
|
||||
export {isSignupQueued} from './util'
|
||||
import {addSessionDebugLog} from './logging'
|
||||
export type {SessionAccount} from '#/state/session/types'
|
||||
|
||||
import {clearPersistedQueryStorage} from '#/lib/persisted-query-storage'
|
||||
import {
|
||||
type SessionApiContext,
|
||||
type SessionStateContext,
|
||||
@@ -40,21 +29,21 @@ import {
|
||||
clearAgeAssuranceDataForDid,
|
||||
} from '#/ageAssurance/data'
|
||||
|
||||
const StateContext = createContext<SessionStateContext>({
|
||||
const StateContext = React.createContext<SessionStateContext>({
|
||||
accounts: [],
|
||||
currentAccount: undefined,
|
||||
hasSession: false,
|
||||
})
|
||||
StateContext.displayName = 'SessionStateContext'
|
||||
|
||||
const AgentContext = createContext<BskyAgent | null>(null)
|
||||
const AgentContext = React.createContext<BskyAgent | null>(null)
|
||||
AgentContext.displayName = 'SessionAgentContext'
|
||||
|
||||
const ApiContext = createContext<SessionApiContext>({
|
||||
const ApiContext = React.createContext<SessionApiContext>({
|
||||
createAccount: async () => {},
|
||||
login: async () => {},
|
||||
logoutCurrentAccount: () => {},
|
||||
logoutEveryAccount: () => {},
|
||||
logoutCurrentAccount: async () => {},
|
||||
logoutEveryAccount: async () => {},
|
||||
resumeSession: async () => {},
|
||||
removeAccount: () => {},
|
||||
partialRefreshSession: async () => {},
|
||||
@@ -105,11 +94,11 @@ class SessionStore {
|
||||
export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
const ax = useAnalyticsBase()
|
||||
const cancelPendingTask = useOneTaskAtATime()
|
||||
const [store] = useState(() => new SessionStore())
|
||||
const state = useSyncExternalStore(store.subscribe, store.getState)
|
||||
const [store] = React.useState(() => new SessionStore())
|
||||
const state = React.useSyncExternalStore(store.subscribe, store.getState)
|
||||
const onboardingDispatch = useOnboardingDispatch()
|
||||
|
||||
const onAgentSessionChange = useCallback(
|
||||
const onAgentSessionChange = React.useCallback(
|
||||
(agent: BskyAgent, accountDid: string, sessionEvent: AtpSessionEvent) => {
|
||||
const refreshedAccount = agentToSessionAccount(agent) // Mutable, so snapshot it right away.
|
||||
if (sessionEvent === 'expired' || sessionEvent === 'create-failed') {
|
||||
@@ -126,7 +115,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
[store],
|
||||
)
|
||||
|
||||
const createAccount = useCallback<SessionApiContext['createAccount']>(
|
||||
const createAccount = React.useCallback<SessionApiContext['createAccount']>(
|
||||
async (params, metrics) => {
|
||||
addSessionDebugLog({type: 'method:start', method: 'createAccount'})
|
||||
const signal = cancelPendingTask()
|
||||
@@ -152,7 +141,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
[ax, store, onAgentSessionChange, cancelPendingTask],
|
||||
)
|
||||
|
||||
const login = useCallback<SessionApiContext['login']>(
|
||||
const login = React.useCallback<SessionApiContext['login']>(
|
||||
async (params, logContext) => {
|
||||
addSessionDebugLog({type: 'method:start', method: 'login'})
|
||||
const signal = cancelPendingTask()
|
||||
@@ -179,7 +168,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
[ax, store, onAgentSessionChange, cancelPendingTask],
|
||||
)
|
||||
|
||||
const logoutCurrentAccount = useCallback<
|
||||
const logoutCurrentAccount = React.useCallback<
|
||||
SessionApiContext['logoutCurrentAccount']
|
||||
>(
|
||||
logContext => {
|
||||
@@ -203,7 +192,6 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
addSessionDebugLog({type: 'method:end', method: 'logout'})
|
||||
if (prevState.currentAgentState.did) {
|
||||
clearAgeAssuranceDataForDid({did: prevState.currentAgentState.did})
|
||||
void clearPersistedQueryStorage(prevState.currentAgentState.did)
|
||||
}
|
||||
// reset onboarding flow on logout
|
||||
onboardingDispatch({type: 'skip'})
|
||||
@@ -211,7 +199,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
[ax, store, cancelPendingTask, onboardingDispatch],
|
||||
)
|
||||
|
||||
const logoutEveryAccount = useCallback<
|
||||
const logoutEveryAccount = React.useCallback<
|
||||
SessionApiContext['logoutEveryAccount']
|
||||
>(
|
||||
logContext => {
|
||||
@@ -234,16 +222,13 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
)
|
||||
addSessionDebugLog({type: 'method:end', method: 'logout'})
|
||||
clearAgeAssuranceData()
|
||||
for (const account of prevState.accounts) {
|
||||
void clearPersistedQueryStorage(account.did)
|
||||
}
|
||||
// reset onboarding flow on logout
|
||||
onboardingDispatch({type: 'skip'})
|
||||
},
|
||||
[store, cancelPendingTask, onboardingDispatch, ax],
|
||||
[store, cancelPendingTask, onboardingDispatch],
|
||||
)
|
||||
|
||||
const resumeSession = useCallback<SessionApiContext['resumeSession']>(
|
||||
const resumeSession = React.useCallback<SessionApiContext['resumeSession']>(
|
||||
async (storedAccount, isSwitchingAccounts = false) => {
|
||||
addSessionDebugLog({
|
||||
type: 'method:start',
|
||||
@@ -273,7 +258,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
[store, onAgentSessionChange, cancelPendingTask, onboardingDispatch],
|
||||
)
|
||||
|
||||
const partialRefreshSession = useCallback<
|
||||
const partialRefreshSession = React.useCallback<
|
||||
SessionApiContext['partialRefreshSession']
|
||||
>(async () => {
|
||||
const agent = state.currentAgentState.agent as BskyAppAgent
|
||||
@@ -290,7 +275,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
})
|
||||
}, [store, state, cancelPendingTask])
|
||||
|
||||
const removeAccount = useCallback<SessionApiContext['removeAccount']>(
|
||||
const removeAccount = React.useCallback<SessionApiContext['removeAccount']>(
|
||||
account => {
|
||||
addSessionDebugLog({
|
||||
type: 'method:start',
|
||||
@@ -307,7 +292,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
},
|
||||
[store, cancelPendingTask],
|
||||
)
|
||||
useEffect(() => {
|
||||
React.useEffect(() => {
|
||||
return persisted.onUpdate('session', nextSession => {
|
||||
const synced = nextSession
|
||||
addSessionDebugLog({type: 'persisted:receive', data: synced})
|
||||
@@ -337,7 +322,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
})
|
||||
}, [store, state, resumeSession])
|
||||
|
||||
const stateContext = useMemo(
|
||||
const stateContext = React.useMemo(
|
||||
() => ({
|
||||
accounts: state.accounts,
|
||||
currentAccount: state.accounts.find(
|
||||
@@ -348,7 +333,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
[state],
|
||||
)
|
||||
|
||||
const api = useMemo(
|
||||
const api = React.useMemo(
|
||||
() => ({
|
||||
createAccount,
|
||||
login,
|
||||
@@ -373,8 +358,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
if (__DEV__ && IS_WEB) window.agent = state.currentAgentState.agent
|
||||
|
||||
const agent = state.currentAgentState.agent as BskyAppAgent
|
||||
const currentAgentRef = useRef(agent)
|
||||
useEffect(() => {
|
||||
const currentAgentRef = React.useRef(agent)
|
||||
React.useEffect(() => {
|
||||
if (currentAgentRef.current !== agent) {
|
||||
// Read the previous value and immediately advance the pointer.
|
||||
const prevAgent = currentAgentRef.current
|
||||
@@ -405,8 +390,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
}
|
||||
|
||||
function useOneTaskAtATime() {
|
||||
const abortController = useRef<AbortController | null>(null)
|
||||
const cancelPendingTask = useCallback(() => {
|
||||
const abortController = React.useRef<AbortController | null>(null)
|
||||
const cancelPendingTask = React.useCallback(() => {
|
||||
if (abortController.current) {
|
||||
abortController.current.abort()
|
||||
}
|
||||
@@ -417,11 +402,11 @@ function useOneTaskAtATime() {
|
||||
}
|
||||
|
||||
export function useSession() {
|
||||
return useContext(StateContext)
|
||||
return React.useContext(StateContext)
|
||||
}
|
||||
|
||||
export function useSessionApi() {
|
||||
return useContext(ApiContext)
|
||||
return React.useContext(ApiContext)
|
||||
}
|
||||
|
||||
export function useRequireAuth() {
|
||||
@@ -429,7 +414,7 @@ export function useRequireAuth() {
|
||||
const closeAll = useCloseAllActiveElements()
|
||||
const {signinDialogControl} = useGlobalDialogsControlContext()
|
||||
|
||||
return useCallback(
|
||||
return React.useCallback(
|
||||
(fn: () => void) => {
|
||||
if (hasSession) {
|
||||
fn()
|
||||
@@ -443,7 +428,7 @@ export function useRequireAuth() {
|
||||
}
|
||||
|
||||
export function useAgent(): BskyAgent {
|
||||
const agent = useContext(AgentContext)
|
||||
const agent = React.useContext(AgentContext)
|
||||
if (!agent) {
|
||||
throw Error('useAgent() must be below <SessionProvider>.')
|
||||
}
|
||||
|
||||
@@ -6,17 +6,17 @@ export function create({id}: {id: string}): DB {
|
||||
const store = new MMKV({id})
|
||||
|
||||
return {
|
||||
get(key: string) {
|
||||
return store.getString(key)
|
||||
async get(key: string): Promise<string | undefined> {
|
||||
return store.getString(key) ?? undefined
|
||||
},
|
||||
set(key: string, value: string) {
|
||||
return store.set(key, value)
|
||||
async set(key: string, value: string): Promise<void> {
|
||||
store.set(key, value)
|
||||
},
|
||||
delete(key: string) {
|
||||
return store.delete(key)
|
||||
async delete(key: string): Promise<void> {
|
||||
store.delete(key)
|
||||
},
|
||||
clear() {
|
||||
return store.clearAll()
|
||||
async clear(): Promise<void> {
|
||||
store.clearAll()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,17 +6,17 @@ export function create({id}: {id: string}): DB {
|
||||
const store = createStore(id, id)
|
||||
|
||||
return {
|
||||
get(key: string) {
|
||||
return get(key, store)
|
||||
async get(key: string): Promise<string | undefined> {
|
||||
return get(key, store) ?? undefined
|
||||
},
|
||||
set(key: string, value: string) {
|
||||
return set(key, value, store)
|
||||
async set(key: string, value: string): Promise<void> {
|
||||
await set(key, value, store)
|
||||
},
|
||||
delete(key: string) {
|
||||
return del(key, store)
|
||||
async delete(key: string): Promise<void> {
|
||||
await del(key, store)
|
||||
},
|
||||
clear() {
|
||||
return clear(store)
|
||||
async clear(): Promise<void> {
|
||||
await clear(store)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
type MaybePromise<T> = T | Promise<T>
|
||||
export type DB = {
|
||||
get(key: string): MaybePromise<string | undefined>
|
||||
set(key: string, value: string): MaybePromise<void>
|
||||
delete(key: string): MaybePromise<void>
|
||||
clear(): MaybePromise<void>
|
||||
get(key: string): Promise<string | undefined>
|
||||
set(key: string, value: string): Promise<void>
|
||||
delete(key: string): Promise<void>
|
||||
clear(): Promise<void>
|
||||
}
|
||||
|
||||
@@ -138,7 +138,7 @@ import {
|
||||
type RestoredVideo,
|
||||
} from './drafts/state/api'
|
||||
import {
|
||||
loadDraftMedia,
|
||||
loadDraft,
|
||||
useCleanupPublishedDraftMutation,
|
||||
useSaveDraftMutation,
|
||||
} from './drafts/state/queries'
|
||||
@@ -482,7 +482,7 @@ export const ComposePost = ({
|
||||
})
|
||||
|
||||
// Load local media files for the draft
|
||||
const {loadedMedia} = await loadDraftMedia(draftSummary.draft)
|
||||
const {loadedMedia} = await loadDraft(draftSummary.draft)
|
||||
|
||||
// Extract original localRefs for orphan detection on save
|
||||
const originalLocalRefs = extractLocalRefs(draftSummary.draft)
|
||||
@@ -1141,57 +1141,41 @@ export const ComposePost = ({
|
||||
{!IS_WEBFooterSticky && footer}
|
||||
</View>
|
||||
|
||||
{replyTo ? (
|
||||
<Prompt.Basic
|
||||
control={discardPromptControl}
|
||||
title={_(msg`Discard draft?`)}
|
||||
description=""
|
||||
confirmButtonCta={_(msg`Discard`)}
|
||||
confirmButtonColor="negative"
|
||||
onConfirm={handleDiscard}
|
||||
/>
|
||||
) : (
|
||||
<Prompt.Outer control={discardPromptControl}>
|
||||
<Prompt.Content>
|
||||
<Prompt.TitleText>
|
||||
{composerState.draftId ? (
|
||||
<Trans>Save changes?</Trans>
|
||||
) : (
|
||||
<Trans>Save draft?</Trans>
|
||||
)}
|
||||
</Prompt.TitleText>
|
||||
<Prompt.DescriptionText>
|
||||
{composerState.draftId ? (
|
||||
<Trans>
|
||||
You have unsaved changes to this draft, would you like to
|
||||
save them?
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
Would you like to save this as a draft to edit later?
|
||||
</Trans>
|
||||
)}
|
||||
</Prompt.DescriptionText>
|
||||
</Prompt.Content>
|
||||
<Prompt.Actions>
|
||||
<Prompt.Action
|
||||
cta={
|
||||
composerState.draftId
|
||||
? _(msg`Save changes`)
|
||||
: _(msg`Save draft`)
|
||||
}
|
||||
onPress={handleSaveDraft}
|
||||
color="primary"
|
||||
/>
|
||||
<Prompt.Action
|
||||
cta={_(msg`Discard`)}
|
||||
onPress={handleDiscard}
|
||||
color="negative_subtle"
|
||||
/>
|
||||
<Prompt.Cancel />
|
||||
</Prompt.Actions>
|
||||
</Prompt.Outer>
|
||||
)}
|
||||
<Prompt.Outer control={discardPromptControl}>
|
||||
<Prompt.Content>
|
||||
<Prompt.TitleText>
|
||||
{composerState.draftId ? (
|
||||
<Trans>Save changes?</Trans>
|
||||
) : (
|
||||
<Trans>Save draft?</Trans>
|
||||
)}
|
||||
</Prompt.TitleText>
|
||||
<Prompt.DescriptionText>
|
||||
{composerState.draftId
|
||||
? _(
|
||||
msg`You have unsaved changes to this draft, would you like to save them?`,
|
||||
)
|
||||
: _(msg`Would you like to save this as a draft to edit later?`)}
|
||||
</Prompt.DescriptionText>
|
||||
</Prompt.Content>
|
||||
<Prompt.Actions>
|
||||
<Prompt.Action
|
||||
cta={
|
||||
composerState.draftId
|
||||
? _(msg`Save changes`)
|
||||
: _(msg`Save draft`)
|
||||
}
|
||||
onPress={handleSaveDraft}
|
||||
color="primary"
|
||||
/>
|
||||
<Prompt.Action
|
||||
cta={_(msg`Discard`)}
|
||||
onPress={handleDiscard}
|
||||
color="negative_subtle"
|
||||
/>
|
||||
<Prompt.Cancel />
|
||||
</Prompt.Actions>
|
||||
</Prompt.Outer>
|
||||
</KeyboardAvoidingView>
|
||||
</BottomSheetPortalProvider>
|
||||
)
|
||||
@@ -2290,40 +2274,22 @@ function VideoUploadToolbar({state}: {state: VideoState}) {
|
||||
|
||||
let text = ''
|
||||
|
||||
const isGif = state.video?.mimeType === 'image/gif'
|
||||
|
||||
switch (state.status) {
|
||||
case 'compressing':
|
||||
if (isGif) {
|
||||
text = _(msg`Compressing GIF...`)
|
||||
} else {
|
||||
text = _(msg`Compressing video...`)
|
||||
}
|
||||
text = _(msg`Compressing video...`)
|
||||
break
|
||||
case 'uploading':
|
||||
if (isGif) {
|
||||
text = _(msg`Uploading GIF...`)
|
||||
} else {
|
||||
text = _(msg`Uploading video...`)
|
||||
}
|
||||
text = _(msg`Uploading video...`)
|
||||
break
|
||||
case 'processing':
|
||||
if (isGif) {
|
||||
text = _(msg`Processing GIF...`)
|
||||
} else {
|
||||
text = _(msg`Processing video...`)
|
||||
}
|
||||
text = _(msg`Processing video...`)
|
||||
break
|
||||
case 'error':
|
||||
text = _(msg`Error`)
|
||||
wheelProgress = 100
|
||||
break
|
||||
case 'done':
|
||||
if (isGif) {
|
||||
text = _(msg`GIF uploaded`)
|
||||
} else {
|
||||
text = _(msg`Video uploaded`)
|
||||
}
|
||||
text = _(msg`Video uploaded`)
|
||||
break
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {useCallback, useEffect, useRef} from 'react'
|
||||
import {Keyboard} from 'react-native'
|
||||
import {File} from 'expo-file-system'
|
||||
import {type ImagePickerAsset} from 'expo-image-picker'
|
||||
import {msg, plural} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
@@ -19,7 +18,6 @@ import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper'
|
||||
import {Image_Stroke2_Corner0_Rounded as ImageIcon} from '#/components/icons/Image'
|
||||
import * as toast from '#/components/Toast'
|
||||
import {IS_NATIVE, IS_WEB} from '#/env'
|
||||
import {isAnimatedGif} from './videos/isAnimatedGif'
|
||||
|
||||
export type SelectMediaButtonProps = {
|
||||
disabled?: boolean
|
||||
@@ -130,7 +128,7 @@ const extensionToMimeType: Record<
|
||||
* `mimeType`. If `mimeType` is not available, we try to infer it through
|
||||
* various means.
|
||||
*/
|
||||
async function classifyImagePickerAsset(asset: ImagePickerAsset): Promise<
|
||||
function classifyImagePickerAsset(asset: ImagePickerAsset):
|
||||
| {
|
||||
success: true
|
||||
type: AssetType
|
||||
@@ -140,8 +138,7 @@ async function classifyImagePickerAsset(asset: ImagePickerAsset): Promise<
|
||||
success: false
|
||||
type: undefined
|
||||
mimeType: undefined
|
||||
}
|
||||
> {
|
||||
} {
|
||||
/*
|
||||
* Try to use the `mimeType` reported by `expo-image-picker` first.
|
||||
*/
|
||||
@@ -181,22 +178,7 @@ async function classifyImagePickerAsset(asset: ImagePickerAsset): Promise<
|
||||
*/
|
||||
let type: AssetType | undefined
|
||||
if (mimeType === 'image/gif') {
|
||||
let bytes: ArrayBuffer | undefined
|
||||
if (IS_WEB) {
|
||||
bytes = await asset.file?.arrayBuffer()
|
||||
} else {
|
||||
const file = new File(asset.uri)
|
||||
if (file.exists) {
|
||||
bytes = await file.arrayBuffer()
|
||||
}
|
||||
}
|
||||
if (bytes) {
|
||||
const {isAnimated} = isAnimatedGif(bytes)
|
||||
type = isAnimated ? 'gif' : 'image'
|
||||
} else {
|
||||
// If we can't read the file, assume it's animated
|
||||
type = 'gif'
|
||||
}
|
||||
type = 'gif'
|
||||
} else if (mimeType?.startsWith('video/')) {
|
||||
type = 'video'
|
||||
} else if (mimeType?.startsWith('image/')) {
|
||||
@@ -254,7 +236,7 @@ async function processImagePickerAssets(
|
||||
let supportedAssets: ValidatedImagePickerAsset[] = []
|
||||
|
||||
for (const asset of assets) {
|
||||
const {success, type, mimeType} = await classifyImagePickerAsset(asset)
|
||||
const {success, type, mimeType} = classifyImagePickerAsset(asset)
|
||||
|
||||
if (!success) {
|
||||
errors.add(SelectedAssetError.Unsupported)
|
||||
@@ -487,7 +469,7 @@ export function SelectMediaButton({
|
||||
useEffect(() => {
|
||||
if (autoOpen && !hasAutoOpened.current && !disabled) {
|
||||
hasAutoOpened.current = true
|
||||
void onPressSelectMedia()
|
||||
onPressSelectMedia()
|
||||
}
|
||||
}, [autoOpen, disabled, onPressSelectMedia])
|
||||
|
||||
|
||||
@@ -1,22 +1,20 @@
|
||||
import {useCallback, useEffect, useMemo, useState} from 'react'
|
||||
import {useCallback, useEffect, useState} from 'react'
|
||||
import {Pressable, View} from 'react-native'
|
||||
import * as VideoThumbnails from 'expo-video-thumbnails'
|
||||
import {msg, plural} from '@lingui/macro'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import * as device from '#/lib/deviceName'
|
||||
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
|
||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||
import {useCurrentAccountProfile} from '#/state/queries/useCurrentAccountProfile'
|
||||
import {logger} from '#/view/com/composer/drafts/state/logger'
|
||||
import {TimeElapsed} from '#/view/com/util/TimeElapsed'
|
||||
import {atoms as a, select, useTheme} from '#/alf'
|
||||
import {Button} from '#/components/Button'
|
||||
import {CirclePlus_Stroke2_Corner0_Rounded as CirclePlusIcon} from '#/components/icons/CirclePlus'
|
||||
import {type Props as SVGIconProps} from '#/components/icons/common'
|
||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button, ButtonIcon} from '#/components/Button'
|
||||
import {DotGrid_Stroke2_Corner0_Rounded as DotsIcon} from '#/components/icons/DotGrid'
|
||||
import {CloseQuote_Stroke2_Corner0_Rounded as CloseQuoteIcon} from '#/components/icons/Quote'
|
||||
import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning'
|
||||
import * as MediaPreview from '#/components/MediaPreview'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
import {RichText} from '#/components/RichText'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {IS_WEB} from '#/env'
|
||||
import {type DraftPostDisplay, type DraftSummary} from './state/schema'
|
||||
@@ -34,28 +32,6 @@ export function DraftItem({
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const discardPromptControl = Prompt.usePromptControl()
|
||||
const post = draft.posts[0]
|
||||
|
||||
const mediaExistsOnOtherDevice =
|
||||
!draft.meta.isOriginatingDevice && draft.meta.hasMissingMedia
|
||||
const mediaIsMissing =
|
||||
draft.meta.isOriginatingDevice && draft.meta.hasMissingMedia
|
||||
const hasMetadata =
|
||||
draft.meta.replyCount > 0 ||
|
||||
mediaExistsOnOtherDevice ||
|
||||
draft.meta.hasQuotes
|
||||
|
||||
const isUnknownDevice = useMemo(() => {
|
||||
const raw = draft.draft.deviceName
|
||||
switch (raw) {
|
||||
case device.FALLBACK_IOS:
|
||||
case device.FALLBACK_ANDROID:
|
||||
case device.FALLBACK_WEB:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}, [draft])
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
onDelete(draft)
|
||||
@@ -63,173 +39,48 @@ export function DraftItem({
|
||||
|
||||
return (
|
||||
<>
|
||||
<View style={[a.relative]}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Open draft`)}
|
||||
accessibilityHint={_(msg`Opens this draft in the composer`)}
|
||||
onPress={() => onSelect(draft)}
|
||||
style={({pressed, hovered}) => [
|
||||
a.rounded_md,
|
||||
a.border,
|
||||
t.atoms.shadow_sm,
|
||||
pressed || hovered
|
||||
? t.atoms.border_contrast_medium
|
||||
: t.atoms.border_contrast_low,
|
||||
{
|
||||
backgroundColor: select(t.name, {
|
||||
light: t.atoms.bg.backgroundColor,
|
||||
dark: t.atoms.bg_contrast_25.backgroundColor,
|
||||
dim: t.atoms.bg_contrast_25.backgroundColor,
|
||||
}),
|
||||
},
|
||||
]}>
|
||||
<View
|
||||
style={[
|
||||
a.rounded_md,
|
||||
a.overflow_hidden,
|
||||
a.p_lg,
|
||||
a.pb_md,
|
||||
a.gap_sm,
|
||||
{
|
||||
paddingTop: 20 + a.pt_md.paddingTop,
|
||||
},
|
||||
]}>
|
||||
<RichText
|
||||
style={[a.text_md, a.leading_snug, a.pointer_events_none]}
|
||||
value={post.text}
|
||||
enableTags
|
||||
disableMentionFacetValidation
|
||||
/>
|
||||
|
||||
{!mediaExistsOnOtherDevice && <DraftMediaPreview post={post} />}
|
||||
|
||||
{hasMetadata && (
|
||||
<View style={[a.gap_xs]}>
|
||||
{mediaExistsOnOtherDevice && (
|
||||
<DraftMetadataTag
|
||||
icon={WarningIcon}
|
||||
text={
|
||||
isUnknownDevice
|
||||
? _(msg`Media stored on another device`)
|
||||
: _(
|
||||
msg({
|
||||
message: `Media stored on ${draft.draft.deviceName}`,
|
||||
comment: `Example: "Media stored on John's iPhone"`,
|
||||
}),
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{mediaIsMissing && (
|
||||
<DraftMetadataTag
|
||||
display="warning"
|
||||
icon={WarningIcon}
|
||||
text={_(msg`Missing media`)}
|
||||
/>
|
||||
)}
|
||||
{draft.meta.hasQuotes && (
|
||||
<DraftMetadataTag
|
||||
icon={CloseQuoteIcon}
|
||||
text={_(msg`Quote post`)}
|
||||
/>
|
||||
)}
|
||||
{draft.meta.replyCount > 0 && (
|
||||
<DraftMetadataTag
|
||||
icon={CirclePlusIcon}
|
||||
text={plural(draft.meta.replyCount, {
|
||||
one: '1 more post',
|
||||
other: '# more posts',
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</Pressable>
|
||||
|
||||
{/* Timestamp */}
|
||||
<View
|
||||
pointerEvents="none"
|
||||
style={[
|
||||
a.absolute,
|
||||
a.pointer_events_none,
|
||||
{
|
||||
top: a.pt_md.paddingTop,
|
||||
left: a.pl_lg.paddingLeft,
|
||||
},
|
||||
]}>
|
||||
<TimeElapsed timestamp={draft.updatedAt}>
|
||||
{({timeElapsed}) => (
|
||||
<Text
|
||||
style={[
|
||||
a.text_sm,
|
||||
t.atoms.text_contrast_medium,
|
||||
a.leading_tight,
|
||||
]}
|
||||
numberOfLines={1}>
|
||||
{timeElapsed}
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Open draft`)}
|
||||
accessibilityHint={_(msg`Opens this draft in the composer`)}
|
||||
onPress={() => onSelect(draft)}
|
||||
style={({pressed, hovered}) => [
|
||||
a.rounded_md,
|
||||
a.overflow_hidden,
|
||||
a.border,
|
||||
t.atoms.bg,
|
||||
t.atoms.border_contrast_low,
|
||||
t.atoms.shadow_sm,
|
||||
(pressed || hovered) && t.atoms.bg_contrast_25,
|
||||
]}>
|
||||
<View style={[a.p_md, a.gap_sm]}>
|
||||
{draft.hasMissingMedia && (
|
||||
<View
|
||||
style={[
|
||||
a.rounded_sm,
|
||||
a.px_sm,
|
||||
a.py_xs,
|
||||
a.mb_xs,
|
||||
t.atoms.bg_contrast_50,
|
||||
]}>
|
||||
<Text style={[a.text_xs, t.atoms.text_contrast_medium]}>
|
||||
<Trans>Some media unavailable (saved on another device)</Trans>
|
||||
</Text>
|
||||
)}
|
||||
</TimeElapsed>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Menu button */}
|
||||
<View
|
||||
style={[
|
||||
a.absolute,
|
||||
{
|
||||
top: a.pt_md.paddingTop,
|
||||
right: a.pr_md.paddingRight,
|
||||
},
|
||||
]}>
|
||||
<Button
|
||||
label={_(msg`More options`)}
|
||||
hitSlop={8}
|
||||
onPress={e => {
|
||||
e.stopPropagation()
|
||||
discardPromptControl.open()
|
||||
}}
|
||||
style={[
|
||||
a.pointer,
|
||||
a.rounded_full,
|
||||
{
|
||||
height: 20,
|
||||
width: 20,
|
||||
},
|
||||
]}>
|
||||
{({pressed, hovered}) => (
|
||||
<>
|
||||
<View
|
||||
style={[
|
||||
a.absolute,
|
||||
a.rounded_full,
|
||||
{
|
||||
top: -4,
|
||||
bottom: -4,
|
||||
left: -4,
|
||||
right: -4,
|
||||
backgroundColor:
|
||||
pressed || hovered
|
||||
? select(t.name, {
|
||||
light: t.atoms.bg_contrast_50.backgroundColor,
|
||||
dark: t.atoms.bg_contrast_100.backgroundColor,
|
||||
dim: t.atoms.bg_contrast_100.backgroundColor,
|
||||
})
|
||||
: 'transparent',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<DotsIcon
|
||||
width={16}
|
||||
fill={t.atoms.text_contrast_low.color}
|
||||
style={[a.z_20]}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{draft.posts.map((post, index) => (
|
||||
<DraftPostRow
|
||||
key={post.id}
|
||||
post={post}
|
||||
isFirst={index === 0}
|
||||
isLast={index === draft.posts.length - 1}
|
||||
timestamp={draft.updatedAt}
|
||||
discardPromptControl={discardPromptControl}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</Pressable>
|
||||
|
||||
<Prompt.Basic
|
||||
control={discardPromptControl}
|
||||
@@ -243,28 +94,125 @@ export function DraftItem({
|
||||
)
|
||||
}
|
||||
|
||||
function DraftMetadataTag({
|
||||
display = 'info',
|
||||
icon: Icon,
|
||||
text,
|
||||
function DraftPostRow({
|
||||
post,
|
||||
isFirst,
|
||||
isLast,
|
||||
timestamp,
|
||||
discardPromptControl,
|
||||
}: {
|
||||
display?: 'info' | 'warning'
|
||||
icon: React.ComponentType<SVGIconProps>
|
||||
text: string
|
||||
post: DraftPostDisplay
|
||||
isFirst: boolean
|
||||
isLast: boolean
|
||||
timestamp: string
|
||||
discardPromptControl: Prompt.PromptControlProps
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const color = {
|
||||
info: t.atoms.text_contrast_medium.color,
|
||||
warning: select(t.name, {
|
||||
light: '#C99A00',
|
||||
dark: '#FFC404',
|
||||
dim: '#FFC404',
|
||||
}),
|
||||
}[display]
|
||||
const profile = useCurrentAccountProfile()
|
||||
|
||||
return (
|
||||
<View style={[a.flex_row, a.align_center, a.gap_xs]}>
|
||||
<Icon size="sm" fill={color} />
|
||||
<Text style={[a.text_sm, a.leading_tight, {color}]}>{text}</Text>
|
||||
<View style={[a.flex_row, a.gap_sm]}>
|
||||
<View style={[a.align_center]}>
|
||||
<UserAvatar type="user" size={42} avatar={profile?.avatar} />
|
||||
{!isLast && (
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.mt_xs,
|
||||
{
|
||||
width: 2,
|
||||
backgroundColor: t.palette.contrast_100,
|
||||
minHeight: 8,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={[a.flex_1, a.gap_2xs]}>
|
||||
<View style={[a.flex_row, a.align_center, a.gap_xs]}>
|
||||
<View style={[a.flex_row, a.align_center, a.flex_1, a.gap_xs]}>
|
||||
{profile && (
|
||||
<>
|
||||
<Text
|
||||
style={[
|
||||
a.text_md,
|
||||
a.font_semi_bold,
|
||||
t.atoms.text,
|
||||
a.leading_snug,
|
||||
]}
|
||||
numberOfLines={1}>
|
||||
{createSanitizedDisplayName(profile)}
|
||||
</Text>
|
||||
<Text
|
||||
style={[
|
||||
a.text_md,
|
||||
t.atoms.text_contrast_medium,
|
||||
a.leading_snug,
|
||||
]}
|
||||
numberOfLines={1}>
|
||||
{sanitizeHandle(profile.handle)}
|
||||
</Text>
|
||||
<Text
|
||||
style={[
|
||||
a.text_md,
|
||||
t.atoms.text_contrast_medium,
|
||||
a.leading_snug,
|
||||
]}>
|
||||
·
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
<TimeElapsed timestamp={timestamp}>
|
||||
{({timeElapsed}) => (
|
||||
<Text
|
||||
style={[
|
||||
a.text_md,
|
||||
t.atoms.text_contrast_medium,
|
||||
a.leading_snug,
|
||||
]}
|
||||
numberOfLines={1}>
|
||||
{timeElapsed}
|
||||
</Text>
|
||||
)}
|
||||
</TimeElapsed>
|
||||
</View>
|
||||
|
||||
{isFirst && (
|
||||
<Button
|
||||
label={_(msg`More options`)}
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
shape="round"
|
||||
size="tiny"
|
||||
onPress={e => {
|
||||
e.stopPropagation()
|
||||
discardPromptControl.open()
|
||||
}}>
|
||||
<ButtonIcon icon={DotsIcon} />
|
||||
</Button>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{post.text ? (
|
||||
<Text style={[a.text_md, a.leading_snug, t.atoms.text]}>
|
||||
{post.text}
|
||||
</Text>
|
||||
) : (
|
||||
<Text
|
||||
style={[
|
||||
a.text_md,
|
||||
a.leading_snug,
|
||||
t.atoms.text_contrast_medium,
|
||||
a.italic,
|
||||
]}>
|
||||
<Trans>(No text)</Trans>
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<DraftMediaPreview post={post} />
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -323,7 +271,7 @@ function DraftMediaPreview({post}: {post: DraftPostDisplay}) {
|
||||
}
|
||||
|
||||
return (
|
||||
<MediaPreview.Outer>
|
||||
<MediaPreview.Outer style={[a.pt_xs]}>
|
||||
{loadedImages.map((image, i) => (
|
||||
<MediaPreview.ImageItem key={i} thumbnail={image.url} alt={image.alt} />
|
||||
))}
|
||||
|
||||
@@ -5,7 +5,7 @@ import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useCallOnce} from '#/lib/once'
|
||||
import {EmptyState} from '#/view/com/util/EmptyState'
|
||||
import {atoms as a, select, useBreakpoints, useTheme, web} from '#/alf'
|
||||
import {atoms as a, useTheme, web} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {PageX_Stroke2_Corner0_Rounded_Large as PageXIcon} from '#/components/icons/PageX'
|
||||
@@ -26,7 +26,6 @@ export function DraftsListDialog({
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const {gtPhone} = useBreakpoints()
|
||||
const ax = useAnalytics()
|
||||
const {data, isLoading, hasNextPage, isFetchingNextPage, fetchNextPage} =
|
||||
useDraftsQuery()
|
||||
@@ -92,7 +91,7 @@ export function DraftsListDialog({
|
||||
const renderItem = useCallback(
|
||||
({item}: {item: DraftSummary}) => {
|
||||
return (
|
||||
<View style={[gtPhone ? [a.px_md, a.pt_md] : [a.px_sm, a.pt_sm]]}>
|
||||
<View style={[a.px_lg, a.mt_lg]}>
|
||||
<DraftItem
|
||||
draft={item}
|
||||
onSelect={handleSelectDraft}
|
||||
@@ -101,7 +100,7 @@ export function DraftsListDialog({
|
||||
</View>
|
||||
)
|
||||
},
|
||||
[handleSelectDraft, handleDeleteDraft, gtPhone],
|
||||
[handleSelectDraft, handleDeleteDraft],
|
||||
)
|
||||
|
||||
const header = useMemo(
|
||||
@@ -163,17 +162,7 @@ export function DraftsListDialog({
|
||||
ListFooterComponent={footerComponent}
|
||||
onEndReached={onEndReached}
|
||||
onEndReachedThreshold={0.5}
|
||||
style={[
|
||||
a.px_0,
|
||||
web({minHeight: 500}),
|
||||
{
|
||||
backgroundColor: select(t.name, {
|
||||
light: t.palette.contrast_50,
|
||||
dark: t.palette.contrast_0,
|
||||
dim: '#000000',
|
||||
}),
|
||||
},
|
||||
]}
|
||||
style={[t.atoms.bg_contrast_50, a.px_0, web({minHeight: 500})]}
|
||||
webInnerContentContainerStyle={[a.py_0]}
|
||||
contentContainerStyle={[a.pb_xl]}
|
||||
/>
|
||||
|
||||
@@ -1,28 +1,21 @@
|
||||
/**
|
||||
* Type converters for Draft API - convert between ComposerState and server Draft types.
|
||||
*/
|
||||
import {type AppBskyDraftDefs, AtUri, RichText} from '@atproto/api'
|
||||
import {type AppBskyDraftDefs, RichText} from '@atproto/api'
|
||||
import {nanoid} from 'nanoid/non-secure'
|
||||
|
||||
import {resolveLink} from '#/lib/api/resolve'
|
||||
import {getDeviceName} from '#/lib/deviceName'
|
||||
import {getImageDim} from '#/lib/media/manip'
|
||||
import {mimeToExt} from '#/lib/media/video/util'
|
||||
import {type ComposerImage} from '#/state/gallery'
|
||||
import {type Gif} from '#/state/queries/tenor'
|
||||
import {threadgateAllowUISettingToAllowRecordValue} from '#/state/queries/threadgate/util'
|
||||
import {createPublicAgent} from '#/state/session/agent'
|
||||
import {
|
||||
type ComposerState,
|
||||
type EmbedDraft,
|
||||
type PostDraft,
|
||||
} from '#/view/com/composer/state/composer'
|
||||
import {type VideoState} from '#/view/com/composer/state/video'
|
||||
import {type AnalyticsContextType} from '#/analytics'
|
||||
import {getDeviceId} from '#/analytics/identifiers'
|
||||
import {logger} from './logger'
|
||||
import {type DraftPostDisplay, type DraftSummary} from './schema'
|
||||
import * as storage from './storage'
|
||||
|
||||
const TENOR_HOSTNAME = 'media.tenor.com'
|
||||
|
||||
@@ -68,14 +61,33 @@ export async function composerStateToDraft(state: ComposerState): Promise<{
|
||||
}),
|
||||
)
|
||||
|
||||
// Convert threadgate settings to server format
|
||||
const threadgateAllow: AppBskyDraftDefs.Draft['threadgateAllow'] = []
|
||||
for (const setting of state.thread.threadgate) {
|
||||
if (setting.type === 'mention') {
|
||||
threadgateAllow.push({
|
||||
$type: 'app.bsky.feed.threadgate#mentionRule' as const,
|
||||
})
|
||||
} else if (setting.type === 'following') {
|
||||
threadgateAllow.push({
|
||||
$type: 'app.bsky.feed.threadgate#followingRule' as const,
|
||||
})
|
||||
} else if (setting.type === 'followers') {
|
||||
threadgateAllow.push({
|
||||
$type: 'app.bsky.feed.threadgate#followerRule' as const,
|
||||
})
|
||||
} else if (setting.type === 'list') {
|
||||
threadgateAllow.push({
|
||||
$type: 'app.bsky.feed.threadgate#listRule' as const,
|
||||
list: setting.list,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const draft: AppBskyDraftDefs.Draft = {
|
||||
$type: 'app.bsky.draft.defs#draft',
|
||||
deviceId: getDeviceId(),
|
||||
deviceName: getDeviceName().slice(0, 100), // max length of 100 in lex
|
||||
posts,
|
||||
threadgateAllow: threadgateAllowUISettingToAllowRecordValue(
|
||||
state.thread.threadgate,
|
||||
),
|
||||
threadgateAllow: threadgateAllow.length > 0 ? threadgateAllow : undefined,
|
||||
postgateEmbeddingRules:
|
||||
state.thread.postgate.embeddingRules &&
|
||||
state.thread.postgate.embeddingRules.length > 0
|
||||
@@ -128,21 +140,15 @@ async function postDraftToServerPost(
|
||||
|
||||
// Add quote record embed
|
||||
if (post.embed.quote) {
|
||||
const resolved = await resolveLink(
|
||||
createPublicAgent(),
|
||||
post.embed.quote.uri,
|
||||
)
|
||||
if (resolved && resolved.type === 'record') {
|
||||
draftPost.embedRecords = [
|
||||
{
|
||||
$type: 'app.bsky.draft.defs#draftEmbedRecord',
|
||||
record: {
|
||||
uri: resolved.record.uri,
|
||||
cid: resolved.record.cid,
|
||||
},
|
||||
draftPost.embedRecords = [
|
||||
{
|
||||
$type: 'app.bsky.draft.defs#draftEmbedRecord',
|
||||
record: {
|
||||
uri: post.embed.quote.uri,
|
||||
cid: '', // We don't have the CID at draft time
|
||||
},
|
||||
]
|
||||
}
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
// Add external link embed (only if no media, otherwise it's ignored)
|
||||
@@ -271,24 +277,16 @@ function serializeGif(gifMedia: {
|
||||
* Convert server DraftView to DraftSummary for list display.
|
||||
* Also checks which media files exist locally.
|
||||
*/
|
||||
export function draftViewToSummary({
|
||||
view,
|
||||
analytics,
|
||||
}: {
|
||||
view: AppBskyDraftDefs.DraftView
|
||||
analytics: AnalyticsContextType
|
||||
}): DraftSummary {
|
||||
const meta = {
|
||||
isOriginatingDevice: view.draft.deviceId === getDeviceId(),
|
||||
postCount: view.draft.posts.length,
|
||||
// minus anchor post
|
||||
replyCount: view.draft.posts.length - 1,
|
||||
hasMedia: false,
|
||||
hasMissingMedia: false,
|
||||
mediaCount: 0,
|
||||
hasQuotes: false,
|
||||
quoteCount: 0,
|
||||
}
|
||||
export function draftViewToSummary(
|
||||
view: AppBskyDraftDefs.DraftView,
|
||||
localMediaExists: (path: string) => boolean,
|
||||
): DraftSummary {
|
||||
const firstPost = view.draft.posts[0]
|
||||
const previewText = firstPost?.text?.slice(0, 100) || ''
|
||||
|
||||
let mediaCount = 0
|
||||
let hasMedia = false
|
||||
let hasMissingMedia = false
|
||||
|
||||
const posts: DraftPostDisplay[] = view.draft.posts.map((post, index) => {
|
||||
const images: DraftPostDisplay['images'] = []
|
||||
@@ -298,11 +296,11 @@ export function draftViewToSummary({
|
||||
// Process images
|
||||
if (post.embedImages) {
|
||||
for (const img of post.embedImages) {
|
||||
meta.mediaCount++
|
||||
meta.hasMedia = true
|
||||
const exists = storage.mediaExists(img.localRef.path)
|
||||
mediaCount++
|
||||
hasMedia = true
|
||||
const exists = localMediaExists(img.localRef.path)
|
||||
if (!exists) {
|
||||
meta.hasMissingMedia = true
|
||||
hasMissingMedia = true
|
||||
}
|
||||
images.push({
|
||||
localPath: img.localRef.path,
|
||||
@@ -315,11 +313,11 @@ export function draftViewToSummary({
|
||||
// Process videos
|
||||
if (post.embedVideos) {
|
||||
for (const vid of post.embedVideos) {
|
||||
meta.mediaCount++
|
||||
meta.hasMedia = true
|
||||
const exists = storage.mediaExists(vid.localRef.path)
|
||||
mediaCount++
|
||||
hasMedia = true
|
||||
const exists = localMediaExists(vid.localRef.path)
|
||||
if (!exists) {
|
||||
meta.hasMissingMedia = true
|
||||
hasMissingMedia = true
|
||||
}
|
||||
videos.push({
|
||||
localPath: vid.localRef.path,
|
||||
@@ -334,18 +332,13 @@ export function draftViewToSummary({
|
||||
for (const ext of post.embedExternals) {
|
||||
const gifData = parseGifFromUrl(ext.uri)
|
||||
if (gifData) {
|
||||
meta.mediaCount++
|
||||
meta.hasMedia = true
|
||||
mediaCount++
|
||||
hasMedia = true
|
||||
gif = gifData
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (post.embedRecords && post.embedRecords.length > 0) {
|
||||
meta.quoteCount += post.embedRecords.length
|
||||
meta.hasQuotes = true
|
||||
}
|
||||
|
||||
return {
|
||||
id: `post-${index}`,
|
||||
text: post.text || '',
|
||||
@@ -355,17 +348,17 @@ export function draftViewToSummary({
|
||||
}
|
||||
})
|
||||
|
||||
if (meta.isOriginatingDevice && meta.hasMissingMedia) {
|
||||
analytics.logger.warn(`Draft is missing media on originating device`, {})
|
||||
}
|
||||
|
||||
return {
|
||||
id: view.id,
|
||||
draft: view.draft,
|
||||
previewText,
|
||||
hasMedia,
|
||||
hasMissingMedia,
|
||||
mediaCount,
|
||||
postCount: view.draft.posts.length,
|
||||
createdAt: view.createdAt,
|
||||
updatedAt: view.updatedAt,
|
||||
draft: view.draft,
|
||||
posts,
|
||||
meta,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -543,9 +536,7 @@ export async function draftToComposerPosts(
|
||||
// Restore quote embed
|
||||
if (post.embedRecords && post.embedRecords.length > 0) {
|
||||
const record = post.embedRecords[0]
|
||||
const urip = new AtUri(record.record.uri)
|
||||
const url = `https://bsky.app/profile/${urip.host}/post/${urip.rkey}`
|
||||
embed.quote = {type: 'link', uri: url}
|
||||
embed.quote = {type: 'link', uri: record.record.uri}
|
||||
}
|
||||
|
||||
// Restore link embed (only if not a GIF)
|
||||
|
||||
@@ -8,8 +8,6 @@ import {
|
||||
import {isNetworkError} from '#/lib/strings/errors'
|
||||
import {useAgent} from '#/state/session'
|
||||
import {type ComposerState} from '#/view/com/composer/state/composer'
|
||||
import {useAnalytics} from '#/analytics'
|
||||
import {getDeviceId} from '#/analytics/identifiers'
|
||||
import {composerStateToDraft, draftViewToSummary} from './api'
|
||||
import {logger} from './logger'
|
||||
import * as storage from './storage'
|
||||
@@ -21,7 +19,6 @@ const DRAFTS_QUERY_KEY = ['drafts']
|
||||
*/
|
||||
export function useDraftsQuery() {
|
||||
const agent = useAgent()
|
||||
const ax = useAnalytics()
|
||||
|
||||
return useInfiniteQuery({
|
||||
queryKey: DRAFTS_QUERY_KEY,
|
||||
@@ -32,10 +29,7 @@ export function useDraftsQuery() {
|
||||
return {
|
||||
cursor: res.data.cursor,
|
||||
drafts: res.data.drafts.map(view =>
|
||||
draftViewToSummary({
|
||||
view,
|
||||
analytics: ax,
|
||||
}),
|
||||
draftViewToSummary(view, path => storage.mediaExists(path)),
|
||||
),
|
||||
}
|
||||
},
|
||||
@@ -48,17 +42,11 @@ export function useDraftsQuery() {
|
||||
* Load a draft's local media for editing.
|
||||
* Takes the full Draft object (from DraftSummary) to avoid re-fetching.
|
||||
*/
|
||||
export async function loadDraftMedia(draft: AppBskyDraftDefs.Draft): Promise<{
|
||||
export async function loadDraft(draft: AppBskyDraftDefs.Draft): Promise<{
|
||||
loadedMedia: Map<string, string>
|
||||
}> {
|
||||
// Load local media files
|
||||
const loadedMedia = new Map<string, string>()
|
||||
|
||||
// can't load media from another device
|
||||
if (draft.deviceId && draft.deviceId !== getDeviceId()) {
|
||||
return {loadedMedia}
|
||||
}
|
||||
|
||||
for (const post of draft.posts) {
|
||||
// Load images
|
||||
if (post.embedImages) {
|
||||
@@ -66,10 +54,10 @@ export async function loadDraftMedia(draft: AppBskyDraftDefs.Draft): Promise<{
|
||||
try {
|
||||
const url = await storage.loadMediaFromLocal(img.localRef.path)
|
||||
loadedMedia.set(img.localRef.path, url)
|
||||
} catch (e: any) {
|
||||
logger.error('Failed to load draft image', {
|
||||
} catch (e) {
|
||||
logger.warn('Failed to load draft image', {
|
||||
path: img.localRef.path,
|
||||
safeMessage: e.message,
|
||||
error: e,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -80,10 +68,10 @@ export async function loadDraftMedia(draft: AppBskyDraftDefs.Draft): Promise<{
|
||||
try {
|
||||
const url = await storage.loadMediaFromLocal(vid.localRef.path)
|
||||
loadedMedia.set(vid.localRef.path, url)
|
||||
} catch (e: any) {
|
||||
logger.error('Failed to load draft video', {
|
||||
} catch (e) {
|
||||
logger.warn('Failed to load draft video', {
|
||||
path: vid.localRef.path,
|
||||
safeMessage: e.message,
|
||||
error: e,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,31 +50,22 @@ export type DraftPostDisplay = {
|
||||
*/
|
||||
export type DraftSummary = {
|
||||
id: string
|
||||
/** The full draft data from the server */
|
||||
draft: AppBskyDraftDefs.Draft
|
||||
/** First ~100 chars of first post */
|
||||
previewText: string
|
||||
/** Whether the draft has media */
|
||||
hasMedia: boolean
|
||||
/** Whether some media is missing (saved on another device) */
|
||||
hasMissingMedia?: boolean
|
||||
/** Number of media items */
|
||||
mediaCount: number
|
||||
/** Number of posts in thread */
|
||||
postCount: number
|
||||
/** ISO timestamp of creation */
|
||||
createdAt: string
|
||||
/** ISO timestamp of last update */
|
||||
updatedAt: string
|
||||
/** The full draft data from the server */
|
||||
draft: AppBskyDraftDefs.Draft
|
||||
/** All posts in the draft for full display */
|
||||
posts: DraftPostDisplay[]
|
||||
/** Metadata about the draft for display purposes */
|
||||
meta: {
|
||||
/** Whether this device is the originating device for the draft */
|
||||
isOriginatingDevice: boolean
|
||||
/** Number of posts in thread */
|
||||
postCount: number
|
||||
/** Number of replies to anchor post */
|
||||
replyCount: number
|
||||
/** Whether the draft has media */
|
||||
hasMedia: boolean
|
||||
/** Whether some media is missing (saved on another device) */
|
||||
hasMissingMedia?: boolean
|
||||
/** Number of media items */
|
||||
mediaCount: number
|
||||
/** Whether any posts in the draft has quotes */
|
||||
hasQuotes: boolean
|
||||
/** Number of quotes in the draft */
|
||||
quoteCount: number
|
||||
}
|
||||
}
|
||||
|
||||
@@ -670,9 +670,6 @@ export function createComposerState({
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (initMention) {
|
||||
// highlight the mention
|
||||
initRichText.detectFacetsWithoutResolution()
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -241,7 +241,7 @@ export function TextInput({
|
||||
}
|
||||
},
|
||||
},
|
||||
content: generateJSON(richTextToHTML(richtext), extensions, {
|
||||
content: generateJSON(richtext.text.toString(), extensions, {
|
||||
preserveWhitespace: 'full',
|
||||
}),
|
||||
autofocus: 'end',
|
||||
@@ -382,36 +382,6 @@ export function TextInput({
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to initialise the editor with RichText, which expects HTML
|
||||
*
|
||||
* All the extensions are able to initialise themselves from plain text, *except*
|
||||
* for the Mention extension - we need to manually convert it into a `<span>` element
|
||||
*
|
||||
* It also escapes HTML characters
|
||||
*/
|
||||
function richTextToHTML(richtext: RichText): string {
|
||||
let html = ''
|
||||
|
||||
for (const segment of richtext.segments()) {
|
||||
if (segment.mention) {
|
||||
html += `<span data-type="mention" data-id="${escapeHTML(segment.mention.did)}"></span>`
|
||||
} else {
|
||||
html += escapeHTML(segment.text)
|
||||
}
|
||||
}
|
||||
|
||||
return html
|
||||
}
|
||||
|
||||
function escapeHTML(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
function editorJsonToText(
|
||||
json: JSONContent,
|
||||
isLastDocumentChild: boolean = false,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {useRef} from 'react'
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {Image} from 'expo-image'
|
||||
import {type ImagePickerAsset} from 'expo-image-picker'
|
||||
@@ -24,7 +24,7 @@ export function VideoPreview({
|
||||
clear: () => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const playerRef = useRef<BlueskyVideoView>(null)
|
||||
const playerRef = React.useRef<BlueskyVideoView>(null)
|
||||
const autoplayDisabled = useAutoplayDisabled()
|
||||
let aspectRatio = asset.width / asset.height
|
||||
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
/**
|
||||
* Checks if a GIF is animated. Cooked up by Claude, validated with some examples.
|
||||
* @param bytes - The GIF bytes, as a Uint8Array.
|
||||
* @returns An object with properties isGif, isAnimated, and frames.
|
||||
*/
|
||||
export function isAnimatedGif(buffer: ArrayBuffer): {
|
||||
isGif: boolean
|
||||
isAnimated: boolean
|
||||
frames: number
|
||||
} {
|
||||
const bytes = new Uint8Array(buffer)
|
||||
// Verify GIF signature
|
||||
const sig = String.fromCharCode(...bytes.slice(0, 6))
|
||||
if (!sig.startsWith('GIF'))
|
||||
return {isGif: false, isAnimated: false, frames: 0}
|
||||
|
||||
let i = 13 // Skip header + logical screen descriptor
|
||||
|
||||
// Skip global color table if present
|
||||
if (bytes[10] & 0x80) {
|
||||
const gctSize = 3 * (1 << ((bytes[10] & 0x07) + 1))
|
||||
i += gctSize
|
||||
}
|
||||
|
||||
let frames = 0
|
||||
|
||||
while (i < bytes.length) {
|
||||
const block = bytes[i++]
|
||||
|
||||
if (block === 0x2c) {
|
||||
// Image descriptor
|
||||
frames++
|
||||
|
||||
// Skip image descriptor fields
|
||||
i += 8
|
||||
// Skip local color table if present
|
||||
if (bytes[i] & 0x80) {
|
||||
const lctSize = 3 * (1 << ((bytes[i] & 0x07) + 1))
|
||||
i += lctSize + 1
|
||||
} else {
|
||||
i++
|
||||
}
|
||||
// Skip image data blocks
|
||||
i++ // LZW minimum code size
|
||||
while (bytes[i]) i += bytes[i] + 1
|
||||
i++
|
||||
} else if (block === 0x21) {
|
||||
// Extension
|
||||
i++ // Extension type
|
||||
while (bytes[i]) i += bytes[i] + 1
|
||||
i++
|
||||
} else if (block === 0x3b) {
|
||||
// Trailer
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return {isGif: true, isAnimated: frames > 1, frames}
|
||||
}
|
||||
@@ -63,7 +63,7 @@ function LightboxContainer({
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityHint={undefined}
|
||||
accessibilityLabel={_(msg`Close image viewer`)}
|
||||
accessibilityLabel={_(msg`Close image lightbox`)}
|
||||
onPress={handleBackgroundPress}
|
||||
style={[a.fixed, a.inset_0, a.z_10]}>
|
||||
<Backdrop />
|
||||
@@ -216,7 +216,7 @@ function LightboxGallery({
|
||||
]}
|
||||
hoverStyle={styles.blurredBackdropHover}
|
||||
color="secondary"
|
||||
label={_(msg`Close image viewer`)}
|
||||
label={_(msg`Close lightbox`)}
|
||||
shape="round"
|
||||
size={gtPhone ? 'large' : 'small'}>
|
||||
<XIcon size={gtPhone ? 'md' : 'sm'} style={{color: t.palette.white}} />
|
||||
|
||||
@@ -199,7 +199,7 @@ function PostInner({
|
||||
style={[a.pb_xs]}
|
||||
/>
|
||||
{richText.text ? (
|
||||
<View style={[a.mb_2xs]}>
|
||||
<View>
|
||||
<RichText
|
||||
enableTags
|
||||
testID="postText"
|
||||
|
||||
@@ -466,7 +466,7 @@ let PostContent = ({
|
||||
additionalCauses={additionalPostAlerts}
|
||||
/>
|
||||
{richText.text ? (
|
||||
<View style={[a.mb_2xs]}>
|
||||
<>
|
||||
<RichText
|
||||
enableTags
|
||||
testID="postText"
|
||||
@@ -479,7 +479,7 @@ let PostContent = ({
|
||||
{limitLines && (
|
||||
<ShowMoreTextButton style={[a.text_md]} onPress={onPressShowMore} />
|
||||
)}
|
||||
</View>
|
||||
</>
|
||||
) : undefined}
|
||||
{postEmbed ? (
|
||||
<View style={[a.pb_xs]}>
|
||||
|
||||
@@ -15,6 +15,7 @@ import {useDedupe} from '#/lib/hooks/useDedupe'
|
||||
import {useHideBottomBarBorder} from '#/lib/hooks/useHideBottomBarBorder'
|
||||
import {useMinimalShellFooterTransform} from '#/lib/hooks/useMinimalShellTransform'
|
||||
import {useNavigationTabState} from '#/lib/hooks/useNavigationTabState'
|
||||
import {usePalette} from '#/lib/hooks/usePalette'
|
||||
import {clamp} from '#/lib/numbers'
|
||||
import {getTabState, TabState} from '#/lib/routes/helpers'
|
||||
import {emitSoftReset} from '#/state/events'
|
||||
@@ -56,7 +57,7 @@ type TabOptions = 'Home' | 'Search' | 'Messages' | 'Notifications' | 'MyProfile'
|
||||
|
||||
export function BottomBar({navigation}: BottomTabBarProps) {
|
||||
const {hasSession, currentAccount} = useSession()
|
||||
const t = useTheme()
|
||||
const pal = usePalette('default')
|
||||
const {_} = useLingui()
|
||||
const safeAreaInsets = useSafeAreaInsets()
|
||||
const {footerHeight} = useShellLayout()
|
||||
@@ -144,10 +145,8 @@ export function BottomBar({navigation}: BottomTabBarProps) {
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.bottomBar,
|
||||
t.atoms.bg,
|
||||
hideBorder
|
||||
? {borderColor: t.atoms.bg.backgroundColor}
|
||||
: t.atoms.border_contrast_low,
|
||||
pal.view,
|
||||
hideBorder ? {borderColor: pal.view.backgroundColor} : pal.border,
|
||||
{paddingBottom: clamp(safeAreaInsets.bottom, 15, 60)},
|
||||
footerMinimalShellTransform,
|
||||
]}
|
||||
@@ -162,12 +161,12 @@ export function BottomBar({navigation}: BottomTabBarProps) {
|
||||
isAtHome ? (
|
||||
<HomeFilled
|
||||
width={iconWidth + 1}
|
||||
style={[styles.ctrlIcon, t.atoms.text, styles.homeIcon]}
|
||||
style={[styles.ctrlIcon, pal.text, styles.homeIcon]}
|
||||
/>
|
||||
) : (
|
||||
<Home
|
||||
width={iconWidth + 1}
|
||||
style={[styles.ctrlIcon, t.atoms.text, styles.homeIcon]}
|
||||
style={[styles.ctrlIcon, pal.text, styles.homeIcon]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -181,13 +180,13 @@ export function BottomBar({navigation}: BottomTabBarProps) {
|
||||
isAtSearch ? (
|
||||
<MagnifyingGlassFilled
|
||||
width={iconWidth + 2}
|
||||
style={[styles.ctrlIcon, t.atoms.text, styles.searchIcon]}
|
||||
style={[styles.ctrlIcon, pal.text, styles.searchIcon]}
|
||||
/>
|
||||
) : (
|
||||
<MagnifyingGlass
|
||||
testID="bottomBarSearchBtn"
|
||||
width={iconWidth + 2}
|
||||
style={[styles.ctrlIcon, t.atoms.text, styles.searchIcon]}
|
||||
style={[styles.ctrlIcon, pal.text, styles.searchIcon]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -202,12 +201,12 @@ export function BottomBar({navigation}: BottomTabBarProps) {
|
||||
isAtMessages ? (
|
||||
<MessageFilled
|
||||
width={iconWidth - 1}
|
||||
style={[styles.ctrlIcon, t.atoms.text, styles.feedsIcon]}
|
||||
style={[styles.ctrlIcon, pal.text, styles.feedsIcon]}
|
||||
/>
|
||||
) : (
|
||||
<Message
|
||||
width={iconWidth - 1}
|
||||
style={[styles.ctrlIcon, t.atoms.text, styles.feedsIcon]}
|
||||
style={[styles.ctrlIcon, pal.text, styles.feedsIcon]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -234,12 +233,12 @@ export function BottomBar({navigation}: BottomTabBarProps) {
|
||||
isAtNotifications ? (
|
||||
<BellFilled
|
||||
width={iconWidth}
|
||||
style={[styles.ctrlIcon, t.atoms.text, styles.bellIcon]}
|
||||
style={[styles.ctrlIcon, pal.text, styles.bellIcon]}
|
||||
/>
|
||||
) : (
|
||||
<Bell
|
||||
width={iconWidth}
|
||||
style={[styles.ctrlIcon, t.atoms.text, styles.bellIcon]}
|
||||
style={[styles.ctrlIcon, pal.text, styles.bellIcon]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -263,28 +262,49 @@ export function BottomBar({navigation}: BottomTabBarProps) {
|
||||
testID="bottomBarProfileBtn"
|
||||
icon={
|
||||
<View style={styles.ctrlIconSizingWrapper}>
|
||||
<View
|
||||
style={[
|
||||
styles.ctrlIcon,
|
||||
styles.profileIcon,
|
||||
isAtMyProfile && [
|
||||
{isAtMyProfile ? (
|
||||
<View
|
||||
style={[
|
||||
styles.ctrlIcon,
|
||||
pal.text,
|
||||
styles.profileIcon,
|
||||
styles.onProfile,
|
||||
{
|
||||
borderColor: t.atoms.text.color,
|
||||
borderColor: pal.text.color,
|
||||
borderWidth: live ? 0 : 1,
|
||||
},
|
||||
],
|
||||
]}>
|
||||
<UserAvatar
|
||||
avatar={demoMode ? BOTTOM_BAR_AVI : profile?.avatar}
|
||||
size={iconWidth - (isAtMyProfile ? 3 : 2)}
|
||||
// See https://github.com/bluesky-social/social-app/pull/1801:
|
||||
usePlainRNImage={true}
|
||||
type={profile?.associated?.labeler ? 'labeler' : 'user'}
|
||||
live={live}
|
||||
hideLiveBadge
|
||||
/>
|
||||
</View>
|
||||
]}>
|
||||
<UserAvatar
|
||||
avatar={demoMode ? BOTTOM_BAR_AVI : profile?.avatar}
|
||||
size={iconWidth - 2}
|
||||
// See https://github.com/bluesky-social/social-app/pull/1801:
|
||||
usePlainRNImage={true}
|
||||
type={profile?.associated?.labeler ? 'labeler' : 'user'}
|
||||
live={live}
|
||||
hideLiveBadge
|
||||
/>
|
||||
</View>
|
||||
) : (
|
||||
<View
|
||||
style={[
|
||||
styles.ctrlIcon,
|
||||
pal.text,
|
||||
styles.profileIcon,
|
||||
{
|
||||
borderWidth: live ? 0 : 1,
|
||||
},
|
||||
]}>
|
||||
<UserAvatar
|
||||
avatar={demoMode ? BOTTOM_BAR_AVI : profile?.avatar}
|
||||
size={iconWidth - 2}
|
||||
// See https://github.com/bluesky-social/social-app/pull/1801:
|
||||
usePlainRNImage={true}
|
||||
type={profile?.associated?.labeler ? 'labeler' : 'user'}
|
||||
live={live}
|
||||
hideLiveBadge
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
}
|
||||
onPress={onPressProfile}
|
||||
@@ -312,7 +332,7 @@ export function BottomBar({navigation}: BottomTabBarProps) {
|
||||
style={{flexDirection: 'row', alignItems: 'center', gap: 8}}>
|
||||
<Logo width={28} />
|
||||
<View style={{paddingTop: 4}}>
|
||||
<Logotype width={80} fill={t.atoms.text.color} />
|
||||
<Logotype width={80} fill={pal.text.color} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
|
||||
@@ -12,19 +12,15 @@ import {makeProfileLink} from '#/lib/routes/links'
|
||||
import {type CommonNavigatorParams} from '#/lib/routes/types'
|
||||
import {useUnreadMessageCount} from '#/state/queries/messages/list-conversations'
|
||||
import {useUnreadNotifications} from '#/state/queries/notifications/unread'
|
||||
import {useProfileQuery} from '#/state/queries/profile'
|
||||
import {useSession} from '#/state/session'
|
||||
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
|
||||
import {useShellLayout} from '#/state/shell/shell-layout'
|
||||
import {useCloseAllActiveElements} from '#/state/util'
|
||||
import {Link} from '#/view/com/util/Link'
|
||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {Logo} from '#/view/icons/Logo'
|
||||
import {Logotype} from '#/view/icons/Logotype'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import {useDialogControl} from '#/components/Dialog'
|
||||
import {SwitchAccountDialog} from '#/components/dialogs/SwitchAccount'
|
||||
import {
|
||||
Bell_Filled_Corner0_Rounded as BellFilled,
|
||||
Bell_Stroke2_Corner0_Rounded as Bell,
|
||||
@@ -41,6 +37,10 @@ import {
|
||||
Message_Stroke2_Corner0_Rounded as Message,
|
||||
Message_Stroke2_Corner0_Rounded_Filled as MessageFilled,
|
||||
} from '#/components/icons/Message'
|
||||
import {
|
||||
UserCircle_Filled_Corner0_Rounded as UserCircleFilled,
|
||||
UserCircle_Stroke2_Corner0_Rounded as UserCircle,
|
||||
} from '#/components/icons/UserCircle'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {styles} from './BottomBarStyles'
|
||||
|
||||
@@ -53,8 +53,6 @@ export function BottomBarWeb() {
|
||||
const closeAllActiveElements = useCloseAllActiveElements()
|
||||
const {footerHeight} = useShellLayout()
|
||||
const hideBorder = useHideBottomBarBorder()
|
||||
const accountSwitchControl = useDialogControl()
|
||||
const {data: profile} = useProfileQuery({did: currentAccount?.did})
|
||||
const iconWidth = 26
|
||||
|
||||
const unreadMessageCount = useUnreadMessageCount()
|
||||
@@ -71,176 +69,158 @@ export function BottomBarWeb() {
|
||||
// setShowLoggedOut(true)
|
||||
}, [requestSwitchToAccount, closeAllActiveElements])
|
||||
|
||||
const onLongPressProfile = React.useCallback(() => {
|
||||
accountSwitchControl.open()
|
||||
}, [accountSwitchControl])
|
||||
|
||||
return (
|
||||
<>
|
||||
<SwitchAccountDialog control={accountSwitchControl} />
|
||||
<Animated.View
|
||||
role="navigation"
|
||||
style={[
|
||||
styles.bottomBar,
|
||||
styles.bottomBarWeb,
|
||||
t.atoms.bg,
|
||||
hideBorder
|
||||
? {borderColor: t.atoms.bg.backgroundColor}
|
||||
: t.atoms.border_contrast_low,
|
||||
footerMinimalShellTransform,
|
||||
]}
|
||||
onLayout={event => footerHeight.set(event.nativeEvent.layout.height)}>
|
||||
{hasSession ? (
|
||||
<>
|
||||
<NavItem routeName="Home" href="/">
|
||||
{({isActive}) => {
|
||||
const Icon = isActive ? HomeFilled : Home
|
||||
return (
|
||||
<Icon
|
||||
aria-hidden={true}
|
||||
width={iconWidth + 1}
|
||||
style={[styles.ctrlIcon, t.atoms.text, styles.homeIcon]}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
</NavItem>
|
||||
<NavItem routeName="Search" href="/search">
|
||||
{({isActive}) => {
|
||||
const Icon = isActive ? MagnifyingGlassFilled : MagnifyingGlass
|
||||
return (
|
||||
<Icon
|
||||
aria-hidden={true}
|
||||
width={iconWidth + 2}
|
||||
style={[styles.ctrlIcon, t.atoms.text, styles.searchIcon]}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
</NavItem>
|
||||
|
||||
<Animated.View
|
||||
role="navigation"
|
||||
style={[
|
||||
styles.bottomBar,
|
||||
styles.bottomBarWeb,
|
||||
t.atoms.bg,
|
||||
hideBorder
|
||||
? {borderColor: t.atoms.bg.backgroundColor}
|
||||
: t.atoms.border_contrast_low,
|
||||
footerMinimalShellTransform,
|
||||
]}
|
||||
onLayout={event => footerHeight.set(event.nativeEvent.layout.height)}>
|
||||
{hasSession ? (
|
||||
<>
|
||||
<NavItem routeName="Home" href="/">
|
||||
{({isActive}) => {
|
||||
const Icon = isActive ? HomeFilled : Home
|
||||
return (
|
||||
<Icon
|
||||
aria-hidden={true}
|
||||
width={iconWidth + 1}
|
||||
style={[styles.ctrlIcon, t.atoms.text, styles.homeIcon]}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
</NavItem>
|
||||
<NavItem routeName="Search" href="/search">
|
||||
{({isActive}) => {
|
||||
const Icon = isActive ? MagnifyingGlassFilled : MagnifyingGlass
|
||||
return (
|
||||
<Icon
|
||||
aria-hidden={true}
|
||||
width={iconWidth + 2}
|
||||
style={[styles.ctrlIcon, t.atoms.text, styles.searchIcon]}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
</NavItem>
|
||||
|
||||
{hasSession && (
|
||||
<>
|
||||
<NavItem
|
||||
routeName="Messages"
|
||||
href="/messages"
|
||||
notificationCount={unreadMessageCount.numUnread}
|
||||
hasNew={unreadMessageCount.hasNew}>
|
||||
{({isActive}) => {
|
||||
const Icon = isActive ? MessageFilled : Message
|
||||
return (
|
||||
<Icon
|
||||
aria-hidden={true}
|
||||
width={iconWidth - 1}
|
||||
style={[
|
||||
styles.ctrlIcon,
|
||||
t.atoms.text,
|
||||
styles.messagesIcon,
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
</NavItem>
|
||||
<NavItem
|
||||
routeName="Notifications"
|
||||
href="/notifications"
|
||||
notificationCount={notificationCountStr}>
|
||||
{({isActive}) => {
|
||||
const Icon = isActive ? BellFilled : Bell
|
||||
return (
|
||||
<Icon
|
||||
aria-hidden={true}
|
||||
width={iconWidth}
|
||||
style={[styles.ctrlIcon, t.atoms.text, styles.bellIcon]}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
</NavItem>
|
||||
<NavItem
|
||||
routeName="Profile"
|
||||
href={
|
||||
currentAccount
|
||||
? makeProfileLink({
|
||||
did: currentAccount.did,
|
||||
handle: currentAccount.handle,
|
||||
})
|
||||
: '/'
|
||||
}
|
||||
onLongPress={onLongPressProfile}>
|
||||
{({isActive}) => (
|
||||
<View style={styles.ctrlIconSizingWrapper}>
|
||||
<View
|
||||
style={[
|
||||
styles.ctrlIcon,
|
||||
styles.profileIcon,
|
||||
isActive && [
|
||||
styles.onProfile,
|
||||
{borderColor: t.atoms.text.color},
|
||||
],
|
||||
]}>
|
||||
<UserAvatar
|
||||
avatar={profile?.avatar}
|
||||
size={iconWidth - 3}
|
||||
type={
|
||||
profile?.associated?.labeler ? 'labeler' : 'user'
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</NavItem>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<View
|
||||
style={[
|
||||
a.w_full,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.justify_between,
|
||||
a.gap_sm,
|
||||
{
|
||||
paddingTop: 14,
|
||||
paddingBottom: 14,
|
||||
paddingLeft: 14,
|
||||
paddingRight: 6,
|
||||
},
|
||||
]}>
|
||||
<View style={[a.flex_row, a.align_center, a.gap_md]}>
|
||||
<Logo width={32} />
|
||||
<View style={{paddingTop: 4}}>
|
||||
<Logotype width={80} fill={t.atoms.text.color} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={[a.flex_row, a.flex_wrap, a.gap_sm]}>
|
||||
<Button
|
||||
onPress={showCreateAccount}
|
||||
label={_(msg`Create account`)}
|
||||
size="small"
|
||||
variant="solid"
|
||||
color="primary">
|
||||
<ButtonText>
|
||||
<Trans>Create account</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
<Button
|
||||
onPress={showSignIn}
|
||||
label={_(msg`Sign in`)}
|
||||
size="small"
|
||||
variant="solid"
|
||||
color="secondary">
|
||||
<ButtonText>
|
||||
<Trans>Sign in</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
{hasSession && (
|
||||
<>
|
||||
<NavItem
|
||||
routeName="Messages"
|
||||
href="/messages"
|
||||
notificationCount={unreadMessageCount.numUnread}
|
||||
hasNew={unreadMessageCount.hasNew}>
|
||||
{({isActive}) => {
|
||||
const Icon = isActive ? MessageFilled : Message
|
||||
return (
|
||||
<Icon
|
||||
aria-hidden={true}
|
||||
width={iconWidth - 1}
|
||||
style={[
|
||||
styles.ctrlIcon,
|
||||
t.atoms.text,
|
||||
styles.messagesIcon,
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
</NavItem>
|
||||
<NavItem
|
||||
routeName="Notifications"
|
||||
href="/notifications"
|
||||
notificationCount={notificationCountStr}>
|
||||
{({isActive}) => {
|
||||
const Icon = isActive ? BellFilled : Bell
|
||||
return (
|
||||
<Icon
|
||||
aria-hidden={true}
|
||||
width={iconWidth}
|
||||
style={[styles.ctrlIcon, t.atoms.text, styles.bellIcon]}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
</NavItem>
|
||||
<NavItem
|
||||
routeName="Profile"
|
||||
href={
|
||||
currentAccount
|
||||
? makeProfileLink({
|
||||
did: currentAccount.did,
|
||||
handle: currentAccount.handle,
|
||||
})
|
||||
: '/'
|
||||
}>
|
||||
{({isActive}) => {
|
||||
const Icon = isActive ? UserCircleFilled : UserCircle
|
||||
return (
|
||||
<Icon
|
||||
aria-hidden={true}
|
||||
width={iconWidth}
|
||||
style={[
|
||||
styles.ctrlIcon,
|
||||
t.atoms.text,
|
||||
styles.profileIcon,
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
</NavItem>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<View
|
||||
style={{
|
||||
width: '100%',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingTop: 14,
|
||||
paddingBottom: 14,
|
||||
paddingLeft: 14,
|
||||
paddingRight: 6,
|
||||
gap: 8,
|
||||
}}>
|
||||
<View style={{flexDirection: 'row', alignItems: 'center', gap: 12}}>
|
||||
<Logo width={32} />
|
||||
<View style={{paddingTop: 4}}>
|
||||
<Logotype width={80} fill={t.atoms.text.color} />
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</Animated.View>
|
||||
</>
|
||||
|
||||
<View style={[a.flex_row, a.flex_wrap, a.gap_sm]}>
|
||||
<Button
|
||||
onPress={showCreateAccount}
|
||||
label={_(msg`Create account`)}
|
||||
size="small"
|
||||
variant="solid"
|
||||
color="primary">
|
||||
<ButtonText>
|
||||
<Trans>Create account</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
<Button
|
||||
onPress={showSignIn}
|
||||
label={_(msg`Sign in`)}
|
||||
size="small"
|
||||
variant="solid"
|
||||
color="secondary">
|
||||
<ButtonText>
|
||||
<Trans>Sign in</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</Animated.View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -250,8 +230,7 @@ const NavItem: React.FC<{
|
||||
routeName: string
|
||||
hasNew?: boolean
|
||||
notificationCount?: string
|
||||
onLongPress?: () => void
|
||||
}> = ({children, href, routeName, hasNew, notificationCount, onLongPress}) => {
|
||||
}> = ({children, href, routeName, hasNew, notificationCount}) => {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {currentAccount} = useSession()
|
||||
@@ -285,8 +264,7 @@ const NavItem: React.FC<{
|
||||
navigationAction={isOnDifferentProfile ? 'push' : 'navigate'}
|
||||
aria-role="link"
|
||||
aria-label={routeName}
|
||||
accessible={true}
|
||||
onLongPress={onLongPress}>
|
||||
accessible={true}>
|
||||
{children({isActive})}
|
||||
{notificationCount ? (
|
||||
<View
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user