diff --git a/package.json b/package.json
index 29e198c9c..417836903 100644
--- a/package.json
+++ b/package.json
@@ -49,7 +49,7 @@
"open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web"
},
"dependencies": {
- "@atproto/api": "^0.12.18",
+ "@atproto/api": "^0.12.19",
"@bam.tech/react-native-image-resizer": "^3.0.4",
"@braintree/sanitize-url": "^6.0.2",
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
diff --git a/src/App.native.tsx b/src/App.native.tsx
index 322e944a4..18461fdd0 100644
--- a/src/App.native.tsx
+++ b/src/App.native.tsx
@@ -14,40 +14,40 @@ import * as SplashScreen from 'expo-splash-screen'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
+import {useIntentHandler} from '#/lib/hooks/useIntentHandler'
+import {QueryProvider} from '#/lib/react-query'
import {
initialize,
Provider as StatsigProvider,
tryFetchGates,
} from '#/lib/statsig/statsig'
+import {s} from '#/lib/styles'
+import {ThemeProvider} from '#/lib/ThemeContext'
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 {Provider as ModalStateProvider} from '#/state/modals'
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 ModerationOptsProvider} from '#/state/preferences/moderation-opts'
-import {readLastActiveAccount} from '#/state/session/util'
-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 {Provider as UnreadNotifsProvider} from '#/state/queries/notifications/unread'
import {
Provider as SessionProvider,
SessionAccount,
useSession,
useSessionApi,
-} from 'state/session'
-import {Provider as ShellStateProvider} from 'state/shell'
-import {Provider as LoggedOutViewProvider} from 'state/shell/logged-out'
-import {Provider as SelectedFeedProvider} from 'state/shell/selected-feed'
-import {TestCtrls} from 'view/com/testing/TestCtrls'
-import * as Toast from 'view/com/util/Toast'
-import {Shell} from 'view/shell'
+} from '#/state/session'
+import {readLastActiveAccount} from '#/state/session/util'
+import {Provider as ShellStateProvider} from '#/state/shell'
+import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out'
+import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
+import {TestCtrls} from '#/view/com/testing/TestCtrls'
+import * as Toast from '#/view/com/util/Toast'
+import {Shell} from '#/view/shell'
import {ThemeProvider as Alf} from '#/alf'
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
import {Provider as PortalProvider} from '#/components/Portal'
@@ -112,10 +112,12 @@ function InnerApp() {
-
-
-
-
+
+
+
+
+
+
@@ -154,21 +156,19 @@ function App() {
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/App.web.tsx b/src/App.web.tsx
index 5c4dc4e63..6af3c7d6f 100644
--- a/src/App.web.tsx
+++ b/src/App.web.tsx
@@ -8,35 +8,35 @@ import {SafeAreaProvider} from 'react-native-safe-area-context'
import {msg} from '@lingui/macro'
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 {ThemeProvider} from '#/lib/ThemeContext'
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 {Provider as ModalStateProvider} from '#/state/modals'
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 ModerationOptsProvider} from '#/state/preferences/moderation-opts'
-import {readLastActiveAccount} from '#/state/session/util'
-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 {Provider as UnreadNotifsProvider} from '#/state/queries/notifications/unread'
import {
Provider as SessionProvider,
SessionAccount,
useSession,
useSessionApi,
-} from 'state/session'
-import {Provider as ShellStateProvider} from 'state/shell'
-import {Provider as LoggedOutViewProvider} from 'state/shell/logged-out'
-import {Provider as SelectedFeedProvider} from 'state/shell/selected-feed'
-import * as Toast from 'view/com/util/Toast'
-import {ToastContainer} from 'view/com/util/Toast.web'
-import {Shell} from 'view/shell/index'
+} from '#/state/session'
+import {readLastActiveAccount} from '#/state/session/util'
+import {Provider as ShellStateProvider} from '#/state/shell'
+import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out'
+import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
+import * as Toast from '#/view/com/util/Toast'
+import {ToastContainer} from '#/view/com/util/Toast.web'
+import {Shell} from '#/view/shell/index'
import {ThemeProvider as Alf} from '#/alf'
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
import {Provider as PortalProvider} from '#/components/Portal'
@@ -96,9 +96,11 @@ function InnerApp() {
-
-
-
+
+
+
+
+
@@ -136,21 +138,19 @@ function App() {
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/lib/statsig/events.ts b/src/lib/statsig/events.ts
index 9939f60c9..0d77ec8a3 100644
--- a/src/lib/statsig/events.ts
+++ b/src/lib/statsig/events.ts
@@ -103,6 +103,8 @@ export type LogEvents = {
'post:unrepost': {
logContext: 'FeedItem' | 'PostThreadItem' | 'Post'
}
+ 'post:mute': {}
+ 'post:unmute': {}
'profile:follow': {
didBecomeMutual: boolean | undefined
followeeClout: number | undefined
diff --git a/src/state/cache/thread-mutes.tsx b/src/state/cache/thread-mutes.tsx
new file mode 100644
index 000000000..b58bd430f
--- /dev/null
+++ b/src/state/cache/thread-mutes.tsx
@@ -0,0 +1,44 @@
+import React from 'react'
+
+type StateContext = Map
+type SetStateContext = (uri: string, value: boolean) => void
+
+const stateContext = React.createContext(new Map())
+const setStateContext = React.createContext(
+ (_: string) => false,
+)
+
+export function Provider({children}: React.PropsWithChildren<{}>) {
+ const [state, setState] = React.useState(() => new Map())
+
+ const setThreadMute = React.useCallback(
+ (uri: string, value: boolean) => {
+ setState(prev => {
+ const next = new Map(prev)
+ next.set(uri, value)
+ return next
+ })
+ },
+ [setState],
+ )
+ return (
+
+
+ {children}
+
+
+ )
+}
+
+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)
+}
diff --git a/src/state/muted-threads.tsx b/src/state/muted-threads.tsx
deleted file mode 100644
index 84a717eb7..000000000
--- a/src/state/muted-threads.tsx
+++ /dev/null
@@ -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(
- persisted.defaults.mutedThreads,
-)
-const toggleContext = React.createContext((_: 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 (
-
-
- {children}
-
-
- )
-}
-
-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)
-}
diff --git a/src/state/queries/notifications/feed.ts b/src/state/queries/notifications/feed.ts
index d9f019af3..0607f07a1 100644
--- a/src/state/queries/notifications/feed.ts
+++ b/src/state/queries/notifications/feed.ts
@@ -26,7 +26,6 @@ import {
useQueryClient,
} from '@tanstack/react-query'
-import {useMutedThreads} from '#/state/muted-threads'
import {useAgent} from '#/state/session'
import {useModerationOpts} from '../../preferences/moderation-opts'
import {STALE} from '..'
@@ -54,7 +53,6 @@ export function useNotificationFeedQuery(opts?: {enabled?: boolean}) {
const agent = useAgent()
const queryClient = useQueryClient()
const moderationOpts = useModerationOpts()
- const threadMutes = useMutedThreads()
const unreads = useUnreadNotificationsApi()
const enabled = opts?.enabled !== false
const lastPageCountRef = useRef(0)
@@ -82,7 +80,6 @@ export function useNotificationFeedQuery(opts?: {enabled?: boolean}) {
cursor: pageParam,
queryClient,
moderationOpts,
- threadMutes,
fetchAdditionalData: true,
})
).page
diff --git a/src/state/queries/notifications/unread.tsx b/src/state/queries/notifications/unread.tsx
index ffb8d03bc..7bb325ea9 100644
--- a/src/state/queries/notifications/unread.tsx
+++ b/src/state/queries/notifications/unread.tsx
@@ -9,7 +9,6 @@ import EventEmitter from 'eventemitter3'
import BroadcastChannel from '#/lib/broadcast'
import {logger} from '#/logger'
-import {useMutedThreads} from '#/state/muted-threads'
import {useAgent, useSession} from '#/state/session'
import {resetBadgeCount} from 'lib/notifications/notifications'
import {useModerationOpts} from '../../preferences/moderation-opts'
@@ -48,7 +47,6 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
const agent = useAgent()
const queryClient = useQueryClient()
const moderationOpts = useModerationOpts()
- const threadMutes = useMutedThreads()
const [numUnread, setNumUnread] = React.useState('')
@@ -147,7 +145,6 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
limit: 40,
queryClient,
moderationOpts,
- threadMutes,
// only fetch subjects when the page is going to be used
// 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
return (
diff --git a/src/state/queries/notifications/util.ts b/src/state/queries/notifications/util.ts
index 466249353..8ed1c0390 100644
--- a/src/state/queries/notifications/util.ts
+++ b/src/state/queries/notifications/util.ts
@@ -1,5 +1,4 @@
import {
- AppBskyEmbedRecord,
AppBskyFeedDefs,
AppBskyFeedLike,
AppBskyFeedPost,
@@ -28,7 +27,6 @@ export async function fetchPage({
limit,
queryClient,
moderationOpts,
- threadMutes,
fetchAdditionalData,
}: {
agent: BskyAgent
@@ -36,7 +34,6 @@ export async function fetchPage({
limit: number
queryClient: QueryClient
moderationOpts: ModerationOpts | undefined
- threadMutes: string[]
fetchAdditionalData: boolean
}): Promise<{page: FeedPage; indexedAt: string | undefined}> {
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()
if (Number.isNaN(seenAt.getTime())) {
seenAt = new Date()
@@ -207,45 +199,3 @@ function getSubjectUri(
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
-}
diff --git a/src/state/queries/post.ts b/src/state/queries/post.ts
index 794f48eb1..8e77bf6b9 100644
--- a/src/state/queries/post.ts
+++ b/src/state/queries/post.ts
@@ -8,6 +8,7 @@ import {logEvent, LogEvents, toClout} from '#/lib/statsig/statsig'
import {updatePostShadow} from '#/state/cache/post-shadow'
import {Shadow} from '#/state/cache/types'
import {useAgent, useSession} from '#/state/session'
+import {useIsThreadMuted, useSetThreadMute} from '../cache/thread-mutes'
import {findProfileQueryData} from './profile'
const RQKEY_ROOT = 'post'
@@ -291,3 +292,72 @@ export function usePostDeleteMutation() {
},
})
}
+
+export function useThreadMuteMutationQueue(
+ post: Shadow,
+ rootUri: string,
+) {
+ const threadMuteMutation = useThreadMuteMutation()
+ const threadUnmuteMutation = useThreadUnmuteMutation()
+ const isThreadMuted = useIsThreadMuted(rootUri, post.viewer?.threadMuted)
+ const setThreadMute = useSetThreadMute()
+
+ const queueToggle = useToggleMutationQueue({
+ initialState: isThreadMuted,
+ runMutation: async (_prev, shouldLike) => {
+ if (shouldLike) {
+ 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})
+ },
+ })
+}
diff --git a/src/view/com/util/forms/PostDropdownBtn.tsx b/src/view/com/util/forms/PostDropdownBtn.tsx
index 2486b73d5..45e00e58c 100644
--- a/src/view/com/util/forms/PostDropdownBtn.tsx
+++ b/src/view/com/util/forms/PostDropdownBtn.tsx
@@ -7,7 +7,7 @@ import {
} from 'react-native'
import * as Clipboard from 'expo-clipboard'
import {
- AppBskyActorDefs,
+ AppBskyFeedDefs,
AppBskyFeedPost,
AtUri,
RichText as RichTextAPI,
@@ -22,12 +22,15 @@ import {richTextToString} from '#/lib/strings/rich-text-helpers'
import {getTranslatorLink} from '#/locale/helpers'
import {logger} from '#/logger'
import {isWeb} from '#/platform/detection'
+import {Shadow} from '#/state/cache/post-shadow'
import {useFeedFeedbackContext} from '#/state/feed-feedback'
-import {useMutedThreads, useToggleThreadMute} from '#/state/muted-threads'
import {useLanguagePrefs} from '#/state/preferences'
import {useHiddenPosts, useHiddenPostsApi} from '#/state/preferences'
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 {getCurrentRoute} from 'lib/routes/helpers'
import {shareUrl} from 'lib/sharing'
@@ -62,9 +65,7 @@ import * as Toast from '../Toast'
let PostDropdownBtn = ({
testID,
- postAuthor,
- postCid,
- postUri,
+ post,
postFeedContext,
record,
richText,
@@ -74,9 +75,7 @@ let PostDropdownBtn = ({
timestamp,
}: {
testID: string
- postAuthor: AppBskyActorDefs.ProfileViewBasic
- postCid: string
- postUri: string
+ post: Shadow
postFeedContext: string | undefined
record: AppBskyFeedPost.Record
richText: RichTextAPI
@@ -92,8 +91,6 @@ let PostDropdownBtn = ({
const {_} = useLingui()
const defaultCtrlColor = theme.palette.default.postCtrl
const langPrefs = useLanguagePrefs()
- const mutedThreads = useMutedThreads()
- const toggleThreadMute = useToggleThreadMute()
const postDeleteMutation = usePostDeleteMutation()
const hiddenPosts = useHiddenPosts()
const {hidePost} = useHiddenPostsApi()
@@ -107,9 +104,15 @@ let PostDropdownBtn = ({
const loggedOutWarningPromptControl = useDialogControl()
const embedPostControl = useDialogControl()
const sendViaChatControl = useDialogControl()
+ const postUri = post.uri
+ const postCid = post.cid
+ const postAuthor = post.author
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 isAuthor = postAuthor.did === currentAccount?.did
@@ -162,18 +165,22 @@ let PostDropdownBtn = ({
const onToggleThreadMute = React.useCallback(() => {
try {
- const muted = toggleThreadMute(rootUri)
- if (muted) {
+ if (isThreadMuted) {
+ unmuteThread()
+ Toast.show(_(msg`You will now receive notifications for this thread`))
+ } else {
+ muteThread()
Toast.show(
_(msg`You will no longer receive notifications for this thread`),
)
- } else {
- Toast.show(_(msg`You will now receive notifications for this thread`))
}
- } catch (e) {
- logger.error('Failed to toggle thread mute', {message: e})
+ } catch (e: any) {
+ 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 str = richTextToString(richText, true)
diff --git a/src/view/com/util/post-ctrls/PostCtrls.tsx b/src/view/com/util/post-ctrls/PostCtrls.tsx
index c389855e3..c0e743db4 100644
--- a/src/view/com/util/post-ctrls/PostCtrls.tsx
+++ b/src/view/com/util/post-ctrls/PostCtrls.tsx
@@ -319,9 +319,7 @@ let PostCtrls = ({
Date: Tue, 18 Jun 2024 11:48:49 -0700
Subject: [PATCH 02/17] Fix: only apply self-thread load-more behavior on the
outer edge of the reply tree (#4559)
---
src/state/queries/post-thread.ts | 13 +++++++++++--
1 file changed, 11 insertions(+), 2 deletions(-)
diff --git a/src/state/queries/post-thread.ts b/src/state/queries/post-thread.ts
index a8b1160fb..f7e5e2ecb 100644
--- a/src/state/queries/post-thread.ts
+++ b/src/state/queries/post-thread.ts
@@ -31,6 +31,7 @@ import {
getEmbeddedPost,
} from './util'
+const REPLY_TREE_DEPTH = 10
const RQKEY_ROOT = 'post-thread'
export const RQKEY = (uri: string) => [RQKEY_ROOT, uri]
type ThreadViewNode = AppBskyFeedGetPostThread.OutputSchema['thread']
@@ -90,7 +91,10 @@ export function usePostThreadQuery(uri: string | undefined) {
gcTime: 0,
queryKey: RQKEY(uri || ''),
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) {
const thread = responseToThreadNodes(res.data.thread)
annotateSelfThread(thread)
@@ -287,7 +291,12 @@ function annotateSelfThread(thread: ThreadNode) {
selfThreadNode.ctx.isSelfThread = true
}
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
}
}
--
2.51.2
From 983d85384b9e736193e6c89107df5ced447a056a Mon Sep 17 00:00:00 2001
From: Eric Bailey
Date: Tue, 18 Jun 2024 13:50:07 -0500
Subject: [PATCH 03/17] Force callers of `getTimeAgo` to pass in the value for
"now" (#4560)
* Remove icky hook for now
* Force callers of getTimeAgo to pass in the 'now' value
* Update usage in Newskie dialog
---
src/components/NewskieDialog.tsx | 7 ++++---
src/lib/hooks/useTimeAgo.ts | 17 ++++-------------
src/view/com/util/TimeElapsed.tsx | 6 ++++--
src/view/screens/Log.tsx | 4 +++-
4 files changed, 15 insertions(+), 19 deletions(-)
diff --git a/src/components/NewskieDialog.tsx b/src/components/NewskieDialog.tsx
index fcdae0daa..789a42d5f 100644
--- a/src/components/NewskieDialog.tsx
+++ b/src/components/NewskieDialog.tsx
@@ -30,12 +30,13 @@ export function NewskieDialog({
const moderation = moderateProfile(profile, moderationOpts)
return sanitizeDisplayName(name, moderation.ui('displayName'))
}, [moderationOpts, profile])
+ const [now] = React.useState(Date.now())
const timeAgo = useGetTimeAgo()
const createdAt = profile.createdAt as string | undefined
const daysOld = React.useMemo(() => {
if (!createdAt) return Infinity
- return differenceInSeconds(new Date(), new Date(createdAt)) / 86400
- }, [createdAt])
+ return differenceInSeconds(now, new Date(createdAt)) / 86400
+ }, [createdAt, now])
if (!createdAt || daysOld > 7) return null
@@ -70,7 +71,7 @@ export function NewskieDialog({
{profileName} joined Bluesky{' '}
- {timeAgo(createdAt, {format: 'long'})} ago
+ {timeAgo(createdAt, now, {format: 'long'})} ago
diff --git a/src/lib/hooks/useTimeAgo.ts b/src/lib/hooks/useTimeAgo.ts
index 5f0782f96..efcb4754b 100644
--- a/src/lib/hooks/useTimeAgo.ts
+++ b/src/lib/hooks/useTimeAgo.ts
@@ -1,4 +1,4 @@
-import {useCallback, useMemo} from 'react'
+import {useCallback} from 'react'
import {msg, plural} from '@lingui/macro'
import {I18nContext, useLingui} from '@lingui/react'
import {differenceInSeconds} from 'date-fns'
@@ -12,25 +12,16 @@ export function useGetTimeAgo() {
const {_} = useLingui()
return useCallback(
(
- date: number | string | Date,
+ earlier: number | string | Date,
+ later: number | string | Date,
options?: Omit,
) => {
- 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,
-): string {
- const timeAgo = useGetTimeAgo()
- return useMemo(() => {
- return timeAgo(date, {...options})
- }, [date, options, timeAgo])
-}
-
const NOW = 5
const MINUTE = 60
const HOUR = MINUTE * 60
diff --git a/src/view/com/util/TimeElapsed.tsx b/src/view/com/util/TimeElapsed.tsx
index d939b3163..a49585182 100644
--- a/src/view/com/util/TimeElapsed.tsx
+++ b/src/view/com/util/TimeElapsed.tsx
@@ -15,12 +15,14 @@ export function TimeElapsed({
const ago = useGetTimeAgo()
const format = timeToString ?? ago
const tick = useTickEveryMinute()
- const [timeElapsed, setTimeAgo] = React.useState(() => format(timestamp))
+ const [timeElapsed, setTimeAgo] = React.useState(() =>
+ format(timestamp, tick),
+ )
const [prevTick, setPrevTick] = React.useState(tick)
if (prevTick !== tick) {
setPrevTick(tick)
- setTimeAgo(format(timestamp))
+ setTimeAgo(format(timestamp, tick))
}
return children({timeElapsed})
diff --git a/src/view/screens/Log.tsx b/src/view/screens/Log.tsx
index e10aa83ab..e6040b77e 100644
--- a/src/view/screens/Log.tsx
+++ b/src/view/screens/Log.tsx
@@ -7,6 +7,7 @@ import {useFocusEffect} from '@react-navigation/native'
import {useGetTimeAgo} from '#/lib/hooks/useTimeAgo'
import {getEntries} from '#/logger/logDump'
+import {useTickEveryMinute} from '#/state/shell'
import {useSetMinimalShellMode} from '#/state/shell'
import {usePalette} from 'lib/hooks/usePalette'
import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types'
@@ -24,6 +25,7 @@ export function LogScreen({}: NativeStackScreenProps<
const setMinimalShellMode = useSetMinimalShellMode()
const [expanded, setExpanded] = React.useState([])
const timeAgo = useGetTimeAgo()
+ const tick = useTickEveryMinute()
useFocusEffect(
React.useCallback(() => {
@@ -72,7 +74,7 @@ export function LogScreen({}: NativeStackScreenProps<
/>
) : undefined}
- {timeAgo(entry.timestamp)}
+ {timeAgo(entry.timestamp, tick)}
{expanded.includes(entry.id) ? (
--
2.51.2
From 4165a02b2d712ba20b9fdbf435d4cb00c03e5e52 Mon Sep 17 00:00:00 2001
From: Eric Bailey
Date: Tue, 18 Jun 2024 13:52:44 -0500
Subject: [PATCH 04/17] Prevent unecessary calls (#4561)
(cherry picked from commit ecb48797675c5be24508bf47141e930c64dac14e)
---
src/components/NewskieDialog.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/components/NewskieDialog.tsx b/src/components/NewskieDialog.tsx
index 789a42d5f..281430e31 100644
--- a/src/components/NewskieDialog.tsx
+++ b/src/components/NewskieDialog.tsx
@@ -30,7 +30,7 @@ export function NewskieDialog({
const moderation = moderateProfile(profile, moderationOpts)
return sanitizeDisplayName(name, moderation.ui('displayName'))
}, [moderationOpts, profile])
- const [now] = React.useState(Date.now())
+ const [now] = React.useState(() => Date.now())
const timeAgo = useGetTimeAgo()
const createdAt = profile.createdAt as string | undefined
const daysOld = React.useMemo(() => {
--
2.51.2
From d6ce16d15ae79c4fef943cd48dfa0cdb072e9596 Mon Sep 17 00:00:00 2001
From: Paul Frazee
Date: Tue, 18 Jun 2024 12:07:56 -0700
Subject: [PATCH 05/17] Implement thread locking (#4545)
* Add the ability to edit threadgates
* Fix bottom border on mobile
* Refresh thread after threadgate edit
---
src/lib/analytics/types.ts | 2 +
src/lib/api/index.ts | 17 +-
src/state/modals/index.tsx | 3 +-
src/state/queries/post-thread.ts | 2 +-
src/state/queries/threadgate.ts | 33 +++
src/view/com/modals/Threadgate.tsx | 11 +-
src/view/com/post-thread/PostThreadItem.tsx | 25 ++-
src/view/com/threadgate/WhoCanReply.tsx | 234 ++++++++++++--------
8 files changed, 219 insertions(+), 108 deletions(-)
diff --git a/src/lib/analytics/types.ts b/src/lib/analytics/types.ts
index cdf535dec..720495ea1 100644
--- a/src/lib/analytics/types.ts
+++ b/src/lib/analytics/types.ts
@@ -32,6 +32,8 @@ export type TrackPropertiesMap = {
'Post:ThreadMute': {} // CAN BE SERVER
'Post:ThreadUnmute': {} // CAN BE SERVER
'Post:Reply': {} // CAN BE SERVER
+ 'Post:EditThreadgateOpened': {}
+ 'Post:ThreadgateEdited': {}
// PROFILE events
'Profile:Follow': {
username: string
diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts
index dfaae2e01..5b1c998cb 100644
--- a/src/lib/api/index.ts
+++ b/src/lib/api/index.ts
@@ -270,7 +270,7 @@ export async function post(agent: BskyAgent, opts: PostOpts) {
return res
}
-async function createThreadgate(
+export async function createThreadgate(
agent: BskyAgent,
postUri: string,
threadgate: ThreadgateSetting[],
@@ -296,10 +296,17 @@ async function createThreadgate(
}
const postUrip = new AtUri(postUri)
- await agent.api.app.bsky.feed.threadgate.create(
- {repo: agent.session!.did, rkey: postUrip.rkey},
- {post: postUri, createdAt: new Date().toISOString(), allow},
- )
+ await agent.api.com.atproto.repo.putRecord({
+ repo: agent.session!.did,
+ collection: 'app.bsky.feed.threadgate',
+ rkey: postUrip.rkey,
+ record: {
+ $type: 'app.bsky.feed.threadgate',
+ post: postUri,
+ allow,
+ createdAt: new Date().toISOString(),
+ },
+ })
}
// helpers
diff --git a/src/state/modals/index.tsx b/src/state/modals/index.tsx
index ced14335b..685b10bd8 100644
--- a/src/state/modals/index.tsx
+++ b/src/state/modals/index.tsx
@@ -70,7 +70,8 @@ export interface SelfLabelModal {
export interface ThreadgateModal {
name: 'threadgate'
settings: ThreadgateSetting[]
- onChange: (settings: ThreadgateSetting[]) => void
+ onChange?: (settings: ThreadgateSetting[]) => void
+ onConfirm?: (settings: ThreadgateSetting[]) => void
}
export interface ChangeHandleModal {
diff --git a/src/state/queries/post-thread.ts b/src/state/queries/post-thread.ts
index f7e5e2ecb..db85e8a17 100644
--- a/src/state/queries/post-thread.ts
+++ b/src/state/queries/post-thread.ts
@@ -32,7 +32,7 @@ import {
} from './util'
const REPLY_TREE_DEPTH = 10
-const RQKEY_ROOT = 'post-thread'
+export const RQKEY_ROOT = 'post-thread'
export const RQKEY = (uri: string) => [RQKEY_ROOT, uri]
type ThreadViewNode = AppBskyFeedGetPostThread.OutputSchema['thread']
diff --git a/src/state/queries/threadgate.ts b/src/state/queries/threadgate.ts
index 489117582..67c6f8c08 100644
--- a/src/state/queries/threadgate.ts
+++ b/src/state/queries/threadgate.ts
@@ -1,5 +1,38 @@
+import {AppBskyFeedDefs, AppBskyFeedThreadgate} from '@atproto/api'
+
export type ThreadgateSetting =
| {type: 'nobody'}
| {type: 'mention'}
| {type: 'following'}
| {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[]
+}
diff --git a/src/view/com/modals/Threadgate.tsx b/src/view/com/modals/Threadgate.tsx
index a2e9f391c..4a9a9e2ab 100644
--- a/src/view/com/modals/Threadgate.tsx
+++ b/src/view/com/modals/Threadgate.tsx
@@ -26,9 +26,11 @@ export const snapPoints = ['60%']
export function Component({
settings,
onChange,
+ onConfirm,
}: {
settings: ThreadgateSetting[]
- onChange: (settings: ThreadgateSetting[]) => void
+ onChange?: (settings: ThreadgateSetting[]) => void
+ onConfirm?: (settings: ThreadgateSetting[]) => void
}) {
const pal = usePalette('default')
const {closeModal} = useModalControls()
@@ -38,12 +40,12 @@ export function Component({
const onPressEverybody = () => {
setSelected([])
- onChange([])
+ onChange?.([])
}
const onPressNobody = () => {
setSelected([{type: 'nobody'}])
- onChange([{type: 'nobody'}])
+ onChange?.([{type: 'nobody'}])
}
const onPressAudience = (setting: ThreadgateSetting) => {
@@ -57,7 +59,7 @@ export function Component({
newSelected.splice(i, 1)
}
setSelected(newSelected)
- onChange(newSelected)
+ onChange?.(newSelected)
}
return (
@@ -124,6 +126,7 @@ export function Component({
testID="confirmBtn"
onPress={() => {
closeModal()
+ onConfirm?.(selected)
}}
style={styles.btn}
accessibilityRole="button"
diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx
index 5ee60e4ea..6d03029d7 100644
--- a/src/view/com/post-thread/PostThreadItem.tsx
+++ b/src/view/com/post-thread/PostThreadItem.tsx
@@ -25,7 +25,7 @@ import {sanitizeHandle} from 'lib/strings/handles'
import {countLines} from 'lib/strings/helpers'
import {niceDate} from 'lib/strings/time'
import {s} from 'lib/styles'
-import {isWeb} from 'platform/detection'
+import {isNative, isWeb} from 'platform/detection'
import {useSession} from 'state/session'
import {PostThreadFollowBtn} from 'view/com/post-thread/PostThreadFollowBtn'
import {atoms as a} from '#/alf'
@@ -189,6 +189,7 @@ let PostThreadItemLoaded = ({
const itemTitle = _(msg`Post by ${post.author.handle}`)
const authorHref = makeProfileLink(post.author)
const authorTitle = post.author.handle
+ const isThreadAuthor = getThreadAuthor(post, record) === currentAccount?.did
const likesHref = React.useMemo(() => {
const urip = new AtUri(post.uri)
return makeProfileLink(post.author, 'post', urip.rkey, 'liked-by')
@@ -395,7 +396,11 @@ let PostThreadItemLoaded = ({
-
+
>
)
} else {
@@ -578,7 +583,9 @@ let PostThreadItemLoaded = ({
post={post}
style={{
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({
outer: {
borderTopWidth: hairlineWidth,
diff --git a/src/view/com/threadgate/WhoCanReply.tsx b/src/view/com/threadgate/WhoCanReply.tsx
index c1e36d481..3ffbaa7ae 100644
--- a/src/view/com/threadgate/WhoCanReply.tsx
+++ b/src/view/com/threadgate/WhoCanReply.tsx
@@ -1,128 +1,172 @@
import React from 'react'
-import {StyleProp, View, ViewStyle} from 'react-native'
-import {
- AppBskyFeedDefs,
- AppBskyFeedThreadgate,
- AppBskyGraphDefs,
- AtUri,
-} from '@atproto/api'
-import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
-import {Trans} from '@lingui/macro'
+import {Keyboard, StyleProp, View, ViewStyle} from 'react-native'
+import {AppBskyFeedDefs, AppBskyGraphDefs, AtUri} from '@atproto/api'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useQueryClient} from '@tanstack/react-query'
+import {useAnalytics} from '#/lib/analytics/analytics'
+import {createThreadgate} from '#/lib/api'
import {useColorSchemeStyle} from '#/lib/hooks/useColorSchemeStyle'
import {usePalette} from '#/lib/hooks/usePalette'
-import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {makeListLink, makeProfileLink} from '#/lib/routes/links'
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 {Text} from '../util/text/Text'
export function WhoCanReply({
post,
+ isThreadAuthor,
style,
}: {
post: AppBskyFeedDefs.PostView
+ isThreadAuthor: boolean
style?: StyleProp
}) {
+ const {track} = useAnalytics()
+ const {_} = useLingui()
const pal = usePalette('default')
- const {isMobile} = useWebMediaQueries()
+ const agent = useAgent()
+ const queryClient = useQueryClient()
+ const {openModal} = useModalControls()
const containerStyles = useColorSchemeStyle(
{
- borderColor: pal.colors.unreadNotifBorder,
backgroundColor: pal.colors.unreadNotifBg,
},
{
- borderColor: pal.colors.unreadNotifBorder,
backgroundColor: pal.colors.unreadNotifBg,
},
)
- const iconStyles = useColorSchemeStyle(
+ const textStyles = useColorSchemeStyle(
+ {color: colors.blue5},
+ {color: colors.blue1},
+ )
+ const hoverStyles = useColorSchemeStyle(
{
- backgroundColor: colors.blue3,
+ backgroundColor: colors.white,
},
{
- backgroundColor: colors.blue3,
+ backgroundColor: pal.colors.background,
},
)
- const textStyles = useColorSchemeStyle(
- {color: colors.gray7},
- {color: colors.blue1},
- )
- const record = React.useMemo(
- () =>
- post.threadgate &&
- AppBskyFeedThreadgate.isRecord(post.threadgate.record) &&
- AppBskyFeedThreadgate.validateRecord(post.threadgate.record).success
- ? post.threadgate.record
- : null,
+ const settings = React.useMemo(
+ () => threadgateViewToSettings(post.threadgate),
[post],
)
- if (record) {
- return (
-
-
-
-
-
-
- {!record.allow?.length ? (
- Replies to this thread are disabled
- ) : (
-
- Only{' '}
- {record.allow.map((rule, i) => (
- <>
-
-
- >
- ))}{' '}
- can reply.
-
+ const isRootPost = !('reply' in post.record)
+
+ const onPressEdit = () => {
+ track('Post:EditThreadgateOpened')
+ if (isNative && Keyboard.isVisible()) {
+ Keyboard.dismiss()
+ }
+ openModal({
+ name: 'threadgate',
+ settings,
+ async onConfirm(newSettings: ThreadgateSetting[]) {
+ try {
+ if (newSettings.length) {
+ await createThreadgate(agent, post.uri, newSettings)
+ } else {
+ await agent.api.com.atproto.repo.deleteRecord({
+ repo: agent.session!.did,
+ collection: 'app.bsky.feed.threadgate',
+ rkey: new AtUri(post.uri).rkey,
+ })
+ }
+ Toast.show('Thread settings updated')
+ queryClient.invalidateQueries({
+ queryKey: [POST_THREAD_RQKEY_ROOT],
+ })
+ track('Post:ThreadgateEdited')
+ } catch (err) {
+ Toast.show(
+ 'There was an issue. Please check your internet connection and try again.',
+ )
+ logger.error('Failed to edit threadgate', {message: err})
+ }
+ },
+ })
+ }
+
+ if (!isRootPost) {
+ return null
+ }
+ if (!settings.length && !isThreadAuthor) {
+ return null
+ }
+
+ return (
+
+
+
+ {!settings.length ? (
+ Everybody can reply.
+ ) : settings[0].type === 'nobody' ? (
+ Replies to this thread are disabled.
+ ) : (
+
+ Only{' '}
+ {settings.map((rule, i) => (
+ <>
+
+
+ >
+ ))}{' '}
+ can reply.
+
+ )}
+
+
+ {isThreadAuthor && (
+
+
+
-
- )
- }
- return null
+ )}
+
+ )
}
function Rule({
@@ -130,15 +174,15 @@ function Rule({
post,
lists,
}: {
- rule: any
+ rule: ThreadgateSetting
post: AppBskyFeedDefs.PostView
lists: AppBskyGraphDefs.ListViewBasic[] | undefined
}) {
const pal = usePalette('default')
- if (AppBskyFeedThreadgate.isMentionRule(rule)) {
+ if (rule.type === 'mention') {
return mentioned users
}
- if (AppBskyFeedThreadgate.isFollowingRule(rule)) {
+ if (rule.type === 'following') {
return (
users followed by{' '}
@@ -151,7 +195,7 @@ function Rule({
)
}
- if (AppBskyFeedThreadgate.isListRule(rule)) {
+ if (rule.type === 'list') {
const list = lists?.find(l => l.uri === rule.list)
if (list) {
const listUrip = new AtUri(list.uri)
--
2.51.2
From 502bcad7017d72fb23c40a268b1e220f892db7da Mon Sep 17 00:00:00 2001
From: Eric Bailey
Date: Tue, 18 Jun 2024 14:09:40 -0500
Subject: [PATCH 06/17] Disable newskie dialog tap in hover card web (#4562)
---
src/components/NewskieDialog.tsx | 3 +++
src/components/ProfileHoverCard/index.web.tsx | 2 +-
src/screens/Profile/Header/Handle.tsx | 6 ++++--
3 files changed, 8 insertions(+), 3 deletions(-)
diff --git a/src/components/NewskieDialog.tsx b/src/components/NewskieDialog.tsx
index 281430e31..0354bfc43 100644
--- a/src/components/NewskieDialog.tsx
+++ b/src/components/NewskieDialog.tsx
@@ -18,8 +18,10 @@ import {Text} from '#/components/Typography'
export function NewskieDialog({
profile,
+ disabled,
}: {
profile: AppBskyActorDefs.ProfileViewDetailed
+ disabled?: boolean
}) {
const {_} = useLingui()
const moderationOpts = useModerationOpts()
@@ -43,6 +45,7 @@ export function NewskieDialog({
return (
diff --git a/src/screens/Profile/Header/Handle.tsx b/src/screens/Profile/Header/Handle.tsx
index 4f438a286..268b7350f 100644
--- a/src/screens/Profile/Header/Handle.tsx
+++ b/src/screens/Profile/Header/Handle.tsx
@@ -12,8 +12,10 @@ import {Text} from '#/components/Typography'
export function ProfileHeaderHandle({
profile,
+ disableTaps,
}: {
profile: Shadow
+ disableTaps?: boolean
}) {
const t = useTheme()
const invalidHandle = isInvalidHandle(profile.handle)
@@ -21,8 +23,8 @@ export function ProfileHeaderHandle({
return (
-
+ pointerEvents={disableTaps ? 'none' : isAndroid ? 'box-only' : 'auto'}>
+
{profile.viewer?.followedBy && !blockHide ? (
--
2.51.2
From 0012d1236ffa4f268fb359931d02335b21610fd6 Mon Sep 17 00:00:00 2001
From: Samuel Newman
Date: Tue, 18 Jun 2024 22:05:59 +0100
Subject: [PATCH 07/17] Migrate local thread mutes (#4523)
* migrate thread mutes
* don't try and clear if not logged in yet
* migrate mutes one at a time
* write before mutating
* only migrate mutes of self posts
* use /** @deprecated */
* shouldLike -> shouldMute
---
src/state/cache/thread-mutes.tsx | 55 +++++++++++++++++++++++++++++++-
src/state/persisted/schema.ts | 3 +-
src/state/queries/post.ts | 4 +--
3 files changed, 58 insertions(+), 4 deletions(-)
diff --git a/src/state/cache/thread-mutes.tsx b/src/state/cache/thread-mutes.tsx
index b58bd430f..dc5104c14 100644
--- a/src/state/cache/thread-mutes.tsx
+++ b/src/state/cache/thread-mutes.tsx
@@ -1,4 +1,7 @@
-import React from 'react'
+import React, {useEffect} from 'react'
+
+import * as persisted from '#/state/persisted'
+import {useAgent, useSession} from '../session'
type StateContext = Map
type SetStateContext = (uri: string, value: boolean) => void
@@ -21,6 +24,9 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
},
[setState],
)
+
+ useMigrateMutes(setThreadMute)
+
return (
@@ -42,3 +48,50 @@ export function useIsThreadMuted(uri: string, defaultValue = false) {
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])
+}
diff --git a/src/state/persisted/schema.ts b/src/state/persisted/schema.ts
index b81cf5962..9d5b17d35 100644
--- a/src/state/persisted/schema.ts
+++ b/src/state/persisted/schema.ts
@@ -74,7 +74,6 @@ export const schema = z.object({
flickr: z.enum(externalEmbedOptions).optional(),
})
.optional(),
- mutedThreads: z.array(z.string()), // should move to server
invites: z.object({
copiedInvites: z.array(z.string()),
}),
@@ -88,6 +87,8 @@ export const schema = z.object({
disableHaptics: z.boolean().optional(),
disableAutoplay: z.boolean().optional(),
kawaii: z.boolean().optional(),
+ /** @deprecated */
+ mutedThreads: z.array(z.string()),
})
export type Schema = z.infer
diff --git a/src/state/queries/post.ts b/src/state/queries/post.ts
index 8e77bf6b9..a511d6b3d 100644
--- a/src/state/queries/post.ts
+++ b/src/state/queries/post.ts
@@ -304,8 +304,8 @@ export function useThreadMuteMutationQueue(
const queueToggle = useToggleMutationQueue({
initialState: isThreadMuted,
- runMutation: async (_prev, shouldLike) => {
- if (shouldLike) {
+ runMutation: async (_prev, shouldMute) => {
+ if (shouldMute) {
await threadMuteMutation.mutateAsync({
uri: rootUri,
})
--
2.51.2
From 853c32b4d8cfe86ed02c688c32fc99e788c33838 Mon Sep 17 00:00:00 2001
From: Paul Frazee
Date: Tue, 18 Jun 2024 14:21:55 -0700
Subject: [PATCH 08/17] Bump labeler limit to 20 (#4565)
* Increase the labeler subscription limit to 20
* Bump @atproto/api@0.12.20
* Fix: correctly count labeler subscriptions
---------
Co-authored-by: dan
---
package.json | 2 +-
src/screens/Profile/Header/ProfileHeaderLabeler.tsx | 6 +++---
yarn.lock | 8 ++++----
3 files changed, 8 insertions(+), 8 deletions(-)
diff --git a/package.json b/package.json
index 417836903..3e27a8198 100644
--- a/package.json
+++ b/package.json
@@ -49,7 +49,7 @@
"open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web"
},
"dependencies": {
- "@atproto/api": "^0.12.19",
+ "@atproto/api": "^0.12.20",
"@bam.tech/react-native-image-resizer": "^3.0.4",
"@braintree/sanitize-url": "^6.0.2",
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
diff --git a/src/screens/Profile/Header/ProfileHeaderLabeler.tsx b/src/screens/Profile/Header/ProfileHeaderLabeler.tsx
index 6588eb2e1..d266decb3 100644
--- a/src/screens/Profile/Header/ProfileHeaderLabeler.tsx
+++ b/src/screens/Profile/Header/ProfileHeaderLabeler.tsx
@@ -82,7 +82,7 @@ let ProfileHeaderLabeler = ({
preferences?.moderationPrefs.labelers.find(l => l.did === profile.did)
const canSubscribe =
isSubscribed ||
- (preferences ? preferences?.moderationPrefs.labelers.length < 9 : false)
+ (preferences ? preferences?.moderationPrefs.labelers.length <= 20 : false)
const {mutateAsync: likeMod, isPending: isLikePending} = useLikeMutation()
const {mutateAsync: unlikeMod, isPending: isUnlikePending} =
useUnlikeMutation()
@@ -328,8 +328,8 @@ function CantSubscribePrompt({
Unable to subscribe
- We're sorry! You can only subscribe to ten labelers, and you've
- reached your limit of ten.
+ We're sorry! You can only subscribe to twenty labelers, and you've
+ reached your limit of twenty.
diff --git a/yarn.lock b/yarn.lock
index 67ff046cb..700ddfe07 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -34,10 +34,10 @@
jsonpointer "^5.0.0"
leven "^3.1.0"
-"@atproto/api@^0.12.19":
- version "0.12.19"
- resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.12.19.tgz#6d842269b6b9cd3fc5864e12824d4fb04cc033cf"
- integrity sha512-dsiTpjqBhjGwNW/qG/tLSgUQnmOSvd8hsQr5d8GCUDGK2AEHWl0KNgLPbwxIBEIo8Jg9NHsvqV7BMoix8YreIg==
+"@atproto/api@^0.12.20":
+ version "0.12.20"
+ resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.12.20.tgz#2cada08c24bc61eb1775ee4c8010c7ed9dc5d6f3"
+ integrity sha512-nt7ZKUQL9j2yQ3tmCCueiIuc0FwdxZYn2fXdLYqltuxlaO5DmaqqULMBKeYJLq4GbvVl/G+ikPJccoSaMWDYOg==
dependencies:
"@atproto/common-web" "^0.3.0"
"@atproto/lexicon" "^0.4.0"
--
2.51.2
From 32b40631851c3c3f5ae2400c4bb89e009e71a9da Mon Sep 17 00:00:00 2001
From: Eric Bailey
Date: Tue, 18 Jun 2024 17:21:34 -0500
Subject: [PATCH 09/17] Verify email reminders (#4510)
* Clarify intent
* Increase email reminder period to once per day
* Fallback
* Snooze immediately after account creation, prevent showing right after signup
* Fix e2e test exports
* Remove redundant check
* Better simple date generation
* Replace in DateField
* Use non-string comparison
* Revert change to unrelated code
* Also parse
* Remove side effect
---
src/Navigation.tsx | 4 +--
src/lib/strings/time.ts | 11 +++++++
src/state/session/agent.ts | 8 +++++
src/state/shell/reminders.e2e.ts | 4 +--
src/state/shell/reminders.ts | 53 +++++++++++++++++++-------------
5 files changed, 53 insertions(+), 27 deletions(-)
diff --git a/src/Navigation.tsx b/src/Navigation.tsx
index 67b89e262..5d4ba0e3f 100644
--- a/src/Navigation.tsx
+++ b/src/Navigation.tsx
@@ -54,8 +54,8 @@ import {useModalControls} from './state/modals'
import {useUnreadNotifications} from './state/queries/notifications/unread'
import {useSession} from './state/session'
import {
- setEmailConfirmationRequested,
shouldRequestEmailConfirmation,
+ snoozeEmailConfirmationPrompt,
} from './state/shell/reminders'
import {AccessibilitySettingsScreen} from './view/screens/AccessibilitySettings'
import {CommunityGuidelinesScreen} from './view/screens/CommunityGuidelines'
@@ -585,7 +585,7 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
if (currentAccount && shouldRequestEmailConfirmation(currentAccount)) {
openModal({name: 'verify-email', showReminder: true})
- setEmailConfirmationRequested()
+ snoozeEmailConfirmationPrompt()
}
}
diff --git a/src/lib/strings/time.ts b/src/lib/strings/time.ts
index 1194e0240..bfefea9bc 100644
--- a/src/lib/strings/time.ts
+++ b/src/lib/strings/time.ts
@@ -19,3 +19,14 @@ export function getAge(birthDate: Date): number {
}
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()
+ )
+}
diff --git a/src/state/session/agent.ts b/src/state/session/agent.ts
index 48f5614bd..6afb3af88 100644
--- a/src/state/session/agent.ts
+++ b/src/state/session/agent.ts
@@ -11,6 +11,7 @@ import {
import {tryFetchGates} from '#/lib/statsig/statsig'
import {getAge} from '#/lib/strings/time'
import {logger} from '#/logger'
+import {snoozeEmailConfirmationPrompt} from '#/state/shell/reminders'
import {
configureModerationForAccount,
configureModerationForGuest,
@@ -191,6 +192,13 @@ export async function createAgentAndCreateAccount(
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)
}
diff --git a/src/state/shell/reminders.e2e.ts b/src/state/shell/reminders.e2e.ts
index e8c12792a..94809a680 100644
--- a/src/state/shell/reminders.e2e.ts
+++ b/src/state/shell/reminders.e2e.ts
@@ -1,7 +1,5 @@
-export function init() {}
-
export function shouldRequestEmailConfirmation() {
return false
}
-export function setEmailConfirmationRequested() {}
+export function snoozeEmailConfirmationPrompt() {}
diff --git a/src/state/shell/reminders.ts b/src/state/shell/reminders.ts
index ee924eb00..db6ee9391 100644
--- a/src/state/shell/reminders.ts
+++ b/src/state/shell/reminders.ts
@@ -1,36 +1,45 @@
+import {simpleAreDatesEqual} from '#/lib/strings/time'
+import {logger} from '#/logger'
import * as persisted from '#/state/persisted'
-import {toHashCode} from 'lib/strings/helpers'
-import {isOnboardingActive} from './onboarding'
import {SessionAccount} from '../session'
+import {isOnboardingActive} from './onboarding'
export function shouldRequestEmailConfirmation(account: SessionAccount) {
- if (!account) {
- return false
- }
- if (account.emailConfirmed) {
- return false
- }
- if (isOnboardingActive()) {
- return false
- }
- // only prompt once
- if (persisted.get('reminders').lastEmailConfirm) {
- return false
- }
+ // ignore logged out
+ if (!account) return false
+ // ignore confirmed accounts, this is the success state of this reminder
+ if (account.emailConfirmed) return false
+ // wait for onboarding to complete
+ if (isOnboardingActive()) return false
+
+ const snoozedAt = persisted.get('reminders').lastEmailConfirm
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
- // this feature rolls out)
- const code = toHashCode(account.did) % 7
- if (code !== today.getDay() && code !== (today.getDay() + 1) % 7) {
+
+ logger.debug('Checking email confirmation reminder', {
+ today,
+ snoozedAt,
+ })
+
+ // never been snoozed, new account
+ if (!snoozedAt) {
+ return true
+ }
+
+ // already snoozed today
+ if (simpleAreDatesEqual(new Date(Date.parse(snoozedAt)), new Date())) {
return false
}
+
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.get('reminders'),
- lastEmailConfirm: new Date().toISOString(),
+ lastEmailConfirm,
})
}
--
2.51.2
From c92ef2fe31c6fcafa9bcb398562bc10191b3f4e4 Mon Sep 17 00:00:00 2001
From: Eric Bailey
Date: Tue, 18 Jun 2024 17:33:17 -0500
Subject: [PATCH 10/17] Better handling of blocks in `KnownFollowers` (#4563)
* Better handle nested conditionals, use renderable items to determine UI
* Better translate
* Fix translation and fix missing case in the process
* Clarify naming
* Add safeguard
* Remove unneeded msg
---------
Co-authored-by: Dan Abramov
---
src/components/KnownFollowers.tsx | 67 ++++++++++++++++++++++---------
1 file changed, 49 insertions(+), 18 deletions(-)
diff --git a/src/components/KnownFollowers.tsx b/src/components/KnownFollowers.tsx
index 63f61ce85..7b861dc66 100644
--- a/src/components/KnownFollowers.tsx
+++ b/src/components/KnownFollowers.tsx
@@ -100,7 +100,15 @@ function KnownFollowersInner({
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 (
- {count > 2 ? (
-
- Followed by{' '}
-
- {slice[0].profile.displayName}
-
- ,{' '}
-
- {slice[1].profile.displayName}
-
- , and{' '}
-
-
- ) : count === 2 ? (
+ {slice.length >= 2 ? (
+ // 2-n followers, including blocks
+ serverCount > 2 ? (
+
+ Followed by{' '}
+
+ {slice[0].profile.displayName}
+
+ ,{' '}
+
+ {slice[1].profile.displayName}
+
+ , and{' '}
+
+
+ ) : (
+ // only 2
+
+ Followed by{' '}
+
+ {slice[0].profile.displayName}
+ {' '}
+ and{' '}
+
+ {slice[1].profile.displayName}
+
+
+ )
+ ) : serverCount > 1 ? (
+ // 1-n followers, including blocks
Followed by{' '}
{slice[0].profile.displayName}
{' '}
and{' '}
-
- {slice[1].profile.displayName}
-
+
) : (
+ // only 1
Followed by{' '}
--
2.51.2
From 2237e10ad256d7271c2b4a0292216b23aa94e07f Mon Sep 17 00:00:00 2001
From: Samuel Newman
Date: Tue, 18 Jun 2024 23:53:54 +0100
Subject: [PATCH 11/17] fix gap between tab bar and its border (#4538)
---
src/view/com/pager/TabBar.tsx | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/view/com/pager/TabBar.tsx b/src/view/com/pager/TabBar.tsx
index e940e8d1a..1c83ecd6e 100644
--- a/src/view/com/pager/TabBar.tsx
+++ b/src/view/com/pager/TabBar.tsx
@@ -180,7 +180,7 @@ const desktopStyles = StyleSheet.create({
position: 'absolute',
left: 0,
right: 0,
- bottom: -1,
+ top: '100%',
borderBottomWidth: 1,
},
})
@@ -207,7 +207,7 @@ const mobileStyles = StyleSheet.create({
position: 'absolute',
left: 0,
right: 0,
- bottom: -1,
+ top: '100%',
borderBottomWidth: hairlineWidth,
},
})
--
2.51.2
From ac08c761687f87baaa60b353c7d432ace8d7eca3 Mon Sep 17 00:00:00 2001
From: Eric Bailey
Date: Tue, 18 Jun 2024 18:03:23 -0500
Subject: [PATCH 12/17] Fix undefined block (#4479)
* Fix undefined block (#4378)
* Fix undefined block
* Changing text and handling all blocks.
* Tweaks
* Update copy, make non-interactive
* Remove console
* Clarify logic
* Pass through parent blocked state for 3p blocks
* Better translation
---------
Co-authored-by: Josh
Co-authored-by: Dan Abramov
---
src/state/queries/post-feed.ts | 6 ++++
src/view/com/posts/FeedItem.tsx | 53 ++++++++++++++++++++------------
src/view/com/posts/FeedSlice.tsx | 4 +++
3 files changed, 43 insertions(+), 20 deletions(-)
diff --git a/src/state/queries/post-feed.ts b/src/state/queries/post-feed.ts
index 2fb80de37..4e44c1c69 100644
--- a/src/state/queries/post-feed.ts
+++ b/src/state/queries/post-feed.ts
@@ -78,6 +78,7 @@ export interface FeedPostSliceItem {
feedContext: string | undefined
moderation: ModerationDecision
parentAuthor?: AppBskyActorDefs.ProfileViewBasic
+ isParentBlocked?: boolean
}
export interface FeedPostSlice {
@@ -311,6 +312,10 @@ export function usePostFeedQuery(
const parentAuthor =
item.reply?.parent?.author ??
slice.items[i + 1]?.reply?.grandparentAuthor
+ const replyRef = item.reply
+ const isParentBlocked = AppBskyFeedDefs.isBlockedPost(
+ replyRef?.parent,
+ )
return {
_reactKey: `${slice._reactKey}-${i}-${item.post.uri}`,
@@ -324,6 +329,7 @@ export function usePostFeedQuery(
feedContext: item.feedContext || slice.feedContext,
moderation: moderations[i],
parentAuthor,
+ isParentBlocked,
}
}
return undefined
diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx
index 675f23a88..cc767a4a3 100644
--- a/src/view/com/posts/FeedItem.tsx
+++ b/src/view/com/posts/FeedItem.tsx
@@ -56,6 +56,7 @@ interface FeedItemProps {
isThreadParent?: boolean
feedContext: string | undefined
hideTopBorder?: boolean
+ isParentBlocked?: boolean
}
export function FeedItem({
@@ -70,6 +71,7 @@ export function FeedItem({
isThreadLastChild,
isThreadParent,
hideTopBorder,
+ isParentBlocked,
}: FeedItemProps & {post: AppBskyFeedDefs.PostView}): React.ReactNode {
const postShadowed = usePostShadow(post)
const richText = useMemo(
@@ -100,6 +102,7 @@ export function FeedItem({
isThreadLastChild={isThreadLastChild}
isThreadParent={isThreadParent}
hideTopBorder={hideTopBorder}
+ isParentBlocked={isParentBlocked}
/>
)
}
@@ -119,6 +122,7 @@ let FeedItemInner = ({
isThreadLastChild,
isThreadParent,
hideTopBorder,
+ isParentBlocked,
}: FeedItemProps & {
richText: RichTextAPI
post: Shadow
@@ -320,7 +324,7 @@ let FeedItemInner = ({
onOpenAuthor={onOpenAuthor}
/>
{!isThreadChild && showReplyTo && parentAuthor && (
-
+
)}
-
- Reply to{' '}
-
-
-
-
+ {blocked ? (
+ Reply to a blocked post
+ ) : (
+
+ Reply to{' '}
+
+
+
+
+ )}
)
diff --git a/src/view/com/posts/FeedSlice.tsx b/src/view/com/posts/FeedSlice.tsx
index aeb24e8bb..3e08f253c 100644
--- a/src/view/com/posts/FeedSlice.tsx
+++ b/src/view/com/posts/FeedSlice.tsx
@@ -34,6 +34,7 @@ let FeedSlice = ({
isThreadParent={isThreadParentAt(slice.items, 0)}
isThreadChild={isThreadChildAt(slice.items, 0)}
hideTopBorder={hideTopBorder}
+ isParentBlocked={slice.items[0].isParentBlocked}
/>
>
@@ -82,6 +85,7 @@ let FeedSlice = ({
isThreadLastChild={
isThreadChildAt(slice.items, i) && slice.items.length === i + 1
}
+ isParentBlocked={slice.items[i].isParentBlocked}
hideTopBorder={hideTopBorder && i === 0}
/>
))}
--
2.51.2
From 07c2be255f42c60401cec1476e3347bdb0b28db5 Mon Sep 17 00:00:00 2001
From: Paul Frazee
Date: Tue, 18 Jun 2024 16:36:46 -0700
Subject: [PATCH 13/17] Collection of moderation fixes (#4566)
* Fix: dont blur parents in threads that embed blocks
* After tapping 'Show hidden replies', show the individual hider cards
* Add shape override to UserAvatar and fix the fallback avi for labelers
* Fix precedence
* Detect shape for DefaultAvatar
---------
Co-authored-by: Dan Abramov
---
src/components/moderation/PostAlerts.tsx | 2 ++
src/components/moderation/PostHider.tsx | 12 ++++++--
src/view/com/post-thread/PostThread.tsx | 6 +++-
src/view/com/util/UserAvatar.tsx | 35 ++++++++++++++++--------
4 files changed, 41 insertions(+), 14 deletions(-)
diff --git a/src/components/moderation/PostAlerts.tsx b/src/components/moderation/PostAlerts.tsx
index 0b48b51d1..ec7529a4f 100644
--- a/src/components/moderation/PostAlerts.tsx
+++ b/src/components/moderation/PostAlerts.tsx
@@ -92,6 +92,8 @@ function PostLabel({
) : (
diff --git a/src/components/moderation/PostHider.tsx b/src/components/moderation/PostHider.tsx
index 8a6474297..b6fb17452 100644
--- a/src/components/moderation/PostHider.tsx
+++ b/src/components/moderation/PostHider.tsx
@@ -1,6 +1,6 @@
import React, {ComponentProps} from 'react'
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 {useLingui} from '@lingui/react'
import {useQueryClient} from '@tanstack/react-query'
@@ -45,7 +45,8 @@ export function PostHider({
const [override, setOverride] = React.useState(false)
const control = useModerationDetailsDialogControl()
const blur =
- modui.blurs[0] || (interpretFilterAsBlur ? modui.filters[0] : undefined)
+ modui.blurs[0] ||
+ (interpretFilterAsBlur ? getBlurrableFilter(modui) : undefined)
const desc = useModerationCauseDescription(blur)
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({
child: {
borderWidth: 0,
diff --git a/src/view/com/post-thread/PostThread.tsx b/src/view/com/post-thread/PostThread.tsx
index 8061eb11c..a6c1a4648 100644
--- a/src/view/com/post-thread/PostThread.tsx
+++ b/src/view/com/post-thread/PostThread.tsx
@@ -331,7 +331,11 @@ export function PostThread({
- setHiddenRepliesState(HiddenRepliesState.ShowAndOverridePostHider)
+ setHiddenRepliesState(
+ item === SHOW_HIDDEN_REPLIES
+ ? HiddenRepliesState.Show
+ : HiddenRepliesState.ShowAndOverridePostHider,
+ )
}
hideTopBorder={index === 0}
/>
diff --git a/src/view/com/util/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx
index 587b466a3..c212ea4c0 100644
--- a/src/view/com/util/UserAvatar.tsx
+++ b/src/view/com/util/UserAvatar.tsx
@@ -35,6 +35,7 @@ export type UserAvatarType = 'user' | 'algo' | 'list' | 'labeler'
interface BaseUserAvatarProps {
type?: UserAvatarType
+ shape?: 'circle' | 'square'
size: number
avatar?: string | null
}
@@ -60,12 +61,16 @@ const BLUR_AMOUNT = isWeb ? 5 : 100
let DefaultAvatar = ({
type,
+ shape: overrideShape,
size,
}: {
type: UserAvatarType
+ shape?: 'square' | 'circle'
size: number
}): React.ReactNode => {
+ const finalShape = overrideShape ?? (type === 'user' ? 'circle' : 'square')
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.
return (
) : (
-
+
{alert}
)
--
2.51.2
From 5159495274934256038b3bd04d01e614a1ee064b Mon Sep 17 00:00:00 2001
From: Eric Bailey
Date: Tue, 18 Jun 2024 18:46:34 -0500
Subject: [PATCH 14/17] Move onboarding start to after successfull account
creation (#4381)
* Move onboarding start to after successfull account creation
* Remove unused dispatch
---
src/screens/Signup/state.ts | 7 +++++--
1 file changed, 5 insertions(+), 2 deletions(-)
diff --git a/src/screens/Signup/state.ts b/src/screens/Signup/state.ts
index facc680bd..87700cb88 100644
--- a/src/screens/Signup/state.ts
+++ b/src/screens/Signup/state.ts
@@ -252,7 +252,6 @@ export function useSubmitSignup({
dispatch({type: 'setIsLoading', value: true})
try {
- onboardingDispatch({type: 'start'}) // start now to avoid flashing the wrong view
await createAccount({
service: state.serviceUrl,
email: state.email,
@@ -262,8 +261,12 @@ export function useSubmitSignup({
inviteCode: state.inviteCode.trim(),
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) {
- onboardingDispatch({type: 'skip'}) // undo starting the onboard
let errMsg = e.toString()
if (e instanceof ComAtprotoServerCreateAccount.InvalidInviteCodeError) {
dispatch({
--
2.51.2
From 7ddbc392c38393db922e47c0a7f56f6855de8152 Mon Sep 17 00:00:00 2001
From: dan
Date: Wed, 19 Jun 2024 02:47:50 +0300
Subject: [PATCH 15/17] [Session] Convert account to session data explicitly
(#4446)
* Extract a variable
* Extract sessionAccountToSession
* Convert account to session data on assignment
---
src/state/session/agent.ts | 36 +++++++++++++++++++++---------------
src/state/session/index.tsx | 5 +++--
2 files changed, 24 insertions(+), 17 deletions(-)
diff --git a/src/state/session/agent.ts b/src/state/session/agent.ts
index 6afb3af88..5a58937fa 100644
--- a/src/state/session/agent.ts
+++ b/src/state/session/agent.ts
@@ -38,21 +38,7 @@ export async function createAgentAndResume(
}
const gates = tryFetchGates(storedAccount.did, 'prefer-low-latency')
const moderation = configureModerationForAccount(agent, storedAccount)
- const prevSession: AtpSessionData = {
- // 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,
- }
+ const prevSession: AtpSessionData = sessionAccountToSession(storedAccount)
if (isSessionExpired(storedAccount)) {
await networkRetry(1, () => agent.resumeSession(prevSession))
} else {
@@ -253,3 +239,23 @@ export function agentToSessionAccount(
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,
+ }
+}
diff --git a/src/state/session/index.tsx b/src/state/session/index.tsx
index 371bd459a..314945bcf 100644
--- a/src/state/session/index.tsx
+++ b/src/state/session/index.tsx
@@ -14,6 +14,7 @@ import {
createAgentAndCreateAccount,
createAgentAndLogin,
createAgentAndResume,
+ sessionAccountToSession,
} from './agent'
import {getInitialState, reducer} from './reducer'
@@ -175,8 +176,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
if (syncedAccount.did !== state.currentAgentState.did) {
resumeSession(syncedAccount)
} else {
- // @ts-ignore we checked for `refreshJwt` above
- state.currentAgentState.agent.session = syncedAccount
+ const agent = state.currentAgentState.agent as BskyAgent
+ agent.session = sessionAccountToSession(syncedAccount)
}
}
})
--
2.51.2
From 3dc34be9291fe76ed0a46266f67304df52c72875 Mon Sep 17 00:00:00 2001
From: Samuel Newman
Date: Wed, 19 Jun 2024 01:09:06 +0100
Subject: [PATCH 16/17] GIF previews in notifications (#4447)
* gifs in notifications
* remove try/catch
* Limit try/catch scope
---------
Co-authored-by: Dan Abramov
---
src/lib/strings/embed-player.ts | 100 +++++++++++++--------
src/view/com/notifications/FeedItem.tsx | 47 ++++++++--
src/view/com/util/images/ImageHorzList.tsx | 57 ++++++++----
3 files changed, 144 insertions(+), 60 deletions(-)
diff --git a/src/lib/strings/embed-player.ts b/src/lib/strings/embed-player.ts
index 30ced1492..44e42fae1 100644
--- a/src/lib/strings/embed-player.ts
+++ b/src/lib/strings/embed-player.ts
@@ -1,7 +1,8 @@
-import {Dimensions, Platform} from 'react-native'
+import {Dimensions} from 'react-native'
import {isSafari} from 'lib/browser'
import {isWeb} from 'platform/detection'
+
const {height: SCREEN_HEIGHT} = Dimensions.get('window')
const IFRAME_HOST = isWeb
@@ -342,40 +343,17 @@ export function parseEmbedPlayerFromUrl(
}
}
- if (urlp.hostname === 'media.tenor.com') {
- let [_, id, filename] = urlp.pathname.split('/')
-
- const h = urlp.searchParams.get('hh')
- const w = urlp.searchParams.get('ww')
- let dimensions
- if (h && w) {
- dimensions = {
- height: Number(h),
- width: Number(w),
- }
- }
-
- 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,
- }
+ const tenorGif = parseTenorGif(urlp)
+ if (tenorGif.success) {
+ const {playerUri, dimensions} = tenorGif
+
+ return {
+ type: 'tenor_gif',
+ source: 'tenor',
+ isGif: true,
+ hideDetails: true,
+ playerUri,
+ 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,
+ }
+}
diff --git a/src/view/com/notifications/FeedItem.tsx b/src/view/com/notifications/FeedItem.tsx
index d6c38ea61..9cd7a2917 100644
--- a/src/view/com/notifications/FeedItem.tsx
+++ b/src/view/com/notifications/FeedItem.tsx
@@ -8,6 +8,7 @@ import {
} from 'react-native'
import {
AppBskyActorDefs,
+ AppBskyEmbedExternal,
AppBskyEmbedImages,
AppBskyEmbedRecordWithMedia,
AppBskyFeedDefs,
@@ -51,6 +52,7 @@ import {TimeElapsed} from '../util/TimeElapsed'
import {PreviewableUserAvatar, UserAvatar} from '../util/UserAvatar'
import hairlineWidth = StyleSheet.hairlineWidth
+import {parseTenorGif} from '#/lib/strings/embed-player'
const MAX_AUTHORS = 5
@@ -465,17 +467,48 @@ function AdditionalPostText({post}: {post?: AppBskyFeedDefs.PostView}) {
const pal = usePalette('default')
if (post && AppBskyFeedPost.isRecord(post?.record)) {
const text = post.record.text
- const images = AppBskyEmbedImages.isView(post.embed)
- ? post.embed.images
- : AppBskyEmbedRecordWithMedia.isView(post.embed) &&
- AppBskyEmbedImages.isView(post.embed.media)
- ? post.embed.media.images
- : undefined
+ let images
+ let isGif = false
+
+ if (AppBskyEmbedImages.isView(post.embed)) {
+ images = post.embed.images
+ } 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 (
<>
{text?.length > 0 && {text}}
{images && images.length > 0 && (
-
+
)}
>
)
diff --git a/src/view/com/util/images/ImageHorzList.tsx b/src/view/com/util/images/ImageHorzList.tsx
index 12eef14f7..bade2a444 100644
--- a/src/view/com/util/images/ImageHorzList.tsx
+++ b/src/view/com/util/images/ImageHorzList.tsx
@@ -2,39 +2,60 @@ import React from 'react'
import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
import {Image} from 'expo-image'
import {AppBskyEmbedImages} from '@atproto/api'
+import {Trans} from '@lingui/macro'
+
+import {atoms as a} from '#/alf'
+import {Text} from '#/components/Typography'
interface Props {
images: AppBskyEmbedImages.ViewImage[]
style?: StyleProp
+ gif?: boolean
}
-export function ImageHorzList({images, style}: Props) {
+export function ImageHorzList({images, style, gif}: Props) {
return (
-
+
{images.map(({thumb, alt}) => (
-
+ style={[a.relative, a.flex_1, {aspectRatio: 1, maxWidth: 100}]}>
+
+ {gif && (
+
+
+ GIF
+
+
+ )}
+
))}
)
}
const styles = StyleSheet.create({
- flexRow: {
- flexDirection: 'row',
- gap: 5,
+ altContainer: {
+ backgroundColor: 'rgba(0, 0, 0, 0.75)',
+ borderRadius: 6,
+ paddingHorizontal: 6,
+ paddingVertical: 3,
+ position: 'absolute',
+ right: 5,
+ bottom: 5,
+ zIndex: 2,
},
- image: {
- maxWidth: 100,
- aspectRatio: 1,
- flex: 1,
- borderRadius: 4,
+ alt: {
+ color: 'white',
+ fontSize: 7,
+ fontWeight: 'bold',
},
})
--
2.51.2
From 8788708bd229ee8a7049285b2e520cc657b41c00 Mon Sep 17 00:00:00 2001
From: Hailey
Date: Tue, 18 Jun 2024 17:33:43 -0700
Subject: [PATCH 17/17] add support for `ListEmptyComponent`, allow `undefined`
data (#4403)
* add support for `ListEmptyComponent`, allow `undefined` data
* change `header` and `footer` to be in line with `emptyComponent`
* don't render `onEndReached` or `onStartReached` `Visibility` if empty
---
src/view/com/util/List.web.tsx | 63 +++++++++++++++++++++-------------
1 file changed, 39 insertions(+), 24 deletions(-)
diff --git a/src/view/com/util/List.web.tsx b/src/view/com/util/List.web.tsx
index 6b0c17762..e917ab1d3 100644
--- a/src/view/com/util/List.web.tsx
+++ b/src/view/com/util/List.web.tsx
@@ -38,6 +38,7 @@ function ListImpl(
{
ListHeaderComponent,
ListFooterComponent,
+ ListEmptyComponent,
containWeb,
contentContainerStyle,
data,
@@ -72,23 +73,35 @@ function ListImpl(
)
}
- let header: JSX.Element | null = null
+ const isEmpty = !data || data.length === 0
+
+ let headerComponent: JSX.Element | null = null
if (ListHeaderComponent != null) {
if (isValidElement(ListHeaderComponent)) {
- header = ListHeaderComponent
+ headerComponent = ListHeaderComponent
} else {
// @ts-ignore Nah it's fine.
- header =
+ headerComponent =
}
}
- let footer: JSX.Element | null = null
+ let footerComponent: JSX.Element | null = null
if (ListFooterComponent != null) {
if (isValidElement(ListFooterComponent)) {
- footer = ListFooterComponent
+ footerComponent = ListFooterComponent
+ } else {
+ // @ts-ignore Nah it's fine.
+ footerComponent =
+ }
+ }
+
+ let emptyComponent: JSX.Element | null = null
+ if (ListEmptyComponent != null) {
+ if (isValidElement(ListEmptyComponent)) {
+ emptyComponent = ListEmptyComponent
} else {
// @ts-ignore Nah it's fine.
- footer =
+ emptyComponent =
}
}
@@ -323,36 +336,38 @@ function ListImpl(
onVisibleChange={handleAboveTheFoldVisibleChange}
style={[styles.aboveTheFoldDetector, {height: headerOffset}]}
/>
- {onStartReached && (
+ {onStartReached && !isEmpty && (
)}
- {header}
- {(data as Array).map((item, index) => {
- const key = keyExtractor!(item, index)
- return (
-
- key={key}
- item={item}
- index={index}
- renderItem={renderItem}
- extraData={extraData}
- onItemSeen={onItemSeen}
- disableContentVisibility={disableContentVisibility}
- />
- )
- })}
- {onEndReached && (
+ {headerComponent}
+ {isEmpty
+ ? emptyComponent
+ : (data as Array)?.map((item, index) => {
+ const key = keyExtractor!(item, index)
+ return (
+
+ key={key}
+ item={item}
+ index={index}
+ renderItem={renderItem}
+ extraData={extraData}
+ onItemSeen={onItemSeen}
+ disableContentVisibility={disableContentVisibility}
+ />
+ )
+ })}
+ {onEndReached && !isEmpty && (
)}
- {footer}
+ {footerComponent}
)