Merge branch 'bluesky-social:main' into zh

zio/stable
Frudrax Cheng 2024-06-19 14:41:03 +08:00 committed by GitHub
commit bdc1ea897f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
49 changed files with 936 additions and 542 deletions

View File

@ -49,7 +49,7 @@
"open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web" "open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web"
}, },
"dependencies": { "dependencies": {
"@atproto/api": "^0.12.18", "@atproto/api": "^0.12.20",
"@bam.tech/react-native-image-resizer": "^3.0.4", "@bam.tech/react-native-image-resizer": "^3.0.4",
"@braintree/sanitize-url": "^6.0.2", "@braintree/sanitize-url": "^6.0.2",
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet", "@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",

View File

@ -14,40 +14,40 @@ import * as SplashScreen from 'expo-splash-screen'
import {msg} from '@lingui/macro' import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useIntentHandler} from '#/lib/hooks/useIntentHandler'
import {QueryProvider} from '#/lib/react-query'
import { import {
initialize, initialize,
Provider as StatsigProvider, Provider as StatsigProvider,
tryFetchGates, tryFetchGates,
} from '#/lib/statsig/statsig' } from '#/lib/statsig/statsig'
import {s} from '#/lib/styles'
import {ThemeProvider} from '#/lib/ThemeContext'
import {logger} from '#/logger' import {logger} from '#/logger'
import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes'
import {Provider as DialogStateProvider} from '#/state/dialogs'
import {Provider as InvitesStateProvider} from '#/state/invites'
import {Provider as LightboxStateProvider} from '#/state/lightbox'
import {MessagesProvider} from '#/state/messages' import {MessagesProvider} from '#/state/messages'
import {Provider as ModalStateProvider} from '#/state/modals'
import {init as initPersistedState} from '#/state/persisted' import {init as initPersistedState} from '#/state/persisted'
import {Provider as PrefsStateProvider} from '#/state/preferences'
import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs' import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs'
import {Provider as ModerationOptsProvider} from '#/state/preferences/moderation-opts' import {Provider as ModerationOptsProvider} from '#/state/preferences/moderation-opts'
import {readLastActiveAccount} from '#/state/session/util' import {Provider as UnreadNotifsProvider} from '#/state/queries/notifications/unread'
import {useIntentHandler} from 'lib/hooks/useIntentHandler'
import {QueryProvider} from 'lib/react-query'
import {s} from 'lib/styles'
import {ThemeProvider} from 'lib/ThemeContext'
import {Provider as DialogStateProvider} from 'state/dialogs'
import {Provider as InvitesStateProvider} from 'state/invites'
import {Provider as LightboxStateProvider} from 'state/lightbox'
import {Provider as ModalStateProvider} from 'state/modals'
import {Provider as MutedThreadsProvider} from 'state/muted-threads'
import {Provider as PrefsStateProvider} from 'state/preferences'
import {Provider as UnreadNotifsProvider} from 'state/queries/notifications/unread'
import { import {
Provider as SessionProvider, Provider as SessionProvider,
SessionAccount, SessionAccount,
useSession, useSession,
useSessionApi, useSessionApi,
} from 'state/session' } from '#/state/session'
import {Provider as ShellStateProvider} from 'state/shell' import {readLastActiveAccount} from '#/state/session/util'
import {Provider as LoggedOutViewProvider} from 'state/shell/logged-out' import {Provider as ShellStateProvider} from '#/state/shell'
import {Provider as SelectedFeedProvider} from 'state/shell/selected-feed' import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out'
import {TestCtrls} from 'view/com/testing/TestCtrls' import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
import * as Toast from 'view/com/util/Toast' import {TestCtrls} from '#/view/com/testing/TestCtrls'
import {Shell} from 'view/shell' import * as Toast from '#/view/com/util/Toast'
import {Shell} from '#/view/shell'
import {ThemeProvider as Alf} from '#/alf' import {ThemeProvider as Alf} from '#/alf'
import {useColorModeTheme} from '#/alf/util/useColorModeTheme' import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
import {Provider as PortalProvider} from '#/components/Portal' import {Provider as PortalProvider} from '#/components/Portal'
@ -112,10 +112,12 @@ function InnerApp() {
<SelectedFeedProvider> <SelectedFeedProvider>
<UnreadNotifsProvider> <UnreadNotifsProvider>
<BackgroundNotificationPreferencesProvider> <BackgroundNotificationPreferencesProvider>
<GestureHandlerRootView style={s.h100pct}> <MutedThreadsProvider>
<TestCtrls /> <GestureHandlerRootView style={s.h100pct}>
<Shell /> <TestCtrls />
</GestureHandlerRootView> <Shell />
</GestureHandlerRootView>
</MutedThreadsProvider>
</BackgroundNotificationPreferencesProvider> </BackgroundNotificationPreferencesProvider>
</UnreadNotifsProvider> </UnreadNotifsProvider>
</SelectedFeedProvider> </SelectedFeedProvider>
@ -154,21 +156,19 @@ function App() {
<SessionProvider> <SessionProvider>
<ShellStateProvider> <ShellStateProvider>
<PrefsStateProvider> <PrefsStateProvider>
<MutedThreadsProvider> <InvitesStateProvider>
<InvitesStateProvider> <ModalStateProvider>
<ModalStateProvider> <DialogStateProvider>
<DialogStateProvider> <LightboxStateProvider>
<LightboxStateProvider> <I18nProvider>
<I18nProvider> <PortalProvider>
<PortalProvider> <InnerApp />
<InnerApp /> </PortalProvider>
</PortalProvider> </I18nProvider>
</I18nProvider> </LightboxStateProvider>
</LightboxStateProvider> </DialogStateProvider>
</DialogStateProvider> </ModalStateProvider>
</ModalStateProvider> </InvitesStateProvider>
</InvitesStateProvider>
</MutedThreadsProvider>
</PrefsStateProvider> </PrefsStateProvider>
</ShellStateProvider> </ShellStateProvider>
</SessionProvider> </SessionProvider>

View File

@ -8,35 +8,35 @@ import {SafeAreaProvider} from 'react-native-safe-area-context'
import {msg} from '@lingui/macro' import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useIntentHandler} from '#/lib/hooks/useIntentHandler'
import {QueryProvider} from '#/lib/react-query'
import {Provider as StatsigProvider} from '#/lib/statsig/statsig' import {Provider as StatsigProvider} from '#/lib/statsig/statsig'
import {ThemeProvider} from '#/lib/ThemeContext'
import {logger} from '#/logger' import {logger} from '#/logger'
import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes'
import {Provider as DialogStateProvider} from '#/state/dialogs'
import {Provider as InvitesStateProvider} from '#/state/invites'
import {Provider as LightboxStateProvider} from '#/state/lightbox'
import {MessagesProvider} from '#/state/messages' import {MessagesProvider} from '#/state/messages'
import {Provider as ModalStateProvider} from '#/state/modals'
import {init as initPersistedState} from '#/state/persisted' import {init as initPersistedState} from '#/state/persisted'
import {Provider as PrefsStateProvider} from '#/state/preferences'
import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs' import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs'
import {Provider as ModerationOptsProvider} from '#/state/preferences/moderation-opts' import {Provider as ModerationOptsProvider} from '#/state/preferences/moderation-opts'
import {readLastActiveAccount} from '#/state/session/util' import {Provider as UnreadNotifsProvider} from '#/state/queries/notifications/unread'
import {useIntentHandler} from 'lib/hooks/useIntentHandler'
import {QueryProvider} from 'lib/react-query'
import {ThemeProvider} from 'lib/ThemeContext'
import {Provider as DialogStateProvider} from 'state/dialogs'
import {Provider as InvitesStateProvider} from 'state/invites'
import {Provider as LightboxStateProvider} from 'state/lightbox'
import {Provider as ModalStateProvider} from 'state/modals'
import {Provider as MutedThreadsProvider} from 'state/muted-threads'
import {Provider as PrefsStateProvider} from 'state/preferences'
import {Provider as UnreadNotifsProvider} from 'state/queries/notifications/unread'
import { import {
Provider as SessionProvider, Provider as SessionProvider,
SessionAccount, SessionAccount,
useSession, useSession,
useSessionApi, useSessionApi,
} from 'state/session' } from '#/state/session'
import {Provider as ShellStateProvider} from 'state/shell' import {readLastActiveAccount} from '#/state/session/util'
import {Provider as LoggedOutViewProvider} from 'state/shell/logged-out' import {Provider as ShellStateProvider} from '#/state/shell'
import {Provider as SelectedFeedProvider} from 'state/shell/selected-feed' import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out'
import * as Toast from 'view/com/util/Toast' import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
import {ToastContainer} from 'view/com/util/Toast.web' import * as Toast from '#/view/com/util/Toast'
import {Shell} from 'view/shell/index' import {ToastContainer} from '#/view/com/util/Toast.web'
import {Shell} from '#/view/shell/index'
import {ThemeProvider as Alf} from '#/alf' import {ThemeProvider as Alf} from '#/alf'
import {useColorModeTheme} from '#/alf/util/useColorModeTheme' import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
import {Provider as PortalProvider} from '#/components/Portal' import {Provider as PortalProvider} from '#/components/Portal'
@ -96,9 +96,11 @@ function InnerApp() {
<SelectedFeedProvider> <SelectedFeedProvider>
<UnreadNotifsProvider> <UnreadNotifsProvider>
<BackgroundNotificationPreferencesProvider> <BackgroundNotificationPreferencesProvider>
<SafeAreaProvider> <MutedThreadsProvider>
<Shell /> <SafeAreaProvider>
</SafeAreaProvider> <Shell />
</SafeAreaProvider>
</MutedThreadsProvider>
</BackgroundNotificationPreferencesProvider> </BackgroundNotificationPreferencesProvider>
</UnreadNotifsProvider> </UnreadNotifsProvider>
</SelectedFeedProvider> </SelectedFeedProvider>
@ -136,21 +138,19 @@ function App() {
<SessionProvider> <SessionProvider>
<ShellStateProvider> <ShellStateProvider>
<PrefsStateProvider> <PrefsStateProvider>
<MutedThreadsProvider> <InvitesStateProvider>
<InvitesStateProvider> <ModalStateProvider>
<ModalStateProvider> <DialogStateProvider>
<DialogStateProvider> <LightboxStateProvider>
<LightboxStateProvider> <I18nProvider>
<I18nProvider> <PortalProvider>
<PortalProvider> <InnerApp />
<InnerApp /> </PortalProvider>
</PortalProvider> </I18nProvider>
</I18nProvider> </LightboxStateProvider>
</LightboxStateProvider> </DialogStateProvider>
</DialogStateProvider> </ModalStateProvider>
</ModalStateProvider> </InvitesStateProvider>
</InvitesStateProvider>
</MutedThreadsProvider>
</PrefsStateProvider> </PrefsStateProvider>
</ShellStateProvider> </ShellStateProvider>
</SessionProvider> </SessionProvider>

View File

@ -54,8 +54,8 @@ import {useModalControls} from './state/modals'
import {useUnreadNotifications} from './state/queries/notifications/unread' import {useUnreadNotifications} from './state/queries/notifications/unread'
import {useSession} from './state/session' import {useSession} from './state/session'
import { import {
setEmailConfirmationRequested,
shouldRequestEmailConfirmation, shouldRequestEmailConfirmation,
snoozeEmailConfirmationPrompt,
} from './state/shell/reminders' } from './state/shell/reminders'
import {AccessibilitySettingsScreen} from './view/screens/AccessibilitySettings' import {AccessibilitySettingsScreen} from './view/screens/AccessibilitySettings'
import {CommunityGuidelinesScreen} from './view/screens/CommunityGuidelines' import {CommunityGuidelinesScreen} from './view/screens/CommunityGuidelines'
@ -585,7 +585,7 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
if (currentAccount && shouldRequestEmailConfirmation(currentAccount)) { if (currentAccount && shouldRequestEmailConfirmation(currentAccount)) {
openModal({name: 'verify-email', showReminder: true}) openModal({name: 'verify-email', showReminder: true})
setEmailConfirmationRequested() snoozeEmailConfirmationPrompt()
} }
} }

View File

@ -100,7 +100,15 @@ function KnownFollowersInner({
moderation, moderation,
} }
}) })
const count = cachedKnownFollowers.count
// Does not have blocks applied. Always >= slices.length
const serverCount = cachedKnownFollowers.count
/*
* We check above too, but here for clarity and a reminder to _check for
* valid indices_
*/
if (slice.length === 0) return null
return ( return (
<Link <Link
@ -164,31 +172,54 @@ function KnownFollowersInner({
}, },
]} ]}
numberOfLines={2}> numberOfLines={2}>
{count > 2 ? ( {slice.length >= 2 ? (
<Trans> // 2-n followers, including blocks
Followed by{' '} serverCount > 2 ? (
<Text key={slice[0].profile.did} style={textStyle}> <Trans>
{slice[0].profile.displayName} Followed by{' '}
</Text> <Text key={slice[0].profile.did} style={textStyle}>
,{' '} {slice[0].profile.displayName}
<Text key={slice[1].profile.did} style={textStyle}> </Text>
{slice[1].profile.displayName} ,{' '}
</Text> <Text key={slice[1].profile.did} style={textStyle}>
, and{' '} {slice[1].profile.displayName}
<Plural value={count - 2} one="# other" other="# others" /> </Text>
</Trans> , and{' '}
) : count === 2 ? ( <Plural
value={serverCount - 2}
one="# other"
other="# others"
/>
</Trans>
) : (
// only 2
<Trans>
Followed by{' '}
<Text key={slice[0].profile.did} style={textStyle}>
{slice[0].profile.displayName}
</Text>{' '}
and{' '}
<Text key={slice[1].profile.did} style={textStyle}>
{slice[1].profile.displayName}
</Text>
</Trans>
)
) : serverCount > 1 ? (
// 1-n followers, including blocks
<Trans> <Trans>
Followed by{' '} Followed by{' '}
<Text key={slice[0].profile.did} style={textStyle}> <Text key={slice[0].profile.did} style={textStyle}>
{slice[0].profile.displayName} {slice[0].profile.displayName}
</Text>{' '} </Text>{' '}
and{' '} and{' '}
<Text key={slice[1].profile.did} style={textStyle}> <Plural
{slice[1].profile.displayName} value={serverCount - 1}
</Text> one="# other"
other="# others"
/>
</Trans> </Trans>
) : ( ) : (
// only 1
<Trans> <Trans>
Followed by{' '} Followed by{' '}
<Text key={slice[0].profile.did} style={textStyle}> <Text key={slice[0].profile.did} style={textStyle}>

View File

@ -18,8 +18,10 @@ import {Text} from '#/components/Typography'
export function NewskieDialog({ export function NewskieDialog({
profile, profile,
disabled,
}: { }: {
profile: AppBskyActorDefs.ProfileViewDetailed profile: AppBskyActorDefs.ProfileViewDetailed
disabled?: boolean
}) { }) {
const {_} = useLingui() const {_} = useLingui()
const moderationOpts = useModerationOpts() const moderationOpts = useModerationOpts()
@ -30,18 +32,20 @@ export function NewskieDialog({
const moderation = moderateProfile(profile, moderationOpts) const moderation = moderateProfile(profile, moderationOpts)
return sanitizeDisplayName(name, moderation.ui('displayName')) return sanitizeDisplayName(name, moderation.ui('displayName'))
}, [moderationOpts, profile]) }, [moderationOpts, profile])
const [now] = React.useState(() => Date.now())
const timeAgo = useGetTimeAgo() const timeAgo = useGetTimeAgo()
const createdAt = profile.createdAt as string | undefined const createdAt = profile.createdAt as string | undefined
const daysOld = React.useMemo(() => { const daysOld = React.useMemo(() => {
if (!createdAt) return Infinity if (!createdAt) return Infinity
return differenceInSeconds(new Date(), new Date(createdAt)) / 86400 return differenceInSeconds(now, new Date(createdAt)) / 86400
}, [createdAt]) }, [createdAt, now])
if (!createdAt || daysOld > 7) return null if (!createdAt || daysOld > 7) return null
return ( return (
<View style={[a.pr_2xs]}> <View style={[a.pr_2xs]}>
<Button <Button
disabled={disabled}
label={_( label={_(
msg`This user is new here. Press for more info about when they joined.`, msg`This user is new here. Press for more info about when they joined.`,
)} )}
@ -70,7 +74,7 @@ export function NewskieDialog({
<Text style={[a.text_md]}> <Text style={[a.text_md]}>
<Trans> <Trans>
{profileName} joined Bluesky{' '} {profileName} joined Bluesky{' '}
{timeAgo(createdAt, {format: 'long'})} ago {timeAgo(createdAt, now, {format: 'long'})} ago
</Trans> </Trans>
</Text> </Text>
</View> </View>

View File

@ -469,7 +469,7 @@ function Inner({
)} )}
</Text> </Text>
<ProfileHeaderHandle profile={profileShadow} /> <ProfileHeaderHandle profile={profileShadow} disableTaps />
</View> </View>
</Link> </Link>

View File

@ -92,6 +92,8 @@ function PostLabel({
<UserAvatar <UserAvatar
avatar={desc.sourceAvi} avatar={desc.sourceAvi}
size={size === 'large' ? 16 : 12} size={size === 'large' ? 16 : 12}
type="labeler"
shape="circle"
/> />
) : ( ) : (
<desc.icon size="sm" fill={t.atoms.text_contrast_medium.color} /> <desc.icon size="sm" fill={t.atoms.text_contrast_medium.color} />

View File

@ -1,6 +1,6 @@
import React, {ComponentProps} from 'react' import React, {ComponentProps} from 'react'
import {Pressable, StyleProp, StyleSheet, View, ViewStyle} from 'react-native' import {Pressable, StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
import {AppBskyActorDefs, ModerationUI} from '@atproto/api' import {AppBskyActorDefs, ModerationCause, ModerationUI} from '@atproto/api'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useQueryClient} from '@tanstack/react-query' import {useQueryClient} from '@tanstack/react-query'
@ -45,7 +45,8 @@ export function PostHider({
const [override, setOverride] = React.useState(false) const [override, setOverride] = React.useState(false)
const control = useModerationDetailsDialogControl() const control = useModerationDetailsDialogControl()
const blur = const blur =
modui.blurs[0] || (interpretFilterAsBlur ? modui.filters[0] : undefined) modui.blurs[0] ||
(interpretFilterAsBlur ? getBlurrableFilter(modui) : undefined)
const desc = useModerationCauseDescription(blur) const desc = useModerationCauseDescription(blur)
const onBeforePress = React.useCallback(() => { const onBeforePress = React.useCallback(() => {
@ -134,6 +135,13 @@ export function PostHider({
) )
} }
function getBlurrableFilter(modui: ModerationUI): ModerationCause | undefined {
// moderation causes get "downgraded" when they originate from embedded content
// a downgraded cause should *only* drive filtering in feeds, so we want to look
// for filters that arent downgraded
return modui.filters.find(filter => !filter.downgraded)
}
const styles = StyleSheet.create({ const styles = StyleSheet.create({
child: { child: {
borderWidth: 0, borderWidth: 0,

View File

@ -32,6 +32,8 @@ export type TrackPropertiesMap = {
'Post:ThreadMute': {} // CAN BE SERVER 'Post:ThreadMute': {} // CAN BE SERVER
'Post:ThreadUnmute': {} // CAN BE SERVER 'Post:ThreadUnmute': {} // CAN BE SERVER
'Post:Reply': {} // CAN BE SERVER 'Post:Reply': {} // CAN BE SERVER
'Post:EditThreadgateOpened': {}
'Post:ThreadgateEdited': {}
// PROFILE events // PROFILE events
'Profile:Follow': { 'Profile:Follow': {
username: string username: string

View File

@ -270,7 +270,7 @@ export async function post(agent: BskyAgent, opts: PostOpts) {
return res return res
} }
async function createThreadgate( export async function createThreadgate(
agent: BskyAgent, agent: BskyAgent,
postUri: string, postUri: string,
threadgate: ThreadgateSetting[], threadgate: ThreadgateSetting[],
@ -296,10 +296,17 @@ async function createThreadgate(
} }
const postUrip = new AtUri(postUri) const postUrip = new AtUri(postUri)
await agent.api.app.bsky.feed.threadgate.create( await agent.api.com.atproto.repo.putRecord({
{repo: agent.session!.did, rkey: postUrip.rkey}, repo: agent.session!.did,
{post: postUri, createdAt: new Date().toISOString(), allow}, collection: 'app.bsky.feed.threadgate',
) rkey: postUrip.rkey,
record: {
$type: 'app.bsky.feed.threadgate',
post: postUri,
allow,
createdAt: new Date().toISOString(),
},
})
} }
// helpers // helpers

View File

@ -1,4 +1,4 @@
import {useCallback, useMemo} from 'react' import {useCallback} from 'react'
import {msg, plural} from '@lingui/macro' import {msg, plural} from '@lingui/macro'
import {I18nContext, useLingui} from '@lingui/react' import {I18nContext, useLingui} from '@lingui/react'
import {differenceInSeconds} from 'date-fns' import {differenceInSeconds} from 'date-fns'
@ -12,25 +12,16 @@ export function useGetTimeAgo() {
const {_} = useLingui() const {_} = useLingui()
return useCallback( return useCallback(
( (
date: number | string | Date, earlier: number | string | Date,
later: number | string | Date,
options?: Omit<TimeAgoOptions, 'lingui'>, options?: Omit<TimeAgoOptions, 'lingui'>,
) => { ) => {
return dateDiff(date, Date.now(), {lingui: _, format: options?.format}) return dateDiff(earlier, later, {lingui: _, format: options?.format})
}, },
[_], [_],
) )
} }
export function useTimeAgo(
date: number | string | Date,
options?: Omit<TimeAgoOptions, 'lingui'>,
): string {
const timeAgo = useGetTimeAgo()
return useMemo(() => {
return timeAgo(date, {...options})
}, [date, options, timeAgo])
}
const NOW = 5 const NOW = 5
const MINUTE = 60 const MINUTE = 60
const HOUR = MINUTE * 60 const HOUR = MINUTE * 60

View File

@ -103,6 +103,8 @@ export type LogEvents = {
'post:unrepost': { 'post:unrepost': {
logContext: 'FeedItem' | 'PostThreadItem' | 'Post' logContext: 'FeedItem' | 'PostThreadItem' | 'Post'
} }
'post:mute': {}
'post:unmute': {}
'profile:follow': { 'profile:follow': {
didBecomeMutual: boolean | undefined didBecomeMutual: boolean | undefined
followeeClout: number | undefined followeeClout: number | undefined

View File

@ -1,7 +1,8 @@
import {Dimensions, Platform} from 'react-native' import {Dimensions} from 'react-native'
import {isSafari} from 'lib/browser' import {isSafari} from 'lib/browser'
import {isWeb} from 'platform/detection' import {isWeb} from 'platform/detection'
const {height: SCREEN_HEIGHT} = Dimensions.get('window') const {height: SCREEN_HEIGHT} = Dimensions.get('window')
const IFRAME_HOST = isWeb const IFRAME_HOST = isWeb
@ -342,40 +343,17 @@ export function parseEmbedPlayerFromUrl(
} }
} }
if (urlp.hostname === 'media.tenor.com') { const tenorGif = parseTenorGif(urlp)
let [_, id, filename] = urlp.pathname.split('/') if (tenorGif.success) {
const {playerUri, dimensions} = tenorGif
const h = urlp.searchParams.get('hh') return {
const w = urlp.searchParams.get('ww') type: 'tenor_gif',
let dimensions source: 'tenor',
if (h && w) { isGif: true,
dimensions = { hideDetails: true,
height: Number(h), playerUri,
width: Number(w), dimensions,
}
}
if (id && filename && dimensions && id.includes('AAAAC')) {
if (Platform.OS === 'web') {
if (isSafari) {
id = id.replace('AAAAC', 'AAAP1')
filename = filename.replace('.gif', '.mp4')
} else {
id = id.replace('AAAAC', 'AAAP3')
filename = filename.replace('.gif', '.webm')
}
} else {
id = id.replace('AAAAC', 'AAAAM')
}
return {
type: 'tenor_gif',
source: 'tenor',
isGif: true,
hideDetails: true,
playerUri: `https://t.gifs.bsky.app/${id}/${filename}`,
dimensions,
}
} }
} }
@ -516,3 +494,55 @@ export function getGiphyMetaUri(url: URL) {
} }
} }
} }
export function parseTenorGif(urlp: URL):
| {success: false}
| {
success: true
playerUri: string
dimensions: {height: number; width: number}
} {
if (urlp.hostname !== 'media.tenor.com') {
return {success: false}
}
let [_, id, filename] = urlp.pathname.split('/')
if (!id || !filename) {
return {success: false}
}
if (!id.includes('AAAAC')) {
return {success: false}
}
const h = urlp.searchParams.get('hh')
const w = urlp.searchParams.get('ww')
if (!h || !w) {
return {success: false}
}
const dimensions = {
height: Number(h),
width: Number(w),
}
if (isWeb) {
if (isSafari) {
id = id.replace('AAAAC', 'AAAP1')
filename = filename.replace('.gif', '.mp4')
} else {
id = id.replace('AAAAC', 'AAAP3')
filename = filename.replace('.gif', '.webm')
}
} else {
id = id.replace('AAAAC', 'AAAAM')
}
return {
success: true,
playerUri: `https://t.gifs.bsky.app/${id}/${filename}`,
dimensions,
}
}

View File

@ -19,3 +19,14 @@ export function getAge(birthDate: Date): number {
} }
return age return age
} }
/**
* Compares two dates by year, month, and day only
*/
export function simpleAreDatesEqual(a: Date, b: Date): boolean {
return (
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate()
)
}

View File

@ -12,8 +12,10 @@ import {Text} from '#/components/Typography'
export function ProfileHeaderHandle({ export function ProfileHeaderHandle({
profile, profile,
disableTaps,
}: { }: {
profile: Shadow<AppBskyActorDefs.ProfileViewDetailed> profile: Shadow<AppBskyActorDefs.ProfileViewDetailed>
disableTaps?: boolean
}) { }) {
const t = useTheme() const t = useTheme()
const invalidHandle = isInvalidHandle(profile.handle) const invalidHandle = isInvalidHandle(profile.handle)
@ -21,8 +23,8 @@ export function ProfileHeaderHandle({
return ( return (
<View <View
style={[a.flex_row, a.gap_xs, a.align_center]} style={[a.flex_row, a.gap_xs, a.align_center]}
pointerEvents={isAndroid ? 'box-only' : 'auto'}> pointerEvents={disableTaps ? 'none' : isAndroid ? 'box-only' : 'auto'}>
<NewskieDialog profile={profile} /> <NewskieDialog profile={profile} disabled={disableTaps} />
{profile.viewer?.followedBy && !blockHide ? ( {profile.viewer?.followedBy && !blockHide ? (
<View style={[t.atoms.bg_contrast_25, a.rounded_xs, a.px_sm, a.py_xs]}> <View style={[t.atoms.bg_contrast_25, a.rounded_xs, a.px_sm, a.py_xs]}>
<Text style={[t.atoms.text, a.text_sm]}> <Text style={[t.atoms.text, a.text_sm]}>

View File

@ -82,7 +82,7 @@ let ProfileHeaderLabeler = ({
preferences?.moderationPrefs.labelers.find(l => l.did === profile.did) preferences?.moderationPrefs.labelers.find(l => l.did === profile.did)
const canSubscribe = const canSubscribe =
isSubscribed || isSubscribed ||
(preferences ? preferences?.moderationPrefs.labelers.length < 9 : false) (preferences ? preferences?.moderationPrefs.labelers.length <= 20 : false)
const {mutateAsync: likeMod, isPending: isLikePending} = useLikeMutation() const {mutateAsync: likeMod, isPending: isLikePending} = useLikeMutation()
const {mutateAsync: unlikeMod, isPending: isUnlikePending} = const {mutateAsync: unlikeMod, isPending: isUnlikePending} =
useUnlikeMutation() useUnlikeMutation()
@ -328,8 +328,8 @@ function CantSubscribePrompt({
<Prompt.TitleText>Unable to subscribe</Prompt.TitleText> <Prompt.TitleText>Unable to subscribe</Prompt.TitleText>
<Prompt.DescriptionText> <Prompt.DescriptionText>
<Trans> <Trans>
We're sorry! You can only subscribe to ten labelers, and you've We're sorry! You can only subscribe to twenty labelers, and you've
reached your limit of ten. reached your limit of twenty.
</Trans> </Trans>
</Prompt.DescriptionText> </Prompt.DescriptionText>
<Prompt.Actions> <Prompt.Actions>

View File

@ -252,7 +252,6 @@ export function useSubmitSignup({
dispatch({type: 'setIsLoading', value: true}) dispatch({type: 'setIsLoading', value: true})
try { try {
onboardingDispatch({type: 'start'}) // start now to avoid flashing the wrong view
await createAccount({ await createAccount({
service: state.serviceUrl, service: state.serviceUrl,
email: state.email, email: state.email,
@ -262,8 +261,12 @@ export function useSubmitSignup({
inviteCode: state.inviteCode.trim(), inviteCode: state.inviteCode.trim(),
verificationCode: verificationCode, verificationCode: verificationCode,
}) })
/*
* Must happen last so that if the user has multiple tabs open and
* createAccount fails, one tab is not stuck in onboarding  Eric
*/
onboardingDispatch({type: 'start'})
} catch (e: any) { } catch (e: any) {
onboardingDispatch({type: 'skip'}) // undo starting the onboard
let errMsg = e.toString() let errMsg = e.toString()
if (e instanceof ComAtprotoServerCreateAccount.InvalidInviteCodeError) { if (e instanceof ComAtprotoServerCreateAccount.InvalidInviteCodeError) {
dispatch({ dispatch({

View File

@ -0,0 +1,97 @@
import React, {useEffect} from 'react'
import * as persisted from '#/state/persisted'
import {useAgent, useSession} from '../session'
type StateContext = Map<string, boolean>
type SetStateContext = (uri: string, value: boolean) => void
const stateContext = React.createContext<StateContext>(new Map())
const setStateContext = React.createContext<SetStateContext>(
(_: string) => false,
)
export function Provider({children}: React.PropsWithChildren<{}>) {
const [state, setState] = React.useState<StateContext>(() => new Map())
const setThreadMute = React.useCallback(
(uri: string, value: boolean) => {
setState(prev => {
const next = new Map(prev)
next.set(uri, value)
return next
})
},
[setState],
)
useMigrateMutes(setThreadMute)
return (
<stateContext.Provider value={state}>
<setStateContext.Provider value={setThreadMute}>
{children}
</setStateContext.Provider>
</stateContext.Provider>
)
}
export function useMutedThreads() {
return React.useContext(stateContext)
}
export function useIsThreadMuted(uri: string, defaultValue = false) {
const state = React.useContext(stateContext)
return state.get(uri) ?? defaultValue
}
export function useSetThreadMute() {
return React.useContext(setStateContext)
}
function useMigrateMutes(setThreadMute: SetStateContext) {
const agent = useAgent()
const {currentAccount} = useSession()
useEffect(() => {
if (currentAccount) {
if (
!persisted
.get('mutedThreads')
.some(uri => uri.includes(currentAccount.did))
) {
return
}
let cancelled = false
const migrate = async () => {
while (!cancelled) {
const threads = persisted.get('mutedThreads')
const root = threads.findLast(uri => uri.includes(currentAccount.did))
if (!root) break
persisted.write(
'mutedThreads',
threads.filter(uri => uri !== root),
)
setThreadMute(root, true)
await agent.api.app.bsky.graph
.muteThread({root})
// not a big deal if this fails, since the post might have been deleted
.catch(console.error)
}
}
migrate()
return () => {
cancelled = true
}
}
}, [agent, currentAccount, setThreadMute])
}

View File

@ -70,7 +70,8 @@ export interface SelfLabelModal {
export interface ThreadgateModal { export interface ThreadgateModal {
name: 'threadgate' name: 'threadgate'
settings: ThreadgateSetting[] settings: ThreadgateSetting[]
onChange: (settings: ThreadgateSetting[]) => void onChange?: (settings: ThreadgateSetting[]) => void
onConfirm?: (settings: ThreadgateSetting[]) => void
} }
export interface ChangeHandleModal { export interface ChangeHandleModal {

View File

@ -1,62 +0,0 @@
import React from 'react'
import * as persisted from '#/state/persisted'
import {track} from '#/lib/analytics/analytics'
type StateContext = persisted.Schema['mutedThreads']
type ToggleContext = (uri: string) => boolean
const stateContext = React.createContext<StateContext>(
persisted.defaults.mutedThreads,
)
const toggleContext = React.createContext<ToggleContext>((_: string) => false)
export function Provider({children}: React.PropsWithChildren<{}>) {
const [state, setState] = React.useState(persisted.get('mutedThreads'))
const toggleThreadMute = React.useCallback(
(uri: string) => {
let muted = false
setState((arr: string[]) => {
if (arr.includes(uri)) {
arr = arr.filter(v => v !== uri)
muted = false
track('Post:ThreadUnmute')
} else {
arr = arr.concat([uri])
muted = true
track('Post:ThreadMute')
}
persisted.write('mutedThreads', arr)
return arr
})
return muted
},
[setState],
)
React.useEffect(() => {
return persisted.onUpdate(() => {
setState(persisted.get('mutedThreads'))
})
}, [setState])
return (
<stateContext.Provider value={state}>
<toggleContext.Provider value={toggleThreadMute}>
{children}
</toggleContext.Provider>
</stateContext.Provider>
)
}
export function useMutedThreads() {
return React.useContext(stateContext)
}
export function useToggleThreadMute() {
return React.useContext(toggleContext)
}
export function isThreadMuted(uri: string) {
return persisted.get('mutedThreads').includes(uri)
}

View File

@ -74,7 +74,6 @@ export const schema = z.object({
flickr: z.enum(externalEmbedOptions).optional(), flickr: z.enum(externalEmbedOptions).optional(),
}) })
.optional(), .optional(),
mutedThreads: z.array(z.string()), // should move to server
invites: z.object({ invites: z.object({
copiedInvites: z.array(z.string()), copiedInvites: z.array(z.string()),
}), }),
@ -88,6 +87,8 @@ export const schema = z.object({
disableHaptics: z.boolean().optional(), disableHaptics: z.boolean().optional(),
disableAutoplay: z.boolean().optional(), disableAutoplay: z.boolean().optional(),
kawaii: z.boolean().optional(), kawaii: z.boolean().optional(),
/** @deprecated */
mutedThreads: z.array(z.string()),
}) })
export type Schema = z.infer<typeof schema> export type Schema = z.infer<typeof schema>

View File

@ -26,7 +26,6 @@ import {
useQueryClient, useQueryClient,
} from '@tanstack/react-query' } from '@tanstack/react-query'
import {useMutedThreads} from '#/state/muted-threads'
import {useAgent} from '#/state/session' import {useAgent} from '#/state/session'
import {useModerationOpts} from '../../preferences/moderation-opts' import {useModerationOpts} from '../../preferences/moderation-opts'
import {STALE} from '..' import {STALE} from '..'
@ -54,7 +53,6 @@ export function useNotificationFeedQuery(opts?: {enabled?: boolean}) {
const agent = useAgent() const agent = useAgent()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const moderationOpts = useModerationOpts() const moderationOpts = useModerationOpts()
const threadMutes = useMutedThreads()
const unreads = useUnreadNotificationsApi() const unreads = useUnreadNotificationsApi()
const enabled = opts?.enabled !== false const enabled = opts?.enabled !== false
const lastPageCountRef = useRef(0) const lastPageCountRef = useRef(0)
@ -82,7 +80,6 @@ export function useNotificationFeedQuery(opts?: {enabled?: boolean}) {
cursor: pageParam, cursor: pageParam,
queryClient, queryClient,
moderationOpts, moderationOpts,
threadMutes,
fetchAdditionalData: true, fetchAdditionalData: true,
}) })
).page ).page

View File

@ -9,7 +9,6 @@ import EventEmitter from 'eventemitter3'
import BroadcastChannel from '#/lib/broadcast' import BroadcastChannel from '#/lib/broadcast'
import {logger} from '#/logger' import {logger} from '#/logger'
import {useMutedThreads} from '#/state/muted-threads'
import {useAgent, useSession} from '#/state/session' import {useAgent, useSession} from '#/state/session'
import {resetBadgeCount} from 'lib/notifications/notifications' import {resetBadgeCount} from 'lib/notifications/notifications'
import {useModerationOpts} from '../../preferences/moderation-opts' import {useModerationOpts} from '../../preferences/moderation-opts'
@ -48,7 +47,6 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
const agent = useAgent() const agent = useAgent()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const moderationOpts = useModerationOpts() const moderationOpts = useModerationOpts()
const threadMutes = useMutedThreads()
const [numUnread, setNumUnread] = React.useState('') const [numUnread, setNumUnread] = React.useState('')
@ -147,7 +145,6 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
limit: 40, limit: 40,
queryClient, queryClient,
moderationOpts, moderationOpts,
threadMutes,
// only fetch subjects when the page is going to be used // only fetch subjects when the page is going to be used
// in the notifications query, otherwise skip it // in the notifications query, otherwise skip it
@ -192,7 +189,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
} }
}, },
} }
}, [setNumUnread, queryClient, moderationOpts, threadMutes, agent]) }, [setNumUnread, queryClient, moderationOpts, agent])
checkUnreadRef.current = api.checkUnread checkUnreadRef.current = api.checkUnread
return ( return (

View File

@ -1,5 +1,4 @@
import { import {
AppBskyEmbedRecord,
AppBskyFeedDefs, AppBskyFeedDefs,
AppBskyFeedLike, AppBskyFeedLike,
AppBskyFeedPost, AppBskyFeedPost,
@ -28,7 +27,6 @@ export async function fetchPage({
limit, limit,
queryClient, queryClient,
moderationOpts, moderationOpts,
threadMutes,
fetchAdditionalData, fetchAdditionalData,
}: { }: {
agent: BskyAgent agent: BskyAgent
@ -36,7 +34,6 @@ export async function fetchPage({
limit: number limit: number
queryClient: QueryClient queryClient: QueryClient
moderationOpts: ModerationOpts | undefined moderationOpts: ModerationOpts | undefined
threadMutes: string[]
fetchAdditionalData: boolean fetchAdditionalData: boolean
}): Promise<{page: FeedPage; indexedAt: string | undefined}> { }): Promise<{page: FeedPage; indexedAt: string | undefined}> {
const res = await agent.listNotifications({ const res = await agent.listNotifications({
@ -67,11 +64,6 @@ export async function fetchPage({
} }
} }
// apply thread muting
notifsGrouped = notifsGrouped.filter(
notif => !isThreadMuted(notif, threadMutes),
)
let seenAt = res.data.seenAt ? new Date(res.data.seenAt) : new Date() let seenAt = res.data.seenAt ? new Date(res.data.seenAt) : new Date()
if (Number.isNaN(seenAt.getTime())) { if (Number.isNaN(seenAt.getTime())) {
seenAt = new Date() seenAt = new Date()
@ -207,45 +199,3 @@ function getSubjectUri(
return notif.reasonSubject return notif.reasonSubject
} }
} }
export function isThreadMuted(notif: FeedNotification, threadMutes: string[]) {
// If there's a subject we want to use that. This will always work on the notifications tab
if (notif.subject) {
const record = notif.subject.record as AppBskyFeedPost.Record
// Check for a quote record
if (
(record.reply && threadMutes.includes(record.reply.root.uri)) ||
(notif.subject.uri && threadMutes.includes(notif.subject.uri))
) {
return true
} else if (
AppBskyEmbedRecord.isMain(record.embed) &&
threadMutes.includes(record.embed.record.uri)
) {
return true
}
} else {
// Otherwise we just do the best that we can
const record = notif.notification.record
if (AppBskyFeedPost.isRecord(record)) {
if (record.reply && threadMutes.includes(record.reply.root.uri)) {
// We can always filter replies
return true
} else if (
AppBskyEmbedRecord.isMain(record.embed) &&
threadMutes.includes(record.embed.record.uri)
) {
// We can also filter quotes if the quoted post is the root
return true
}
} else if (
AppBskyFeedRepost.isRecord(record) &&
threadMutes.includes(record.subject.uri)
) {
// Finally we can filter reposts, again if the post is the root
return true
}
}
return false
}

View File

@ -78,6 +78,7 @@ export interface FeedPostSliceItem {
feedContext: string | undefined feedContext: string | undefined
moderation: ModerationDecision moderation: ModerationDecision
parentAuthor?: AppBskyActorDefs.ProfileViewBasic parentAuthor?: AppBskyActorDefs.ProfileViewBasic
isParentBlocked?: boolean
} }
export interface FeedPostSlice { export interface FeedPostSlice {
@ -311,6 +312,10 @@ export function usePostFeedQuery(
const parentAuthor = const parentAuthor =
item.reply?.parent?.author ?? item.reply?.parent?.author ??
slice.items[i + 1]?.reply?.grandparentAuthor slice.items[i + 1]?.reply?.grandparentAuthor
const replyRef = item.reply
const isParentBlocked = AppBskyFeedDefs.isBlockedPost(
replyRef?.parent,
)
return { return {
_reactKey: `${slice._reactKey}-${i}-${item.post.uri}`, _reactKey: `${slice._reactKey}-${i}-${item.post.uri}`,
@ -324,6 +329,7 @@ export function usePostFeedQuery(
feedContext: item.feedContext || slice.feedContext, feedContext: item.feedContext || slice.feedContext,
moderation: moderations[i], moderation: moderations[i],
parentAuthor, parentAuthor,
isParentBlocked,
} }
} }
return undefined return undefined

View File

@ -31,7 +31,8 @@ import {
getEmbeddedPost, getEmbeddedPost,
} from './util' } from './util'
const RQKEY_ROOT = 'post-thread' const REPLY_TREE_DEPTH = 10
export const RQKEY_ROOT = 'post-thread'
export const RQKEY = (uri: string) => [RQKEY_ROOT, uri] export const RQKEY = (uri: string) => [RQKEY_ROOT, uri]
type ThreadViewNode = AppBskyFeedGetPostThread.OutputSchema['thread'] type ThreadViewNode = AppBskyFeedGetPostThread.OutputSchema['thread']
@ -90,7 +91,10 @@ export function usePostThreadQuery(uri: string | undefined) {
gcTime: 0, gcTime: 0,
queryKey: RQKEY(uri || ''), queryKey: RQKEY(uri || ''),
async queryFn() { async queryFn() {
const res = await agent.getPostThread({uri: uri!, depth: 10}) const res = await agent.getPostThread({
uri: uri!,
depth: REPLY_TREE_DEPTH,
})
if (res.success) { if (res.success) {
const thread = responseToThreadNodes(res.data.thread) const thread = responseToThreadNodes(res.data.thread)
annotateSelfThread(thread) annotateSelfThread(thread)
@ -287,7 +291,12 @@ function annotateSelfThread(thread: ThreadNode) {
selfThreadNode.ctx.isSelfThread = true selfThreadNode.ctx.isSelfThread = true
} }
const last = selfThreadNodes[selfThreadNodes.length - 1] const last = selfThreadNodes[selfThreadNodes.length - 1]
if (last && last.post.replyCount && !last.replies?.length) { if (
last &&
last.ctx.depth === REPLY_TREE_DEPTH && // at the edge of the tree depth
last.post.replyCount && // has replies
!last.replies?.length // replies were not hydrated
) {
last.ctx.hasMoreSelfThread = true last.ctx.hasMoreSelfThread = true
} }
} }

View File

@ -8,6 +8,7 @@ import {logEvent, LogEvents, toClout} from '#/lib/statsig/statsig'
import {updatePostShadow} from '#/state/cache/post-shadow' import {updatePostShadow} from '#/state/cache/post-shadow'
import {Shadow} from '#/state/cache/types' import {Shadow} from '#/state/cache/types'
import {useAgent, useSession} from '#/state/session' import {useAgent, useSession} from '#/state/session'
import {useIsThreadMuted, useSetThreadMute} from '../cache/thread-mutes'
import {findProfileQueryData} from './profile' import {findProfileQueryData} from './profile'
const RQKEY_ROOT = 'post' const RQKEY_ROOT = 'post'
@ -291,3 +292,72 @@ export function usePostDeleteMutation() {
}, },
}) })
} }
export function useThreadMuteMutationQueue(
post: Shadow<AppBskyFeedDefs.PostView>,
rootUri: string,
) {
const threadMuteMutation = useThreadMuteMutation()
const threadUnmuteMutation = useThreadUnmuteMutation()
const isThreadMuted = useIsThreadMuted(rootUri, post.viewer?.threadMuted)
const setThreadMute = useSetThreadMute()
const queueToggle = useToggleMutationQueue<boolean>({
initialState: isThreadMuted,
runMutation: async (_prev, shouldMute) => {
if (shouldMute) {
await threadMuteMutation.mutateAsync({
uri: rootUri,
})
return true
} else {
await threadUnmuteMutation.mutateAsync({
uri: rootUri,
})
return false
}
},
onSuccess(finalIsMuted) {
// finalize
setThreadMute(rootUri, finalIsMuted)
},
})
const queueMuteThread = useCallback(() => {
// optimistically update
setThreadMute(rootUri, true)
return queueToggle(true)
}, [setThreadMute, rootUri, queueToggle])
const queueUnmuteThread = useCallback(() => {
// optimistically update
setThreadMute(rootUri, false)
return queueToggle(false)
}, [rootUri, setThreadMute, queueToggle])
return [isThreadMuted, queueMuteThread, queueUnmuteThread] as const
}
function useThreadMuteMutation() {
const agent = useAgent()
return useMutation<
{},
Error,
{uri: string} // the root post's uri
>({
mutationFn: ({uri}) => {
logEvent('post:mute', {})
return agent.api.app.bsky.graph.muteThread({root: uri})
},
})
}
function useThreadUnmuteMutation() {
const agent = useAgent()
return useMutation<{}, Error, {uri: string}>({
mutationFn: ({uri}) => {
logEvent('post:unmute', {})
return agent.api.app.bsky.graph.unmuteThread({root: uri})
},
})
}

View File

@ -1,5 +1,38 @@
import {AppBskyFeedDefs, AppBskyFeedThreadgate} from '@atproto/api'
export type ThreadgateSetting = export type ThreadgateSetting =
| {type: 'nobody'} | {type: 'nobody'}
| {type: 'mention'} | {type: 'mention'}
| {type: 'following'} | {type: 'following'}
| {type: 'list'; list: string} | {type: 'list'; list: string}
export function threadgateViewToSettings(
threadgate: AppBskyFeedDefs.ThreadgateView | undefined,
): ThreadgateSetting[] {
const record =
threadgate &&
AppBskyFeedThreadgate.isRecord(threadgate.record) &&
AppBskyFeedThreadgate.validateRecord(threadgate.record).success
? threadgate.record
: null
if (!record) {
return []
}
if (!record.allow?.length) {
return [{type: 'nobody'}]
}
return record.allow
.map(allow => {
if (allow.$type === 'app.bsky.feed.threadgate#mentionRule') {
return {type: 'mention'}
}
if (allow.$type === 'app.bsky.feed.threadgate#followingRule') {
return {type: 'following'}
}
if (allow.$type === 'app.bsky.feed.threadgate#listRule') {
return {type: 'list', list: allow.list}
}
return undefined
})
.filter(Boolean) as ThreadgateSetting[]
}

View File

@ -11,6 +11,7 @@ import {
import {tryFetchGates} from '#/lib/statsig/statsig' import {tryFetchGates} from '#/lib/statsig/statsig'
import {getAge} from '#/lib/strings/time' import {getAge} from '#/lib/strings/time'
import {logger} from '#/logger' import {logger} from '#/logger'
import {snoozeEmailConfirmationPrompt} from '#/state/shell/reminders'
import { import {
configureModerationForAccount, configureModerationForAccount,
configureModerationForGuest, configureModerationForGuest,
@ -37,21 +38,7 @@ export async function createAgentAndResume(
} }
const gates = tryFetchGates(storedAccount.did, 'prefer-low-latency') const gates = tryFetchGates(storedAccount.did, 'prefer-low-latency')
const moderation = configureModerationForAccount(agent, storedAccount) const moderation = configureModerationForAccount(agent, storedAccount)
const prevSession: AtpSessionData = { const prevSession: AtpSessionData = sessionAccountToSession(storedAccount)
// Sorted in the same property order as when returned by BskyAgent (alphabetical).
accessJwt: storedAccount.accessJwt ?? '',
did: storedAccount.did,
email: storedAccount.email,
emailAuthFactor: storedAccount.emailAuthFactor,
emailConfirmed: storedAccount.emailConfirmed,
handle: storedAccount.handle,
refreshJwt: storedAccount.refreshJwt ?? '',
/**
* @see https://github.com/bluesky-social/atproto/blob/c5d36d5ba2a2c2a5c4f366a5621c06a5608e361e/packages/api/src/agent.ts#L188
*/
active: storedAccount.active ?? true,
status: storedAccount.status,
}
if (isSessionExpired(storedAccount)) { if (isSessionExpired(storedAccount)) {
await networkRetry(1, () => agent.resumeSession(prevSession)) await networkRetry(1, () => agent.resumeSession(prevSession))
} else { } else {
@ -191,6 +178,13 @@ export async function createAgentAndCreateAccount(
agent.setPersonalDetails({birthDate: birthDate.toISOString()}) agent.setPersonalDetails({birthDate: birthDate.toISOString()})
} }
try {
// snooze first prompt after signup, defer to next prompt
snoozeEmailConfirmationPrompt()
} catch (e: any) {
logger.error(e, {context: `session: failed snoozeEmailConfirmationPrompt`})
}
return prepareAgent(agent, gates, moderation, onSessionChange) return prepareAgent(agent, gates, moderation, onSessionChange)
} }
@ -245,3 +239,23 @@ export function agentToSessionAccount(
pdsUrl: agent.pdsUrl?.toString(), pdsUrl: agent.pdsUrl?.toString(),
} }
} }
export function sessionAccountToSession(
account: SessionAccount,
): AtpSessionData {
return {
// Sorted in the same property order as when returned by BskyAgent (alphabetical).
accessJwt: account.accessJwt ?? '',
did: account.did,
email: account.email,
emailAuthFactor: account.emailAuthFactor,
emailConfirmed: account.emailConfirmed,
handle: account.handle,
refreshJwt: account.refreshJwt ?? '',
/**
* @see https://github.com/bluesky-social/atproto/blob/c5d36d5ba2a2c2a5c4f366a5621c06a5608e361e/packages/api/src/agent.ts#L188
*/
active: account.active ?? true,
status: account.status,
}
}

View File

@ -14,6 +14,7 @@ import {
createAgentAndCreateAccount, createAgentAndCreateAccount,
createAgentAndLogin, createAgentAndLogin,
createAgentAndResume, createAgentAndResume,
sessionAccountToSession,
} from './agent' } from './agent'
import {getInitialState, reducer} from './reducer' import {getInitialState, reducer} from './reducer'
@ -175,8 +176,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
if (syncedAccount.did !== state.currentAgentState.did) { if (syncedAccount.did !== state.currentAgentState.did) {
resumeSession(syncedAccount) resumeSession(syncedAccount)
} else { } else {
// @ts-ignore we checked for `refreshJwt` above const agent = state.currentAgentState.agent as BskyAgent
state.currentAgentState.agent.session = syncedAccount agent.session = sessionAccountToSession(syncedAccount)
} }
} }
}) })

View File

@ -1,7 +1,5 @@
export function init() {}
export function shouldRequestEmailConfirmation() { export function shouldRequestEmailConfirmation() {
return false return false
} }
export function setEmailConfirmationRequested() {} export function snoozeEmailConfirmationPrompt() {}

View File

@ -1,36 +1,45 @@
import {simpleAreDatesEqual} from '#/lib/strings/time'
import {logger} from '#/logger'
import * as persisted from '#/state/persisted' import * as persisted from '#/state/persisted'
import {toHashCode} from 'lib/strings/helpers'
import {isOnboardingActive} from './onboarding'
import {SessionAccount} from '../session' import {SessionAccount} from '../session'
import {isOnboardingActive} from './onboarding'
export function shouldRequestEmailConfirmation(account: SessionAccount) { export function shouldRequestEmailConfirmation(account: SessionAccount) {
if (!account) { // ignore logged out
return false if (!account) return false
} // ignore confirmed accounts, this is the success state of this reminder
if (account.emailConfirmed) { if (account.emailConfirmed) return false
return false // wait for onboarding to complete
} if (isOnboardingActive()) return false
if (isOnboardingActive()) {
return false const snoozedAt = persisted.get('reminders').lastEmailConfirm
}
// only prompt once
if (persisted.get('reminders').lastEmailConfirm) {
return false
}
const today = new Date() const today = new Date()
// shard the users into 2 day of the week buckets
// (this is to avoid a sudden influx of email updates when logger.debug('Checking email confirmation reminder', {
// this feature rolls out) today,
const code = toHashCode(account.did) % 7 snoozedAt,
if (code !== today.getDay() && code !== (today.getDay() + 1) % 7) { })
// never been snoozed, new account
if (!snoozedAt) {
return true
}
// already snoozed today
if (simpleAreDatesEqual(new Date(Date.parse(snoozedAt)), new Date())) {
return false return false
} }
return true return true
} }
export function setEmailConfirmationRequested() { export function snoozeEmailConfirmationPrompt() {
const lastEmailConfirm = new Date().toISOString()
logger.debug('Snoozing email confirmation reminder', {
snoozedAt: lastEmailConfirm,
})
persisted.write('reminders', { persisted.write('reminders', {
...persisted.get('reminders'), ...persisted.get('reminders'),
lastEmailConfirm: new Date().toISOString(), lastEmailConfirm,
}) })
} }

View File

@ -26,9 +26,11 @@ export const snapPoints = ['60%']
export function Component({ export function Component({
settings, settings,
onChange, onChange,
onConfirm,
}: { }: {
settings: ThreadgateSetting[] settings: ThreadgateSetting[]
onChange: (settings: ThreadgateSetting[]) => void onChange?: (settings: ThreadgateSetting[]) => void
onConfirm?: (settings: ThreadgateSetting[]) => void
}) { }) {
const pal = usePalette('default') const pal = usePalette('default')
const {closeModal} = useModalControls() const {closeModal} = useModalControls()
@ -38,12 +40,12 @@ export function Component({
const onPressEverybody = () => { const onPressEverybody = () => {
setSelected([]) setSelected([])
onChange([]) onChange?.([])
} }
const onPressNobody = () => { const onPressNobody = () => {
setSelected([{type: 'nobody'}]) setSelected([{type: 'nobody'}])
onChange([{type: 'nobody'}]) onChange?.([{type: 'nobody'}])
} }
const onPressAudience = (setting: ThreadgateSetting) => { const onPressAudience = (setting: ThreadgateSetting) => {
@ -57,7 +59,7 @@ export function Component({
newSelected.splice(i, 1) newSelected.splice(i, 1)
} }
setSelected(newSelected) setSelected(newSelected)
onChange(newSelected) onChange?.(newSelected)
} }
return ( return (
@ -124,6 +126,7 @@ export function Component({
testID="confirmBtn" testID="confirmBtn"
onPress={() => { onPress={() => {
closeModal() closeModal()
onConfirm?.(selected)
}} }}
style={styles.btn} style={styles.btn}
accessibilityRole="button" accessibilityRole="button"

View File

@ -8,6 +8,7 @@ import {
} from 'react-native' } from 'react-native'
import { import {
AppBskyActorDefs, AppBskyActorDefs,
AppBskyEmbedExternal,
AppBskyEmbedImages, AppBskyEmbedImages,
AppBskyEmbedRecordWithMedia, AppBskyEmbedRecordWithMedia,
AppBskyFeedDefs, AppBskyFeedDefs,
@ -51,6 +52,7 @@ import {TimeElapsed} from '../util/TimeElapsed'
import {PreviewableUserAvatar, UserAvatar} from '../util/UserAvatar' import {PreviewableUserAvatar, UserAvatar} from '../util/UserAvatar'
import hairlineWidth = StyleSheet.hairlineWidth import hairlineWidth = StyleSheet.hairlineWidth
import {parseTenorGif} from '#/lib/strings/embed-player'
const MAX_AUTHORS = 5 const MAX_AUTHORS = 5
@ -465,17 +467,48 @@ function AdditionalPostText({post}: {post?: AppBskyFeedDefs.PostView}) {
const pal = usePalette('default') const pal = usePalette('default')
if (post && AppBskyFeedPost.isRecord(post?.record)) { if (post && AppBskyFeedPost.isRecord(post?.record)) {
const text = post.record.text const text = post.record.text
const images = AppBskyEmbedImages.isView(post.embed) let images
? post.embed.images let isGif = false
: AppBskyEmbedRecordWithMedia.isView(post.embed) &&
AppBskyEmbedImages.isView(post.embed.media) if (AppBskyEmbedImages.isView(post.embed)) {
? post.embed.media.images images = post.embed.images
: undefined } else if (
AppBskyEmbedRecordWithMedia.isView(post.embed) &&
AppBskyEmbedImages.isView(post.embed.media)
) {
images = post.embed.media.images
} else if (
AppBskyEmbedExternal.isView(post.embed) &&
post.embed.external.thumb
) {
let url: URL | undefined
try {
url = new URL(post.embed.external.uri)
} catch {}
if (url) {
const {success} = parseTenorGif(url)
if (success) {
isGif = true
images = [
{
thumb: post.embed.external.thumb,
alt: post.embed.external.title,
fullsize: post.embed.external.thumb,
},
]
}
}
}
return ( return (
<> <>
{text?.length > 0 && <Text style={pal.textLight}>{text}</Text>} {text?.length > 0 && <Text style={pal.textLight}>{text}</Text>}
{images && images.length > 0 && ( {images && images.length > 0 && (
<ImageHorzList images={images} style={styles.additionalPostImages} /> <ImageHorzList
images={images}
style={styles.additionalPostImages}
gif={isGif}
/>
)} )}
</> </>
) )

View File

@ -180,7 +180,7 @@ const desktopStyles = StyleSheet.create({
position: 'absolute', position: 'absolute',
left: 0, left: 0,
right: 0, right: 0,
bottom: -1, top: '100%',
borderBottomWidth: 1, borderBottomWidth: 1,
}, },
}) })
@ -207,7 +207,7 @@ const mobileStyles = StyleSheet.create({
position: 'absolute', position: 'absolute',
left: 0, left: 0,
right: 0, right: 0,
bottom: -1, top: '100%',
borderBottomWidth: hairlineWidth, borderBottomWidth: hairlineWidth,
}, },
}) })

View File

@ -331,7 +331,11 @@ export function PostThread({
<PostThreadShowHiddenReplies <PostThreadShowHiddenReplies
type={item === SHOW_HIDDEN_REPLIES ? 'hidden' : 'muted'} type={item === SHOW_HIDDEN_REPLIES ? 'hidden' : 'muted'}
onPress={() => onPress={() =>
setHiddenRepliesState(HiddenRepliesState.ShowAndOverridePostHider) setHiddenRepliesState(
item === SHOW_HIDDEN_REPLIES
? HiddenRepliesState.Show
: HiddenRepliesState.ShowAndOverridePostHider,
)
} }
hideTopBorder={index === 0} hideTopBorder={index === 0}
/> />

View File

@ -25,7 +25,7 @@ import {sanitizeHandle} from 'lib/strings/handles'
import {countLines} from 'lib/strings/helpers' import {countLines} from 'lib/strings/helpers'
import {niceDate} from 'lib/strings/time' import {niceDate} from 'lib/strings/time'
import {s} from 'lib/styles' import {s} from 'lib/styles'
import {isWeb} from 'platform/detection' import {isNative, isWeb} from 'platform/detection'
import {useSession} from 'state/session' import {useSession} from 'state/session'
import {PostThreadFollowBtn} from 'view/com/post-thread/PostThreadFollowBtn' import {PostThreadFollowBtn} from 'view/com/post-thread/PostThreadFollowBtn'
import {atoms as a} from '#/alf' import {atoms as a} from '#/alf'
@ -189,6 +189,7 @@ let PostThreadItemLoaded = ({
const itemTitle = _(msg`Post by ${post.author.handle}`) const itemTitle = _(msg`Post by ${post.author.handle}`)
const authorHref = makeProfileLink(post.author) const authorHref = makeProfileLink(post.author)
const authorTitle = post.author.handle const authorTitle = post.author.handle
const isThreadAuthor = getThreadAuthor(post, record) === currentAccount?.did
const likesHref = React.useMemo(() => { const likesHref = React.useMemo(() => {
const urip = new AtUri(post.uri) const urip = new AtUri(post.uri)
return makeProfileLink(post.author, 'post', urip.rkey, 'liked-by') return makeProfileLink(post.author, 'post', urip.rkey, 'liked-by')
@ -395,7 +396,11 @@ let PostThreadItemLoaded = ({
</View> </View>
</View> </View>
</View> </View>
<WhoCanReply post={post} /> <WhoCanReply
post={post}
isThreadAuthor={isThreadAuthor}
style={{borderBottomWidth: isNative ? 1 : 0}}
/>
</> </>
) )
} else { } else {
@ -578,7 +583,9 @@ let PostThreadItemLoaded = ({
post={post} post={post}
style={{ style={{
marginTop: 4, marginTop: 4,
borderBottomWidth: 1,
}} }}
isThreadAuthor={isThreadAuthor}
/> />
</> </>
) )
@ -681,6 +688,20 @@ function ExpandedPostDetails({
) )
} }
function getThreadAuthor(
post: AppBskyFeedDefs.PostView,
record: AppBskyFeedPost.Record,
): string {
if (!record.reply) {
return post.author.did
}
try {
return new AtUri(record.reply.root.uri).host
} catch {
return ''
}
}
const styles = StyleSheet.create({ const styles = StyleSheet.create({
outer: { outer: {
borderTopWidth: hairlineWidth, borderTopWidth: hairlineWidth,

View File

@ -56,6 +56,7 @@ interface FeedItemProps {
isThreadParent?: boolean isThreadParent?: boolean
feedContext: string | undefined feedContext: string | undefined
hideTopBorder?: boolean hideTopBorder?: boolean
isParentBlocked?: boolean
} }
export function FeedItem({ export function FeedItem({
@ -70,6 +71,7 @@ export function FeedItem({
isThreadLastChild, isThreadLastChild,
isThreadParent, isThreadParent,
hideTopBorder, hideTopBorder,
isParentBlocked,
}: FeedItemProps & {post: AppBskyFeedDefs.PostView}): React.ReactNode { }: FeedItemProps & {post: AppBskyFeedDefs.PostView}): React.ReactNode {
const postShadowed = usePostShadow(post) const postShadowed = usePostShadow(post)
const richText = useMemo( const richText = useMemo(
@ -100,6 +102,7 @@ export function FeedItem({
isThreadLastChild={isThreadLastChild} isThreadLastChild={isThreadLastChild}
isThreadParent={isThreadParent} isThreadParent={isThreadParent}
hideTopBorder={hideTopBorder} hideTopBorder={hideTopBorder}
isParentBlocked={isParentBlocked}
/> />
) )
} }
@ -119,6 +122,7 @@ let FeedItemInner = ({
isThreadLastChild, isThreadLastChild,
isThreadParent, isThreadParent,
hideTopBorder, hideTopBorder,
isParentBlocked,
}: FeedItemProps & { }: FeedItemProps & {
richText: RichTextAPI richText: RichTextAPI
post: Shadow<AppBskyFeedDefs.PostView> post: Shadow<AppBskyFeedDefs.PostView>
@ -320,7 +324,7 @@ let FeedItemInner = ({
onOpenAuthor={onOpenAuthor} onOpenAuthor={onOpenAuthor}
/> />
{!isThreadChild && showReplyTo && parentAuthor && ( {!isThreadChild && showReplyTo && parentAuthor && (
<ReplyToLabel profile={parentAuthor} /> <ReplyToLabel blocked={isParentBlocked} profile={parentAuthor} />
)} )}
<LabelsOnMyPost post={post} /> <LabelsOnMyPost post={post} />
<PostContent <PostContent
@ -409,9 +413,14 @@ let PostContent = ({
} }
PostContent = memo(PostContent) PostContent = memo(PostContent)
function ReplyToLabel({profile}: {profile: AppBskyActorDefs.ProfileViewBasic}) { function ReplyToLabel({
profile,
blocked,
}: {
profile: AppBskyActorDefs.ProfileViewBasic
blocked?: boolean
}) {
const pal = usePalette('default') const pal = usePalette('default')
return ( return (
<View style={[s.flexRow, s.mb2, s.alignCenter]}> <View style={[s.flexRow, s.mb2, s.alignCenter]}>
<FontAwesomeIcon <FontAwesomeIcon
@ -424,23 +433,27 @@ function ReplyToLabel({profile}: {profile: AppBskyActorDefs.ProfileViewBasic}) {
style={[pal.textLight, s.mr2]} style={[pal.textLight, s.mr2]}
lineHeight={1.2} lineHeight={1.2}
numberOfLines={1}> numberOfLines={1}>
<Trans context="description"> {blocked ? (
Reply to{' '} <Trans context="description">Reply to a blocked post</Trans>
<ProfileHoverCard inline did={profile.did}> ) : (
<TextLinkOnWebOnly <Trans context="description">
type="md" Reply to{' '}
style={pal.textLight} <ProfileHoverCard inline did={profile.did}>
lineHeight={1.2} <TextLinkOnWebOnly
numberOfLines={1} type="md"
href={makeProfileLink(profile)} style={pal.textLight}
text={ lineHeight={1.2}
profile.displayName numberOfLines={1}
? sanitizeDisplayName(profile.displayName) href={makeProfileLink(profile)}
: sanitizeHandle(profile.handle) text={
} profile.displayName
/> ? sanitizeDisplayName(profile.displayName)
</ProfileHoverCard> : sanitizeHandle(profile.handle)
</Trans> }
/>
</ProfileHoverCard>
</Trans>
)}
</Text> </Text>
</View> </View>
) )

View File

@ -34,6 +34,7 @@ let FeedSlice = ({
isThreadParent={isThreadParentAt(slice.items, 0)} isThreadParent={isThreadParentAt(slice.items, 0)}
isThreadChild={isThreadChildAt(slice.items, 0)} isThreadChild={isThreadChildAt(slice.items, 0)}
hideTopBorder={hideTopBorder} hideTopBorder={hideTopBorder}
isParentBlocked={slice.items[0].isParentBlocked}
/> />
<FeedItem <FeedItem
key={slice.items[1]._reactKey} key={slice.items[1]._reactKey}
@ -46,6 +47,7 @@ let FeedSlice = ({
moderation={slice.items[1].moderation} moderation={slice.items[1].moderation}
isThreadParent={isThreadParentAt(slice.items, 1)} isThreadParent={isThreadParentAt(slice.items, 1)}
isThreadChild={isThreadChildAt(slice.items, 1)} isThreadChild={isThreadChildAt(slice.items, 1)}
isParentBlocked={slice.items[1].isParentBlocked}
/> />
<ViewFullThread slice={slice} /> <ViewFullThread slice={slice} />
<FeedItem <FeedItem
@ -59,6 +61,7 @@ let FeedSlice = ({
moderation={slice.items[last].moderation} moderation={slice.items[last].moderation}
isThreadParent={isThreadParentAt(slice.items, last)} isThreadParent={isThreadParentAt(slice.items, last)}
isThreadChild={isThreadChildAt(slice.items, last)} isThreadChild={isThreadChildAt(slice.items, last)}
isParentBlocked={slice.items[2].isParentBlocked}
isThreadLastChild isThreadLastChild
/> />
</> </>
@ -82,6 +85,7 @@ let FeedSlice = ({
isThreadLastChild={ isThreadLastChild={
isThreadChildAt(slice.items, i) && slice.items.length === i + 1 isThreadChildAt(slice.items, i) && slice.items.length === i + 1
} }
isParentBlocked={slice.items[i].isParentBlocked}
hideTopBorder={hideTopBorder && i === 0} hideTopBorder={hideTopBorder && i === 0}
/> />
))} ))}

View File

@ -1,128 +1,172 @@
import React from 'react' import React from 'react'
import {StyleProp, View, ViewStyle} from 'react-native' import {Keyboard, StyleProp, View, ViewStyle} from 'react-native'
import { import {AppBskyFeedDefs, AppBskyGraphDefs, AtUri} from '@atproto/api'
AppBskyFeedDefs, import {msg, Trans} from '@lingui/macro'
AppBskyFeedThreadgate, import {useLingui} from '@lingui/react'
AppBskyGraphDefs, import {useQueryClient} from '@tanstack/react-query'
AtUri,
} from '@atproto/api'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {Trans} from '@lingui/macro'
import {useAnalytics} from '#/lib/analytics/analytics'
import {createThreadgate} from '#/lib/api'
import {useColorSchemeStyle} from '#/lib/hooks/useColorSchemeStyle' import {useColorSchemeStyle} from '#/lib/hooks/useColorSchemeStyle'
import {usePalette} from '#/lib/hooks/usePalette' import {usePalette} from '#/lib/hooks/usePalette'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {makeListLink, makeProfileLink} from '#/lib/routes/links' import {makeListLink, makeProfileLink} from '#/lib/routes/links'
import {colors} from '#/lib/styles' import {colors} from '#/lib/styles'
import {logger} from '#/logger'
import {isNative} from '#/platform/detection'
import {useModalControls} from '#/state/modals'
import {RQKEY_ROOT as POST_THREAD_RQKEY_ROOT} from '#/state/queries/post-thread'
import {
ThreadgateSetting,
threadgateViewToSettings,
} from '#/state/queries/threadgate'
import {useAgent} from '#/state/session'
import * as Toast from 'view/com/util/Toast'
import {Button} from '#/components/Button'
import {TextLink} from '../util/Link' import {TextLink} from '../util/Link'
import {Text} from '../util/text/Text' import {Text} from '../util/text/Text'
export function WhoCanReply({ export function WhoCanReply({
post, post,
isThreadAuthor,
style, style,
}: { }: {
post: AppBskyFeedDefs.PostView post: AppBskyFeedDefs.PostView
isThreadAuthor: boolean
style?: StyleProp<ViewStyle> style?: StyleProp<ViewStyle>
}) { }) {
const {track} = useAnalytics()
const {_} = useLingui()
const pal = usePalette('default') const pal = usePalette('default')
const {isMobile} = useWebMediaQueries() const agent = useAgent()
const queryClient = useQueryClient()
const {openModal} = useModalControls()
const containerStyles = useColorSchemeStyle( const containerStyles = useColorSchemeStyle(
{ {
borderColor: pal.colors.unreadNotifBorder,
backgroundColor: pal.colors.unreadNotifBg, backgroundColor: pal.colors.unreadNotifBg,
}, },
{ {
borderColor: pal.colors.unreadNotifBorder,
backgroundColor: pal.colors.unreadNotifBg, backgroundColor: pal.colors.unreadNotifBg,
}, },
) )
const iconStyles = useColorSchemeStyle(
{
backgroundColor: colors.blue3,
},
{
backgroundColor: colors.blue3,
},
)
const textStyles = useColorSchemeStyle( const textStyles = useColorSchemeStyle(
{color: colors.gray7}, {color: colors.blue5},
{color: colors.blue1}, {color: colors.blue1},
) )
const record = React.useMemo( const hoverStyles = useColorSchemeStyle(
() => {
post.threadgate && backgroundColor: colors.white,
AppBskyFeedThreadgate.isRecord(post.threadgate.record) && },
AppBskyFeedThreadgate.validateRecord(post.threadgate.record).success {
? post.threadgate.record backgroundColor: pal.colors.background,
: null, },
)
const settings = React.useMemo(
() => threadgateViewToSettings(post.threadgate),
[post], [post],
) )
if (record) { const isRootPost = !('reply' in post.record)
return (
<View const onPressEdit = () => {
style={[ track('Post:EditThreadgateOpened')
{ if (isNative && Keyboard.isVisible()) {
flexDirection: 'row', Keyboard.dismiss()
alignItems: 'center', }
gap: isMobile ? 8 : 10, openModal({
paddingHorizontal: isMobile ? 16 : 18, name: 'threadgate',
paddingVertical: 12, settings,
borderWidth: 1, async onConfirm(newSettings: ThreadgateSetting[]) {
borderLeftWidth: isMobile ? 0 : 1, try {
borderRightWidth: isMobile ? 0 : 1, if (newSettings.length) {
}, await createThreadgate(agent, post.uri, newSettings)
containerStyles, } else {
style, await agent.api.com.atproto.repo.deleteRecord({
]}> repo: agent.session!.did,
<View collection: 'app.bsky.feed.threadgate',
style={[ rkey: new AtUri(post.uri).rkey,
{ })
flexDirection: 'row', }
alignItems: 'center', Toast.show('Thread settings updated')
justifyContent: 'center', queryClient.invalidateQueries({
width: 32, queryKey: [POST_THREAD_RQKEY_ROOT],
height: 32, })
borderRadius: 19, track('Post:ThreadgateEdited')
}, } catch (err) {
iconStyles, Toast.show(
]}> 'There was an issue. Please check your internet connection and try again.',
<FontAwesomeIcon )
icon={['far', 'comments']} logger.error('Failed to edit threadgate', {message: err})
size={16} }
color={'#fff'} },
/> })
</View>
<View style={{flex: 1}}>
<Text type="sm" style={[{flexWrap: 'wrap'}, textStyles]}>
{!record.allow?.length ? (
<Trans>Replies to this thread are disabled</Trans>
) : (
<Trans>
Only{' '}
{record.allow.map((rule, i) => (
<>
<Rule
key={`rule-${i}`}
rule={rule}
post={post}
lists={post.threadgate!.lists}
/>
<Separator
key={`sep-${i}`}
i={i}
length={record.allow!.length}
/>
</>
))}{' '}
can reply.
</Trans>
)}
</Text>
</View>
</View>
)
} }
return null
if (!isRootPost) {
return null
}
if (!settings.length && !isThreadAuthor) {
return null
}
return (
<View
style={[
{
flexDirection: 'row',
alignItems: 'center',
gap: 10,
paddingLeft: 18,
paddingRight: 14,
paddingVertical: 10,
borderTopWidth: 1,
},
pal.border,
containerStyles,
style,
]}>
<View style={{flex: 1, paddingVertical: 6}}>
<Text type="sm" style={[{flexWrap: 'wrap'}, textStyles]}>
{!settings.length ? (
<Trans>Everybody can reply.</Trans>
) : settings[0].type === 'nobody' ? (
<Trans>Replies to this thread are disabled.</Trans>
) : (
<Trans>
Only{' '}
{settings.map((rule, i) => (
<>
<Rule
key={`rule-${i}`}
rule={rule}
post={post}
lists={post.threadgate!.lists}
/>
<Separator key={`sep-${i}`} i={i} length={settings.length} />
</>
))}{' '}
can reply.
</Trans>
)}
</Text>
</View>
{isThreadAuthor && (
<View>
<Button label={_(msg`Edit`)} onPress={onPressEdit}>
{({hovered}) => (
<View
style={[
hovered && hoverStyles,
{paddingVertical: 6, paddingHorizontal: 8, borderRadius: 8},
]}>
<Text type="sm" style={pal.link}>
<Trans>Edit</Trans>
</Text>
</View>
)}
</Button>
</View>
)}
</View>
)
} }
function Rule({ function Rule({
@ -130,15 +174,15 @@ function Rule({
post, post,
lists, lists,
}: { }: {
rule: any rule: ThreadgateSetting
post: AppBskyFeedDefs.PostView post: AppBskyFeedDefs.PostView
lists: AppBskyGraphDefs.ListViewBasic[] | undefined lists: AppBskyGraphDefs.ListViewBasic[] | undefined
}) { }) {
const pal = usePalette('default') const pal = usePalette('default')
if (AppBskyFeedThreadgate.isMentionRule(rule)) { if (rule.type === 'mention') {
return <Trans>mentioned users</Trans> return <Trans>mentioned users</Trans>
} }
if (AppBskyFeedThreadgate.isFollowingRule(rule)) { if (rule.type === 'following') {
return ( return (
<Trans> <Trans>
users followed by{' '} users followed by{' '}
@ -151,7 +195,7 @@ function Rule({
</Trans> </Trans>
) )
} }
if (AppBskyFeedThreadgate.isListRule(rule)) { if (rule.type === 'list') {
const list = lists?.find(l => l.uri === rule.list) const list = lists?.find(l => l.uri === rule.list)
if (list) { if (list) {
const listUrip = new AtUri(list.uri) const listUrip = new AtUri(list.uri)

View File

@ -38,6 +38,7 @@ function ListImpl<ItemT>(
{ {
ListHeaderComponent, ListHeaderComponent,
ListFooterComponent, ListFooterComponent,
ListEmptyComponent,
containWeb, containWeb,
contentContainerStyle, contentContainerStyle,
data, data,
@ -72,23 +73,35 @@ function ListImpl<ItemT>(
) )
} }
let header: JSX.Element | null = null const isEmpty = !data || data.length === 0
let headerComponent: JSX.Element | null = null
if (ListHeaderComponent != null) { if (ListHeaderComponent != null) {
if (isValidElement(ListHeaderComponent)) { if (isValidElement(ListHeaderComponent)) {
header = ListHeaderComponent headerComponent = ListHeaderComponent
} else { } else {
// @ts-ignore Nah it's fine. // @ts-ignore Nah it's fine.
header = <ListHeaderComponent /> headerComponent = <ListHeaderComponent />
} }
} }
let footer: JSX.Element | null = null let footerComponent: JSX.Element | null = null
if (ListFooterComponent != null) { if (ListFooterComponent != null) {
if (isValidElement(ListFooterComponent)) { if (isValidElement(ListFooterComponent)) {
footer = ListFooterComponent footerComponent = ListFooterComponent
} else { } else {
// @ts-ignore Nah it's fine. // @ts-ignore Nah it's fine.
footer = <ListFooterComponent /> footerComponent = <ListFooterComponent />
}
}
let emptyComponent: JSX.Element | null = null
if (ListEmptyComponent != null) {
if (isValidElement(ListEmptyComponent)) {
emptyComponent = ListEmptyComponent
} else {
// @ts-ignore Nah it's fine.
emptyComponent = <ListEmptyComponent />
} }
} }
@ -323,36 +336,38 @@ function ListImpl<ItemT>(
onVisibleChange={handleAboveTheFoldVisibleChange} onVisibleChange={handleAboveTheFoldVisibleChange}
style={[styles.aboveTheFoldDetector, {height: headerOffset}]} style={[styles.aboveTheFoldDetector, {height: headerOffset}]}
/> />
{onStartReached && ( {onStartReached && !isEmpty && (
<Visibility <Visibility
root={containWeb ? nativeRef : null} root={containWeb ? nativeRef : null}
onVisibleChange={onHeadVisibilityChange} onVisibleChange={onHeadVisibilityChange}
topMargin={(onStartReachedThreshold ?? 0) * 100 + '%'} topMargin={(onStartReachedThreshold ?? 0) * 100 + '%'}
/> />
)} )}
{header} {headerComponent}
{(data as Array<ItemT>).map((item, index) => { {isEmpty
const key = keyExtractor!(item, index) ? emptyComponent
return ( : (data as Array<ItemT>)?.map((item, index) => {
<Row<ItemT> const key = keyExtractor!(item, index)
key={key} return (
item={item} <Row<ItemT>
index={index} key={key}
renderItem={renderItem} item={item}
extraData={extraData} index={index}
onItemSeen={onItemSeen} renderItem={renderItem}
disableContentVisibility={disableContentVisibility} extraData={extraData}
/> onItemSeen={onItemSeen}
) disableContentVisibility={disableContentVisibility}
})} />
{onEndReached && ( )
})}
{onEndReached && !isEmpty && (
<Visibility <Visibility
root={containWeb ? nativeRef : null} root={containWeb ? nativeRef : null}
onVisibleChange={onTailVisibilityChange} onVisibleChange={onTailVisibilityChange}
bottomMargin={(onEndReachedThreshold ?? 0) * 100 + '%'} bottomMargin={(onEndReachedThreshold ?? 0) * 100 + '%'}
/> />
)} )}
{footer} {footerComponent}
</View> </View>
</View> </View>
) )

View File

@ -15,12 +15,14 @@ export function TimeElapsed({
const ago = useGetTimeAgo() const ago = useGetTimeAgo()
const format = timeToString ?? ago const format = timeToString ?? ago
const tick = useTickEveryMinute() const tick = useTickEveryMinute()
const [timeElapsed, setTimeAgo] = React.useState(() => format(timestamp)) const [timeElapsed, setTimeAgo] = React.useState(() =>
format(timestamp, tick),
)
const [prevTick, setPrevTick] = React.useState(tick) const [prevTick, setPrevTick] = React.useState(tick)
if (prevTick !== tick) { if (prevTick !== tick) {
setPrevTick(tick) setPrevTick(tick)
setTimeAgo(format(timestamp)) setTimeAgo(format(timestamp, tick))
} }
return children({timeElapsed}) return children({timeElapsed})

View File

@ -35,6 +35,7 @@ export type UserAvatarType = 'user' | 'algo' | 'list' | 'labeler'
interface BaseUserAvatarProps { interface BaseUserAvatarProps {
type?: UserAvatarType type?: UserAvatarType
shape?: 'circle' | 'square'
size: number size: number
avatar?: string | null avatar?: string | null
} }
@ -60,12 +61,16 @@ const BLUR_AMOUNT = isWeb ? 5 : 100
let DefaultAvatar = ({ let DefaultAvatar = ({
type, type,
shape: overrideShape,
size, size,
}: { }: {
type: UserAvatarType type: UserAvatarType
shape?: 'square' | 'circle'
size: number size: number
}): React.ReactNode => { }): React.ReactNode => {
const finalShape = overrideShape ?? (type === 'user' ? 'circle' : 'square')
if (type === 'algo') { if (type === 'algo') {
// TODO: shape=circle
// Font Awesome Pro 6.4.0 by @fontawesome -https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. // Font Awesome Pro 6.4.0 by @fontawesome -https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc.
return ( return (
<Svg <Svg
@ -84,6 +89,7 @@ let DefaultAvatar = ({
) )
} }
if (type === 'list') { if (type === 'list') {
// TODO: shape=circle
// Font Awesome Pro 6.4.0 by @fontawesome -https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. // Font Awesome Pro 6.4.0 by @fontawesome -https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc.
return ( return (
<Svg <Svg
@ -117,14 +123,18 @@ let DefaultAvatar = ({
viewBox="0 0 32 32" viewBox="0 0 32 32"
fill="none" fill="none"
stroke="none"> stroke="none">
<Rect {finalShape === 'square' ? (
x="0" <Rect
y="0" x="0"
width="32" y="0"
height="32" width="32"
rx="3" height="32"
fill={tokens.color.temp_purple} rx="3"
/> fill={tokens.color.temp_purple}
/>
) : (
<Circle cx="16" cy="16" r="16" fill={tokens.color.temp_purple} />
)}
<Path <Path
d="M24 9.75L16 7L8 9.75V15.9123C8 20.8848 12 23 16 25.1579C20 23 24 20.8848 24 15.9123V9.75Z" d="M24 9.75L16 7L8 9.75V15.9123C8 20.8848 12 23 16 25.1579C20 23 24 20.8848 24 15.9123V9.75Z"
stroke="white" stroke="white"
@ -135,6 +145,7 @@ let DefaultAvatar = ({
</Svg> </Svg>
) )
} }
// TODO: shape=square
return ( return (
<Svg <Svg
testID="userAvatarFallback" testID="userAvatarFallback"
@ -159,6 +170,7 @@ export {DefaultAvatar}
let UserAvatar = ({ let UserAvatar = ({
type = 'user', type = 'user',
shape: overrideShape,
size, size,
avatar, avatar,
moderation, moderation,
@ -166,9 +178,10 @@ let UserAvatar = ({
}: UserAvatarProps): React.ReactNode => { }: UserAvatarProps): React.ReactNode => {
const pal = usePalette('default') const pal = usePalette('default')
const backgroundColor = pal.colors.backgroundLight const backgroundColor = pal.colors.backgroundLight
const finalShape = overrideShape ?? (type === 'user' ? 'circle' : 'square')
const aviStyle = useMemo(() => { const aviStyle = useMemo(() => {
if (type === 'algo' || type === 'list' || type === 'labeler') { if (finalShape === 'square') {
return { return {
width: size, width: size,
height: size, height: size,
@ -182,7 +195,7 @@ let UserAvatar = ({
borderRadius: Math.floor(size / 2), borderRadius: Math.floor(size / 2),
backgroundColor, backgroundColor,
} }
}, [type, size, backgroundColor]) }, [finalShape, size, backgroundColor])
const alert = useMemo(() => { const alert = useMemo(() => {
if (!moderation?.alert) { if (!moderation?.alert) {
@ -224,7 +237,7 @@ let UserAvatar = ({
</View> </View>
) : ( ) : (
<View style={{width: size, height: size}}> <View style={{width: size, height: size}}>
<DefaultAvatar type={type} size={size} /> <DefaultAvatar type={type} shape={finalShape} size={size} />
{alert} {alert}
</View> </View>
) )

View File

@ -7,7 +7,7 @@ import {
} from 'react-native' } from 'react-native'
import * as Clipboard from 'expo-clipboard' import * as Clipboard from 'expo-clipboard'
import { import {
AppBskyActorDefs, AppBskyFeedDefs,
AppBskyFeedPost, AppBskyFeedPost,
AtUri, AtUri,
RichText as RichTextAPI, RichText as RichTextAPI,
@ -22,12 +22,15 @@ import {richTextToString} from '#/lib/strings/rich-text-helpers'
import {getTranslatorLink} from '#/locale/helpers' import {getTranslatorLink} from '#/locale/helpers'
import {logger} from '#/logger' import {logger} from '#/logger'
import {isWeb} from '#/platform/detection' import {isWeb} from '#/platform/detection'
import {Shadow} from '#/state/cache/post-shadow'
import {useFeedFeedbackContext} from '#/state/feed-feedback' import {useFeedFeedbackContext} from '#/state/feed-feedback'
import {useMutedThreads, useToggleThreadMute} from '#/state/muted-threads'
import {useLanguagePrefs} from '#/state/preferences' import {useLanguagePrefs} from '#/state/preferences'
import {useHiddenPosts, useHiddenPostsApi} from '#/state/preferences' import {useHiddenPosts, useHiddenPostsApi} from '#/state/preferences'
import {useOpenLink} from '#/state/preferences/in-app-browser' import {useOpenLink} from '#/state/preferences/in-app-browser'
import {usePostDeleteMutation} from '#/state/queries/post' import {
usePostDeleteMutation,
useThreadMuteMutationQueue,
} from '#/state/queries/post'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import {getCurrentRoute} from 'lib/routes/helpers' import {getCurrentRoute} from 'lib/routes/helpers'
import {shareUrl} from 'lib/sharing' import {shareUrl} from 'lib/sharing'
@ -62,9 +65,7 @@ import * as Toast from '../Toast'
let PostDropdownBtn = ({ let PostDropdownBtn = ({
testID, testID,
postAuthor, post,
postCid,
postUri,
postFeedContext, postFeedContext,
record, record,
richText, richText,
@ -74,9 +75,7 @@ let PostDropdownBtn = ({
timestamp, timestamp,
}: { }: {
testID: string testID: string
postAuthor: AppBskyActorDefs.ProfileViewBasic post: Shadow<AppBskyFeedDefs.PostView>
postCid: string
postUri: string
postFeedContext: string | undefined postFeedContext: string | undefined
record: AppBskyFeedPost.Record record: AppBskyFeedPost.Record
richText: RichTextAPI richText: RichTextAPI
@ -92,8 +91,6 @@ let PostDropdownBtn = ({
const {_} = useLingui() const {_} = useLingui()
const defaultCtrlColor = theme.palette.default.postCtrl const defaultCtrlColor = theme.palette.default.postCtrl
const langPrefs = useLanguagePrefs() const langPrefs = useLanguagePrefs()
const mutedThreads = useMutedThreads()
const toggleThreadMute = useToggleThreadMute()
const postDeleteMutation = usePostDeleteMutation() const postDeleteMutation = usePostDeleteMutation()
const hiddenPosts = useHiddenPosts() const hiddenPosts = useHiddenPosts()
const {hidePost} = useHiddenPostsApi() const {hidePost} = useHiddenPostsApi()
@ -107,9 +104,15 @@ let PostDropdownBtn = ({
const loggedOutWarningPromptControl = useDialogControl() const loggedOutWarningPromptControl = useDialogControl()
const embedPostControl = useDialogControl() const embedPostControl = useDialogControl()
const sendViaChatControl = useDialogControl() const sendViaChatControl = useDialogControl()
const postUri = post.uri
const postCid = post.cid
const postAuthor = post.author
const rootUri = record.reply?.root?.uri || postUri const rootUri = record.reply?.root?.uri || postUri
const isThreadMuted = mutedThreads.includes(rootUri) const [isThreadMuted, muteThread, unmuteThread] = useThreadMuteMutationQueue(
post,
rootUri,
)
const isPostHidden = hiddenPosts && hiddenPosts.includes(postUri) const isPostHidden = hiddenPosts && hiddenPosts.includes(postUri)
const isAuthor = postAuthor.did === currentAccount?.did const isAuthor = postAuthor.did === currentAccount?.did
@ -162,18 +165,22 @@ let PostDropdownBtn = ({
const onToggleThreadMute = React.useCallback(() => { const onToggleThreadMute = React.useCallback(() => {
try { try {
const muted = toggleThreadMute(rootUri) if (isThreadMuted) {
if (muted) { unmuteThread()
Toast.show(_(msg`You will now receive notifications for this thread`))
} else {
muteThread()
Toast.show( Toast.show(
_(msg`You will no longer receive notifications for this thread`), _(msg`You will no longer receive notifications for this thread`),
) )
} else {
Toast.show(_(msg`You will now receive notifications for this thread`))
} }
} catch (e) { } catch (e: any) {
logger.error('Failed to toggle thread mute', {message: e}) if (e?.name !== 'AbortError') {
logger.error('Failed to toggle thread mute', {message: e})
Toast.show(_(msg`Failed to toggle thread mute, please try again`))
}
} }
}, [rootUri, toggleThreadMute, _]) }, [isThreadMuted, unmuteThread, _, muteThread])
const onCopyPostText = React.useCallback(() => { const onCopyPostText = React.useCallback(() => {
const str = richTextToString(richText, true) const str = richTextToString(richText, true)

View File

@ -2,39 +2,60 @@ import React from 'react'
import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native' import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
import {Image} from 'expo-image' import {Image} from 'expo-image'
import {AppBskyEmbedImages} from '@atproto/api' import {AppBskyEmbedImages} from '@atproto/api'
import {Trans} from '@lingui/macro'
import {atoms as a} from '#/alf'
import {Text} from '#/components/Typography'
interface Props { interface Props {
images: AppBskyEmbedImages.ViewImage[] images: AppBskyEmbedImages.ViewImage[]
style?: StyleProp<ViewStyle> style?: StyleProp<ViewStyle>
gif?: boolean
} }
export function ImageHorzList({images, style}: Props) { export function ImageHorzList({images, style, gif}: Props) {
return ( return (
<View style={[styles.flexRow, style]}> <View style={[a.flex_row, a.gap_xs, style]}>
{images.map(({thumb, alt}) => ( {images.map(({thumb, alt}) => (
<Image <View
key={thumb} key={thumb}
source={{uri: thumb}} style={[a.relative, a.flex_1, {aspectRatio: 1, maxWidth: 100}]}>
style={styles.image} <Image
accessible={true} key={thumb}
accessibilityIgnoresInvertColors source={{uri: thumb}}
accessibilityHint={alt} style={[a.flex_1, a.rounded_xs]}
accessibilityLabel="" accessible={true}
/> accessibilityIgnoresInvertColors
accessibilityHint={alt}
accessibilityLabel=""
/>
{gif && (
<View style={styles.altContainer}>
<Text style={styles.alt}>
<Trans>GIF</Trans>
</Text>
</View>
)}
</View>
))} ))}
</View> </View>
) )
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
flexRow: { altContainer: {
flexDirection: 'row', backgroundColor: 'rgba(0, 0, 0, 0.75)',
gap: 5, borderRadius: 6,
paddingHorizontal: 6,
paddingVertical: 3,
position: 'absolute',
right: 5,
bottom: 5,
zIndex: 2,
}, },
image: { alt: {
maxWidth: 100, color: 'white',
aspectRatio: 1, fontSize: 7,
flex: 1, fontWeight: 'bold',
borderRadius: 4,
}, },
}) })

View File

@ -319,9 +319,7 @@ let PostCtrls = ({
<View style={big ? a.align_center : [a.flex_1, a.align_start]}> <View style={big ? a.align_center : [a.flex_1, a.align_start]}>
<PostDropdownBtn <PostDropdownBtn
testID="postDropdownBtn" testID="postDropdownBtn"
postAuthor={post.author} post={post}
postCid={post.cid}
postUri={post.uri}
postFeedContext={feedContext} postFeedContext={feedContext}
record={record} record={record}
richText={richText} richText={richText}

View File

@ -7,6 +7,7 @@ import {useFocusEffect} from '@react-navigation/native'
import {useGetTimeAgo} from '#/lib/hooks/useTimeAgo' import {useGetTimeAgo} from '#/lib/hooks/useTimeAgo'
import {getEntries} from '#/logger/logDump' import {getEntries} from '#/logger/logDump'
import {useTickEveryMinute} from '#/state/shell'
import {useSetMinimalShellMode} from '#/state/shell' import {useSetMinimalShellMode} from '#/state/shell'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types' import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types'
@ -24,6 +25,7 @@ export function LogScreen({}: NativeStackScreenProps<
const setMinimalShellMode = useSetMinimalShellMode() const setMinimalShellMode = useSetMinimalShellMode()
const [expanded, setExpanded] = React.useState<string[]>([]) const [expanded, setExpanded] = React.useState<string[]>([])
const timeAgo = useGetTimeAgo() const timeAgo = useGetTimeAgo()
const tick = useTickEveryMinute()
useFocusEffect( useFocusEffect(
React.useCallback(() => { React.useCallback(() => {
@ -72,7 +74,7 @@ export function LogScreen({}: NativeStackScreenProps<
/> />
) : undefined} ) : undefined}
<Text type="sm" style={[styles.ts, pal.textLight]}> <Text type="sm" style={[styles.ts, pal.textLight]}>
{timeAgo(entry.timestamp)} {timeAgo(entry.timestamp, tick)}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
{expanded.includes(entry.id) ? ( {expanded.includes(entry.id) ? (

View File

@ -34,10 +34,10 @@
jsonpointer "^5.0.0" jsonpointer "^5.0.0"
leven "^3.1.0" leven "^3.1.0"
"@atproto/api@^0.12.18": "@atproto/api@^0.12.20":
version "0.12.18" version "0.12.20"
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.12.18.tgz#490a6f22966a3b605c22154fe7befc78bf640821" resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.12.20.tgz#2cada08c24bc61eb1775ee4c8010c7ed9dc5d6f3"
integrity sha512-Ii3J/uzmyw1qgnfhnvAsmuXa8ObRSCHelsF8TmQrgMWeXCbfypeS/VESm++1Z9+xHK7bHPOwSek3RmWB0cqEbQ== integrity sha512-nt7ZKUQL9j2yQ3tmCCueiIuc0FwdxZYn2fXdLYqltuxlaO5DmaqqULMBKeYJLq4GbvVl/G+ikPJccoSaMWDYOg==
dependencies: dependencies:
"@atproto/common-web" "^0.3.0" "@atproto/common-web" "^0.3.0"
"@atproto/lexicon" "^0.4.0" "@atproto/lexicon" "^0.4.0"