diff options
author | Paul Frazee <pfrazee@gmail.com> | 2023-05-02 23:32:16 -0500 |
---|---|---|
committer | GitHub <noreply@github.com> | 2023-05-02 23:32:16 -0500 |
commit | 883700e09029bd0d9221edb9910d065da5786fe0 (patch) | |
tree | cdf6c849c8b378dffcfc305834a4b31ecbefb7ae | |
parent | 2eb0d8c095b70b7ec39b7a1acbd126457dea9322 (diff) | |
download | voidsky-883700e09029bd0d9221edb9910d065da5786fe0.tar.zst |
[APP-601] Add muted accounts list (#565)
* Add muted accounts list * Fix icon for muted accounts
-rw-r--r-- | bskyweb/cmd/bskyweb/server.go | 1 | ||||
-rw-r--r-- | src/Navigation.tsx | 2 | ||||
-rw-r--r-- | src/lib/routes/types.ts | 1 | ||||
-rw-r--r-- | src/routes.ts | 1 | ||||
-rw-r--r-- | src/state/models/lists/muted-accounts.ts | 106 | ||||
-rw-r--r-- | src/view/com/profile/ProfileCard.tsx | 3 | ||||
-rw-r--r-- | src/view/com/util/Link.tsx | 10 | ||||
-rw-r--r-- | src/view/screens/MutedAccounts.tsx | 168 | ||||
-rw-r--r-- | src/view/screens/Settings.tsx | 14 | ||||
-rw-r--r-- | web/index.html | 3 |
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] { |