diff --git a/bskyweb/cmd/bskyweb/server.go b/bskyweb/cmd/bskyweb/server.go
index b901e226ce..07804e7cee 100644
--- a/bskyweb/cmd/bskyweb/server.go
+++ b/bskyweb/cmd/bskyweb/server.go
@@ -93,6 +93,7 @@ func serve(cctx *cli.Context) error {
e.GET("/notifications", server.WebGeneric)
e.GET("/settings", server.WebGeneric)
e.GET("/settings/app-passwords", server.WebGeneric)
+ e.GET("/settings/muted-accounts", server.WebGeneric)
e.GET("/settings/blocked-accounts", server.WebGeneric)
e.GET("/sys/debug", server.WebGeneric)
e.GET("/sys/log", server.WebGeneric)
diff --git a/src/Navigation.tsx b/src/Navigation.tsx
index 412c63f338..9a163fc43b 100644
--- a/src/Navigation.tsx
+++ b/src/Navigation.tsx
@@ -49,6 +49,7 @@ import {TermsOfServiceScreen} from './view/screens/TermsOfService'
import {CommunityGuidelinesScreen} from './view/screens/CommunityGuidelines'
import {CopyrightPolicyScreen} from './view/screens/CopyrightPolicy'
import {AppPasswords} from 'view/screens/AppPasswords'
+import {MutedAccounts} from 'view/screens/MutedAccounts'
import {BlockedAccounts} from 'view/screens/BlockedAccounts'
import {getRoutingInstrumentation} from 'lib/sentry'
@@ -90,6 +91,7 @@ function commonScreens(Stack: typeof HomeTab) {
/>
+
>
)
diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts
index 3aff821174..34e6e6a468 100644
--- a/src/lib/routes/types.ts
+++ b/src/lib/routes/types.ts
@@ -20,6 +20,7 @@ export type CommonNavigatorParams = {
CommunityGuidelines: undefined
CopyrightPolicy: undefined
AppPasswords: undefined
+ MutedAccounts: undefined
BlockedAccounts: undefined
}
diff --git a/src/routes.ts b/src/routes.ts
index 15595775e2..43d31ee099 100644
--- a/src/routes.ts
+++ b/src/routes.ts
@@ -14,6 +14,7 @@ export const router = new Router({
Debug: '/sys/debug',
Log: '/sys/log',
AppPasswords: '/settings/app-passwords',
+ MutedAccounts: '/settings/muted-accounts',
BlockedAccounts: '/settings/blocked-accounts',
Support: '/support',
PrivacyPolicy: '/support/privacy',
diff --git a/src/state/models/lists/muted-accounts.ts b/src/state/models/lists/muted-accounts.ts
new file mode 100644
index 0000000000..9c3e1157b6
--- /dev/null
+++ b/src/state/models/lists/muted-accounts.ts
@@ -0,0 +1,106 @@
+import {makeAutoObservable} from 'mobx'
+import {
+ AppBskyGraphGetMutes as GetMutes,
+ AppBskyActorDefs as ActorDefs,
+} from '@atproto/api'
+import {RootStoreModel} from '../root-store'
+import {cleanError} from 'lib/strings/errors'
+import {bundleAsync} from 'lib/async/bundle'
+
+const PAGE_SIZE = 30
+
+export class MutedAccountsModel {
+ // state
+ isLoading = false
+ isRefreshing = false
+ hasLoaded = false
+ error = ''
+ hasMore = true
+ loadMoreCursor?: string
+
+ // data
+ mutes: ActorDefs.ProfileView[] = []
+
+ constructor(public rootStore: RootStoreModel) {
+ makeAutoObservable(
+ this,
+ {
+ rootStore: false,
+ },
+ {autoBind: true},
+ )
+ }
+
+ get hasContent() {
+ return this.mutes.length > 0
+ }
+
+ get hasError() {
+ return this.error !== ''
+ }
+
+ get isEmpty() {
+ return this.hasLoaded && !this.hasContent
+ }
+
+ // public api
+ // =
+
+ async refresh() {
+ return this.loadMore(true)
+ }
+
+ loadMore = bundleAsync(async (replace: boolean = false) => {
+ if (!replace && !this.hasMore) {
+ return
+ }
+ this._xLoading(replace)
+ try {
+ const res = await this.rootStore.agent.app.bsky.graph.getMutes({
+ limit: PAGE_SIZE,
+ cursor: replace ? undefined : this.loadMoreCursor,
+ })
+ if (replace) {
+ this._replaceAll(res)
+ } else {
+ this._appendAll(res)
+ }
+ this._xIdle()
+ } catch (e: any) {
+ this._xIdle(e)
+ }
+ })
+
+ // state transitions
+ // =
+
+ _xLoading(isRefreshing = false) {
+ this.isLoading = true
+ this.isRefreshing = isRefreshing
+ this.error = ''
+ }
+
+ _xIdle(err?: any) {
+ this.isLoading = false
+ this.isRefreshing = false
+ this.hasLoaded = true
+ this.error = cleanError(err)
+ if (err) {
+ this.rootStore.log.error('Failed to fetch user followers', err)
+ }
+ }
+
+ // helper functions
+ // =
+
+ _replaceAll(res: GetMutes.Response) {
+ this.mutes = []
+ this._appendAll(res)
+ }
+
+ _appendAll(res: GetMutes.Response) {
+ this.loadMoreCursor = res.data.cursor
+ this.hasMore = !!this.loadMoreCursor
+ this.mutes = this.mutes.concat(res.data.mutes)
+ }
+}
diff --git a/src/view/com/profile/ProfileCard.tsx b/src/view/com/profile/ProfileCard.tsx
index 66c1721413..12d6318337 100644
--- a/src/view/com/profile/ProfileCard.tsx
+++ b/src/view/com/profile/ProfileCard.tsx
@@ -60,7 +60,8 @@ export const ProfileCard = observer(
]}
href={`/profile/${profile.handle}`}
title={profile.handle}
- asAnchor>
+ asAnchor
+ anchorNoUnderline>
{
children?: React.ReactNode
noFeedback?: boolean
asAnchor?: boolean
+ anchorNoUnderline?: boolean
}
export const Link = observer(function Link({
@@ -48,6 +49,7 @@ export const Link = observer(function Link({
noFeedback,
asAnchor,
accessible,
+ anchorNoUnderline,
...props
}: Props) {
const store = useStores()
@@ -78,6 +80,14 @@ export const Link = observer(function Link({
)
}
+
+ if (anchorNoUnderline) {
+ // @ts-ignore web only -prf
+ props.dataSet = props.dataSet || {}
+ // @ts-ignore web only -prf
+ props.dataSet.noUnderline = 1
+ }
+
return (
+export const MutedAccounts = withAuthRequired(
+ observer(({}: Props) => {
+ const pal = usePalette('default')
+ const store = useStores()
+ const {screen} = useAnalytics()
+ const mutedAccounts = useMemo(() => new MutedAccountsModel(store), [store])
+
+ useFocusEffect(
+ React.useCallback(() => {
+ screen('MutedAccounts')
+ store.shell.setMinimalShellMode(false)
+ mutedAccounts.refresh()
+ }, [screen, store, mutedAccounts]),
+ )
+
+ const onRefresh = React.useCallback(() => {
+ mutedAccounts.refresh()
+ }, [mutedAccounts])
+ const onEndReached = React.useCallback(() => {
+ mutedAccounts
+ .loadMore()
+ .catch(err =>
+ store.log.error('Failed to load more muted accounts', err),
+ )
+ }, [mutedAccounts, store])
+
+ const renderItem = ({
+ item,
+ index,
+ }: {
+ item: ActorDefs.ProfileView
+ index: number
+ }) => (
+
+ )
+ return (
+
+
+
+ Muted accounts have their posts removed from your feed and from your
+ notifications. Mutes are completely private.
+
+ {!mutedAccounts.hasContent ? (
+
+
+
+ You have not muted any accounts yet. To mute an account, go to
+ their profile and selected "Mute account" from the menu on their
+ account.
+
+
+
+ ) : (
+ item.did}
+ refreshControl={
+
+ }
+ onEndReached={onEndReached}
+ renderItem={renderItem}
+ initialNumToRender={15}
+ ListFooterComponent={() => (
+
+ {mutedAccounts.isLoading && }
+
+ )}
+ extraData={mutedAccounts.isLoading}
+ // @ts-ignore our .web version only -prf
+ desktopFixedHeight
+ />
+ )}
+
+ )
+ }),
+)
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ paddingBottom: isDesktopWeb ? 0 : 100,
+ },
+ containerDesktop: {
+ borderLeftWidth: 1,
+ borderRightWidth: 1,
+ },
+ title: {
+ textAlign: 'center',
+ marginTop: 12,
+ marginBottom: 12,
+ },
+ description: {
+ textAlign: 'center',
+ paddingHorizontal: 30,
+ marginBottom: 14,
+ },
+ descriptionDesktop: {
+ marginTop: 14,
+ },
+
+ flex1: {
+ flex: 1,
+ },
+ empty: {
+ paddingHorizontal: 20,
+ paddingVertical: 20,
+ borderRadius: 16,
+ marginHorizontal: 24,
+ marginTop: 10,
+ },
+ emptyText: {
+ textAlign: 'center',
+ },
+
+ footer: {
+ height: 200,
+ paddingTop: 20,
+ },
+})
diff --git a/src/view/screens/Settings.tsx b/src/view/screens/Settings.tsx
index 7c48ce96b4..35c7f45520 100644
--- a/src/view/screens/Settings.tsx
+++ b/src/view/screens/Settings.tsx
@@ -288,6 +288,20 @@ export const SettingsScreen = withAuthRequired(
Content moderation
+
+
+
+
+
+ Muted accounts
+
+