about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--bskyweb/cmd/bskyweb/server.go1
-rw-r--r--src/Navigation.tsx2
-rw-r--r--src/lib/routes/types.ts1
-rw-r--r--src/routes.ts1
-rw-r--r--src/state/models/lists/muted-accounts.ts106
-rw-r--r--src/view/com/profile/ProfileCard.tsx3
-rw-r--r--src/view/com/util/Link.tsx10
-rw-r--r--src/view/screens/MutedAccounts.tsx168
-rw-r--r--src/view/screens/Settings.tsx14
-rw-r--r--web/index.html3
10 files changed, 308 insertions, 1 deletions
diff --git a/bskyweb/cmd/bskyweb/server.go b/bskyweb/cmd/bskyweb/server.go
index b901e226c..07804e7ce 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 412c63f33..9a163fc43 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) {
       />
       <Stack.Screen name="CopyrightPolicy" component={CopyrightPolicyScreen} />
       <Stack.Screen name="AppPasswords" component={AppPasswords} />
+      <Stack.Screen name="MutedAccounts" component={MutedAccounts} />
       <Stack.Screen name="BlockedAccounts" component={BlockedAccounts} />
     </>
   )
diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts
index 3aff82117..34e6e6a46 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 15595775e..43d31ee09 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 000000000..9c3e1157b
--- /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 66c172141..12d631833 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>
         <View style={styles.layout}>
           <View style={styles.layoutAvi}>
             <UserAvatar
diff --git a/src/view/com/util/Link.tsx b/src/view/com/util/Link.tsx
index 503e22084..253f80bdc 100644
--- a/src/view/com/util/Link.tsx
+++ b/src/view/com/util/Link.tsx
@@ -37,6 +37,7 @@ interface Props extends ComponentProps<typeof TouchableOpacity> {
   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({
       </TouchableWithoutFeedback>
     )
   }
+
+  if (anchorNoUnderline) {
+    // @ts-ignore web only -prf
+    props.dataSet = props.dataSet || {}
+    // @ts-ignore web only -prf
+    props.dataSet.noUnderline = 1
+  }
+
   return (
     <TouchableOpacity
       testID={testID}
diff --git a/src/view/screens/MutedAccounts.tsx b/src/view/screens/MutedAccounts.tsx
new file mode 100644
index 000000000..f7120051f
--- /dev/null
+++ b/src/view/screens/MutedAccounts.tsx
@@ -0,0 +1,168 @@
+import React, {useMemo} from 'react'
+import {
+  ActivityIndicator,
+  FlatList,
+  RefreshControl,
+  StyleSheet,
+  View,
+} from 'react-native'
+import {AppBskyActorDefs as ActorDefs} from '@atproto/api'
+import {Text} from '../com/util/text/Text'
+import {useStores} from 'state/index'
+import {usePalette} from 'lib/hooks/usePalette'
+import {isDesktopWeb} from 'platform/detection'
+import {withAuthRequired} from 'view/com/auth/withAuthRequired'
+import {observer} from 'mobx-react-lite'
+import {NativeStackScreenProps} from '@react-navigation/native-stack'
+import {CommonNavigatorParams} from 'lib/routes/types'
+import {MutedAccountsModel} from 'state/models/lists/muted-accounts'
+import {useAnalytics} from 'lib/analytics'
+import {useFocusEffect} from '@react-navigation/native'
+import {ViewHeader} from '../com/util/ViewHeader'
+import {CenteredView} from 'view/com/util/Views'
+import {ProfileCard} from 'view/com/profile/ProfileCard'
+
+type Props = NativeStackScreenProps<CommonNavigatorParams, 'MutedAccounts'>
+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
+    }) => (
+      <ProfileCard
+        testID={`mutedAccount-${index}`}
+        key={item.did}
+        profile={item}
+        overrideModeration
+      />
+    )
+    return (
+      <CenteredView
+        style={[
+          styles.container,
+          isDesktopWeb && styles.containerDesktop,
+          pal.view,
+          pal.border,
+        ]}
+        testID="mutedAccountsScreen">
+        <ViewHeader title="Muted Accounts" showOnDesktop />
+        <Text
+          type="sm"
+          style={[
+            styles.description,
+            pal.text,
+            isDesktopWeb && styles.descriptionDesktop,
+          ]}>
+          Muted accounts have their posts removed from your feed and from your
+          notifications. Mutes are completely private.
+        </Text>
+        {!mutedAccounts.hasContent ? (
+          <View style={[pal.border, !isDesktopWeb && styles.flex1]}>
+            <View style={[styles.empty, pal.viewLight]}>
+              <Text type="lg" style={[pal.text, styles.emptyText]}>
+                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.
+              </Text>
+            </View>
+          </View>
+        ) : (
+          <FlatList
+            style={[!isDesktopWeb && styles.flex1]}
+            data={mutedAccounts.mutes}
+            keyExtractor={(item: ActorDefs.ProfileView) => item.did}
+            refreshControl={
+              <RefreshControl
+                refreshing={mutedAccounts.isRefreshing}
+                onRefresh={onRefresh}
+                tintColor={pal.colors.text}
+                titleColor={pal.colors.text}
+              />
+            }
+            onEndReached={onEndReached}
+            renderItem={renderItem}
+            initialNumToRender={15}
+            ListFooterComponent={() => (
+              <View style={styles.footer}>
+                {mutedAccounts.isLoading && <ActivityIndicator />}
+              </View>
+            )}
+            extraData={mutedAccounts.isLoading}
+            // @ts-ignore our .web version only -prf
+            desktopFixedHeight
+          />
+        )}
+      </CenteredView>
+    )
+  }),
+)
+
+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 7c48ce96b..35c7f4552 100644
--- a/src/view/screens/Settings.tsx
+++ b/src/view/screens/Settings.tsx
@@ -289,6 +289,20 @@ export const SettingsScreen = withAuthRequired(
             </Text>
           </TouchableOpacity>
           <Link
+            testID="mutedAccountsBtn"
+            style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]}
+            href="/settings/muted-accounts">
+            <View style={[styles.iconContainer, pal.btn]}>
+              <FontAwesomeIcon
+                icon={['far', 'eye-slash']}
+                style={pal.text as FontAwesomeIconStyle}
+              />
+            </View>
+            <Text type="lg" style={pal.text}>
+              Muted accounts
+            </Text>
+          </Link>
+          <Link
             testID="blockedAccountsBtn"
             style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]}
             href="/settings/blocked-accounts">
diff --git a/web/index.html b/web/index.html
index ea08e9d55..f88fd727b 100644
--- a/web/index.html
+++ b/web/index.html
@@ -67,6 +67,9 @@
       a[role="link"]:hover {
         text-decoration: underline;
       }
+      a[role="link"][data-no-underline="1"]:hover {
+        text-decoration: none;
+      }
 
       /* Styling hacks */
       *[data-word-wrap] {