Compare commits

...

26 Commits

Author SHA1 Message Date
Eric Bailey 8cf21d56cf Update android splash colors 2026-02-04 13:54:06 -06:00
Eric Bailey 546201c93c intl pull and extract:all (#9818) 2026-02-04 13:14:22 -06:00
Samuel Newman b3cbb3440a Make GIFs look like GIFs (#9809)
* set presentation field in record

* refer to it as a gif in the composer

* video player gif presentation style

* tweak badge

* only do the "manual loop" when absolutely necessary

* mute gifs

* reuse gif controls component for tenor gifs

* update media previews in notifications

* edit comment

* remove outdated prop
2026-02-04 13:05:17 -06:00
Samuel Newman da5c356fc7 Only treat animated gifs as videos, leave static gifs as images (#9814)
* only treat *animated* gifs as videos

* fix export
2026-02-04 13:00:29 -06:00
Eric Bailey 6f01503eac intl pull and extract:all (#9817) 2026-02-04 12:52:39 -06:00
pfrazee 24dbe799dc Nightly source-language update 2026-02-04 03:06:15 +00:00
Samuel Newman 3197cbcf36 disable saving drafts for replies (#9815) 2026-02-03 09:32:13 -08:00
Samuel Newman 017120b3e8 Add recId to suggestedUser:* events (#9764)
* add recId to onboarding

* add recId to follow dialog

* add recId to profile header suggestions

* add recId to feed interstitials, fix animation on native

* fix claude feedback

* fix yarn.lock ci
2026-02-03 10:42:12 -06:00
Samuel Newman 27d9462639 Add animation to content hider (#9812)
* add layout animation to contenthider

* add layout animation to posthider
2026-02-03 08:03:36 -08:00
Samuel Newman b2c95bc35a remove superjson (#9811) 2026-02-03 07:52:19 -08:00
pfrazee cbd48c855a Nightly source-language update 2026-02-03 03:09:12 +00:00
Samuel Newman 421c18ea7c fix image patch version (#9810) 2026-02-02 15:35:04 -08:00
Samuel Newman 26c84399dd Add light haptic feedback to Edit Profile button (#9787)
https://claude.ai/code/session_018ZjPcCUDPfTjQ5SGac3Zgs

Co-authored-by: Claude <noreply@anthropic.com>
2026-02-02 15:33:55 -08:00
Samuel Newman 4f1d4821c5 Speed up startup by persisting some queries (#9594)
* persist startup queries

* Use IDB for query storage (#9687)

* Add storage abstraction for persisted query data

Introduce a platform-specific storage abstraction layer for react-query
persistence:
- Native: Uses MMKV for high-performance synchronous storage
- Web: Uses IndexedDB via the `idb` library for efficient async storage

This replaces the previous AsyncStorage implementation with more performant
platform-native solutions. The abstraction maintains API compatibility with
@tanstack/query-async-storage-persister.

* Refactor storage abstraction to use factory pattern

Change createPersistedQueryStorage to a factory function that accepts a
storage ID, allowing multiple isolated storage instances:
- Native: Each instance gets its own MMKV store
- Web: Each instance gets its own IndexedDB database

Adopt the factory pattern in:
- react-query.tsx: Uses 'persisted_queries' storage
- ageAssurance/data.tsx: Uses 'age_assurance' storage

This provides better separation between different query client caches
and allows each to be managed independently.

---------

Co-authored-by: Claude <noreply@anthropic.com>

* Refactor to use archival storage

(cherry picked from commit a773b40e41c96f821cd32260919ce1437c0fc3ab)

* Improve archive db types

(cherry picked from commit 80e4959ba2aa00c984c26aed2f7dfae1095720b0)

* rm idb

* clear on logout, bust on app version

* create abstraction for persisting queries, make gcTime infinite

* Rm abstraction

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Eric Bailey <git@esb.lol>
2026-02-02 11:55:01 -06:00
mozzius bfd5ed480f Nightly source-language update 2026-02-01 10:24:34 +00:00
Samuel Newman d9ef02737b Rename "lightbox" to "image viewer" in user-facing strings (#9807)
https://claude.ai/code/session_01LWKpUAY5WJL7UfeFJd9kVU

Co-authored-by: Claude <noreply@anthropic.com>
2026-02-01 12:21:15 +02:00
mozzius 809b318fe4 Nightly source-language update 2026-02-01 08:10:57 +00:00
Samuel Newman 864af57fd9 Fix i18n string interpolation for device name (#9806)
Use a separate translatable string for "Media stored on another device"
instead of interpolating a translated fragment into another string.
This allows translators to properly handle the complete sentence for
languages with different grammar structures.

https://claude.ai/code/session_01TJMLcXE9HHneEXMEBJqL4u

Co-authored-by: Claude <noreply@anthropic.com>
2026-02-01 00:08:28 -08:00
Eric Bailey ab98130295 Rm entitlement for now (#9804) 2026-01-30 20:30:00 -06:00
estrattonbailey a168e97d07 Nightly source-language update 2026-01-31 02:20:20 +00:00
Eric Bailey 748706daab Draft previews (#9803)
* Send deviceId and platform

* Add deviceId and deviceName to drafts, skip loading media for other devies

* WIP new preview

* show rich text in drafts list

(cherry picked from commit fb70d53d59)

* New draft preview UI

* Tighten up spacing in draft list

* Add i18n comments

---------

Co-authored-by: Samuel Newman <mozzius@protonmail.com>
2026-01-30 17:00:54 -06:00
Spence Pope 45d6e50eb4 lint (#9802) 2026-01-30 22:48:31 +02:00
Eric Bailey e2a56b0191 Drafts fixes (#9795)
* Allow saving quote post drafts

* Remove warning logs for failed media loading

* Fix threadgates
2026-01-30 10:26:32 -06:00
Samuel Newman 1eb09b7210 Detect facets when composer is opened with mention (#9625)
* highlight the initial mention

* fix mention initalisation on web
2026-01-30 10:14:37 -06:00
Samuel Newman ee99320e1f fix copy-to-cache (#9799) 2026-01-30 09:47:35 -06:00
Samuel Newman 9689a6daaa add 2px margin below post text (#9748) 2026-01-30 05:44:04 -08:00
100 changed files with 29369 additions and 23491 deletions
+5 -4
View File
@@ -119,6 +119,7 @@ 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: [
@@ -312,22 +313,22 @@ module.exports = function (_config) {
{
ios: {
enableFullScreenImage_legacy: true, // iOS only
backgroundColor: '#A8CCFF', // primary_200
backgroundColor: '#006AFF', // primary_500
image: './assets/splash/splash.png',
resizeMode: 'cover',
dark: {
enableFullScreenImage_legacy: true, // iOS only
backgroundColor: '#00398A', // primary_800
backgroundColor: '#002861', // primary_900
image: './assets/splash/splash-dark.png',
resizeMode: 'cover',
},
},
android: {
backgroundColor: '#A8CCFF', // primary_200
backgroundColor: '#006AFF', // primary_500
image: './assets/splash/android-splash-logo-white.png',
imageWidth: 102, // even division of 306px
dark: {
backgroundColor: '#00398A', // primary_800
backgroundColor: '#002861', // primary_900
image: './assets/splash/android-splash-logo-white.png',
imageWidth: 102,
},
+2 -2
View File
@@ -73,7 +73,7 @@
"icons:optimize": "svgo -f ./assets/icons"
},
"dependencies": {
"@atproto/api": "^0.18.18",
"@atproto/api": "^0.18.20",
"@bitdrift/react-native": "^0.6.8",
"@braintree/sanitize-url": "^6.0.2",
"@bsky.app/alf": "^0.1.6",
@@ -229,7 +229,7 @@
"zod": "^3.20.2"
},
"devDependencies": {
"@atproto/dev-env": "^0.3.206",
"@atproto/dev-env": "^0.3.208",
"@babel/core": "^7.26.0",
"@babel/preset-env": "^7.26.0",
"@babel/runtime": "^7.26.0",
+2 -1
View File
@@ -146,7 +146,8 @@ export function Splash(props: React.PropsWithChildren<Props>) {
withTiming(
1,
{duration: 400, easing: Easing.out(Easing.cubic)},
async () => {
() => {
'worklet'
// set these values to check animation at specific point
outroLogo.set(() =>
withTiming(
+2 -2
View File
@@ -6,7 +6,6 @@ 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'
@@ -14,6 +13,7 @@ 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: AsyncStorage,
storage: createPersistedQueryStorage('age-assurance'),
key: 'age-assurance-query-client',
})
const [, cacheHydrationPromise] = persistQueryClient({
+5 -5
View File
@@ -467,8 +467,8 @@ export type Events = {
| 'InterstitialProfile'
| 'Profile'
| 'Onboarding'
location: 'Card' | 'Profile'
recId?: number
location: 'Card' | 'Profile' | 'FollowAll'
recId?: number | string
position: number
suggestedDid: string
category: string | null
@@ -479,7 +479,7 @@ export type Events = {
| 'InterstitialDiscover'
| 'InterstitialProfile'
| 'Onboarding'
recId?: number
recId?: number | string
position: number
suggestedDid: string
category: string | null
@@ -492,7 +492,7 @@ export type Events = {
| 'Profile'
| 'Onboarding'
| 'ProgressGuide'
recId?: number
recId?: number | string
position: number
suggestedDid: string
category: string | null
@@ -507,7 +507,7 @@ export type Events = {
}
'suggestedUser:dismiss': {
logContext: 'InterstitialDiscover' | 'InterstitialProfile'
recId?: number
recId?: number | string
position: number
suggestedDid: string
}
+101 -120
View File
@@ -1,6 +1,12 @@
import React, {useCallback, useEffect, useRef} from 'react'
import {useCallback, useEffect, useMemo, useRef, useState} from 'react'
import {ScrollView, View} from 'react-native'
import Animated, {LinearTransition} from 'react-native-reanimated'
import Animated, {
Easing,
FadeIn,
FadeOut,
LayoutAnimationConfig,
LinearTransition,
} from 'react-native-reanimated'
import {type AppBskyFeedDefs, AtUri} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
@@ -21,6 +27,7 @@ import {type SeenPost} from '#/state/userActionHistory'
import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture'
import {
atoms as a,
native,
useBreakpoints,
useTheme,
type ViewStyleProp,
@@ -152,7 +159,7 @@ function sortSeenPosts(postA: SeenPost, postB: SeenPost): 0 | 1 | -1 {
function useExperimentalSuggestedUsersQuery() {
const {currentAccount} = useSession()
const userActionSnapshot = userActionHistory.useActionHistorySnapshot()
const dids = React.useMemo(() => {
const dids = useMemo(() => {
const {likes, follows, followSuggestions, seen} = userActionSnapshot
const likeDids = likes
.map(l => new AtUri(l))
@@ -225,67 +232,54 @@ export function SuggestedFollowsProfile({did}: {did: string}) {
isFetchingNextPage,
} = useSuggestedFollowsQuery({limit: 25})
const [dismissedDids, setDismissedDids] = React.useState<Set<string>>(
new Set(),
)
const [dismissingDids, setDismissingDids] = React.useState<Set<string>>(
new Set(),
)
const [dismissedDids, setDismissedDids] = useState<Set<string>>(new Set())
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)
const onDismiss = useCallback((dismissedDid: string) => {
setDismissedDids(prev => new Set(prev).add(dismissedDid))
}, [])
// Combine profiles from the actor-specific query with fallback suggestions
const allProfiles = React.useMemo(() => {
const allProfiles = useMemo(() => {
const actorProfiles = data?.suggestions ?? []
const fallbackProfiles =
moreSuggestions?.pages.flatMap(page => page.actors) ?? []
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: bsky.profile.AnyProfileView[] = []
const combined: {actor: bsky.profile.AnyProfileView; recId?: number}[] = []
for (const profile of actorProfiles) {
if (!seen.has(profile.did)) {
seen.add(profile.did)
combined.push(profile)
combined.push({actor: profile, recId: data?.recId})
}
}
for (const profile of fallbackProfiles) {
if (!seen.has(profile.did) && profile.did !== did) {
seen.add(profile.did)
if (!seen.has(profile.actor.did) && profile.actor.did !== did) {
seen.add(profile.actor.did)
combined.push(profile)
}
}
return combined
}, [data?.suggestions, moreSuggestions?.pages, did])
}, [data?.suggestions, moreSuggestions?.pages, did, data?.recId])
const filteredProfiles = React.useMemo(() => {
return allProfiles.filter(p => !dismissedDids.has(p.did))
const filteredProfiles = useMemo(() => {
return allProfiles.filter(p => !dismissedDids.has(p.actor.did))
}, [allProfiles, dismissedDids])
// Fetch more when running low
React.useEffect(() => {
useEffect(() => {
if (
moderationOpts &&
filteredProfiles.length < maxLength &&
hasNextPage &&
!isFetchingNextPage
) {
fetchNextPage()
void fetchNextPage()
}
}, [
filteredProfiles.length,
@@ -301,11 +295,9 @@ export function SuggestedFollowsProfile({did}: {did: string}) {
isSuggestionsLoading={isSuggestionsLoading}
profiles={filteredProfiles}
totalProfileCount={allProfiles.length}
recId={data?.recId}
error={error}
viewContext="profile"
onDismiss={onDismiss}
dismissingDids={dismissingDids}
/>
)
}
@@ -327,46 +319,36 @@ export function SuggestedFollowsHome() {
error: suggestionsError,
} = useSuggestedFollowsQuery({limit: 25})
const [dismissedDids, setDismissedDids] = React.useState<Set<string>>(
new Set(),
)
const [dismissingDids, setDismissingDids] = React.useState<Set<string>>(
new Set(),
)
const [dismissedDids, setDismissedDids] = 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)
const onDismiss = useCallback((did: string) => {
setDismissedDids(prev => new Set(prev).add(did))
}, [])
// Combine profiles from experimental query with paginated suggestions
const allProfiles = React.useMemo(() => {
const allProfiles = useMemo(() => {
const fallbackProfiles =
moreSuggestions?.pages.flatMap(page => page.actors) ?? []
moreSuggestions?.pages.flatMap(page =>
page.actors.map(actor => ({actor, recId: page.recId})),
) ?? []
// Dedupe by did, preferring experimental profiles
const seen = new Set<string>()
const combined: bsky.profile.AnyProfileView[] = []
const combined: Array<{
actor: bsky.profile.AnyProfileView
recId?: number
}> = []
for (const profile of experimentalProfiles) {
if (!seen.has(profile.did)) {
seen.add(profile.did)
combined.push(profile)
combined.push({actor: profile, recId: undefined})
}
}
for (const profile of fallbackProfiles) {
if (!seen.has(profile.did)) {
seen.add(profile.did)
if (!seen.has(profile.actor.did)) {
seen.add(profile.actor.did)
combined.push(profile)
}
}
@@ -374,19 +356,19 @@ export function SuggestedFollowsHome() {
return combined
}, [experimentalProfiles, moreSuggestions?.pages])
const filteredProfiles = React.useMemo(() => {
return allProfiles.filter(p => !dismissedDids.has(p.did))
const filteredProfiles = useMemo(() => {
return allProfiles.filter(p => !dismissedDids.has(p.actor.did))
}, [allProfiles, dismissedDids])
// Fetch more when running low
React.useEffect(() => {
useEffect(() => {
if (
moderationOpts &&
filteredProfiles.length < maxLength &&
hasNextPage &&
!isFetchingNextPage
) {
fetchNextPage()
void fetchNextPage()
}
}, [
filteredProfiles.length,
@@ -405,7 +387,6 @@ export function SuggestedFollowsHome() {
error={experimentalError || suggestionsError}
viewContext="feed"
onDismiss={onDismiss}
dismissingDids={dismissingDids}
/>
)
}
@@ -415,18 +396,14 @@ export function ProfileGrid({
error,
profiles,
totalProfileCount,
recId,
viewContext = 'feed',
onDismiss,
dismissingDids,
isVisible = true,
}: {
isSuggestionsLoading: boolean
profiles: bsky.profile.AnyProfileView[]
profiles: {actor: bsky.profile.AnyProfileView; recId?: number}[]
totalProfileCount?: number
recId?: number
error: Error | null
dismissingDids?: Set<string>
viewContext: 'profile' | 'profileHeader' | 'feed'
onDismiss?: (did: string) => void
isVisible?: boolean
@@ -463,18 +440,18 @@ export function ProfileGrid({
const profilesToShow = profiles.slice(0, maxLength)
profilesToShow.forEach((profile, index) => {
if (!seenProfilesRef.current.has(profile.did)) {
seenProfilesRef.current.add(profile.did)
if (!seenProfilesRef.current.has(profile.actor.did)) {
seenProfilesRef.current.add(profile.actor.did)
ax.metric('suggestedUser:seen', {
logContext,
recId,
recId: profile.recId,
position: index,
suggestedDid: profile.did,
suggestedDid: profile.actor.did,
category: null,
})
}
})
}, [ax, isLoading, error, profiles, maxLength, logContext, recId])
}, [ax, isLoading, error, profiles, maxLength, logContext])
// For profile header, fire when isVisible becomes true
useEffect(() => {
@@ -540,8 +517,15 @@ export function ProfileGrid({
? null
: profiles.slice(0, maxLength).map((profile, index) => (
<Animated.View
key={profile.did}
layout={LinearTransition.duration(DISMISS_ANIMATION_DURATION)}
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))}
style={[
a.flex_1,
gtMobile &&
@@ -550,22 +534,17 @@ 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}
profile={profile.actor}
onPress={() => {
ax.metric('suggestedUser:press', {
logContext: isFeedContext
? 'InterstitialDiscover'
: 'InterstitialProfile',
recId,
recId: profile.recId,
position: index,
suggestedDid: profile.did,
suggestedDid: profile.actor.did,
category: null,
})
}}
@@ -581,14 +560,14 @@ export function ProfileGrid({
label={_(msg`Dismiss this suggestion`)}
onPress={e => {
e.preventDefault()
onDismiss(profile.did)
onDismiss(profile.actor.did)
ax.metric('suggestedUser:dismiss', {
logContext: isFeedContext
? 'InterstitialDiscover'
: 'InterstitialProfile',
position: index,
suggestedDid: profile.did,
recId,
suggestedDid: profile.actor.did,
recId: profile.recId,
})
}}
style={[
@@ -621,18 +600,18 @@ export function ProfileGrid({
a.mb_auto,
]}>
<ProfileCard.Avatar
profile={profile}
profile={profile.actor}
moderationOpts={moderationOpts}
disabledPreview
size={88}
/>
<View style={[a.flex_col, a.align_center, a.max_w_full]}>
<ProfileCard.Name
profile={profile}
profile={profile.actor}
moderationOpts={moderationOpts}
/>
<ProfileCard.Description
profile={profile}
profile={profile.actor}
numberOfLines={2}
style={[
t.atoms.text_contrast_medium,
@@ -644,7 +623,7 @@ export function ProfileGrid({
</View>
<ProfileCard.FollowButton
profile={profile}
profile={profile.actor}
moderationOpts={moderationOpts}
logContext="FeedInterstitial"
withIcon={false}
@@ -655,9 +634,9 @@ export function ProfileGrid({
? 'InterstitialDiscover'
: 'InterstitialProfile',
location: 'Card',
recId,
recId: profile.recId,
position: index,
suggestedDid: profile.did,
suggestedDid: profile.actor.did,
category: null,
})
}}
@@ -726,35 +705,37 @@ export function ProfileGrid({
<FollowDialogWithoutGuide control={followDialogControl} />
{gtMobile ? (
<View style={[a.p_lg, a.pt_md]}>
<View style={[a.flex_1, a.flex_row, a.flex_wrap, a.gap_md]}>
{content}
<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>
</View>
</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}
) : (
<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>
)}
{!isProfileHeaderContext && (
<SeeMoreSuggestedProfilesCard
onPress={() => {
followDialogControl.open()
ax.metric('suggestedUser:seeMore', {
logContext: 'Explore',
})
}}
/>
)}
</ScrollView>
</BlockDrawerGesture>
)}
</LayoutAnimationConfig>
</View>
)
}
@@ -795,7 +776,7 @@ export function SuggestedFeeds() {
const navigation = useNavigation<NavigationProp>()
const {gtMobile} = useBreakpoints()
const feeds = React.useMemo(() => {
const feeds = useMemo(() => {
const items: AppBskyFeedDefs.GeneratorView[] = []
if (!data) return items
+26 -20
View File
@@ -50,7 +50,11 @@ export function Embed({
} else if (e.type === 'video') {
return (
<Outer style={style}>
<VideoItem thumbnail={e.view.thumbnail} alt={e.view.alt} />
{e.view.presentation === 'gif' ? (
<GifItem thumbnail={e.view.thumbnail} alt={e.view.alt} />
) : (
<VideoItem thumbnail={e.view.thumbnail} alt={e.view.alt} />
)}
</Outer>
)
} else if (
@@ -81,11 +85,29 @@ 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
@@ -103,7 +125,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]}>
@@ -125,22 +147,6 @@ 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]}>
@@ -157,7 +163,7 @@ const styles = StyleSheet.create({
paddingHorizontal: 6,
paddingVertical: 3,
position: 'absolute',
right: 5,
left: 5,
bottom: 5,
zIndex: 2,
},
@@ -1,6 +1,5 @@
import {useRef, useState} from 'react'
import {
Pressable,
type StyleProp,
StyleSheet,
TouchableOpacity,
@@ -17,60 +16,13 @@ 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 {Loader} from '#/components/Loader'
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
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'
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>
)
}
import {GifPresentationControls} from '../VideoEmbed/GifPresentationControls'
export function GifEmbed({
params,
@@ -120,8 +72,6 @@ export function GifEmbed({
style={[
a.rounded_md,
a.overflow_hidden,
a.border,
t.atoms.border_contrast_low,
{backgroundColor: t.palette.black},
{aspectRatio},
style,
@@ -139,10 +89,11 @@ export function GifEmbed({
right: -2,
},
]}>
<PlaybackControls
<MediaInsetBorder />
<GifPresentationControls
onPress={onPress}
isPlaying={playerState.isPlaying}
isLoaded={playerState.isLoaded}
isLoading={!playerState.isLoaded}
/>
<GifView
source={params.playerUri}
@@ -0,0 +1,78 @@
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,6 +15,7 @@ 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({
@@ -50,12 +51,14 @@ 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={autoplayDisabled ? false : muted}
beginMuted={isGif || autoplayDisabled ? false : muted}
style={[a.rounded_sm]}
onActiveChange={e => {
setIsActive(e.nativeEvent.isActive)
@@ -82,25 +85,35 @@ export function VideoEmbedInnerNative({
}
accessibilityHint=""
/>
<VideoControls
enterFullscreen={() => {
videoRef.current?.enterFullscreen(true)
}}
toggleMuted={() => {
videoRef.current?.toggleMuted()
}}
togglePlayback={() => {
videoRef.current?.togglePlayback()
}}
isPlaying={isPlaying}
timeRemaining={timeRemaining}
/>
{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}
/>
)}
<MediaInsetBorder />
</View>
)
}
function VideoControls({
function VideoPresentationControls({
enterFullscreen,
toggleMuted,
togglePlayback,
@@ -21,7 +21,7 @@ export function VideoEmbedInnerWeb({
active: boolean
setActive: () => void
onScreen: boolean
lastKnownTime: React.MutableRefObject<number | undefined>
lastKnownTime: React.RefObject<number | undefined>
}) {
const containerRef = useRef<HTMLDivElement>(null)
const videoRef = useRef<HTMLVideoElement>(null)
@@ -37,7 +37,7 @@ export function VideoEmbedInnerWeb({
throw error
}
const hlsRef = useHLS({
const {hlsRef, loop} = useHLS({
playlist: embed.playlist,
setHasSubtitleTrack,
setError,
@@ -64,11 +64,12 @@ export function VideoEmbedInnerWeb({
style={{width: '100%', height: '100%', objectFit: 'contain'}}
playsInline
preload="none"
muted={!focused}
muted={embed.presentation === 'gif' || !focused}
aria-labelledby={embed.alt ? figId : undefined}
onTimeUpdate={e => {
lastKnownTime.current = e.currentTarget.currentTime
}}
loop={loop}
/>
{embed.alt && (
<figcaption
@@ -99,6 +100,7 @@ export function VideoEmbedInnerWeb({
onScreen={onScreen}
fullscreenRef={containerRef}
hasSubtitleTrack={hasSubtitleTrack}
isGif={embed.presentation === 'gif'}
/>
</div>
</View>
@@ -192,29 +194,6 @@ 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
@@ -242,20 +221,6 @@ 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)
})
@@ -293,17 +258,65 @@ 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()
}
}, [
playlist,
setError,
setHasSubtitleTrack,
videoRef,
handleFragChange,
flushOnLoop,
Hls,
])
}, [videoRef, flushOnLoop, hasLowQualityFragmentAtStart])
return hlsRef
return {
hlsRef,
loop: !hasLowQualityFragmentAtStart,
}
}
@@ -27,6 +27,7 @@ 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'
@@ -44,6 +45,7 @@ export function Controls({
fullscreenRef,
hlsLoading,
hasSubtitleTrack,
isGif,
}: {
videoRef: React.RefObject<HTMLVideoElement | null>
hlsRef: React.RefObject<Hls | undefined | null>
@@ -55,6 +57,7 @@ export function Controls({
fullscreenRef: React.RefObject<HTMLDivElement | null>
hlsLoading: boolean
hasSubtitleTrack: boolean
isGif: boolean
}) {
const {
play,
@@ -287,6 +290,16 @@ export function Controls({
((focused || autoplayDisabled) && !playing) ||
(interactingViaKeypress ? hasFocus : hovered)
if (isGif) {
return (
<GifPresentationControls
isPlaying={playing}
isLoading={showSpinner}
onPress={onPressPlayPause}
/>
)
}
return (
<div
style={{
+20 -15
View File
@@ -9,6 +9,7 @@ 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'
@@ -207,6 +208,15 @@ function DialogInner({guide}: {guide?: Follow10ProgressGuide}) {
}
}
if (
hasSearchText &&
!isFetchingSearchResults &&
!_items.length &&
!isSearchResultsError
) {
_items.push({type: 'empty', key: 'empty', message: _(msg`No results`)})
}
return _items
}, [
_,
@@ -219,17 +229,9 @@ 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) {
@@ -262,7 +264,7 @@ function DialogInner({guide}: {guide?: Follow10ProgressGuide}) {
const selectedInterestRef = useRef(selectedInterest)
selectedInterestRef.current = selectedInterest
const onViewableItemsChanged = useRef(
const onViewableItemsChanged = useNonReactiveCallback(
({viewableItems}: {viewableItems: ViewToken[]}) => {
for (const viewableItem of viewableItems) {
const item = viewableItem.item as Item
@@ -274,7 +276,7 @@ function DialogInner({guide}: {guide?: Follow10ProgressGuide}) {
)
ax.metric('suggestedUser:seen', {
logContext: 'ProgressGuide',
recId: undefined,
recId: hasSearchText ? undefined : suggestions?.recId,
position: position !== -1 ? position : 0,
suggestedDid: item.profile.did,
category: selectedInterestRef.current,
@@ -283,10 +285,13 @@ function DialogInner({guide}: {guide?: Follow10ProgressGuide}) {
}
}
},
).current
const viewabilityConfig = useRef({
itemVisiblePercentThreshold: 50,
}).current
)
const viewabilityConfig = useMemo(
() => ({
itemVisiblePercentThreshold: 50,
}),
[],
)
const onSelectTab = useCallback(
(interest: string) => {
+24 -7
View File
@@ -1,4 +1,4 @@
import React from 'react'
import {useMemo} from 'react'
import {type StyleProp, type TextStyle} from 'react-native'
import {AppBskyRichtextFacet, RichText as RichTextAPI} from '@atproto/api'
@@ -27,6 +27,16 @@ 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({
@@ -44,12 +54,17 @@ export function RichText({
onLayout,
onTextLayout,
shouldProxyLinks,
disableMentionFacetValidation,
}: RichTextProps) {
const richText = React.useMemo(
() =>
value instanceof RichTextAPI ? value : new RichTextAPI({text: value}),
[value],
)
const richText = useMemo(() => {
if (value instanceof RichTextAPI) {
return value
} else {
const rt = new RichTextAPI({text: value})
rt.detectFacetsWithoutResolution()
return rt
}
}, [value])
const plainStyles = [a.leading_snug, style]
const interactiveStyles = [plainStyles, interactiveStyle]
@@ -98,9 +113,11 @@ export function RichText({
const link = segment.link
const mention = segment.mention
const tag = segment.tag
if (
mention &&
AppBskyRichtextFacet.validateMention(mention).success &&
(disableMentionFacetValidation ||
AppBskyRichtextFacet.validateMention(mention).success) &&
!disableLinks
) {
els.push(
+13 -6
View File
@@ -1,5 +1,10 @@
import React from 'react'
import {type StyleProp, View, type ViewStyle} from 'react-native'
import {useMemo, useState} from 'react'
import {
LayoutAnimation,
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'
@@ -64,16 +69,17 @@ 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] = React.useState(false)
const [override, setOverride] = useState(false)
const control = useModerationDetailsDialogControl()
const {labelDefs} = useLabelDefinitions()
const globalLabelStrings = useGlobalLabelStrings()
@@ -81,7 +87,7 @@ function ContentHiderActive({
const blur = modui?.blurs[0]
const desc = useModerationCauseDescription(blur)
const labelName = React.useMemo(() => {
const labelName = useMemo(() => {
if (!modui?.blurs || !blur) {
return undefined
}
@@ -150,6 +156,7 @@ function ContentHiderActive({
e.preventDefault()
e.stopPropagation()
if (!modui.noOverride) {
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
setOverride(v => !v)
} else {
control.open()
+10 -8
View File
@@ -1,5 +1,6 @@
import React, {type ComponentProps} from 'react'
import {useCallback, useState} from 'react'
import {
LayoutAnimation,
Pressable,
type StyleProp,
StyleSheet,
@@ -17,7 +18,7 @@ import {useQueryClient} from '@tanstack/react-query'
import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription'
import {addStyle} from '#/lib/styles'
import {precacheProfile} from '#/state/queries/profile'
import {unstableCacheProfileView} from '#/state/queries/unstable-profile-cache'
// 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'
@@ -27,7 +28,7 @@ import {
} from '#/components/moderation/ModerationDetailsDialog'
import {Text} from '#/components/Typography'
interface Props extends ComponentProps<typeof Link> {
interface Props extends React.ComponentProps<typeof Link> {
disabled: boolean
iconSize: number
iconStyles: StyleProp<ViewStyle>
@@ -54,15 +55,15 @@ export function PostHider({
const queryClient = useQueryClient()
const t = useTheme()
const {_} = useLingui()
const [override, setOverride] = React.useState(false)
const [override, setOverride] = useState(false)
const control = useModerationDetailsDialogControl()
const blur =
modui.blurs[0] ||
(interpretFilterAsBlur ? getBlurrableFilter(modui) : undefined)
const desc = useModerationCauseDescription(blur)
const onBeforePress = React.useCallback(() => {
precacheProfile(queryClient, profile)
const onBeforePress = useCallback(() => {
unstableCacheProfileView(queryClient, profile)
}, [queryClient, profile])
if (!blur || (disabled && !modui.noOverride)) {
@@ -83,14 +84,15 @@ export function PostHider({
<Pressable
onPress={() => {
if (!modui.noOverride) {
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
setOverride(v => !v)
}
}}
accessibilityRole="button"
accessibilityHint={
accessibilityLabel={
override ? _(msg`Hides the content`) : _(msg`Shows the content`)
}
accessibilityLabel=""
accessibilityHint=""
style={[
a.flex_row,
a.align_center,
@@ -0,0 +1,87 @@
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')
})
})
})
+2
View File
@@ -354,6 +354,8 @@ 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') {
+18
View File
@@ -0,0 +1,18 @@
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
}
}
+41
View File
@@ -0,0 +1,41 @@
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()
}
+11 -9
View File
@@ -1,27 +1,26 @@
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()
@@ -138,7 +137,8 @@ const dehydrateOptions: PersistQueryClientProviderProps['persistOptions']['dehyd
{
shouldDehydrateMutation: (_: any) => false,
shouldDehydrateQuery: query => {
return STORED_CACHE_QUERY_KEY_ROOTS.includes(String(query.queryKey[0]))
const root = String(query.queryKey[0])
return root === PERSISTED_QUERY_ROOT
},
}
@@ -177,14 +177,16 @@ 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: AsyncStorage,
storage,
key: 'queryClient-' + (currentDid ?? 'logged-out'),
})
return {
persister: asyncPersister,
dehydrateOptions,
}
buster: env.APP_VERSION,
} satisfies Omit<PersistQueryClientOptions, 'queryClient'>
})
useEffect(() => {
if (IS_WEB) {
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
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
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
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
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
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
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
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
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
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
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
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -97,6 +97,17 @@ 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) {
@@ -135,14 +146,14 @@ export function StepSuggestedAccounts() {
seenProfilesRef.current.add(did)
ax.metric('suggestedUser:seen', {
logContext: 'Onboarding',
recId: undefined,
recId: suggestedUsers?.recId,
position,
suggestedDid: did,
category: selectedInterest,
})
}
},
[ax, selectedInterest],
[ax, selectedInterest, suggestedUsers?.recId],
)
return (
@@ -220,6 +231,7 @@ export function StepSuggestedAccounts() {
position={index}
category={selectedInterest}
onSeen={onProfileSeen}
recId={suggestedUsers.recId}
/>
))}
</View>
@@ -234,7 +246,7 @@ export function StepSuggestedAccounts() {
color="secondary"
size="large"
label={_(msg`Retry`)}
onPress={() => refetch()}>
onPress={() => void refetch()}>
<ButtonText>
<Trans>Retry</Trans>
</ButtonText>
@@ -329,12 +341,14 @@ 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()
@@ -401,7 +415,7 @@ function SuggestedProfileCard({
ax.metric('suggestedUser:follow', {
logContext: 'Onboarding',
location: 'Card',
recId: undefined,
recId,
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 {AnimatedProfileHeaderSuggestedFollows} from './SuggestedFollows'
import {ProfileHeaderSuggestedFollows} from './SuggestedFollows'
interface Props {
profile: AppBskyActorDefs.ProfileViewDetailed
@@ -193,7 +193,7 @@ let ProfileHeaderStandard = ({
/>
</ProfileHeaderShell>
<AnimatedProfileHeaderSuggestedFollows
<ProfileHeaderSuggestedFollows
isExpanded={showSuggestedFollows}
actorDid={profile.did}
/>
@@ -317,7 +317,10 @@ export function HeaderStandardButtons({
testID="profileHeaderEditProfileButton"
size="small"
color="secondary"
onPress={editProfileControl.open}
onPress={() => {
playHaptic('Light')
editProfileControl.open()
}}
label={_(msg`Edit profile`)}>
<ButtonText>
<Trans>Edit Profile</Trans>
+86 -189
View File
@@ -1,5 +1,4 @@
import React from 'react'
import {type AppBskyActorDefs} from '@atproto/api'
import {useCallback, useEffect, useMemo, useState} from 'react'
import {AccordionAnimation} from '#/lib/custom-animations/AccordionAnimation'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
@@ -10,198 +9,17 @@ import {
import {useBreakpoints} from '#/alf'
import {ProfileGrid} from '#/components/FeedInterstitials'
import {IS_ANDROID} from '#/env'
import type * as bsky from '#/types/bsky'
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({
export function ProfileHeaderSuggestedFollows({
isExpanded,
actorDid,
}: {
isExpanded: boolean
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,
])
const {allProfiles, filteredProfiles, onDismiss, isLoading, error} =
useProfileHeaderSuggestions(actorDid)
if (!allProfiles.length && !isLoading) return null
@@ -218,13 +36,92 @@ export function AnimatedProfileHeaderSuggestedFollows({
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,6 +45,7 @@ export function useSuggestedUsers({
data: searched?.data
? {
actors: searched.data.pages.flatMap(p => p.actors) ?? [],
recId: undefined,
}
: undefined,
isLoading: searched.isLoading,
+17 -16
View File
@@ -270,27 +270,29 @@ 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
}
const cacheDir = IS_WEB && getImageCacheDirectory()
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
}
// On web (non-blob URLs) or if already in cache dir, no need to copy
// Native: copy to cache directory to survive OS temp file cleanup
const cacheDir = getImageCacheDirectory()
if (!cacheDir || from.startsWith(cacheDir)) {
return from
}
@@ -298,7 +300,6 @@ 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}`
+24 -19
View File
@@ -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,7 +22,11 @@ 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 {STALE} from '#/state/queries'
import {
PERSISTED_QUERY_GCTIME,
PERSISTED_QUERY_ROOT,
STALE,
} from '#/state/queries'
import {RQKEY as listQueryKey} from '#/state/queries/list'
import {usePreferencesQuery} from '#/state/queries/preferences'
import {useAgent, useSession} from '#/state/session'
@@ -114,7 +118,7 @@ export function hydrateFeedGenerator(
avatar: view.avatar,
displayName: view.displayName
? sanitizeDisplayName(view.displayName)
: `Feed by ${sanitizeHandle(view.creator.handle, '@')}`,
: t`Feed by ${sanitizeHandle(view.creator.handle, '@')}`,
description: new RichText({
text: view.description || '',
facets: (view.descriptionFacets || [])?.slice(),
@@ -155,7 +159,7 @@ export function hydrateList(view: AppBskyGraphDefs.ListView): FeedSourceInfo {
creatorHandle: view.creator.handle,
displayName: view.name
? sanitizeDisplayName(view.name)
: `User List by ${sanitizeHandle(view.creator.handle, '@')}`,
: t`User List by ${sanitizeHandle(view.creator.handle, '@')}`,
contentMode: undefined,
}
}
@@ -238,13 +242,7 @@ export function useGetPopularFeedsQuery(options?: GetPopularFeedsOptions) {
)
const lastPageCountRef = useRef(0)
const query = useInfiniteQuery<
AppBskyUnspeccedGetPopularFeedGenerators.OutputSchema,
Error,
InfiniteData<AppBskyUnspeccedGetPopularFeedGenerators.OutputSchema>,
QueryKey,
string | undefined
>({
const query = useInfiniteQuery({
enabled: Boolean(moderationOpts) && options?.enabled !== false,
queryKey: createGetPopularFeedsQueryKey(options),
queryFn: async ({pageParam}) => {
@@ -261,7 +259,7 @@ export function useGetPopularFeedsQuery(options?: GetPopularFeedsOptions) {
return res.data
},
initialPageParam: undefined,
initialPageParam: undefined as string | undefined,
getNextPageParam: lastPage => lastPage.cursor,
select: useCallback(
(
@@ -418,7 +416,10 @@ const PWI_DISCOVER_FEED_STUB: SavedFeedSourceInfo = {
contentMode: undefined,
}
const pinnedFeedInfosQueryKeyRoot = 'pinnedFeedsInfos'
const createPinnedFeedInfosQueryKeyRoot = (
kind: 'pinned' | 'saved',
feedUris: string[],
) => [PERSISTED_QUERY_ROOT, 'feed-info', kind, feedUris]
export function usePinnedFeedsInfos() {
const {hasSession} = useSession()
@@ -427,13 +428,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]
@@ -535,9 +536,13 @@ 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 || {
+20
View File
@@ -18,3 +18,23 @@ 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
+10 -6
View File
@@ -3,8 +3,11 @@ import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
import {z} from 'zod'
import {MAX_LABELERS} from '#/lib/constants'
import {labelersDetailedInfoQueryKeyRoot} from '#/lib/react-query'
import {STALE} from '#/state/queries'
import {
PERSISTED_QUERY_GCTIME,
PERSISTED_QUERY_ROOT,
STALE,
} from '#/state/queries'
import {
preferencesQueryKey,
usePreferencesQuery,
@@ -23,8 +26,9 @@ export const labelersInfoQueryKey = (dids: string[]) => [
dids.slice().sort(),
]
export const labelersDetailedInfoQueryKey = (dids: string[]) => [
labelersDetailedInfoQueryKeyRoot,
const persistedLabelersDetailedInfoQueryKey = (dids: string[]) => [
PERSISTED_QUERY_ROOT,
'labelers-detailed-info',
dids,
]
@@ -65,8 +69,8 @@ export function useLabelersDetailedInfoQuery({dids}: {dids: string[]}) {
const agent = useAgent()
return useQuery({
enabled: !!dids.length,
queryKey: labelersDetailedInfoQueryKey(dids),
gcTime: 1000 * 60 * 60 * 6, // 6 hours
queryKey: persistedLabelersDetailedInfoQueryKey(dids),
gcTime: PERSISTED_QUERY_GCTIME,
staleTime: STALE.MINUTES.ONE,
queryFn: async () => {
const res = await agent.app.bsky.labeler.getServices({
+17 -4
View File
@@ -9,7 +9,11 @@ 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 {STALE} from '#/state/queries'
import {
PERSISTED_QUERY_GCTIME,
PERSISTED_QUERY_ROOT,
STALE,
} from '#/state/queries'
import {
DEFAULT_HOME_FEED_PREFS,
DEFAULT_LOGGED_OUT_PREFERENCES,
@@ -29,18 +33,18 @@ export * from '#/state/queries/preferences/const'
export * from '#/state/queries/preferences/moderation'
export * from '#/state/queries/preferences/types'
const preferencesQueryKeyRoot = 'getPreferences'
export const preferencesQueryKey = [preferencesQueryKeyRoot]
export const preferencesQueryKey = [PERSISTED_QUERY_ROOT, 'getPreferences']
export function usePreferencesQuery() {
const agent = useAgent()
const aa = useAgeAssurance()
return useQuery({
const query = 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
@@ -92,6 +96,15 @@ 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() {
+43 -28
View File
@@ -1,4 +1,13 @@
import React from 'react'
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
useSyncExternalStore,
} from 'react'
import {type AtpSessionEvent, type BskyAgent} from '@atproto/api'
import * as persisted from '#/state/persisted'
@@ -19,6 +28,8 @@ 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,
@@ -29,21 +40,21 @@ import {
clearAgeAssuranceDataForDid,
} from '#/ageAssurance/data'
const StateContext = React.createContext<SessionStateContext>({
const StateContext = createContext<SessionStateContext>({
accounts: [],
currentAccount: undefined,
hasSession: false,
})
StateContext.displayName = 'SessionStateContext'
const AgentContext = React.createContext<BskyAgent | null>(null)
const AgentContext = createContext<BskyAgent | null>(null)
AgentContext.displayName = 'SessionAgentContext'
const ApiContext = React.createContext<SessionApiContext>({
const ApiContext = createContext<SessionApiContext>({
createAccount: async () => {},
login: async () => {},
logoutCurrentAccount: async () => {},
logoutEveryAccount: async () => {},
logoutCurrentAccount: () => {},
logoutEveryAccount: () => {},
resumeSession: async () => {},
removeAccount: () => {},
partialRefreshSession: async () => {},
@@ -94,11 +105,11 @@ class SessionStore {
export function Provider({children}: React.PropsWithChildren<{}>) {
const ax = useAnalyticsBase()
const cancelPendingTask = useOneTaskAtATime()
const [store] = React.useState(() => new SessionStore())
const state = React.useSyncExternalStore(store.subscribe, store.getState)
const [store] = useState(() => new SessionStore())
const state = useSyncExternalStore(store.subscribe, store.getState)
const onboardingDispatch = useOnboardingDispatch()
const onAgentSessionChange = React.useCallback(
const onAgentSessionChange = useCallback(
(agent: BskyAgent, accountDid: string, sessionEvent: AtpSessionEvent) => {
const refreshedAccount = agentToSessionAccount(agent) // Mutable, so snapshot it right away.
if (sessionEvent === 'expired' || sessionEvent === 'create-failed') {
@@ -115,7 +126,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
[store],
)
const createAccount = React.useCallback<SessionApiContext['createAccount']>(
const createAccount = useCallback<SessionApiContext['createAccount']>(
async (params, metrics) => {
addSessionDebugLog({type: 'method:start', method: 'createAccount'})
const signal = cancelPendingTask()
@@ -141,7 +152,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
[ax, store, onAgentSessionChange, cancelPendingTask],
)
const login = React.useCallback<SessionApiContext['login']>(
const login = useCallback<SessionApiContext['login']>(
async (params, logContext) => {
addSessionDebugLog({type: 'method:start', method: 'login'})
const signal = cancelPendingTask()
@@ -168,7 +179,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
[ax, store, onAgentSessionChange, cancelPendingTask],
)
const logoutCurrentAccount = React.useCallback<
const logoutCurrentAccount = useCallback<
SessionApiContext['logoutCurrentAccount']
>(
logContext => {
@@ -192,6 +203,7 @@ 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'})
@@ -199,7 +211,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
[ax, store, cancelPendingTask, onboardingDispatch],
)
const logoutEveryAccount = React.useCallback<
const logoutEveryAccount = useCallback<
SessionApiContext['logoutEveryAccount']
>(
logContext => {
@@ -222,13 +234,16 @@ 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],
[store, cancelPendingTask, onboardingDispatch, ax],
)
const resumeSession = React.useCallback<SessionApiContext['resumeSession']>(
const resumeSession = useCallback<SessionApiContext['resumeSession']>(
async (storedAccount, isSwitchingAccounts = false) => {
addSessionDebugLog({
type: 'method:start',
@@ -258,7 +273,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
[store, onAgentSessionChange, cancelPendingTask, onboardingDispatch],
)
const partialRefreshSession = React.useCallback<
const partialRefreshSession = useCallback<
SessionApiContext['partialRefreshSession']
>(async () => {
const agent = state.currentAgentState.agent as BskyAppAgent
@@ -275,7 +290,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
})
}, [store, state, cancelPendingTask])
const removeAccount = React.useCallback<SessionApiContext['removeAccount']>(
const removeAccount = useCallback<SessionApiContext['removeAccount']>(
account => {
addSessionDebugLog({
type: 'method:start',
@@ -292,7 +307,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
},
[store, cancelPendingTask],
)
React.useEffect(() => {
useEffect(() => {
return persisted.onUpdate('session', nextSession => {
const synced = nextSession
addSessionDebugLog({type: 'persisted:receive', data: synced})
@@ -322,7 +337,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
})
}, [store, state, resumeSession])
const stateContext = React.useMemo(
const stateContext = useMemo(
() => ({
accounts: state.accounts,
currentAccount: state.accounts.find(
@@ -333,7 +348,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
[state],
)
const api = React.useMemo(
const api = useMemo(
() => ({
createAccount,
login,
@@ -358,8 +373,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 = React.useRef(agent)
React.useEffect(() => {
const currentAgentRef = useRef(agent)
useEffect(() => {
if (currentAgentRef.current !== agent) {
// Read the previous value and immediately advance the pointer.
const prevAgent = currentAgentRef.current
@@ -390,8 +405,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
}
function useOneTaskAtATime() {
const abortController = React.useRef<AbortController | null>(null)
const cancelPendingTask = React.useCallback(() => {
const abortController = useRef<AbortController | null>(null)
const cancelPendingTask = useCallback(() => {
if (abortController.current) {
abortController.current.abort()
}
@@ -402,11 +417,11 @@ function useOneTaskAtATime() {
}
export function useSession() {
return React.useContext(StateContext)
return useContext(StateContext)
}
export function useSessionApi() {
return React.useContext(ApiContext)
return useContext(ApiContext)
}
export function useRequireAuth() {
@@ -414,7 +429,7 @@ export function useRequireAuth() {
const closeAll = useCloseAllActiveElements()
const {signinDialogControl} = useGlobalDialogsControlContext()
return React.useCallback(
return useCallback(
(fn: () => void) => {
if (hasSession) {
fn()
@@ -428,7 +443,7 @@ export function useRequireAuth() {
}
export function useAgent(): BskyAgent {
const agent = React.useContext(AgentContext)
const agent = useContext(AgentContext)
if (!agent) {
throw Error('useAgent() must be below <SessionProvider>.')
}
+8 -8
View File
@@ -6,17 +6,17 @@ export function create({id}: {id: string}): DB {
const store = new MMKV({id})
return {
async get(key: string): Promise<string | undefined> {
return store.getString(key) ?? undefined
get(key: string) {
return store.getString(key)
},
async set(key: string, value: string): Promise<void> {
store.set(key, value)
set(key: string, value: string) {
return store.set(key, value)
},
async delete(key: string): Promise<void> {
store.delete(key)
delete(key: string) {
return store.delete(key)
},
async clear(): Promise<void> {
store.clearAll()
clear() {
return store.clearAll()
},
}
}
+8 -8
View File
@@ -6,17 +6,17 @@ export function create({id}: {id: string}): DB {
const store = createStore(id, id)
return {
async get(key: string): Promise<string | undefined> {
return get(key, store) ?? undefined
get(key: string) {
return get(key, store)
},
async set(key: string, value: string): Promise<void> {
await set(key, value, store)
set(key: string, value: string) {
return set(key, value, store)
},
async delete(key: string): Promise<void> {
await del(key, store)
delete(key: string) {
return del(key, store)
},
async clear(): Promise<void> {
await clear(store)
clear() {
return clear(store)
},
}
}
+5 -4
View File
@@ -1,6 +1,7 @@
type MaybePromise<T> = T | Promise<T>
export type DB = {
get(key: string): Promise<string | undefined>
set(key: string, value: string): Promise<void>
delete(key: string): Promise<void>
clear(): Promise<void>
get(key: string): MaybePromise<string | undefined>
set(key: string, value: string): MaybePromise<void>
delete(key: string): MaybePromise<void>
clear(): MaybePromise<void>
}
+75 -41
View File
@@ -138,7 +138,7 @@ import {
type RestoredVideo,
} from './drafts/state/api'
import {
loadDraft,
loadDraftMedia,
useCleanupPublishedDraftMutation,
useSaveDraftMutation,
} from './drafts/state/queries'
@@ -482,7 +482,7 @@ export const ComposePost = ({
})
// Load local media files for the draft
const {loadedMedia} = await loadDraft(draftSummary.draft)
const {loadedMedia} = await loadDraftMedia(draftSummary.draft)
// Extract original localRefs for orphan detection on save
const originalLocalRefs = extractLocalRefs(draftSummary.draft)
@@ -1141,41 +1141,57 @@ export const ComposePost = ({
{!IS_WEBFooterSticky && footer}
</View>
<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>
{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>
)}
</KeyboardAvoidingView>
</BottomSheetPortalProvider>
)
@@ -2274,22 +2290,40 @@ function VideoUploadToolbar({state}: {state: VideoState}) {
let text = ''
const isGif = state.video?.mimeType === 'image/gif'
switch (state.status) {
case 'compressing':
text = _(msg`Compressing video...`)
if (isGif) {
text = _(msg`Compressing GIF...`)
} else {
text = _(msg`Compressing video...`)
}
break
case 'uploading':
text = _(msg`Uploading video...`)
if (isGif) {
text = _(msg`Uploading GIF...`)
} else {
text = _(msg`Uploading video...`)
}
break
case 'processing':
text = _(msg`Processing video...`)
if (isGif) {
text = _(msg`Processing GIF...`)
} else {
text = _(msg`Processing video...`)
}
break
case 'error':
text = _(msg`Error`)
wheelProgress = 100
break
case 'done':
text = _(msg`Video uploaded`)
if (isGif) {
text = _(msg`GIF uploaded`)
} else {
text = _(msg`Video uploaded`)
}
break
}
+23 -5
View File
@@ -1,5 +1,6 @@
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'
@@ -18,6 +19,7 @@ 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
@@ -128,7 +130,7 @@ const extensionToMimeType: Record<
* `mimeType`. If `mimeType` is not available, we try to infer it through
* various means.
*/
function classifyImagePickerAsset(asset: ImagePickerAsset):
async function classifyImagePickerAsset(asset: ImagePickerAsset): Promise<
| {
success: true
type: AssetType
@@ -138,7 +140,8 @@ function classifyImagePickerAsset(asset: ImagePickerAsset):
success: false
type: undefined
mimeType: undefined
} {
}
> {
/*
* Try to use the `mimeType` reported by `expo-image-picker` first.
*/
@@ -178,7 +181,22 @@ function classifyImagePickerAsset(asset: ImagePickerAsset):
*/
let type: AssetType | undefined
if (mimeType === 'image/gif') {
type = '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'
}
} else if (mimeType?.startsWith('video/')) {
type = 'video'
} else if (mimeType?.startsWith('image/')) {
@@ -236,7 +254,7 @@ async function processImagePickerAssets(
let supportedAssets: ValidatedImagePickerAsset[] = []
for (const asset of assets) {
const {success, type, mimeType} = classifyImagePickerAsset(asset)
const {success, type, mimeType} = await classifyImagePickerAsset(asset)
if (!success) {
errors.add(SelectedAssetError.Unsupported)
@@ -469,7 +487,7 @@ export function SelectMediaButton({
useEffect(() => {
if (autoOpen && !hasAutoOpened.current && !disabled) {
hasAutoOpened.current = true
onPressSelectMedia()
void onPressSelectMedia()
}
}, [autoOpen, disabled, onPressSelectMedia])
+216 -164
View File
@@ -1,20 +1,22 @@
import {useCallback, useEffect, useState} from 'react'
import {useCallback, useEffect, useMemo, useState} from 'react'
import {Pressable, View} from 'react-native'
import * as VideoThumbnails from 'expo-video-thumbnails'
import {msg, Trans} from '@lingui/macro'
import {msg, plural} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
import {sanitizeHandle} from '#/lib/strings/handles'
import {useCurrentAccountProfile} from '#/state/queries/useCurrentAccountProfile'
import * as device from '#/lib/deviceName'
import {logger} from '#/view/com/composer/drafts/state/logger'
import {TimeElapsed} from '#/view/com/util/TimeElapsed'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonIcon} from '#/components/Button'
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 {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'
@@ -32,6 +34,28 @@ 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)
@@ -39,48 +63,173 @@ export function DraftItem({
return (
<>
<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>
</View>
)}
{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 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}
</Text>
)}
</TimeElapsed>
</View>
</Pressable>
{/* 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>
</View>
</View>
<Prompt.Basic
control={discardPromptControl}
@@ -94,125 +243,28 @@ export function DraftItem({
)
}
function DraftPostRow({
post,
isFirst,
isLast,
timestamp,
discardPromptControl,
function DraftMetadataTag({
display = 'info',
icon: Icon,
text,
}: {
post: DraftPostDisplay
isFirst: boolean
isLast: boolean
timestamp: string
discardPromptControl: Prompt.PromptControlProps
display?: 'info' | 'warning'
icon: React.ComponentType<SVGIconProps>
text: string
}) {
const {_} = useLingui()
const t = useTheme()
const profile = useCurrentAccountProfile()
const color = {
info: t.atoms.text_contrast_medium.color,
warning: select(t.name, {
light: '#C99A00',
dark: '#FFC404',
dim: '#FFC404',
}),
}[display]
return (
<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,
]}>
&middot;
</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 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>
)
}
@@ -271,7 +323,7 @@ function DraftMediaPreview({post}: {post: DraftPostDisplay}) {
}
return (
<MediaPreview.Outer style={[a.pt_xs]}>
<MediaPreview.Outer>
{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, useTheme, web} from '#/alf'
import {atoms as a, select, useBreakpoints, 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,6 +26,7 @@ export function DraftsListDialog({
}) {
const {_} = useLingui()
const t = useTheme()
const {gtPhone} = useBreakpoints()
const ax = useAnalytics()
const {data, isLoading, hasNextPage, isFetchingNextPage, fetchNextPage} =
useDraftsQuery()
@@ -91,7 +92,7 @@ export function DraftsListDialog({
const renderItem = useCallback(
({item}: {item: DraftSummary}) => {
return (
<View style={[a.px_lg, a.mt_lg]}>
<View style={[gtPhone ? [a.px_md, a.pt_md] : [a.px_sm, a.pt_sm]]}>
<DraftItem
draft={item}
onSelect={handleSelectDraft}
@@ -100,7 +101,7 @@ export function DraftsListDialog({
</View>
)
},
[handleSelectDraft, handleDeleteDraft],
[handleSelectDraft, handleDeleteDraft, gtPhone],
)
const header = useMemo(
@@ -162,7 +163,17 @@ export function DraftsListDialog({
ListFooterComponent={footerComponent}
onEndReached={onEndReached}
onEndReachedThreshold={0.5}
style={[t.atoms.bg_contrast_50, a.px_0, web({minHeight: 500})]}
style={[
a.px_0,
web({minHeight: 500}),
{
backgroundColor: select(t.name, {
light: t.palette.contrast_50,
dark: t.palette.contrast_0,
dim: '#000000',
}),
},
]}
webInnerContentContainerStyle={[a.py_0]}
contentContainerStyle={[a.pb_xl]}
/>
+69 -60
View File
@@ -1,21 +1,28 @@
/**
* Type converters for Draft API - convert between ComposerState and server Draft types.
*/
import {type AppBskyDraftDefs, RichText} from '@atproto/api'
import {type AppBskyDraftDefs, AtUri, 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'
@@ -61,33 +68,14 @@ 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: threadgateAllow.length > 0 ? threadgateAllow : undefined,
threadgateAllow: threadgateAllowUISettingToAllowRecordValue(
state.thread.threadgate,
),
postgateEmbeddingRules:
state.thread.postgate.embeddingRules &&
state.thread.postgate.embeddingRules.length > 0
@@ -140,15 +128,21 @@ async function postDraftToServerPost(
// Add quote record embed
if (post.embed.quote) {
draftPost.embedRecords = [
{
$type: 'app.bsky.draft.defs#draftEmbedRecord',
record: {
uri: post.embed.quote.uri,
cid: '', // We don't have the CID at draft time
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,
},
},
},
]
]
}
}
// Add external link embed (only if no media, otherwise it's ignored)
@@ -277,16 +271,24 @@ function serializeGif(gifMedia: {
* Convert server DraftView to DraftSummary for list display.
* Also checks which media files exist locally.
*/
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
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,
}
const posts: DraftPostDisplay[] = view.draft.posts.map((post, index) => {
const images: DraftPostDisplay['images'] = []
@@ -296,11 +298,11 @@ export function draftViewToSummary(
// Process images
if (post.embedImages) {
for (const img of post.embedImages) {
mediaCount++
hasMedia = true
const exists = localMediaExists(img.localRef.path)
meta.mediaCount++
meta.hasMedia = true
const exists = storage.mediaExists(img.localRef.path)
if (!exists) {
hasMissingMedia = true
meta.hasMissingMedia = true
}
images.push({
localPath: img.localRef.path,
@@ -313,11 +315,11 @@ export function draftViewToSummary(
// Process videos
if (post.embedVideos) {
for (const vid of post.embedVideos) {
mediaCount++
hasMedia = true
const exists = localMediaExists(vid.localRef.path)
meta.mediaCount++
meta.hasMedia = true
const exists = storage.mediaExists(vid.localRef.path)
if (!exists) {
hasMissingMedia = true
meta.hasMissingMedia = true
}
videos.push({
localPath: vid.localRef.path,
@@ -332,13 +334,18 @@ export function draftViewToSummary(
for (const ext of post.embedExternals) {
const gifData = parseGifFromUrl(ext.uri)
if (gifData) {
mediaCount++
hasMedia = true
meta.mediaCount++
meta.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 || '',
@@ -348,17 +355,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,
}
}
@@ -536,7 +543,9 @@ export async function draftToComposerPosts(
// Restore quote embed
if (post.embedRecords && post.embedRecords.length > 0) {
const record = post.embedRecords[0]
embed.quote = {type: 'link', uri: record.record.uri}
const urip = new AtUri(record.record.uri)
const url = `https://bsky.app/profile/${urip.host}/post/${urip.rkey}`
embed.quote = {type: 'link', uri: url}
}
// Restore link embed (only if not a GIF)
+20 -8
View File
@@ -8,6 +8,8 @@ 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'
@@ -19,6 +21,7 @@ const DRAFTS_QUERY_KEY = ['drafts']
*/
export function useDraftsQuery() {
const agent = useAgent()
const ax = useAnalytics()
return useInfiniteQuery({
queryKey: DRAFTS_QUERY_KEY,
@@ -29,7 +32,10 @@ export function useDraftsQuery() {
return {
cursor: res.data.cursor,
drafts: res.data.drafts.map(view =>
draftViewToSummary(view, path => storage.mediaExists(path)),
draftViewToSummary({
view,
analytics: ax,
}),
),
}
},
@@ -42,11 +48,17 @@ 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 loadDraft(draft: AppBskyDraftDefs.Draft): Promise<{
export async function loadDraftMedia(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) {
@@ -54,10 +66,10 @@ export async function loadDraft(draft: AppBskyDraftDefs.Draft): Promise<{
try {
const url = await storage.loadMediaFromLocal(img.localRef.path)
loadedMedia.set(img.localRef.path, url)
} catch (e) {
logger.warn('Failed to load draft image', {
} catch (e: any) {
logger.error('Failed to load draft image', {
path: img.localRef.path,
error: e,
safeMessage: e.message,
})
}
}
@@ -68,10 +80,10 @@ export async function loadDraft(draft: AppBskyDraftDefs.Draft): Promise<{
try {
const url = await storage.loadMediaFromLocal(vid.localRef.path)
loadedMedia.set(vid.localRef.path, url)
} catch (e) {
logger.warn('Failed to load draft video', {
} catch (e: any) {
logger.error('Failed to load draft video', {
path: vid.localRef.path,
error: e,
safeMessage: e.message,
})
}
}
+21 -12
View File
@@ -50,22 +50,31 @@ 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
}
}
+3
View File
@@ -670,6 +670,9 @@ export function createComposerState({
}
}
}
} else if (initMention) {
// highlight the mention
initRichText.detectFacetsWithoutResolution()
}
return {
@@ -241,7 +241,7 @@ export function TextInput({
}
},
},
content: generateJSON(richtext.text.toString(), extensions, {
content: generateJSON(richTextToHTML(richtext), extensions, {
preserveWhitespace: 'full',
}),
autofocus: 'end',
@@ -382,6 +382,36 @@ 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
function editorJsonToText(
json: JSONContent,
isLastDocumentChild: boolean = false,
@@ -1,4 +1,4 @@
import React from 'react'
import {useRef} 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 = React.useRef<BlueskyVideoView>(null)
const playerRef = useRef<BlueskyVideoView>(null)
const autoplayDisabled = useAutoplayDisabled()
let aspectRatio = asset.width / asset.height
@@ -0,0 +1,59 @@
/**
* 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}
}
+2 -2
View File
@@ -63,7 +63,7 @@ function LightboxContainer({
return (
<Pressable
accessibilityHint={undefined}
accessibilityLabel={_(msg`Close image lightbox`)}
accessibilityLabel={_(msg`Close image viewer`)}
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 lightbox`)}
label={_(msg`Close image viewer`)}
shape="round"
size={gtPhone ? 'large' : 'small'}>
<XIcon size={gtPhone ? 'md' : 'sm'} style={{color: t.palette.white}} />
+1 -1
View File
@@ -199,7 +199,7 @@ function PostInner({
style={[a.pb_xs]}
/>
{richText.text ? (
<View>
<View style={[a.mb_2xs]}>
<RichText
enableTags
testID="postText"
+2 -2
View File
@@ -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]}>
+32 -52
View File
@@ -15,7 +15,6 @@ 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'
@@ -57,7 +56,7 @@ type TabOptions = 'Home' | 'Search' | 'Messages' | 'Notifications' | 'MyProfile'
export function BottomBar({navigation}: BottomTabBarProps) {
const {hasSession, currentAccount} = useSession()
const pal = usePalette('default')
const t = useTheme()
const {_} = useLingui()
const safeAreaInsets = useSafeAreaInsets()
const {footerHeight} = useShellLayout()
@@ -145,8 +144,10 @@ export function BottomBar({navigation}: BottomTabBarProps) {
<Animated.View
style={[
styles.bottomBar,
pal.view,
hideBorder ? {borderColor: pal.view.backgroundColor} : pal.border,
t.atoms.bg,
hideBorder
? {borderColor: t.atoms.bg.backgroundColor}
: t.atoms.border_contrast_low,
{paddingBottom: clamp(safeAreaInsets.bottom, 15, 60)},
footerMinimalShellTransform,
]}
@@ -161,12 +162,12 @@ export function BottomBar({navigation}: BottomTabBarProps) {
isAtHome ? (
<HomeFilled
width={iconWidth + 1}
style={[styles.ctrlIcon, pal.text, styles.homeIcon]}
style={[styles.ctrlIcon, t.atoms.text, styles.homeIcon]}
/>
) : (
<Home
width={iconWidth + 1}
style={[styles.ctrlIcon, pal.text, styles.homeIcon]}
style={[styles.ctrlIcon, t.atoms.text, styles.homeIcon]}
/>
)
}
@@ -180,13 +181,13 @@ export function BottomBar({navigation}: BottomTabBarProps) {
isAtSearch ? (
<MagnifyingGlassFilled
width={iconWidth + 2}
style={[styles.ctrlIcon, pal.text, styles.searchIcon]}
style={[styles.ctrlIcon, t.atoms.text, styles.searchIcon]}
/>
) : (
<MagnifyingGlass
testID="bottomBarSearchBtn"
width={iconWidth + 2}
style={[styles.ctrlIcon, pal.text, styles.searchIcon]}
style={[styles.ctrlIcon, t.atoms.text, styles.searchIcon]}
/>
)
}
@@ -201,12 +202,12 @@ export function BottomBar({navigation}: BottomTabBarProps) {
isAtMessages ? (
<MessageFilled
width={iconWidth - 1}
style={[styles.ctrlIcon, pal.text, styles.feedsIcon]}
style={[styles.ctrlIcon, t.atoms.text, styles.feedsIcon]}
/>
) : (
<Message
width={iconWidth - 1}
style={[styles.ctrlIcon, pal.text, styles.feedsIcon]}
style={[styles.ctrlIcon, t.atoms.text, styles.feedsIcon]}
/>
)
}
@@ -233,12 +234,12 @@ export function BottomBar({navigation}: BottomTabBarProps) {
isAtNotifications ? (
<BellFilled
width={iconWidth}
style={[styles.ctrlIcon, pal.text, styles.bellIcon]}
style={[styles.ctrlIcon, t.atoms.text, styles.bellIcon]}
/>
) : (
<Bell
width={iconWidth}
style={[styles.ctrlIcon, pal.text, styles.bellIcon]}
style={[styles.ctrlIcon, t.atoms.text, styles.bellIcon]}
/>
)
}
@@ -262,49 +263,28 @@ export function BottomBar({navigation}: BottomTabBarProps) {
testID="bottomBarProfileBtn"
icon={
<View style={styles.ctrlIconSizingWrapper}>
{isAtMyProfile ? (
<View
style={[
styles.ctrlIcon,
pal.text,
styles.profileIcon,
<View
style={[
styles.ctrlIcon,
styles.profileIcon,
isAtMyProfile && [
styles.onProfile,
{
borderColor: pal.text.color,
borderColor: t.atoms.text.color,
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
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>
)}
],
]}>
<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>
</View>
}
onPress={onPressProfile}
@@ -332,7 +312,7 @@ export function BottomBar({navigation}: BottomTabBarProps) {
style={{flexDirection: 'row', alignItems: 'center', gap: 8}}>
<Logo width={28} />
<View style={{paddingTop: 4}}>
<Logotype width={80} fill={pal.text.color} />
<Logotype width={80} fill={t.atoms.text.color} />
</View>
</View>
+177 -155
View File
@@ -12,15 +12,19 @@ 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,
@@ -37,10 +41,6 @@ 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,6 +53,8 @@ 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()
@@ -69,158 +71,176 @@ export function BottomBarWeb() {
// setShowLoggedOut(true)
}, [requestSwitchToAccount, closeAllActiveElements])
return (
<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>
const onLongPressProfile = React.useCallback(() => {
accountSwitchControl.open()
}, [accountSwitchControl])
{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} />
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>
{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>
</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>
</View>
</View>
</>
)}
</Animated.View>
</>
)}
</Animated.View>
</>
)
}
@@ -230,7 +250,8 @@ const NavItem: React.FC<{
routeName: string
hasNew?: boolean
notificationCount?: string
}> = ({children, href, routeName, hasNew, notificationCount}) => {
onLongPress?: () => void
}> = ({children, href, routeName, hasNew, notificationCount, onLongPress}) => {
const t = useTheme()
const {_} = useLingui()
const {currentAccount} = useSession()
@@ -264,7 +285,8 @@ const NavItem: React.FC<{
navigationAction={isOnDifferentProfile ? 'push' : 'navigate'}
aria-role="link"
aria-label={routeName}
accessible={true}>
accessible={true}
onLongPress={onLongPress}>
{children({isActive})}
{notificationCount ? (
<View
+183 -102
View File
@@ -20,7 +20,7 @@
"@jridgewell/gen-mapping" "^0.3.0"
"@jridgewell/trace-mapping" "^0.3.9"
"@atproto-labs/did-resolver@0.2.6":
"@atproto-labs/did-resolver@^0.2.6":
version "0.2.6"
resolved "https://registry.yarnpkg.com/@atproto-labs/did-resolver/-/did-resolver-0.2.6.tgz#15f0beab797187a67279389f6503f87a257cd898"
integrity sha512-2K1bC04nI2fmgNcvof+yA28IhGlpWn2JKYlPa7To9JTKI45FINCGkQSGiL2nyXlyzDJJ34fZ1aq6/IRFIOIiqg==
@@ -32,7 +32,7 @@
"@atproto/did" "0.3.0"
zod "^3.23.8"
"@atproto-labs/fetch-node@0.2.0":
"@atproto-labs/fetch-node@^0.2.0":
version "0.2.0"
resolved "https://registry.yarnpkg.com/@atproto-labs/fetch-node/-/fetch-node-0.2.0.tgz#438989f3165f52e21e7636fb87ea9c7317ae7f2a"
integrity sha512-Krq09nH/aeoiU2s9xdHA0FjTEFWG9B5FFenipv1iRixCcPc7V3DhTNDawxG9gI8Ny0k4dBVS9WTRN/IDzBx86Q==
@@ -42,19 +42,19 @@
ipaddr.js "^2.1.0"
undici "^6.14.1"
"@atproto-labs/fetch@0.2.3":
"@atproto-labs/fetch@0.2.3", "@atproto-labs/fetch@^0.2.3":
version "0.2.3"
resolved "https://registry.yarnpkg.com/@atproto-labs/fetch/-/fetch-0.2.3.tgz#d47afec078f630c50e291c56264cc0ff13d0c6cc"
integrity sha512-NZtbJOCbxKUFRFKMpamT38PUQMY0hX0p7TG5AEYOPhZKZEP7dHZ1K2s1aB8MdVH0qxmqX7nQleNrrvLf09Zfdw==
dependencies:
"@atproto-labs/pipe" "0.1.1"
"@atproto-labs/pipe@0.1.1":
"@atproto-labs/pipe@0.1.1", "@atproto-labs/pipe@^0.1.1":
version "0.1.1"
resolved "https://registry.yarnpkg.com/@atproto-labs/pipe/-/pipe-0.1.1.tgz#1c4232d16bf95f251e993cb6ee440f9aa4e87ce6"
integrity sha512-hdNw2oUs2B6BN1lp+32pF7cp8EMKuIN5Qok2Vvv/aOpG/3tNSJ9YkvfI0k6Zd188LeDDYRUpYpxcoFIcGH/FNg==
"@atproto-labs/simple-store-memory@0.1.4":
"@atproto-labs/simple-store-memory@0.1.4", "@atproto-labs/simple-store-memory@^0.1.4":
version "0.1.4"
resolved "https://registry.yarnpkg.com/@atproto-labs/simple-store-memory/-/simple-store-memory-0.1.4.tgz#e38c7b27e0f77c0bdba1329deb89593fbec27316"
integrity sha512-3mKY4dP8I7yKPFj9VKpYyCRzGJOi5CEpOLPlRhoJyLmgs3J4RzDrjn323Oakjz2Aj2JzRU/AIvWRAZVhpYNJHw==
@@ -62,19 +62,19 @@
"@atproto-labs/simple-store" "0.3.0"
lru-cache "^10.2.0"
"@atproto-labs/simple-store-redis@0.0.1":
"@atproto-labs/simple-store-redis@^0.0.1":
version "0.0.1"
resolved "https://registry.yarnpkg.com/@atproto-labs/simple-store-redis/-/simple-store-redis-0.0.1.tgz#1dfc92cbec9b648c4349255aebb7bce9d7dc5eeb"
integrity sha512-hGkfDNVtTqwcRx27k6u25pgwNIHq3xDCRuojkfHf6c1B9R5rKphdZJ91Mn3lCvsyDB/lUqqLuzKuXQWFml/u5g==
dependencies:
"@atproto-labs/simple-store" "0.3.0"
"@atproto-labs/simple-store@0.3.0":
"@atproto-labs/simple-store@0.3.0", "@atproto-labs/simple-store@^0.3.0":
version "0.3.0"
resolved "https://registry.yarnpkg.com/@atproto-labs/simple-store/-/simple-store-0.3.0.tgz#65c0a5c949fe6c8dc3bdaf13ab40848f20073593"
integrity sha512-nOb6ONKBRJHRlukW1sVawUkBqReLlLx6hT35VS3imaNPwiXDxLnTK7lxw3Lrl9k5yugSBDQAkZAq3MPTEFSUBQ==
"@atproto-labs/xrpc-utils@0.0.24":
"@atproto-labs/xrpc-utils@^0.0.24":
version "0.0.24"
resolved "https://registry.yarnpkg.com/@atproto-labs/xrpc-utils/-/xrpc-utils-0.0.24.tgz#0546778b9b83854d8a160dc4dea145e5a23ae8fc"
integrity sha512-wWXd2Ht47UsL/UbDCr3twMFSZrh0xSI56u4O3kz0DTU4G+530mCG71mMVE6eeYcR+j6FEjp7o2Ld6c7wFklYGw==
@@ -96,6 +96,20 @@
tlds "^1.234.0"
zod "^3.23.8"
"@atproto/api@^0.18.19", "@atproto/api@^0.18.20":
version "0.18.20"
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.18.20.tgz#3fdbb7b7fae90bd59101970c2b56cc31e8cf417d"
integrity sha512-BZYZkh2VJIFCXEnc/vzKwAwWjAQQTgbNJ8FBxpBK+z+KYh99O0uPCsRYKoCQsRrnkgrhzdU9+g2G+7zanTIGbw==
dependencies:
"@atproto/common-web" "^0.4.15"
"@atproto/lexicon" "^0.6.1"
"@atproto/syntax" "^0.4.3"
"@atproto/xrpc" "^0.7.7"
await-lock "^2.2.2"
multiformats "^9.9.0"
tlds "^1.234.0"
zod "^3.23.8"
"@atproto/aws@^0.2.31":
version "0.2.31"
resolved "https://registry.yarnpkg.com/@atproto/aws/-/aws-0.2.31.tgz#e46d7db34ee57c4f9817269f1e73a7eddba2b9b8"
@@ -114,15 +128,15 @@
multiformats "^9.9.0"
uint8arrays "3.0.0"
"@atproto/bsky@^0.0.212":
version "0.0.212"
resolved "https://registry.yarnpkg.com/@atproto/bsky/-/bsky-0.0.212.tgz#97416f5b788935a9b6c5696961aa2080f15c5c7b"
integrity sha512-xbdblgzpdKiUNMKL6qVURAUwATw9nrR7Rj8ygQDj3QEmmgMyEA5PEutCtoh3jlBr4bF2asZlyjySrtmAEixKNQ==
"@atproto/bsky@^0.0.214":
version "0.0.214"
resolved "https://registry.yarnpkg.com/@atproto/bsky/-/bsky-0.0.214.tgz#54183957e323fe0800606acdcc3137acadf6672e"
integrity sha512-Fn+o3WcaX57EmcLoUdcViY4wU2UzfbyNlS/qFQu+1clADtL3pthylwTJgwUU73cZE+7MILZPfTzhQTc15snR9A==
dependencies:
"@atproto-labs/fetch-node" "0.2.0"
"@atproto-labs/xrpc-utils" "0.0.24"
"@atproto/api" "^0.18.18"
"@atproto/common" "^0.5.9"
"@atproto-labs/fetch-node" "^0.2.0"
"@atproto-labs/xrpc-utils" "^0.0.24"
"@atproto/api" "^0.18.20"
"@atproto/common" "^0.5.10"
"@atproto/crypto" "^0.4.5"
"@atproto/did" "^0.3.0"
"@atproto/identity" "^0.4.10"
@@ -130,7 +144,7 @@
"@atproto/repo" "^0.8.12"
"@atproto/sync" "^0.1.39"
"@atproto/syntax" "^0.4.3"
"@atproto/xrpc-server" "^0.10.10"
"@atproto/xrpc-server" "^0.10.11"
"@bufbuild/protobuf" "^1.5.0"
"@connectrpc/connect" "^1.1.4"
"@connectrpc/connect-express" "^1.1.4"
@@ -190,6 +204,16 @@
"@atproto/syntax" "0.4.3"
zod "^3.23.8"
"@atproto/common-web@^0.4.15":
version "0.4.15"
resolved "https://registry.yarnpkg.com/@atproto/common-web/-/common-web-0.4.15.tgz#1fffedf62d69b8c96f7b360e11c4180446a2cc82"
integrity sha512-A4l9gyqUNez8CjZp/Trypz/D3WIQsNj8dN05WR6+RoBbvwc9JhWjKPrm+WoVYc/F16RPdXHLkE3BEJlGIyYIiA==
dependencies:
"@atproto/lex-data" "^0.0.10"
"@atproto/lex-json" "^0.0.10"
"@atproto/syntax" "^0.4.3"
zod "^3.23.8"
"@atproto/common@0.1.0":
version "0.1.0"
resolved "https://registry.yarnpkg.com/@atproto/common/-/common-0.1.0.tgz#4216a8fef5b985ab62ac21252a0f8ca0f4a0f210"
@@ -222,6 +246,18 @@
multiformats "^9.9.0"
pino "^8.21.0"
"@atproto/common@^0.5.10":
version "0.5.10"
resolved "https://registry.yarnpkg.com/@atproto/common/-/common-0.5.10.tgz#f0d6b7e012b3dd3991d7e2975e26913d4f41e90f"
integrity sha512-A1+4W3JmjZIgmtJFLJBAaoVruZhRL0ANtyjZ91aJR4rjHcZuaQ+v4IFR1UcE6yyTATacLdBk6ADy8OtxXzq14g==
dependencies:
"@atproto/common-web" "^0.4.15"
"@atproto/lex-cbor" "^0.0.10"
"@atproto/lex-data" "^0.0.10"
iso-datestring-validator "^2.2.2"
multiformats "^9.9.0"
pino "^8.21.0"
"@atproto/crypto@0.1.0":
version "0.1.0"
resolved "https://registry.yarnpkg.com/@atproto/crypto/-/crypto-0.1.0.tgz#bc73a479f9dbe06fa025301c182d7f7ab01bc568"
@@ -233,7 +269,7 @@
one-webcrypto "^1.0.3"
uint8arrays "3.0.0"
"@atproto/crypto@0.4.5", "@atproto/crypto@^0.4.4", "@atproto/crypto@^0.4.5":
"@atproto/crypto@^0.4.4", "@atproto/crypto@^0.4.5":
version "0.4.5"
resolved "https://registry.yarnpkg.com/@atproto/crypto/-/crypto-0.4.5.tgz#fc6ad4fdfe8338147196c8050791cc6a22657eb6"
integrity sha512-n40aKkMoCatP0u9Yvhrdk6fXyOHFDDbkdm4h4HCyWW+KlKl8iXfD5iV+ECq+w5BM+QH25aIpt3/j6EUNerhLxw==
@@ -242,23 +278,23 @@
"@noble/hashes" "^1.6.1"
uint8arrays "3.0.0"
"@atproto/dev-env@^0.3.206":
version "0.3.206"
resolved "https://registry.yarnpkg.com/@atproto/dev-env/-/dev-env-0.3.206.tgz#2a32642e2d1e90dd979ebfa40c892bf700ae16b8"
integrity sha512-H3GfypJ8fJbGPC4DjHNXM0lFi1HzF//YN2S93QTWR+nVW3994893RsA28mdwY0p5jppF2lhQ4EbG3fPBko6F9g==
"@atproto/dev-env@^0.3.208":
version "0.3.208"
resolved "https://registry.yarnpkg.com/@atproto/dev-env/-/dev-env-0.3.208.tgz#01d07d4b1ef2404adb07bdddf324393989231a52"
integrity sha512-ttx+MIT21iFICW7sZNaFJoCPlxZAqgFy1ztOEn1ebW2MkJzsNHSq2oQC8MHxmpbYEOMPekTXC2iMHW6PYVQJxQ==
dependencies:
"@atproto/api" "^0.18.18"
"@atproto/bsky" "^0.0.212"
"@atproto/api" "^0.18.20"
"@atproto/bsky" "^0.0.214"
"@atproto/bsync" "^0.0.23"
"@atproto/common-web" "^0.4.14"
"@atproto/common-web" "^0.4.15"
"@atproto/crypto" "^0.4.5"
"@atproto/identity" "^0.4.10"
"@atproto/lexicon" "^0.6.1"
"@atproto/ozone" "^0.1.162"
"@atproto/pds" "^0.4.206"
"@atproto/pds" "^0.4.207"
"@atproto/sync" "^0.1.39"
"@atproto/syntax" "^0.4.3"
"@atproto/xrpc-server" "^0.10.10"
"@atproto/xrpc-server" "^0.10.11"
"@did-plc/lib" "^0.0.1"
"@did-plc/server" "^0.0.1"
dotenv "^16.0.3"
@@ -283,7 +319,7 @@
"@atproto/common-web" "^0.4.4"
"@atproto/crypto" "^0.4.4"
"@atproto/jwk-jose@0.1.11":
"@atproto/jwk-jose@^0.1.11":
version "0.1.11"
resolved "https://registry.yarnpkg.com/@atproto/jwk-jose/-/jwk-jose-0.1.11.tgz#ef64bce940a66e267fc3cf0db8df4dbd062bb28a"
integrity sha512-i4Fnr2sTBYmMmHXl7NJh8GrCH+tDQEVWrcDMDnV5DjJfkgT17wIqvojIw9SNbSL4Uf0OtfEv6AgG0A+mgh8b5Q==
@@ -291,7 +327,7 @@
"@atproto/jwk" "0.6.0"
jose "^5.2.0"
"@atproto/jwk@0.6.0":
"@atproto/jwk@0.6.0", "@atproto/jwk@^0.6.0":
version "0.6.0"
resolved "https://registry.yarnpkg.com/@atproto/jwk/-/jwk-0.6.0.tgz#e813f77d9c89c025d4074340777fafaa2fba08a5"
integrity sha512-bDoJPvt7TrQVi/rBfBrSSpGykhtIriKxeYCYQTiPRKFfyRhbgpElF0wPXADjIswnbzZdOwbY63az4E/CFVT3Tw==
@@ -299,7 +335,7 @@
multiformats "^9.9.0"
zod "^3.23.8"
"@atproto/lex-cbor@0.0.9", "@atproto/lex-cbor@^0.0.9":
"@atproto/lex-cbor@0.0.9":
version "0.0.9"
resolved "https://registry.yarnpkg.com/@atproto/lex-cbor/-/lex-cbor-0.0.9.tgz#d4b227ae37c1b44b76a2eca826b23c4b74bd4062"
integrity sha512-szkS569j1eZsIxZKh2VZHVq7pSpewy1wHh8c6nVYekHfYcJhFkevQq/DjTeatZ7YZKNReGYthQulgaZq2ytfWQ==
@@ -307,17 +343,25 @@
"@atproto/lex-data" "0.0.9"
tslib "^2.8.1"
"@atproto/lex-client@0.0.10":
"@atproto/lex-cbor@^0.0.10":
version "0.0.10"
resolved "https://registry.yarnpkg.com/@atproto/lex-client/-/lex-client-0.0.10.tgz#0baa7fee22d2efec17e8848351957a6e10917323"
integrity sha512-n3g9KoY5hw7W29mcR4TrjN5qOi6JiWty7r1heqLLfYiq5TxaRx9/QBa2hbN4h1p4xxICPZoDlNtuGq8YV4U8mg==
resolved "https://registry.yarnpkg.com/@atproto/lex-cbor/-/lex-cbor-0.0.10.tgz#712abc4fedef4854c341e32d0fd1608e45616984"
integrity sha512-5RtV90iIhRNCXXvvETd3KlraV8XGAAAgOmiszUb+l8GySDU/sGk7AlVvArFfXnj/S/GXJq8DP6IaUxCw/sPASA==
dependencies:
"@atproto/lex-data" "0.0.9"
"@atproto/lex-json" "0.0.9"
"@atproto/lex-schema" "0.0.10"
"@atproto/lex-data" "^0.0.10"
tslib "^2.8.1"
"@atproto/lex-data@0.0.9", "@atproto/lex-data@^0.0.9":
"@atproto/lex-client@^0.0.11":
version "0.0.11"
resolved "https://registry.yarnpkg.com/@atproto/lex-client/-/lex-client-0.0.11.tgz#b8e8d0da81ed27f1093d0a1e1c7fdb994394e323"
integrity sha512-2DCidAlhATtZc1Z11PUd+C98BiW/Od4pWtDlQSAxkjHSC/56ZwuSkZQVx27ISk1HldfKVc9qUvQWA9nhmrxYIw==
dependencies:
"@atproto/lex-data" "^0.0.10"
"@atproto/lex-json" "^0.0.10"
"@atproto/lex-schema" "^0.0.11"
tslib "^2.8.1"
"@atproto/lex-data@0.0.9":
version "0.0.9"
resolved "https://registry.yarnpkg.com/@atproto/lex-data/-/lex-data-0.0.9.tgz#19bed9399571d8d653a0bfba99b641cb98300e84"
integrity sha512-1slwe4sG0cyWtsq16+rBoWIxNDqGPkkvN+PV6JuzA7dgUK9bjUmXBGQU4eZlUPSS43X1Nhmr/9VjgKmEzU9vDw==
@@ -327,12 +371,22 @@
uint8arrays "3.0.0"
unicode-segmenter "^0.14.0"
"@atproto/lex-document@0.0.11":
version "0.0.11"
resolved "https://registry.yarnpkg.com/@atproto/lex-document/-/lex-document-0.0.11.tgz#8cfdd6ab5b5befac4d1409c76e2d5a310845c1dc"
integrity sha512-ePtFOU7yYAp1IL1mPDrAyo+ajN9V7W8z6BY4xXEM/m9U3vCVNC+SIkgkfwumqSUqOtBy4gpz52ppK+R/9S8UWg==
"@atproto/lex-data@^0.0.10":
version "0.0.10"
resolved "https://registry.yarnpkg.com/@atproto/lex-data/-/lex-data-0.0.10.tgz#091c012af817a869951ce0e61eb757bd6c29797a"
integrity sha512-FDbcy8VIUVzS9Mi1F8SMxbkL/jOUmRRpqbeM1xB4A0fMxeZJTxf6naAbFt4gYF3quu/+TPJGmio6/7cav05FqQ==
dependencies:
"@atproto/lex-schema" "0.0.10"
multiformats "^9.9.0"
tslib "^2.8.1"
uint8arrays "3.0.0"
unicode-segmenter "^0.14.0"
"@atproto/lex-document@^0.0.12":
version "0.0.12"
resolved "https://registry.yarnpkg.com/@atproto/lex-document/-/lex-document-0.0.12.tgz#18e0ca344101bb742a2930f663473e0213721f52"
integrity sha512-+no+ZXyCNdOdjkOj6a4n4WHAQzZz3M6VJTwx7IQQC2+to41/4fFr6k8U1y3Jtq2lYcbHqapkJj3RGIxzFcrtwA==
dependencies:
"@atproto/lex-schema" "^0.0.11"
core-js "^3"
tslib "^2.8.1"
@@ -344,28 +398,36 @@
"@atproto/lex-data" "0.0.9"
tslib "^2.8.1"
"@atproto/lex-resolver@0.0.12":
version "0.0.12"
resolved "https://registry.yarnpkg.com/@atproto/lex-resolver/-/lex-resolver-0.0.12.tgz#fb6cd78c78c0acfc9a92d9e42abe7ff18b4c3a41"
integrity sha512-Q3/olki+Wl3AVk/QlDfQJ6ttZuIe6RcT6UJ0eAb04f1r7vXihMyZSb4kPoDF86FoIWsxU8P8+FFHYszm7S+jAw==
"@atproto/lex-json@^0.0.10":
version "0.0.10"
resolved "https://registry.yarnpkg.com/@atproto/lex-json/-/lex-json-0.0.10.tgz#18de1c8cc3ba564e5412bce1f8e22c198c956e64"
integrity sha512-L6MyXU17C5ODMeob8myQ2F3xvgCTvJUtM0ew8qSApnN//iDasB/FDGgd7ty4UVNmx4NQ/rtvz8xV94YpG6kneQ==
dependencies:
"@atproto-labs/did-resolver" "0.2.6"
"@atproto/crypto" "0.4.5"
"@atproto/lex-client" "0.0.10"
"@atproto/lex-data" "0.0.9"
"@atproto/lex-document" "0.0.11"
"@atproto/lex-schema" "0.0.10"
"@atproto/repo" "0.8.12"
"@atproto/syntax" "0.4.3"
"@atproto/lex-data" "^0.0.10"
tslib "^2.8.1"
"@atproto/lex-schema@0.0.10":
version "0.0.10"
resolved "https://registry.yarnpkg.com/@atproto/lex-schema/-/lex-schema-0.0.10.tgz#ec2cf46617317afa31c5693f886f70ba4483163b"
integrity sha512-970BZVHtsLn03k2wkpYzdY2o/oZycqUReG1UblOkWYkbhQd04WqliiGrpUie/ls25oJs37ymI+fCDPcYg9tuQg==
"@atproto/lex-resolver@^0.0.13":
version "0.0.13"
resolved "https://registry.yarnpkg.com/@atproto/lex-resolver/-/lex-resolver-0.0.13.tgz#6407b813001d3a3f9e4048197755deccb3cc6f62"
integrity sha512-CcqCE6W3ZVMVzAihatpVbXLxO15mtvddzRzovIJ9QxBTpUmtkdmIk9/gle0oAsRToTJYQy2a6dwAmnAosxP/XQ==
dependencies:
"@atproto/lex-data" "0.0.9"
"@atproto/syntax" "0.4.3"
"@atproto-labs/did-resolver" "^0.2.6"
"@atproto/crypto" "^0.4.5"
"@atproto/lex-client" "^0.0.11"
"@atproto/lex-data" "^0.0.10"
"@atproto/lex-document" "^0.0.12"
"@atproto/lex-schema" "^0.0.11"
"@atproto/repo" "^0.8.12"
"@atproto/syntax" "^0.4.3"
tslib "^2.8.1"
"@atproto/lex-schema@^0.0.11":
version "0.0.11"
resolved "https://registry.yarnpkg.com/@atproto/lex-schema/-/lex-schema-0.0.11.tgz#7ff8a1e94971cb750cacaa40e30f546a64db0acd"
integrity sha512-1vLUPQIMeawKP6ehSx2RiqaJDkiseFTXyUk3C4PaoFCktaH8FgVgPVKgUSSy02m1pxVMKnsOBV5psKeN53HG+Q==
dependencies:
"@atproto/lex-data" "^0.0.10"
"@atproto/syntax" "^0.4.3"
tslib "^2.8.1"
"@atproto/lexicon@^0.6.0", "@atproto/lexicon@^0.6.1":
@@ -379,7 +441,7 @@
multiformats "^9.9.0"
zod "^3.23.8"
"@atproto/oauth-provider-api@0.3.7":
"@atproto/oauth-provider-api@0.3.7", "@atproto/oauth-provider-api@^0.3.7":
version "0.3.7"
resolved "https://registry.yarnpkg.com/@atproto/oauth-provider-api/-/oauth-provider-api-0.3.7.tgz#7b911256536a72dbba6f38081a200836a3ab50b8"
integrity sha512-7yU9vuQFt/hy4NzlDtn+LuhIGvVKkhgWAkCmopnseMPBw6oGPT90uOsTxMkVGtHuKVvBSz7hOXoELXpnZq3gDQ==
@@ -387,42 +449,42 @@
"@atproto/jwk" "0.6.0"
"@atproto/oauth-types" "0.6.2"
"@atproto/oauth-provider-frontend@0.2.8":
"@atproto/oauth-provider-frontend@^0.2.8":
version "0.2.8"
resolved "https://registry.yarnpkg.com/@atproto/oauth-provider-frontend/-/oauth-provider-frontend-0.2.8.tgz#97f6dc33257b0f846229839dd74f606528f49846"
integrity sha512-wHypQrsbwE6LUlyDADfiJfOH5pDAHWm4l/v1dwkQhRndOai7L+knsbdLAOZVXuz6bRs7oV2Frb3mSS03OS4Gdw==
optionalDependencies:
"@atproto/oauth-provider-api" "0.3.7"
"@atproto/oauth-provider-ui@0.4.2":
"@atproto/oauth-provider-ui@^0.4.2":
version "0.4.2"
resolved "https://registry.yarnpkg.com/@atproto/oauth-provider-ui/-/oauth-provider-ui-0.4.2.tgz#4edd41c71fd1bf5aaa1df219fb8a20c0b8d90738"
integrity sha512-j3Afu23JYy68GY8L4t/cTEZz1PnrvxLrjiG/nPhXPURZbCnqZ1puIU+HRVCBJfqKyCwaWthXgUjwaL+SFlPGKA==
optionalDependencies:
"@atproto/oauth-provider-api" "0.3.7"
"@atproto/oauth-provider@^0.15.6":
version "0.15.6"
resolved "https://registry.yarnpkg.com/@atproto/oauth-provider/-/oauth-provider-0.15.6.tgz#ae71dfbb0073575011b81be47ee1adf9c1dc5d30"
integrity sha512-612+MrwjqKQ56wuwTAqFvYs65TX2kPLrmlJwWjUlXm5oh0jmHZG9BBZ0I3WciAeGfIRMp9mWYMxokPxVJdkzgg==
"@atproto/oauth-provider@^0.15.7":
version "0.15.7"
resolved "https://registry.yarnpkg.com/@atproto/oauth-provider/-/oauth-provider-0.15.7.tgz#fc5bd420984dac60f726949d2473bbdb3ddff991"
integrity sha512-rsPez44TJGVC5vFdwSTfSOLIIG5O1JF8NXLe1BjjsUbzRuflN6TyM6HyfBJVsnOVF2bOq6W6xPcnBFUJHCSyUQ==
dependencies:
"@atproto-labs/fetch" "0.2.3"
"@atproto-labs/fetch-node" "0.2.0"
"@atproto-labs/pipe" "0.1.1"
"@atproto-labs/simple-store" "0.3.0"
"@atproto-labs/simple-store-memory" "0.1.4"
"@atproto/common" "^0.5.9"
"@atproto/did" "0.3.0"
"@atproto/jwk" "0.6.0"
"@atproto/jwk-jose" "0.1.11"
"@atproto/lex-document" "0.0.11"
"@atproto/lex-resolver" "0.0.12"
"@atproto/oauth-provider-api" "0.3.7"
"@atproto/oauth-provider-frontend" "0.2.8"
"@atproto/oauth-provider-ui" "0.4.2"
"@atproto/oauth-scopes" "0.3.1"
"@atproto/oauth-types" "0.6.2"
"@atproto/syntax" "0.4.3"
"@atproto-labs/fetch" "^0.2.3"
"@atproto-labs/fetch-node" "^0.2.0"
"@atproto-labs/pipe" "^0.1.1"
"@atproto-labs/simple-store" "^0.3.0"
"@atproto-labs/simple-store-memory" "^0.1.4"
"@atproto/common" "^0.5.10"
"@atproto/did" "^0.3.0"
"@atproto/jwk" "^0.6.0"
"@atproto/jwk-jose" "^0.1.11"
"@atproto/lex-document" "^0.0.12"
"@atproto/lex-resolver" "^0.0.13"
"@atproto/oauth-provider-api" "^0.3.7"
"@atproto/oauth-provider-frontend" "^0.2.8"
"@atproto/oauth-provider-ui" "^0.4.2"
"@atproto/oauth-scopes" "^0.3.1"
"@atproto/oauth-types" "^0.6.2"
"@atproto/syntax" "^0.4.3"
"@hapi/accept" "^6.0.3"
"@hapi/address" "^5.1.1"
"@hapi/bourne" "^3.0.0"
@@ -435,7 +497,7 @@
jose "^5.2.0"
zod "^3.23.8"
"@atproto/oauth-scopes@0.3.1", "@atproto/oauth-scopes@^0.3.1":
"@atproto/oauth-scopes@^0.3.1":
version "0.3.1"
resolved "https://registry.yarnpkg.com/@atproto/oauth-scopes/-/oauth-scopes-0.3.1.tgz#cd0b3cdc31e14f6f1829f664fa1b3179f6ad9b51"
integrity sha512-eUD9C78uYH+0ZUmiV/X6pRj4BKlH9I1xxJYW1Gb/qJiATuTZkJVm02urJb/BkWX4Qpxy4rOr8EProNg1wByIEA==
@@ -443,7 +505,7 @@
"@atproto/did" "^0.3.0"
"@atproto/syntax" "^0.4.3"
"@atproto/oauth-types@0.6.2":
"@atproto/oauth-types@0.6.2", "@atproto/oauth-types@^0.6.2":
version "0.6.2"
resolved "https://registry.yarnpkg.com/@atproto/oauth-types/-/oauth-types-0.6.2.tgz#d829fae63421dcea7ac703e84460175d2f8d9299"
integrity sha512-2cuboM4RQBCYR8NQC5uGRkW6KgCgKyq/B5/+tnMmWZYtZGVUQvsUWQHK/ZiMCnVXbcDNtc/RIEJQJDZ8FXMoxg==
@@ -483,30 +545,30 @@
undici "^6.14.1"
ws "^8.12.0"
"@atproto/pds@^0.4.206":
version "0.4.206"
resolved "https://registry.yarnpkg.com/@atproto/pds/-/pds-0.4.206.tgz#2a0ae5b190568d0b23ad00a8c3e1d1217b212611"
integrity sha512-n2L0B3ZVe+7Bokuxvns8i/fBHuBepcvW2XSS5CYdMqWSaY6h5hezJjEM5O3nOx0CgCqXMmIMdnVtcIu0Dk5FPA==
"@atproto/pds@^0.4.207":
version "0.4.207"
resolved "https://registry.yarnpkg.com/@atproto/pds/-/pds-0.4.207.tgz#20ff3afcf1541b96f016a6843d69d3731b562f19"
integrity sha512-LtzgqeD4aB3O1efzDbhdEopdujB4GG/klLT4N/3v35+0Jpylrkht5Ux2yJGKAqJkzFCfpBdRhNMWpJqS2ssu8A==
dependencies:
"@atproto-labs/fetch-node" "0.2.0"
"@atproto-labs/simple-store" "0.3.0"
"@atproto-labs/simple-store-memory" "0.1.4"
"@atproto-labs/simple-store-redis" "0.0.1"
"@atproto-labs/xrpc-utils" "0.0.24"
"@atproto/api" "^0.18.18"
"@atproto-labs/fetch-node" "^0.2.0"
"@atproto-labs/simple-store" "^0.3.0"
"@atproto-labs/simple-store-memory" "^0.1.4"
"@atproto-labs/simple-store-redis" "^0.0.1"
"@atproto-labs/xrpc-utils" "^0.0.24"
"@atproto/api" "^0.18.19"
"@atproto/aws" "^0.2.31"
"@atproto/common" "^0.5.9"
"@atproto/common" "^0.5.10"
"@atproto/crypto" "^0.4.5"
"@atproto/identity" "^0.4.10"
"@atproto/lex-cbor" "^0.0.9"
"@atproto/lex-data" "^0.0.9"
"@atproto/lex-cbor" "^0.0.10"
"@atproto/lex-data" "^0.0.10"
"@atproto/lexicon" "^0.6.1"
"@atproto/oauth-provider" "^0.15.6"
"@atproto/oauth-provider" "^0.15.7"
"@atproto/oauth-scopes" "^0.3.1"
"@atproto/repo" "^0.8.12"
"@atproto/syntax" "^0.4.3"
"@atproto/xrpc" "^0.7.7"
"@atproto/xrpc-server" "^0.10.10"
"@atproto/xrpc-server" "^0.10.11"
"@did-plc/lib" "^0.0.4"
"@hapi/address" "^5.1.1"
better-sqlite3 "^10.0.0"
@@ -535,7 +597,7 @@
undici "^6.19.8"
zod "^3.23.8"
"@atproto/repo@0.8.12", "@atproto/repo@^0.8.11", "@atproto/repo@^0.8.12":
"@atproto/repo@^0.8.11", "@atproto/repo@^0.8.12":
version "0.8.12"
resolved "https://registry.yarnpkg.com/@atproto/repo/-/repo-0.8.12.tgz#f1caee2ff954a917f921569eac22ab669b39a0af"
integrity sha512-QpVTVulgfz5PUiCTELlDBiRvnsnwrFWi+6CfY88VwXzrRHd9NE8GItK7sfxQ6U65vD/idH8ddCgFrlrsn1REPQ==
@@ -599,6 +661,25 @@
ws "^8.12.0"
zod "^3.23.8"
"@atproto/xrpc-server@^0.10.11":
version "0.10.11"
resolved "https://registry.yarnpkg.com/@atproto/xrpc-server/-/xrpc-server-0.10.11.tgz#cd6f880ebb6a913db2925a23aa0ef0b05f5b02fd"
integrity sha512-7XR+n1G4j1PO33slr2Agl+lmTXbEQzk5iaCJmrcsfTC/0BbCHqSSbm+WHduz3EH4dfFioZsnDo1UesCF0EQEtg==
dependencies:
"@atproto/common" "^0.5.10"
"@atproto/crypto" "^0.4.5"
"@atproto/lex-cbor" "^0.0.10"
"@atproto/lex-data" "^0.0.10"
"@atproto/lexicon" "^0.6.1"
"@atproto/ws-client" "^0.0.4"
"@atproto/xrpc" "^0.7.7"
express "^4.17.2"
http-errors "^2.0.0"
mime-types "^2.1.35"
rate-limiter-flexible "^2.4.1"
ws "^8.12.0"
zod "^3.23.8"
"@atproto/xrpc@^0.7.6", "@atproto/xrpc@^0.7.7":
version "0.7.7"
resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.7.7.tgz#c0e3106c854cb9bc7d3129de2f31b8256eb0ed11"