diff options
Diffstat (limited to 'src')
79 files changed, 3757 insertions, 595 deletions
diff --git a/src/Navigation.tsx b/src/Navigation.tsx index 17d80dfdc..45ab439b6 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -33,11 +33,14 @@ import {useStores} from './state' import {HomeScreen} from './view/screens/Home' import {SearchScreen} from './view/screens/Search' import {NotificationsScreen} from './view/screens/Notifications' +import {ModerationScreen} from './view/screens/Moderation' +import {ModerationMuteListsScreen} from './view/screens/ModerationMuteLists' import {NotFoundScreen} from './view/screens/NotFound' import {SettingsScreen} from './view/screens/Settings' import {ProfileScreen} from './view/screens/Profile' import {ProfileFollowersScreen} from './view/screens/ProfileFollowers' import {ProfileFollowsScreen} from './view/screens/ProfileFollows' +import {ProfileListScreen} from './view/screens/ProfileList' import {PostThreadScreen} from './view/screens/PostThread' import {PostLikedByScreen} from './view/screens/PostLikedBy' import {PostRepostedByScreen} from './view/screens/PostRepostedBy' @@ -49,12 +52,13 @@ 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 {ModerationMutedAccounts} from 'view/screens/ModerationMutedAccounts' +import {ModerationBlockedAccounts} from 'view/screens/ModerationBlockedAccounts' import {getRoutingInstrumentation} from 'lib/sentry' import {SavedFeeds} from './view/screens/SavedFeeds' import {CustomFeed} from './view/screens/CustomFeed' import {PinnedFeeds} from 'view/screens/PinnedFeeds' +import {bskyTitle} from 'lib/strings/headings' const navigationRef = createNavigationContainerRef<AllNavigatorParams>() @@ -69,36 +73,125 @@ const Tab = createBottomTabNavigator<BottomTabNavigatorParams>() /** * These "common screens" are reused across stacks. */ -function commonScreens(Stack: typeof HomeTab) { +function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) { + const title = (page: string) => bskyTitle(page, unreadCountLabel) + return ( <> - <Stack.Screen name="NotFound" component={NotFoundScreen} /> - <Stack.Screen name="Settings" component={SettingsScreen} /> - <Stack.Screen name="Profile" component={ProfileScreen} /> + <Stack.Screen + name="NotFound" + component={NotFoundScreen} + options={{title: title('Not Found')}} + /> + <Stack.Screen + name="Moderation" + component={ModerationScreen} + options={{title: title('Moderation')}} + /> + <Stack.Screen + name="ModerationMuteLists" + component={ModerationMuteListsScreen} + options={{title: title('Mute Lists')}} + /> + <Stack.Screen + name="ModerationMutedAccounts" + component={ModerationMutedAccounts} + options={{title: title('Muted Accounts')}} + /> + <Stack.Screen + name="ModerationBlockedAccounts" + component={ModerationBlockedAccounts} + options={{title: title('Blocked Accounts')}} + /> + <Stack.Screen + name="Settings" + component={SettingsScreen} + options={{title: title('Settings')}} + /> + <Stack.Screen + name="Profile" + component={ProfileScreen} + options={({route}) => ({title: title(`@${route.params.name}`)})} + /> <Stack.Screen name="ProfileFollowers" component={ProfileFollowersScreen} + options={({route}) => ({ + title: title(`People following @${route.params.name}`), + })} + /> + <Stack.Screen + name="ProfileFollows" + component={ProfileFollowsScreen} + options={({route}) => ({ + title: title(`People followed by @${route.params.name}`), + })} + /> + <Stack.Screen + name="ProfileList" + component={ProfileListScreen} + options={{title: title('Mute List')}} + /> + <Stack.Screen + name="PostThread" + component={PostThreadScreen} + options={({route}) => ({title: title(`Post by @${route.params.name}`)})} + /> + <Stack.Screen + name="PostLikedBy" + component={PostLikedByScreen} + options={({route}) => ({title: title(`Post by @${route.params.name}`)})} + /> + <Stack.Screen + name="PostRepostedBy" + component={PostRepostedByScreen} + options={({route}) => ({title: title(`Post by @${route.params.name}`)})} + /> + <Stack.Screen + name="Debug" + component={DebugScreen} + options={{title: title('Debug')}} + /> + <Stack.Screen + name="Log" + component={LogScreen} + options={{title: title('Log')}} + /> + <Stack.Screen + name="Support" + component={SupportScreen} + options={{title: title('Support')}} + /> + <Stack.Screen + name="PrivacyPolicy" + component={PrivacyPolicyScreen} + options={{title: title('Privacy Policy')}} + /> + <Stack.Screen + name="TermsOfService" + component={TermsOfServiceScreen} + options={{title: title('Terms of Service')}} /> - <Stack.Screen name="ProfileFollows" component={ProfileFollowsScreen} /> - <Stack.Screen name="PostThread" component={PostThreadScreen} /> - <Stack.Screen name="PostLikedBy" component={PostLikedByScreen} /> - <Stack.Screen name="PostRepostedBy" component={PostRepostedByScreen} /> - <Stack.Screen name="Debug" component={DebugScreen} /> - <Stack.Screen name="Log" component={LogScreen} /> - <Stack.Screen name="Support" component={SupportScreen} /> - <Stack.Screen name="PrivacyPolicy" component={PrivacyPolicyScreen} /> - <Stack.Screen name="TermsOfService" component={TermsOfServiceScreen} /> <Stack.Screen name="CommunityGuidelines" component={CommunityGuidelinesScreen} + options={{title: title('Community Guidelines')}} + /> + <Stack.Screen + name="CopyrightPolicy" + component={CopyrightPolicyScreen} + options={{title: title('Copyright Policy')}} + /> + <Stack.Screen + name="AppPasswords" + component={AppPasswords} + options={{title: title('App Passwords')}} /> <Stack.Screen name="CopyrightPolicy" component={CopyrightPolicyScreen} /> <Stack.Screen name="AppPasswords" component={AppPasswords} /> <Stack.Screen name="SavedFeeds" component={SavedFeeds} /> <Stack.Screen name="PinnedFeeds" component={PinnedFeeds} /> <Stack.Screen name="CustomFeed" component={CustomFeed} /> - <Stack.Screen name="MutedAccounts" component={MutedAccounts} /> - <Stack.Screen name="BlockedAccounts" component={BlockedAccounts} /> </> ) } @@ -210,8 +303,10 @@ const MyProfileTabNavigator = observer(() => { * The FlatNavigator is used by Web to represent the routes * in a single ("flat") stack. */ -function FlatNavigator() { +const FlatNavigator = observer(() => { const pal = usePalette('default') + const unreadCountLabel = useStores().me.notifications.unreadCountLabel + const title = (page: string) => bskyTitle(page, unreadCountLabel) return ( <Flat.Navigator screenOptions={{ @@ -221,13 +316,25 @@ function FlatNavigator() { animationDuration: 250, contentStyle: [pal.view], }}> - <Flat.Screen name="Home" component={HomeScreen} /> - <Flat.Screen name="Search" component={SearchScreen} /> - <Flat.Screen name="Notifications" component={NotificationsScreen} /> - {commonScreens(Flat as typeof HomeTab)} + <Flat.Screen + name="Home" + component={HomeScreen} + options={{title: title('Home')}} + /> + <Flat.Screen + name="Search" + component={SearchScreen} + options={{title: title('Search')}} + /> + <Flat.Screen + name="Notifications" + component={NotificationsScreen} + options={{title: title('Notifications')}} + /> + {commonScreens(Flat as typeof HomeTab, unreadCountLabel)} </Flat.Navigator> ) -} +}) /** * The RoutesContainer should wrap all components which need access diff --git a/src/lib/constants.ts b/src/lib/constants.ts index df325b059..6d0d4797b 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -33,42 +33,50 @@ export function TEAM_HANDLES(serviceUrl: string) { } } +// NOTE +// this is a temporary list that we periodically update +// it is used in the search interface if the user doesn't follow anybody +// -prf export const PROD_SUGGESTED_FOLLOWS = [ - 'faithlove.art', - 'danielkoeth.bsky.social', 'bsky.app', 'jay.bsky.team', 'pfrazee.com', 'why.bsky.team', - 'support.bsky.team', + 'dholms.xyz', + 'emily.bsky.team', + 'rose.bsky.team', 'jack.bsky.social', - 'earthquake.bsky.social', + 'faithlove.art', + 'annaghughes.bsky.social', + 'astrokatie.com', + 'whysharksmatter.bsky.social', 'jamesgunn.bsky.social', 'seangunn.bsky.social', 'kumail.bsky.social', 'craignewmark.bsky.social', - 'grimes.bsky.social', 'xychelsea.tv', + 'catsofyore.bsky.social', 'mcq.bsky.social', 'mmasnick.bsky.social', - 'nitasha.bsky.social', - 'kenklippenstein.bsky.social', - 'jaypeters.bsky.social', - 'miyagawa.bsky.social', - 'anildash.com', - 'tiffani.bsky.social', 'kelseyhightower.com', 'aliafonzy.bsky.social', - 'tszzl.bsky.social', + 'bradfitz.com', 'danabramov.bsky.social', 'shinyakato.dev', 'karpathy.bsky.social', 'lookitup.baby', + 'pariss.blacktechpipeline.com', + 'swiftonsecurity.com', + 'ericajoy.astrel.la', + 'b0rk.jvns.ca', + 'vickiboykis.com', 'brooke.vibe.camp', - 'mollywhite.net', 'amir.blue', - 'zoink.bsky.social', 'moskov.bsky.social', + 'neilhimself.bsky.social', + 'kylierobison.com', + 'carnage4life.bsky.social', + 'lolennui.bsky.social', ] export const STAGING_SUGGESTED_FOLLOWS = ['arcalinea', 'paul', 'paul2'].map( handle => `${handle}.staging.bsky.dev`, diff --git a/src/lib/hooks/useSetTitle.ts b/src/lib/hooks/useSetTitle.ts new file mode 100644 index 000000000..c5c7a5ca1 --- /dev/null +++ b/src/lib/hooks/useSetTitle.ts @@ -0,0 +1,20 @@ +import {useEffect} from 'react' +import {useNavigation} from '@react-navigation/native' + +import {NavigationProp} from 'lib/routes/types' +import {bskyTitle} from 'lib/strings/headings' +import {useStores} from 'state/index' + +/** + * Requires consuming component to be wrapped in `observer`: + * https://stackoverflow.com/a/71488009 + */ +export function useSetTitle(title?: string) { + const navigation = useNavigation<NavigationProp>() + const {unreadCountLabel} = useStores().me.notifications + useEffect(() => { + if (title) { + navigation.setOptions({title: bskyTitle(title, unreadCountLabel)}) + } + }, [title, navigation, unreadCountLabel]) +} diff --git a/src/lib/icons.tsx b/src/lib/icons.tsx index 960090ad7..0c7b7512a 100644 --- a/src/lib/icons.tsx +++ b/src/lib/icons.tsx @@ -322,6 +322,35 @@ export function MoonIcon({ // Copyright (c) 2020 Refactoring UI Inc. // https://github.com/tailwindlabs/heroicons/blob/master/LICENSE +export function SunIcon({ + style, + size, + strokeWidth = 1.5, +}: { + style?: StyleProp<ViewStyle> + size?: string | number + strokeWidth?: number +}) { + return ( + <Svg + fill="none" + viewBox="0 0 24 24" + width={size || 32} + height={size || 32} + strokeWidth={strokeWidth} + stroke="currentColor" + style={style}> + <Path + d="M12 3V5.25M18.364 5.63604L16.773 7.22703M21 12H18.75M18.364 18.364L16.773 16.773M12 18.75V21M7.22703 16.773L5.63604 18.364M5.25 12H3M7.22703 7.22703L5.63604 5.63604M15.75 12C15.75 14.0711 14.0711 15.75 12 15.75C9.92893 15.75 8.25 14.0711 8.25 12C8.25 9.92893 9.92893 8.25 12 8.25C14.0711 8.25 15.75 9.92893 15.75 12Z" + strokeLinecap="round" + strokeLinejoin="round" + /> + </Svg> + ) +} + +// Copyright (c) 2020 Refactoring UI Inc. +// https://github.com/tailwindlabs/heroicons/blob/master/LICENSE export function UserIcon({ style, size, @@ -828,3 +857,29 @@ export function InfoCircleIcon({ </Svg> ) } + +export function HandIcon({ + style, + size, + strokeWidth = 1.5, +}: { + style?: StyleProp<TextStyle> + size?: string | number + strokeWidth?: number +}) { + return ( + <Svg + width={size} + height={size} + viewBox="0 0 76 76" + stroke="currentColor" + strokeWidth={strokeWidth} + strokeLinecap="round" + fill="none" + style={style}> + <Path d="M33.5 37V11.5C33.5 8.46243 31.0376 6 28 6V6C24.9624 6 22.5 8.46243 22.5 11.5V48V48C22.5 48.5802 21.8139 48.8874 21.3811 48.501L13.2252 41.2189C10.72 38.9821 6.81945 39.4562 4.92296 42.228L4.77978 42.4372C3.17708 44.7796 3.50863 47.9385 5.56275 49.897L16.0965 59.9409C20.9825 64.5996 26.7533 68.231 33.0675 70.6201V70.6201C38.8234 72.798 45.1766 72.798 50.9325 70.6201L51.9256 70.2444C57.4044 68.1713 61.8038 63.9579 64.1113 58.5735V58.5735C65.6874 54.8962 66.5 50.937 66.5 46.9362V22.5C66.5 19.4624 64.0376 17 61 17V17C57.9624 17 55.5 19.4624 55.5 22.5V36.5" /> + <Path d="M55.5 37V11.5C55.5 8.46243 53.0376 6 50 6V6C46.9624 6 44.5 8.46243 44.5 11.5V37" /> + <Path d="M44.5 37V8.5C44.5 5.46243 42.0376 3 39 3V3C35.9624 3 33.5 5.46243 33.5 8.5V37" /> + </Svg> + ) +} diff --git a/src/lib/labeling/helpers.ts b/src/lib/labeling/helpers.ts index baac0ed5a..447b0a99a 100644 --- a/src/lib/labeling/helpers.ts +++ b/src/lib/labeling/helpers.ts @@ -1,5 +1,6 @@ import { AppBskyActorDefs, + AppBskyGraphDefs, AppBskyEmbedRecordWithMedia, AppBskyEmbedRecord, AppBskyEmbedImages, @@ -16,6 +17,7 @@ import { Label, LabelValGroup, ModerationBehaviorCode, + ModerationBehavior, PostModeration, ProfileModeration, PostLabelInfo, @@ -127,11 +129,15 @@ export function getPostModeration( // muting if (postInfo.isMuted) { + let msg = 'Post from an account you muted.' + if (postInfo.mutedByList) { + msg = `Muted by ${postInfo.mutedByList.name}` + } return { avatar, - list: hide('Post from an account you muted.'), - thread: warn('Post from an account you muted.'), - view: warn('Post from an account you muted.'), + list: isMute(hide(msg)), + thread: isMute(warn(msg)), + view: isMute(warn(msg)), } } @@ -273,6 +279,7 @@ export function getProfileViewBasicLabelInfo( profileLabels: filterProfileLabels(profile.labels), isMuted: profile.viewer?.muted || false, isBlocking: !!profile.viewer?.blocking || false, + isBlockedBy: !!profile.viewer?.blockedBy || false, } } @@ -302,6 +309,21 @@ export function getEmbedMuted(embed?: Embed): boolean { return false } +export function getEmbedMutedByList( + embed?: Embed, +): AppBskyGraphDefs.ListViewBasic | undefined { + if (!embed) { + return undefined + } + if ( + AppBskyEmbedRecord.isView(embed) && + AppBskyEmbedRecord.isViewRecord(embed.record) + ) { + return embed.record.author.viewer?.mutedByList + } + return undefined +} + export function getEmbedBlocking(embed?: Embed): boolean { if (!embed) { return false @@ -401,6 +423,11 @@ function warnContent(reason: string) { } } +function isMute(behavior: ModerationBehavior): ModerationBehavior { + behavior.isMute = true + return behavior +} + function warnImages(reason: string) { return { behavior: ModerationBehaviorCode.WarnImages, diff --git a/src/lib/labeling/types.ts b/src/lib/labeling/types.ts index 078043076..1ee058024 100644 --- a/src/lib/labeling/types.ts +++ b/src/lib/labeling/types.ts @@ -1,4 +1,4 @@ -import {ComAtprotoLabelDefs} from '@atproto/api' +import {ComAtprotoLabelDefs, AppBskyGraphDefs} from '@atproto/api' import {LabelPreferencesModel} from 'state/models/ui/preferences' export type Label = ComAtprotoLabelDefs.Label @@ -22,6 +22,7 @@ export interface PostLabelInfo { accountLabels: Label[] profileLabels: Label[] isMuted: boolean + mutedByList?: AppBskyGraphDefs.ListViewBasic isBlocking: boolean isBlockedBy: boolean } @@ -44,6 +45,7 @@ export enum ModerationBehaviorCode { export interface ModerationBehavior { behavior: ModerationBehaviorCode + isMute?: boolean noOverride?: boolean reason?: string } diff --git a/src/lib/media/picker.tsx b/src/lib/media/picker.tsx index af4a3e4d3..7c6dfcc44 100644 --- a/src/lib/media/picker.tsx +++ b/src/lib/media/picker.tsx @@ -1,12 +1,16 @@ import { - openPicker as openPickerFn, openCamera as openCameraFn, openCropper as openCropperFn, - ImageOrVideo, + Image as RNImage, } from 'react-native-image-crop-picker' import {RootStoreModel} from 'state/index' -import {PickerOpts, CameraOpts, CropperOptions} from './types' -import {Image as RNImage} from 'react-native-image-crop-picker' +import {CameraOpts, CropperOptions} from './types' +import { + ImagePickerOptions, + launchImageLibraryAsync, + MediaTypeOptions, +} from 'expo-image-picker' +import {getDataUriSize} from './util' /** * NOTE @@ -19,27 +23,22 @@ import {Image as RNImage} from 'react-native-image-crop-picker' export async function openPicker( _store: RootStoreModel, - opts?: PickerOpts, -): Promise<RNImage[]> { - const items = await openPickerFn({ - mediaType: 'photo', // TODO: eventually add other media types - multiple: opts?.multiple, - maxFiles: opts?.maxFiles, - forceJpg: true, // ios only - compressImageQuality: 0.8, + opts?: ImagePickerOptions, +) { + const response = await launchImageLibraryAsync({ + exif: false, + mediaTypes: MediaTypeOptions.Images, + quality: 1, + ...opts, }) - const toMedia = (item: ImageOrVideo) => ({ - path: item.path, - mime: item.mime, - size: item.size, - width: item.width, - height: item.height, - }) - if (Array.isArray(items)) { - return items.map(toMedia) - } - return [toMedia(items)] + return (response.assets ?? []).map(image => ({ + mime: 'image/jpeg', + height: image.height, + width: image.width, + path: image.uri, + size: getDataUriSize(image.uri), + })) } export async function openCamera( @@ -55,6 +54,7 @@ export async function openCamera( forceJpg: true, // ios only compressImageQuality: 0.8, }) + return { path: item.path, mime: item.mime, @@ -67,11 +67,10 @@ export async function openCamera( export async function openCropper( _store: RootStoreModel, opts: CropperOptions, -): Promise<RNImage> { +) { const item = await openCropperFn({ ...opts, forceJpg: true, // ios only - compressImageQuality: 0.8, }) return { diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts index b1dcbb999..8b96aaad7 100644 --- a/src/lib/routes/types.ts +++ b/src/lib/routes/types.ts @@ -5,13 +5,19 @@ export type {NativeStackScreenProps} from '@react-navigation/native-stack' export type CommonNavigatorParams = { NotFound: undefined + Moderation: undefined + ModerationMuteLists: undefined + ModerationMutedAccounts: undefined + ModerationBlockedAccounts: undefined Settings: undefined Profile: {name: string; hideBackButton?: boolean} ProfileFollowers: {name: string} ProfileFollows: {name: string} + ProfileList: {name: string; rkey: string} PostThread: {name: string; rkey: string} PostLikedBy: {name: string; rkey: string} PostRepostedBy: {name: string; rkey: string} + CustomFeed: {name: string; rkey: string; displayName?: string} Debug: undefined Log: undefined Support: undefined @@ -22,9 +28,6 @@ export type CommonNavigatorParams = { AppPasswords: undefined SavedFeeds: undefined PinnedFeeds: undefined - CustomFeed: {name: string; rkey: string; displayName?: string} - MutedAccounts: undefined - BlockedAccounts: undefined } export type BottomTabNavigatorParams = CommonNavigatorParams & { diff --git a/src/lib/strings/display-names.ts b/src/lib/strings/display-names.ts index 555151b55..b98153732 100644 --- a/src/lib/strings/display-names.ts +++ b/src/lib/strings/display-names.ts @@ -10,3 +10,18 @@ export function sanitizeDisplayName(str: string): string { } return '' } + +export function combinedDisplayName({ + handle, + displayName, +}: { + handle?: string + displayName?: string +}): string { + if (!handle) { + return '' + } + return displayName + ? `${sanitizeDisplayName(displayName)} (@${handle})` + : `@${handle}` +} diff --git a/src/lib/strings/headings.ts b/src/lib/strings/headings.ts new file mode 100644 index 000000000..a88a69645 --- /dev/null +++ b/src/lib/strings/headings.ts @@ -0,0 +1,4 @@ +export function bskyTitle(page: string, unreadCountLabel?: string) { + const unreadPrefix = unreadCountLabel ? `(${unreadCountLabel}) ` : '' + return `${unreadPrefix}${page} - Bluesky` +} diff --git a/src/lib/strings/url-helpers.ts b/src/lib/strings/url-helpers.ts index 549587f74..a5412920e 100644 --- a/src/lib/strings/url-helpers.ts +++ b/src/lib/strings/url-helpers.ts @@ -94,6 +94,15 @@ export function convertBskyAppUrlIfNeeded(url: string): string { return url } +export function listUriToHref(url: string): string { + try { + const {hostname, rkey} = new AtUri(url) + return `/profile/${hostname}/lists/${rkey}` + } catch { + return '' + } +} + export function getYoutubeVideoId(link: string): string | undefined { let url try { diff --git a/src/routes.ts b/src/routes.ts index 6998226c0..7501e7abf 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -5,20 +5,23 @@ export const router = new Router({ Search: '/search', Notifications: '/notifications', Settings: '/settings', + Moderation: '/moderation', + ModerationMuteLists: '/moderation/mute-lists', + ModerationMutedAccounts: '/moderation/muted-accounts', + ModerationBlockedAccounts: '/moderation/blocked-accounts', Profile: '/profile/:name', ProfileFollowers: '/profile/:name/followers', ProfileFollows: '/profile/:name/follows', + ProfileList: '/profile/:name/lists/:rkey', PostThread: '/profile/:name/post/:rkey', PostLikedBy: '/profile/:name/post/:rkey/liked-by', PostRepostedBy: '/profile/:name/post/:rkey/reposted-by', + CustomFeed: '/profile/:name/feed/:rkey', Debug: '/sys/debug', Log: '/sys/log', AppPasswords: '/settings/app-passwords', SavedFeeds: '/settings/saved-feeds', PinnedFeeds: '/settings/pinned-feeds', - CustomFeed: '/profile/:name/feed/:rkey', - MutedAccounts: '/settings/muted-accounts', - BlockedAccounts: '/settings/blocked-accounts', Support: '/support', PrivacyPolicy: '/support/privacy', TermsOfService: '/support/tos', diff --git a/src/state/models/content/list-membership.ts b/src/state/models/content/list-membership.ts new file mode 100644 index 000000000..b4af4472b --- /dev/null +++ b/src/state/models/content/list-membership.ts @@ -0,0 +1,112 @@ +import {makeAutoObservable} from 'mobx' +import {AtUri, AppBskyGraphListitem} from '@atproto/api' +import {runInAction} from 'mobx' +import {RootStoreModel} from '../root-store' + +const PAGE_SIZE = 100 +interface Membership { + uri: string + value: AppBskyGraphListitem.Record +} + +export class ListMembershipModel { + // data + memberships: Membership[] = [] + + constructor(public rootStore: RootStoreModel, public subject: string) { + makeAutoObservable( + this, + { + rootStore: false, + }, + {autoBind: true}, + ) + } + + // public api + // = + + async fetch() { + // NOTE + // this approach to determining list membership is too inefficient to work at any scale + // it needs to be replaced with server side list membership queries + // -prf + let cursor + let records = [] + for (let i = 0; i < 100; i++) { + const res = await this.rootStore.agent.app.bsky.graph.listitem.list({ + repo: this.rootStore.me.did, + cursor, + limit: PAGE_SIZE, + }) + records = records.concat( + res.records.filter(record => record.value.subject === this.subject), + ) + cursor = res.cursor + if (!cursor) { + break + } + } + runInAction(() => { + this.memberships = records + }) + } + + getMembership(listUri: string) { + return this.memberships.find(m => m.value.list === listUri) + } + + isMember(listUri: string) { + return !!this.getMembership(listUri) + } + + async add(listUri: string) { + if (this.isMember(listUri)) { + return + } + const res = await this.rootStore.agent.app.bsky.graph.listitem.create( + { + repo: this.rootStore.me.did, + }, + { + subject: this.subject, + list: listUri, + createdAt: new Date().toISOString(), + }, + ) + const {rkey} = new AtUri(res.uri) + const record = await this.rootStore.agent.app.bsky.graph.listitem.get({ + repo: this.rootStore.me.did, + rkey, + }) + runInAction(() => { + this.memberships = this.memberships.concat([record]) + }) + } + + async remove(listUri: string) { + const membership = this.getMembership(listUri) + if (!membership) { + return + } + const {rkey} = new AtUri(membership.uri) + await this.rootStore.agent.app.bsky.graph.listitem.delete({ + repo: this.rootStore.me.did, + rkey, + }) + runInAction(() => { + this.memberships = this.memberships.filter(m => m.value.list !== listUri) + }) + } + + async updateTo(uris: string) { + for (const uri of uris) { + await this.add(uri) + } + for (const membership of this.memberships) { + if (!uris.includes(membership.value.list)) { + await this.remove(membership.value.list) + } + } + } +} diff --git a/src/state/models/content/list.ts b/src/state/models/content/list.ts new file mode 100644 index 000000000..673ee9430 --- /dev/null +++ b/src/state/models/content/list.ts @@ -0,0 +1,257 @@ +import {makeAutoObservable} from 'mobx' +import { + AtUri, + AppBskyGraphGetList as GetList, + AppBskyGraphDefs as GraphDefs, + AppBskyGraphList, +} from '@atproto/api' +import {Image as RNImage} from 'react-native-image-crop-picker' +import {RootStoreModel} from '../root-store' +import * as apilib from 'lib/api/index' +import {cleanError} from 'lib/strings/errors' +import {bundleAsync} from 'lib/async/bundle' + +const PAGE_SIZE = 30 + +export class ListModel { + // state + isLoading = false + isRefreshing = false + hasLoaded = false + error = '' + loadMoreError = '' + hasMore = true + loadMoreCursor?: string + + // data + list: GraphDefs.ListView | null = null + items: GraphDefs.ListItemView[] = [] + + static async createModList( + rootStore: RootStoreModel, + { + name, + description, + avatar, + }: {name: string; description: string; avatar: RNImage | undefined}, + ) { + const record: AppBskyGraphList.Record = { + purpose: 'app.bsky.graph.defs#modlist', + name, + description, + avatar: undefined, + createdAt: new Date().toISOString(), + } + if (avatar) { + const blobRes = await apilib.uploadBlob( + rootStore, + avatar.path, + avatar.mime, + ) + record.avatar = blobRes.data.blob + } + const res = await rootStore.agent.app.bsky.graph.list.create( + { + repo: rootStore.me.did, + }, + record, + ) + await rootStore.agent.app.bsky.graph.muteActorList({list: res.uri}) + return res + } + + constructor(public rootStore: RootStoreModel, public uri: string) { + makeAutoObservable( + this, + { + rootStore: false, + }, + {autoBind: true}, + ) + } + + get hasContent() { + return this.items.length > 0 + } + + get hasError() { + return this.error !== '' + } + + get isEmpty() { + return this.hasLoaded && !this.hasContent + } + + get isOwner() { + return this.list?.creator.did === this.rootStore.me.did + } + + // 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.getList({ + list: this.uri, + limit: PAGE_SIZE, + cursor: replace ? undefined : this.loadMoreCursor, + }) + if (replace) { + this._replaceAll(res) + } else { + this._appendAll(res) + } + this._xIdle() + } catch (e: any) { + this._xIdle(replace ? e : undefined, !replace ? e : undefined) + } + }) + + async updateMetadata({ + name, + description, + avatar, + }: { + name: string + description: string + avatar: RNImage | null | undefined + }) { + if (!this.isOwner) { + throw new Error('Cannot edit this list') + } + + // get the current record + const {rkey} = new AtUri(this.uri) + const {value: record} = await this.rootStore.agent.app.bsky.graph.list.get({ + repo: this.rootStore.me.did, + rkey, + }) + + // update the fields + record.name = name + record.description = description + if (avatar) { + const blobRes = await apilib.uploadBlob( + this.rootStore, + avatar.path, + avatar.mime, + ) + record.avatar = blobRes.data.blob + } else if (avatar === null) { + record.avatar = undefined + } + return await this.rootStore.agent.com.atproto.repo.putRecord({ + repo: this.rootStore.me.did, + collection: 'app.bsky.graph.list', + rkey, + record, + }) + } + + async delete() { + // fetch all the listitem records that belong to this list + let cursor + let records = [] + for (let i = 0; i < 100; i++) { + const res = await this.rootStore.agent.app.bsky.graph.listitem.list({ + repo: this.rootStore.me.did, + cursor, + limit: PAGE_SIZE, + }) + records = records.concat( + res.records.filter(record => record.value.list === this.uri), + ) + cursor = res.cursor + if (!cursor) { + break + } + } + + // batch delete the list and listitem records + const createDel = (uri: string) => { + const urip = new AtUri(uri) + return { + $type: 'com.atproto.repo.applyWrites#delete', + collection: urip.collection, + rkey: urip.rkey, + } + } + await this.rootStore.agent.com.atproto.repo.applyWrites({ + repo: this.rootStore.me.did, + writes: [createDel(this.uri)].concat( + records.map(record => createDel(record.uri)), + ), + }) + } + + async subscribe() { + await this.rootStore.agent.app.bsky.graph.muteActorList({ + list: this.list.uri, + }) + await this.refresh() + } + + async unsubscribe() { + await this.rootStore.agent.app.bsky.graph.unmuteActorList({ + list: this.list.uri, + }) + await this.refresh() + } + + /** + * Attempt to load more again after a failure + */ + async retryLoadMore() { + this.loadMoreError = '' + this.hasMore = true + return this.loadMore() + } + + // state transitions + // = + + _xLoading(isRefreshing = false) { + this.isLoading = true + this.isRefreshing = isRefreshing + this.error = '' + } + + _xIdle(err?: any, loadMoreErr?: any) { + this.isLoading = false + this.isRefreshing = false + this.hasLoaded = true + this.error = cleanError(err) + this.loadMoreError = cleanError(loadMoreErr) + if (err) { + this.rootStore.log.error('Failed to fetch user items', err) + } + if (loadMoreErr) { + this.rootStore.log.error('Failed to fetch user items', loadMoreErr) + } + } + + // helper functions + // = + + _replaceAll(res: GetList.Response) { + this.items = [] + this._appendAll(res) + } + + _appendAll(res: GetList.Response) { + this.loadMoreCursor = res.data.cursor + this.hasMore = !!this.loadMoreCursor + this.list = res.data.list + this.items = this.items.concat( + res.data.items.map(item => ({...item, _reactKey: item.subject})), + ) + } +} diff --git a/src/state/models/content/post-thread.ts b/src/state/models/content/post-thread.ts index a0f75493a..74a75d803 100644 --- a/src/state/models/content/post-thread.ts +++ b/src/state/models/content/post-thread.ts @@ -14,6 +14,7 @@ import {PostLabelInfo, PostModeration} from 'lib/labeling/types' import { getEmbedLabels, getEmbedMuted, + getEmbedMutedByList, getEmbedBlocking, getEmbedBlockedBy, filterAccountLabels, @@ -70,6 +71,9 @@ export class PostThreadItemModel { this.post.author.viewer?.muted || getEmbedMuted(this.post.embed) || false, + mutedByList: + this.post.author.viewer?.mutedByList || + getEmbedMutedByList(this.post.embed), isBlocking: !!this.post.author.viewer?.blocking || getEmbedBlocking(this.post.embed) || diff --git a/src/state/models/content/profile.ts b/src/state/models/content/profile.ts index dddf488a3..9d8378f79 100644 --- a/src/state/models/content/profile.ts +++ b/src/state/models/content/profile.ts @@ -2,6 +2,7 @@ import {makeAutoObservable, runInAction} from 'mobx' import { AtUri, ComAtprotoLabelDefs, + AppBskyGraphDefs, AppBskyActorGetProfile as GetProfile, AppBskyActorProfile, RichText, @@ -18,10 +19,9 @@ import { filterProfileLabels, } from 'lib/labeling/helpers' -export const ACTOR_TYPE_USER = 'app.bsky.system.actorUser' - export class ProfileViewerModel { muted?: boolean + mutedByList?: AppBskyGraphDefs.ListViewBasic following?: string followedBy?: string blockedBy?: boolean diff --git a/src/state/models/feeds/notifications.ts b/src/state/models/feeds/notifications.ts index 3ffd10b99..73424f03e 100644 --- a/src/state/models/feeds/notifications.ts +++ b/src/state/models/feeds/notifications.ts @@ -111,6 +111,7 @@ export class NotificationsFeedItemModel { addedInfo?.profileLabels || [], ), isMuted: this.author.viewer?.muted || addedInfo?.isMuted || false, + mutedByList: this.author.viewer?.mutedByList || addedInfo?.mutedByList, isBlocking: !!this.author.viewer?.blocking || addedInfo?.isBlocking || false, isBlockedBy: diff --git a/src/state/models/feeds/posts.ts b/src/state/models/feeds/posts.ts index 7adc1cb1c..dfd92b35c 100644 --- a/src/state/models/feeds/posts.ts +++ b/src/state/models/feeds/posts.ts @@ -25,6 +25,7 @@ import {PostLabelInfo, PostModeration} from 'lib/labeling/types' import { getEmbedLabels, getEmbedMuted, + getEmbedMutedByList, getEmbedBlocking, getEmbedBlockedBy, getPostModeration, @@ -106,6 +107,9 @@ export class PostsFeedItemModel { this.post.author.viewer?.muted || getEmbedMuted(this.post.embed) || false, + mutedByList: + this.post.author.viewer?.mutedByList || + getEmbedMutedByList(this.post.embed), isBlocking: !!this.post.author.viewer?.blocking || getEmbedBlocking(this.post.embed) || diff --git a/src/state/models/lists/lists-list.ts b/src/state/models/lists/lists-list.ts new file mode 100644 index 000000000..309ab0e03 --- /dev/null +++ b/src/state/models/lists/lists-list.ts @@ -0,0 +1,214 @@ +import {makeAutoObservable} from 'mobx' +import { + AppBskyGraphGetLists as GetLists, + AppBskyGraphGetListMutes as GetListMutes, + AppBskyGraphDefs as GraphDefs, +} 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 ListsListModel { + // state + isLoading = false + isRefreshing = false + hasLoaded = false + error = '' + loadMoreError = '' + hasMore = true + loadMoreCursor?: string + + // data + lists: GraphDefs.ListView[] = [] + + constructor( + public rootStore: RootStoreModel, + public source: 'my-modlists' | string, + ) { + makeAutoObservable( + this, + { + rootStore: false, + }, + {autoBind: true}, + ) + } + + get hasContent() { + return this.lists.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 { + let res + if (this.source === 'my-modlists') { + res = { + success: true, + headers: {}, + data: { + subject: undefined, + lists: [], + }, + } + const [res1, res2] = await Promise.all([ + fetchAllUserLists(this.rootStore, this.rootStore.me.did), + fetchAllMyMuteLists(this.rootStore), + ]) + for (let list of res1.data.lists) { + if (list.purpose === 'app.bsky.graph.defs#modlist') { + res.data.lists.push(list) + } + } + for (let list of res2.data.lists) { + if ( + list.purpose === 'app.bsky.graph.defs#modlist' && + !res.data.lists.find(l => l.uri === list.uri) + ) { + res.data.lists.push(list) + } + } + } else { + res = await this.rootStore.agent.app.bsky.graph.getLists({ + actor: this.source, + limit: PAGE_SIZE, + cursor: replace ? undefined : this.loadMoreCursor, + }) + } + if (replace) { + this._replaceAll(res) + } else { + this._appendAll(res) + } + this._xIdle() + } catch (e: any) { + this._xIdle(replace ? e : undefined, !replace ? e : undefined) + } + }) + + /** + * Attempt to load more again after a failure + */ + async retryLoadMore() { + this.loadMoreError = '' + this.hasMore = true + return this.loadMore() + } + + // state transitions + // = + + _xLoading(isRefreshing = false) { + this.isLoading = true + this.isRefreshing = isRefreshing + this.error = '' + } + + _xIdle(err?: any, loadMoreErr?: any) { + this.isLoading = false + this.isRefreshing = false + this.hasLoaded = true + this.error = cleanError(err) + this.loadMoreError = cleanError(loadMoreErr) + if (err) { + this.rootStore.log.error('Failed to fetch user lists', err) + } + if (loadMoreErr) { + this.rootStore.log.error('Failed to fetch user lists', loadMoreErr) + } + } + + // helper functions + // = + + _replaceAll(res: GetLists.Response | GetListMutes.Response) { + this.lists = [] + this._appendAll(res) + } + + _appendAll(res: GetLists.Response | GetListMutes.Response) { + this.loadMoreCursor = res.data.cursor + this.hasMore = !!this.loadMoreCursor + this.lists = this.lists.concat( + res.data.lists.map(list => ({...list, _reactKey: list.uri})), + ) + } +} + +async function fetchAllUserLists( + store: RootStoreModel, + did: string, +): Promise<GetLists.Response> { + let acc: GetLists.Response = { + success: true, + headers: {}, + data: { + subject: undefined, + lists: [], + }, + } + + let cursor + for (let i = 0; i < 100; i++) { + const res = await store.agent.app.bsky.graph.getLists({ + actor: did, + cursor, + limit: 50, + }) + cursor = res.data.cursor + acc.data.lists = acc.data.lists.concat(res.data.lists) + if (!cursor) { + break + } + } + + return acc +} + +async function fetchAllMyMuteLists( + store: RootStoreModel, +): Promise<GetListMutes.Response> { + let acc: GetListMutes.Response = { + success: true, + headers: {}, + data: { + subject: undefined, + lists: [], + }, + } + + let cursor + for (let i = 0; i < 100; i++) { + const res = await store.agent.app.bsky.graph.getListMutes({ + cursor, + limit: 50, + }) + cursor = res.data.cursor + acc.data.lists = acc.data.lists.concat(res.data.lists) + if (!cursor) { + break + } + } + + return acc +} diff --git a/src/state/models/media/gallery.ts b/src/state/models/media/gallery.ts index 86bf8a314..67f8d2ea1 100644 --- a/src/state/models/media/gallery.ts +++ b/src/state/models/media/gallery.ts @@ -52,16 +52,14 @@ export class GalleryModel { } async edit(image: ImageModel) { - if (!isNative) { + if (isNative) { + this.crop(image) + } else { this.rootStore.shell.openModal({ name: 'edit-image', image, gallery: this, }) - - return - } else { - this.crop(image) } } @@ -104,10 +102,14 @@ export class GalleryModel { async pick() { const images = await openPicker(this.rootStore, { - multiple: true, - maxFiles: 4 - this.images.length, + selectionLimit: 4 - this.size, + allowsMultipleSelection: true, }) - await Promise.all(images.map(image => this.add(image))) + return await Promise.all( + images.map(image => { + this.add(image) + }), + ) } } diff --git a/src/state/models/media/image.ts b/src/state/models/media/image.ts index ff464a5a9..ec93bf5b6 100644 --- a/src/state/models/media/image.ts +++ b/src/state/models/media/image.ts @@ -13,12 +13,12 @@ import {compressAndResizeImageForPost} from 'lib/media/manip' // Cases to consider: ExternalEmbed export interface ImageManipulationAttributes { + aspectRatio?: '4:3' | '1:1' | '3:4' | 'None' rotate?: number scale?: number position?: Position flipHorizontal?: boolean flipVertical?: boolean - aspectRatio?: '4:3' | '1:1' | '3:4' | 'None' } export class ImageModel implements RNImage { @@ -34,14 +34,14 @@ export class ImageModel implements RNImage { scaledHeight: number = POST_IMG_MAX.height // Web manipulation - aspectRatio?: ImageManipulationAttributes['aspectRatio'] - position?: Position = undefined - prev?: RNImage = undefined - rotation?: number = 0 - scale?: number = 1 - flipHorizontal?: boolean = false - flipVertical?: boolean = false - + prev?: RNImage + attributes: ImageManipulationAttributes = { + aspectRatio: '1:1', + scale: 1, + flipHorizontal: false, + flipVertical: false, + rotate: 0, + } prevAttributes: ImageManipulationAttributes = {} constructor(public rootStore: RootStoreModel, image: RNImage) { @@ -65,6 +65,25 @@ export class ImageModel implements RNImage { // : MAX_IMAGE_SIZE_IN_BYTES / this.size // } + setRatio(aspectRatio: ImageManipulationAttributes['aspectRatio']) { + this.attributes.aspectRatio = aspectRatio + } + + setRotate(degrees: number) { + this.attributes.rotate = degrees + this.manipulate({}) + } + + flipVertical() { + this.attributes.flipVertical = !this.attributes.flipVertical + this.manipulate({}) + } + + flipHorizontal() { + this.attributes.flipHorizontal = !this.attributes.flipHorizontal + this.manipulate({}) + } + get ratioMultipliers() { return { '4:3': 4 / 3, @@ -116,7 +135,7 @@ export class ImageModel implements RNImage { // Only for mobile async crop() { try { - const cropped = await openCropper(this.rootStore, { + const cropped = await openCropper({ mediaType: 'photo', path: this.path, freeStyleCropEnabled: true, @@ -162,33 +181,19 @@ export class ImageModel implements RNImage { crop?: ActionCrop['crop'] } & ImageManipulationAttributes, ) { - const {aspectRatio, crop, flipHorizontal, flipVertical, rotate, scale} = - attributes + const {aspectRatio, crop, position, scale} = attributes const modifiers = [] - if (flipHorizontal !== undefined) { - this.flipHorizontal = flipHorizontal - } - - if (flipVertical !== undefined) { - this.flipVertical = flipVertical - } - - if (this.flipHorizontal) { + if (this.attributes.flipHorizontal) { modifiers.push({flip: FlipType.Horizontal}) } - if (this.flipVertical) { + if (this.attributes.flipVertical) { modifiers.push({flip: FlipType.Vertical}) } - // TODO: Fix rotation -- currently not functional - if (rotate !== undefined) { - this.rotation = rotate - } - - if (this.rotation !== undefined) { - modifiers.push({rotate: this.rotation}) + if (this.attributes.rotate !== undefined) { + modifiers.push({rotate: this.attributes.rotate}) } if (crop !== undefined) { @@ -203,18 +208,21 @@ export class ImageModel implements RNImage { } if (scale !== undefined) { - this.scale = scale + this.attributes.scale = scale + } + + if (position !== undefined) { + this.attributes.position = position } if (aspectRatio !== undefined) { - this.aspectRatio = aspectRatio + this.attributes.aspectRatio = aspectRatio } - const ratioMultiplier = this.ratioMultipliers[this.aspectRatio ?? '1:1'] + const ratioMultiplier = + this.ratioMultipliers[this.attributes.aspectRatio ?? '1:1'] - // TODO: Ollie - should support up to 2000 but smaller images that scale - // up need an updated compression factor calculation. Use 1000 for now. - const MAX_SIDE = 1000 + const MAX_SIDE = 2000 const result = await ImageManipulator.manipulateAsync( this.path, @@ -223,7 +231,7 @@ export class ImageModel implements RNImage { {resize: ratioMultiplier > 1 ? {width: MAX_SIDE} : {height: MAX_SIDE}}, ], { - compress: 0.7, // TODO: revisit compression calculation + compress: 0.9, format: SaveFormat.JPEG, }, ) @@ -238,16 +246,12 @@ export class ImageModel implements RNImage { }) } + resetCompressed() { + this.manipulate({}) + } + previous() { this.compressed = this.prev - - const {flipHorizontal, flipVertical, rotate, position, scale} = - this.prevAttributes - - this.scale = scale - this.rotation = rotate - this.flipHorizontal = flipHorizontal - this.flipVertical = flipVertical - this.position = position + this.attributes = this.prevAttributes } } diff --git a/src/state/models/root-store.ts b/src/state/models/root-store.ts index 8cd23efcd..f2a352a79 100644 --- a/src/state/models/root-store.ts +++ b/src/state/models/root-store.ts @@ -37,7 +37,7 @@ export class RootStoreModel { log = new LogModel() session = new SessionModel(this) shell = new ShellUiModel(this) - preferences = new PreferencesModel() + preferences = new PreferencesModel(this) me = new MeModel(this) invitedUsers = new InvitedUsers(this) profiles = new ProfilesCache(this) @@ -126,6 +126,7 @@ export class RootStoreModel { this.log.debug('RootStoreModel:handleSessionChange') this.agent = agent this.me.clear() + /* dont await */ this.preferences.sync() await this.me.load() if (!hadSession) { resetNavigation() @@ -161,6 +162,7 @@ export class RootStoreModel { } try { await this.me.updateIfNeeded() + await this.preferences.sync() } catch (e: any) { this.log.error('Failed to fetch latest state', e) } diff --git a/src/state/models/ui/preferences.ts b/src/state/models/ui/preferences.ts index fcd33af8e..1471420fc 100644 --- a/src/state/models/ui/preferences.ts +++ b/src/state/models/ui/preferences.ts @@ -1,7 +1,8 @@ -import {makeAutoObservable} from 'mobx' +import {makeAutoObservable, runInAction} from 'mobx' import {getLocales} from 'expo-localization' import {isObj, hasProp} from 'lib/type-guards' -import {ComAtprotoLabelDefs} from '@atproto/api' +import {RootStoreModel} from '../root-store' +import {ComAtprotoLabelDefs, AppBskyActorDefs} from '@atproto/api' import {LabelValGroup} from 'lib/labeling/types' import {getLabelValueGroup} from 'lib/labeling/helpers' import { @@ -15,6 +16,15 @@ import {isIOS} from 'platform/detection' const deviceLocales = getLocales() export type LabelPreference = 'show' | 'warn' | 'hide' +const LABEL_GROUPS = [ + 'nsfw', + 'nudity', + 'suggestive', + 'gore', + 'hate', + 'spam', + 'impersonation', +] export class LabelPreferencesModel { nsfw: LabelPreference = 'hide' @@ -36,7 +46,7 @@ export class PreferencesModel { deviceLocales?.map?.(locale => locale.languageCode) || [] contentLabels = new LabelPreferencesModel() - constructor() { + constructor(public rootStore: RootStoreModel) { makeAutoObservable(this, {}, {autoBind: true}) } @@ -65,6 +75,35 @@ export class PreferencesModel { } } + async sync() { + const res = await this.rootStore.agent.app.bsky.actor.getPreferences({}) + runInAction(() => { + for (const pref of res.data.preferences) { + if ( + AppBskyActorDefs.isAdultContentPref(pref) && + AppBskyActorDefs.validateAdultContentPref(pref).success + ) { + this.adultContentEnabled = pref.enabled + } else if ( + AppBskyActorDefs.isContentLabelPref(pref) && + AppBskyActorDefs.validateAdultContentPref(pref).success + ) { + if (LABEL_GROUPS.includes(pref.label)) { + this.contentLabels[pref.label] = pref.visibility + } + } + } + }) + } + + async update(cb: (prefs: AppBskyActorDefs.Preferences) => void) { + const res = await this.rootStore.agent.app.bsky.actor.getPreferences({}) + cb(res.data.preferences) + await this.rootStore.agent.app.bsky.actor.putPreferences({ + preferences: res.data.preferences, + }) + } + hasContentLanguage(code2: string) { return this.contentLanguages.includes(code2) } @@ -79,11 +118,48 @@ export class PreferencesModel { } } - setContentLabelPref( + async setContentLabelPref( key: keyof LabelPreferencesModel, value: LabelPreference, ) { this.contentLabels[key] = value + + await this.update((prefs: AppBskyActorDefs.Preferences) => { + const existing = prefs.find( + pref => + AppBskyActorDefs.isContentLabelPref(pref) && + AppBskyActorDefs.validateAdultContentPref(pref).success && + pref.label === key, + ) + if (existing) { + existing.visibility = value + } else { + prefs.push({ + $type: 'app.bsky.actor.defs#contentLabelPref', + label: key, + visibility: value, + }) + } + }) + } + + async setAdultContentEnabled(v: boolean) { + this.adultContentEnabled = v + await this.update((prefs: AppBskyActorDefs.Preferences) => { + const existing = prefs.find( + pref => + AppBskyActorDefs.isAdultContentPref(pref) && + AppBskyActorDefs.validateAdultContentPref(pref).success, + ) + if (existing) { + existing.enabled = v + } else { + prefs.push({ + $type: 'app.bsky.actor.defs#adultContentPref', + enabled: v, + }) + } + }) } getLabelPreference(labels: ComAtprotoLabelDefs.Label[] | undefined): { diff --git a/src/state/models/ui/profile.ts b/src/state/models/ui/profile.ts index 855955d12..4f604bfc0 100644 --- a/src/state/models/ui/profile.ts +++ b/src/state/models/ui/profile.ts @@ -1,20 +1,23 @@ import {makeAutoObservable} from 'mobx' +import {AppBskyFeedDefs} from '@atproto/api' import {RootStoreModel} from '../root-store' import {ProfileModel} from '../content/profile' import {PostsFeedModel} from '../feeds/posts' import {ActorFeedsModel} from '../feeds/algo/actor' -import {AppBskyFeedDefs} from '@atproto/api' +import {ListsListModel} from '../lists/lists-list' export enum Sections { Posts = 'Posts', PostsWithReplies = 'Posts & replies', CustomAlgorithms = 'Algos', + Lists = 'Lists', } const USER_SELECTOR_ITEMS = [ Sections.Posts, Sections.PostsWithReplies, Sections.CustomAlgorithms, + Sections.Lists, ] export interface ProfileUiParams { @@ -30,6 +33,7 @@ export class ProfileUiModel { profile: ProfileModel feed: PostsFeedModel algos: ActorFeedsModel + lists: ListsListModel // ui state selectedViewIndex = 0 @@ -52,14 +56,17 @@ export class ProfileUiModel { limit: 10, }) this.algos = new ActorFeedsModel(rootStore, {actor: params.user}) + this.lists = new ListsListModel(rootStore, params.user) } - get currentView(): PostsFeedModel | ActorFeedsModel { + get currentView(): PostsFeedModel | ActorFeedsModel | ListsListModel { if ( this.selectedView === Sections.Posts || this.selectedView === Sections.PostsWithReplies ) { return this.feed + } else if (this.selectedView === Sections.Lists) { + return this.lists } if (this.selectedView === Sections.CustomAlgorithms) { return this.algos @@ -121,6 +128,12 @@ export class ProfileUiModel { } else if (this.feed.isEmpty) { arr = arr.concat([ProfileUiModel.EMPTY_ITEM]) } + } else if (this.selectedView === Sections.Lists) { + if (this.lists.hasContent) { + arr = this.lists.lists + } else if (this.lists.isEmpty) { + arr = arr.concat([ProfileUiModel.EMPTY_ITEM]) + } } else { // fallback, add empty item, to show empty message arr = arr.concat([ProfileUiModel.EMPTY_ITEM]) @@ -135,6 +148,8 @@ export class ProfileUiModel { this.selectedView === Sections.PostsWithReplies ) { return this.feed.hasContent && this.feed.hasMore && this.feed.isLoading + } else if (this.selectedView === Sections.Lists) { + return this.lists.hasContent && this.lists.hasMore && this.lists.isLoading } return false } @@ -155,6 +170,11 @@ export class ProfileUiModel { .setup() .catch(err => this.rootStore.log.error('Failed to fetch feed', err)), ]) + // HACK: need to use the DID as a param, not the username -prf + this.lists.source = this.profile.did + this.lists + .loadMore() + .catch(err => this.rootStore.log.error('Failed to fetch lists', err)) } async update() { diff --git a/src/state/models/ui/shell.ts b/src/state/models/ui/shell.ts index 67f8e16d4..9b9a176be 100644 --- a/src/state/models/ui/shell.ts +++ b/src/state/models/ui/shell.ts @@ -5,6 +5,7 @@ import {ProfileModel} from '../content/profile' import {isObj, hasProp} from 'lib/type-guards' import {Image as RNImage} from 'react-native-image-crop-picker' import {ImageModel} from '../media/image' +import {ListModel} from '../content/list' import {GalleryModel} from '../media/gallery' export interface ConfirmModal { @@ -38,6 +39,19 @@ export interface ReportAccountModal { did: string } +export interface CreateOrEditMuteListModal { + name: 'create-or-edit-mute-list' + list?: ListModel + onSave?: (uri: string) => void +} + +export interface ListAddRemoveUserModal { + name: 'list-add-remove-user' + subject: string + displayName: string + onUpdate?: () => void +} + export interface EditImageModal { name: 'edit-image' image: ImageModel @@ -102,9 +116,11 @@ export type Modal = | ContentFilteringSettingsModal | ContentLanguagesSettingsModal - // Reporting + // Moderation | ReportAccountModal | ReportPostModal + | CreateMuteListModal + | ListAddRemoveUserModal // Posts | AltTextImageModal diff --git a/src/view/com/composer/photos/Gallery.tsx b/src/view/com/composer/photos/Gallery.tsx index accd96803..436824952 100644 --- a/src/view/com/composer/photos/Gallery.tsx +++ b/src/view/com/composer/photos/Gallery.tsx @@ -61,7 +61,6 @@ export const Gallery = observer(function ({gallery}: Props) { borderRadius: 5, paddingHorizontal: 10, position: 'absolute' as const, - width: 46, zIndex: 1, ...(isOverflow ? { @@ -112,11 +111,11 @@ export const Gallery = observer(function ({gallery}: Props) { testID="altTextButton" accessibilityRole="button" accessibilityLabel="Add alt text" - accessibilityHint="Opens modal for inputting image alt text" + accessibilityHint="" onPress={() => { handleAddImageAltText(image) }} - style={[styles.imageControl, imageControlLabelStyle]}> + style={imageControlLabelStyle}> <Text style={styles.imageControlTextContent}>ALT</Text> </TouchableOpacity> <View style={imageControlsSubgroupStyle}> @@ -187,9 +186,14 @@ const styles = StyleSheet.create({ justifyContent: 'center', }, imageControlTextContent: { + borderRadius: 6, color: 'white', fontSize: 12, fontWeight: 'bold', letterSpacing: 1, + backgroundColor: 'rgba(0, 0, 0, 0.75)', + borderWidth: 0.5, + paddingHorizontal: 10, + paddingVertical: 3, }, }) diff --git a/src/view/com/composer/text-input/web/Autocomplete.tsx b/src/view/com/composer/text-input/web/Autocomplete.tsx index 475ec119b..87820b97b 100644 --- a/src/view/com/composer/text-input/web/Autocomplete.tsx +++ b/src/view/com/composer/text-input/web/Autocomplete.tsx @@ -4,7 +4,7 @@ import React, { useImperativeHandle, useState, } from 'react' -import {StyleSheet, View} from 'react-native' +import {Pressable, StyleSheet, View} from 'react-native' import {ReactRenderer} from '@tiptap/react' import tippy, {Instance as TippyInstance} from 'tippy.js' import { @@ -158,7 +158,7 @@ const MentionList = forwardRef<MentionListRef, SuggestionProps>( const isSelected = selectedIndex === index return ( - <View + <Pressable key={item.handle} style={[ isSelected ? pal.viewLight : undefined, @@ -169,7 +169,11 @@ const MentionList = forwardRef<MentionListRef, SuggestionProps>( : index === items.length - 1 ? styles.lastMention : undefined, - ]}> + ]} + onPress={() => { + selectItem(index) + }} + accessibilityRole="button"> <View style={styles.avatarAndDisplayName}> <UserAvatar avatar={item.avatar ?? null} size={26} /> <Text style={pal.text} numberOfLines={1}> @@ -179,7 +183,7 @@ const MentionList = forwardRef<MentionListRef, SuggestionProps>( <Text type="xs" style={pal.textLight} numberOfLines={1}> @{item.handle} </Text> - </View> + </Pressable> ) }) ) : ( diff --git a/src/view/com/lightbox/Lightbox.web.tsx b/src/view/com/lightbox/Lightbox.web.tsx index eff9af2d2..d389279b1 100644 --- a/src/view/com/lightbox/Lightbox.web.tsx +++ b/src/view/com/lightbox/Lightbox.web.tsx @@ -21,6 +21,9 @@ interface Img { export const Lightbox = observer(function Lightbox() { const store = useStores() + + const onClose = useCallback(() => store.shell.closeLightbox(), [store.shell]) + if (!store.shell.isLightboxActive) { return null } @@ -29,8 +32,6 @@ export const Lightbox = observer(function Lightbox() { const initialIndex = activeLightbox instanceof models.ImagesLightbox ? activeLightbox.index : 0 - const onClose = () => store.shell.closeLightbox() - let imgs: Img[] | undefined if (activeLightbox instanceof models.ProfileImageLightbox) { const opts = activeLightbox diff --git a/src/view/com/lists/ListCard.tsx b/src/view/com/lists/ListCard.tsx new file mode 100644 index 000000000..7cbdaaf64 --- /dev/null +++ b/src/view/com/lists/ListCard.tsx @@ -0,0 +1,155 @@ +import React from 'react' +import {StyleSheet, View} from 'react-native' +import {AtUri, AppBskyGraphDefs, RichText} from '@atproto/api' +import {Link} from '../util/Link' +import {Text} from '../util/text/Text' +import {RichText as RichTextCom} from '../util/text/RichText' +import {UserAvatar} from '../util/UserAvatar' +import {s} from 'lib/styles' +import {usePalette} from 'lib/hooks/usePalette' +import {useStores} from 'state/index' +import {sanitizeDisplayName} from 'lib/strings/display-names' + +export const ListCard = ({ + testID, + list, + noBg, + noBorder, + renderButton, +}: { + testID?: string + list: AppBskyGraphDefs.ListView + noBg?: boolean + noBorder?: boolean + renderButton?: () => JSX.Element +}) => { + const pal = usePalette('default') + const store = useStores() + + const rkey = React.useMemo(() => { + try { + const urip = new AtUri(list.uri) + return urip.rkey + } catch { + return '' + } + }, [list]) + + const descriptionRichText = React.useMemo(() => { + if (list.description) { + return new RichText({ + text: list.description, + facets: list.descriptionFacets, + }) + } + return undefined + }, [list]) + + return ( + <Link + testID={testID} + style={[ + styles.outer, + pal.border, + noBorder && styles.outerNoBorder, + !noBg && pal.view, + ]} + href={`/profile/${list.creator.did}/lists/${rkey}`} + title={list.name} + asAnchor + anchorNoUnderline> + <View style={styles.layout}> + <View style={styles.layoutAvi}> + <UserAvatar size={40} avatar={list.avatar} /> + </View> + <View style={styles.layoutContent}> + <Text + type="lg" + style={[s.bold, pal.text]} + numberOfLines={1} + lineHeight={1.2}> + {sanitizeDisplayName(list.name)} + </Text> + <Text type="md" style={[pal.textLight]} numberOfLines={1}> + {list.purpose === 'app.bsky.graph.defs#modlist' && 'Mute list'} by{' '} + {list.creator.did === store.me.did + ? 'you' + : `@${list.creator.handle}`} + </Text> + {!!list.viewer?.muted && ( + <View style={s.flexRow}> + <View style={[s.mt5, pal.btn, styles.pill]}> + <Text type="xs" style={pal.text}> + Subscribed + </Text> + </View> + </View> + )} + </View> + {renderButton ? ( + <View style={styles.layoutButton}>{renderButton()}</View> + ) : undefined} + </View> + {descriptionRichText ? ( + <View style={styles.details}> + <RichTextCom + style={pal.text} + numberOfLines={20} + richText={descriptionRichText} + /> + </View> + ) : undefined} + </Link> + ) +} + +const styles = StyleSheet.create({ + outer: { + borderTopWidth: 1, + paddingHorizontal: 6, + }, + outerNoBorder: { + borderTopWidth: 0, + }, + layout: { + flexDirection: 'row', + alignItems: 'center', + }, + layoutAvi: { + width: 54, + paddingLeft: 4, + paddingTop: 8, + paddingBottom: 10, + }, + avi: { + width: 40, + height: 40, + borderRadius: 20, + resizeMode: 'cover', + }, + layoutContent: { + flex: 1, + paddingRight: 10, + paddingTop: 10, + paddingBottom: 10, + }, + layoutButton: { + paddingRight: 10, + }, + details: { + paddingLeft: 54, + paddingRight: 10, + paddingBottom: 10, + }, + pill: { + borderRadius: 4, + paddingHorizontal: 6, + paddingVertical: 2, + }, + btn: { + paddingVertical: 7, + borderRadius: 50, + marginLeft: 6, + paddingHorizontal: 14, + }, +}) diff --git a/src/view/com/lists/ListItems.tsx b/src/view/com/lists/ListItems.tsx new file mode 100644 index 000000000..32cafdb83 --- /dev/null +++ b/src/view/com/lists/ListItems.tsx @@ -0,0 +1,387 @@ +import React, {MutableRefObject} from 'react' +import { + ActivityIndicator, + RefreshControl, + StyleProp, + StyleSheet, + View, + ViewStyle, +} from 'react-native' +import {AppBskyActorDefs, AppBskyGraphDefs, RichText} from '@atproto/api' +import {observer} from 'mobx-react-lite' +import {FlatList} from '../util/Views' +import {ProfileCardFeedLoadingPlaceholder} from '../util/LoadingPlaceholder' +import {ErrorMessage} from '../util/error/ErrorMessage' +import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn' +import {ProfileCard} from '../profile/ProfileCard' +import {Button} from '../util/forms/Button' +import {Text} from '../util/text/Text' +import {RichText as RichTextCom} from '../util/text/RichText' +import {UserAvatar} from '../util/UserAvatar' +import {TextLink} from '../util/Link' +import {ListModel} from 'state/models/content/list' +import {useAnalytics} from 'lib/analytics' +import {usePalette} from 'lib/hooks/usePalette' +import {useStores} from 'state/index' +import {s} from 'lib/styles' +import {isDesktopWeb} from 'platform/detection' + +const LOADING_ITEM = {_reactKey: '__loading__'} +const HEADER_ITEM = {_reactKey: '__header__'} +const EMPTY_ITEM = {_reactKey: '__empty__'} +const ERROR_ITEM = {_reactKey: '__error__'} +const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'} + +export const ListItems = observer( + ({ + list, + style, + scrollElRef, + onPressTryAgain, + onToggleSubscribed, + onPressEditList, + onPressDeleteList, + renderEmptyState, + testID, + headerOffset = 0, + }: { + list: ListModel + style?: StyleProp<ViewStyle> + scrollElRef?: MutableRefObject<FlatList<any> | null> + onPressTryAgain?: () => void + onToggleSubscribed?: () => void + onPressEditList?: () => void + onPressDeleteList?: () => void + renderEmptyState?: () => JSX.Element + testID?: string + headerOffset?: number + }) => { + const pal = usePalette('default') + const store = useStores() + const {track} = useAnalytics() + const [isRefreshing, setIsRefreshing] = React.useState(false) + + const data = React.useMemo(() => { + let items: any[] = [HEADER_ITEM] + if (list.hasLoaded) { + if (list.hasError) { + items = items.concat([ERROR_ITEM]) + } + if (list.isEmpty) { + items = items.concat([EMPTY_ITEM]) + } else { + items = items.concat(list.items) + } + if (list.loadMoreError) { + items = items.concat([LOAD_MORE_ERROR_ITEM]) + } + } else if (list.isLoading) { + items = items.concat([LOADING_ITEM]) + } + return items + }, [ + list.hasError, + list.hasLoaded, + list.isLoading, + list.isEmpty, + list.items, + list.loadMoreError, + ]) + + // events + // = + + const onRefresh = React.useCallback(async () => { + track('Lists:onRefresh') + setIsRefreshing(true) + try { + await list.refresh() + } catch (err) { + list.rootStore.log.error('Failed to refresh lists', err) + } + setIsRefreshing(false) + }, [list, track, setIsRefreshing]) + + const onEndReached = React.useCallback(async () => { + track('Lists:onEndReached') + try { + await list.loadMore() + } catch (err) { + list.rootStore.log.error('Failed to load more lists', err) + } + }, [list, track]) + + const onPressRetryLoadMore = React.useCallback(() => { + list.retryLoadMore() + }, [list]) + + const onPressEditMembership = React.useCallback( + (profile: AppBskyActorDefs.ProfileViewBasic) => { + store.shell.openModal({ + name: 'list-add-remove-user', + subject: profile.did, + displayName: profile.displayName || profile.handle, + onUpdate() { + list.refresh() + }, + }) + }, + [store, list], + ) + + // rendering + // = + + const renderMemberButton = React.useCallback( + (profile: AppBskyActorDefs.ProfileViewBasic) => { + if (!list.isOwner) { + return null + } + return ( + <Button + type="default" + label="Edit" + onPress={() => onPressEditMembership(profile)} + /> + ) + }, + [list, onPressEditMembership], + ) + + const renderItem = React.useCallback( + ({item}: {item: any}) => { + if (item === EMPTY_ITEM) { + if (renderEmptyState) { + return renderEmptyState() + } + return <View /> + } else if (item === HEADER_ITEM) { + return list.list ? ( + <ListHeader + list={list.list} + isOwner={list.isOwner} + onToggleSubscribed={onToggleSubscribed} + onPressEditList={onPressEditList} + onPressDeleteList={onPressDeleteList} + /> + ) : null + } else if (item === ERROR_ITEM) { + return ( + <ErrorMessage + message={list.error} + onPressTryAgain={onPressTryAgain} + /> + ) + } else if (item === LOAD_MORE_ERROR_ITEM) { + return ( + <LoadMoreRetryBtn + label="There was an issue fetching the list. Tap here to try again." + onPress={onPressRetryLoadMore} + /> + ) + } else if (item === LOADING_ITEM) { + return <ProfileCardFeedLoadingPlaceholder /> + } + return ( + <ProfileCard + testID={`user-${ + (item as AppBskyGraphDefs.ListItemView).subject.handle + }`} + profile={(item as AppBskyGraphDefs.ListItemView).subject} + renderButton={renderMemberButton} + /> + ) + }, + [ + list, + onPressTryAgain, + onPressRetryLoadMore, + renderMemberButton, + onPressEditList, + onPressDeleteList, + onToggleSubscribed, + renderEmptyState, + ], + ) + + const Footer = React.useCallback( + () => + list.isLoading ? ( + <View style={styles.feedFooter}> + <ActivityIndicator /> + </View> + ) : ( + <View /> + ), + [list], + ) + + return ( + <View testID={testID} style={style}> + {data.length > 0 && ( + <FlatList + testID={testID ? `${testID}-flatlist` : undefined} + ref={scrollElRef} + data={data} + keyExtractor={item => item._reactKey} + renderItem={renderItem} + ListFooterComponent={Footer} + refreshControl={ + <RefreshControl + refreshing={isRefreshing} + onRefresh={onRefresh} + tintColor={pal.colors.text} + titleColor={pal.colors.text} + progressViewOffset={headerOffset} + /> + } + contentContainerStyle={s.contentContainer} + style={{paddingTop: headerOffset}} + onEndReached={onEndReached} + onEndReachedThreshold={0.6} + removeClippedSubviews={true} + contentOffset={{x: 0, y: headerOffset * -1}} + // @ts-ignore our .web version only -prf + desktopFixedHeight + /> + )} + </View> + ) + }, +) + +const ListHeader = observer( + ({ + list, + isOwner, + onToggleSubscribed, + onPressEditList, + onPressDeleteList, + }: { + list: AppBskyGraphDefs.ListView + isOwner: boolean + onToggleSubscribed?: () => void + onPressEditList?: () => void + onPressDeleteList?: () => void + }) => { + const pal = usePalette('default') + const store = useStores() + const descriptionRT = React.useMemo( + () => + list?.description && + new RichText({text: list.description, facets: list.descriptionFacets}), + [list], + ) + return ( + <> + <View style={[styles.header, pal.border]}> + <View style={s.flex1}> + <Text testID="listName" type="title-xl" style={[pal.text, s.bold]}> + {list.name} + </Text> + {list && ( + <Text type="md" style={[pal.textLight]} numberOfLines={1}> + {list.purpose === 'app.bsky.graph.defs#modlist' && 'Mute list '} + by{' '} + {list.creator.did === store.me.did ? ( + 'you' + ) : ( + <TextLink + text={`@${list.creator.handle}`} + href={`/profile/${list.creator.did}`} + /> + )} + </Text> + )} + {descriptionRT && ( + <RichTextCom + testID="listDescription" + style={[pal.text, styles.headerDescription]} + richText={descriptionRT} + /> + )} + {isDesktopWeb && ( + <View style={styles.headerBtns}> + {list.viewer?.muted ? ( + <Button + type="inverted" + label="Unsubscribe" + accessibilityLabel="Unsubscribe" + accessibilityHint="" + onPress={onToggleSubscribed} + /> + ) : ( + <Button + type="primary" + label="Subscribe & Mute" + accessibilityLabel="Subscribe and mute" + accessibilityHint="" + onPress={onToggleSubscribed} + /> + )} + {isOwner && ( + <Button + type="default" + label="Edit List" + accessibilityLabel="Edit list" + accessibilityHint="" + onPress={onPressEditList} + /> + )} + {isOwner && ( + <Button + type="default" + label="Delete List" + accessibilityLabel="Delete list" + accessibilityHint="" + onPress={onPressDeleteList} + /> + )} + </View> + )} + </View> + <View> + <UserAvatar avatar={list.avatar} size={64} /> + </View> + </View> + <View style={[styles.fakeSelector, pal.border]}> + <View + style={[styles.fakeSelectorItem, {borderColor: pal.colors.link}]}> + <Text type="md-medium" style={[pal.text]}> + Muted users + </Text> + </View> + </View> + </> + ) + }, +) + +const styles = StyleSheet.create({ + header: { + flexDirection: 'row', + gap: 12, + paddingHorizontal: 16, + paddingTop: 12, + paddingBottom: 16, + borderTopWidth: 1, + }, + headerDescription: { + marginTop: 8, + }, + headerBtns: { + flexDirection: 'row', + gap: 8, + marginTop: 12, + }, + fakeSelector: { + flexDirection: 'row', + paddingHorizontal: isDesktopWeb ? 16 : 6, + }, + fakeSelectorItem: { + paddingHorizontal: 12, + paddingBottom: 8, + borderBottomWidth: 3, + }, + feedFooter: {paddingTop: 20}, +}) diff --git a/src/view/com/lists/ListsList.tsx b/src/view/com/lists/ListsList.tsx new file mode 100644 index 000000000..88b71acc0 --- /dev/null +++ b/src/view/com/lists/ListsList.tsx @@ -0,0 +1,240 @@ +import React, {MutableRefObject} from 'react' +import { + ActivityIndicator, + RefreshControl, + StyleProp, + StyleSheet, + View, + ViewStyle, +} from 'react-native' +import {observer} from 'mobx-react-lite' +import { + FontAwesomeIcon, + FontAwesomeIconStyle, +} from '@fortawesome/react-native-fontawesome' +import {AppBskyGraphDefs as GraphDefs} from '@atproto/api' +import {FlatList} from '../util/Views' +import {ListCard} from './ListCard' +import {ProfileCardFeedLoadingPlaceholder} from '../util/LoadingPlaceholder' +import {ErrorMessage} from '../util/error/ErrorMessage' +import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn' +import {Button} from '../util/forms/Button' +import {Text} from '../util/text/Text' +import {ListsListModel} from 'state/models/lists/lists-list' +import {useAnalytics} from 'lib/analytics' +import {usePalette} from 'lib/hooks/usePalette' +import {s} from 'lib/styles' + +const LOADING_ITEM = {_reactKey: '__loading__'} +const CREATENEW_ITEM = {_reactKey: '__loading__'} +const EMPTY_ITEM = {_reactKey: '__empty__'} +const ERROR_ITEM = {_reactKey: '__error__'} +const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'} + +export const ListsList = observer( + ({ + listsList, + showAddBtns, + style, + scrollElRef, + onPressTryAgain, + onPressCreateNew, + renderItem, + renderEmptyState, + testID, + headerOffset = 0, + }: { + listsList: ListsListModel + showAddBtns?: boolean + style?: StyleProp<ViewStyle> + scrollElRef?: MutableRefObject<FlatList<any> | null> + onPressCreateNew: () => void + onPressTryAgain?: () => void + renderItem?: (list: GraphDefs.ListView) => JSX.Element + renderEmptyState?: () => JSX.Element + testID?: string + headerOffset?: number + }) => { + const pal = usePalette('default') + const {track} = useAnalytics() + const [isRefreshing, setIsRefreshing] = React.useState(false) + + const data = React.useMemo(() => { + let items: any[] = [] + if (listsList.hasLoaded) { + if (listsList.hasError) { + items = items.concat([ERROR_ITEM]) + } + if (listsList.isEmpty) { + items = items.concat([EMPTY_ITEM]) + } else { + if (showAddBtns) { + items = items.concat([CREATENEW_ITEM]) + } + items = items.concat(listsList.lists) + } + if (listsList.loadMoreError) { + items = items.concat([LOAD_MORE_ERROR_ITEM]) + } + } else if (listsList.isLoading) { + items = items.concat([LOADING_ITEM]) + } + return items + }, [ + listsList.hasError, + listsList.hasLoaded, + listsList.isLoading, + listsList.isEmpty, + listsList.lists, + listsList.loadMoreError, + showAddBtns, + ]) + + // events + // = + + const onRefresh = React.useCallback(async () => { + track('Lists:onRefresh') + setIsRefreshing(true) + try { + await listsList.refresh() + } catch (err) { + listsList.rootStore.log.error('Failed to refresh lists', err) + } + setIsRefreshing(false) + }, [listsList, track, setIsRefreshing]) + + const onEndReached = React.useCallback(async () => { + track('Lists:onEndReached') + try { + await listsList.loadMore() + } catch (err) { + listsList.rootStore.log.error('Failed to load more lists', err) + } + }, [listsList, track]) + + const onPressRetryLoadMore = React.useCallback(() => { + listsList.retryLoadMore() + }, [listsList]) + + // rendering + // = + + const renderItemInner = React.useCallback( + ({item}: {item: any}) => { + if (item === EMPTY_ITEM) { + if (renderEmptyState) { + return renderEmptyState() + } + return <View /> + } else if (item === CREATENEW_ITEM) { + return <CreateNewItem onPress={onPressCreateNew} /> + } else if (item === ERROR_ITEM) { + return ( + <ErrorMessage + message={listsList.error} + onPressTryAgain={onPressTryAgain} + /> + ) + } else if (item === LOAD_MORE_ERROR_ITEM) { + return ( + <LoadMoreRetryBtn + label="There was an issue fetching your lists. Tap here to try again." + onPress={onPressRetryLoadMore} + /> + ) + } else if (item === LOADING_ITEM) { + return <ProfileCardFeedLoadingPlaceholder /> + } + return renderItem ? ( + renderItem(item) + ) : ( + <ListCard list={item} testID={`list-${item.name}`} /> + ) + }, + [ + listsList, + onPressTryAgain, + onPressRetryLoadMore, + onPressCreateNew, + renderItem, + renderEmptyState, + ], + ) + + const Footer = React.useCallback( + () => + listsList.isLoading ? ( + <View style={styles.feedFooter}> + <ActivityIndicator /> + </View> + ) : ( + <View /> + ), + [listsList], + ) + + return ( + <View testID={testID} style={style}> + {data.length > 0 && ( + <FlatList + testID={testID ? `${testID}-flatlist` : undefined} + ref={scrollElRef} + data={data} + keyExtractor={item => item._reactKey} + renderItem={renderItemInner} + ListFooterComponent={Footer} + refreshControl={ + <RefreshControl + refreshing={isRefreshing} + onRefresh={onRefresh} + tintColor={pal.colors.text} + titleColor={pal.colors.text} + progressViewOffset={headerOffset} + /> + } + contentContainerStyle={s.contentContainer} + style={{paddingTop: headerOffset}} + onEndReached={onEndReached} + onEndReachedThreshold={0.6} + removeClippedSubviews={true} + contentOffset={{x: 0, y: headerOffset * -1}} + // @ts-ignore our .web version only -prf + desktopFixedHeight + /> + )} + </View> + ) + }, +) + +function CreateNewItem({onPress}: {onPress: () => void}) { + const pal = usePalette('default') + + return ( + <View style={[styles.createNewContainer]}> + <Button type="default" onPress={onPress} style={styles.createNewButton}> + <FontAwesomeIcon icon="plus" style={pal.text as FontAwesomeIconStyle} /> + <Text type="button" style={pal.text}> + New Mute List + </Text> + </Button> + </View> + ) +} + +const styles = StyleSheet.create({ + createNewContainer: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 18, + paddingTop: 18, + paddingBottom: 16, + }, + createNewButton: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + feedFooter: {paddingTop: 20}, +}) diff --git a/src/view/com/modals/ChangeHandle.tsx b/src/view/com/modals/ChangeHandle.tsx index ad7ff5a6d..a5c74bc0b 100644 --- a/src/view/com/modals/ChangeHandle.tsx +++ b/src/view/com/modals/ChangeHandle.tsx @@ -144,8 +144,11 @@ export function Component({onChanged}: {onChanged: () => void}) { </Text> </TouchableOpacity> </View> - <Text type="2xl-bold" style={[styles.titleMiddle, pal.text]}> - Change my handle + <Text + type="2xl-bold" + style={[styles.titleMiddle, pal.text]} + numberOfLines={1}> + Change handle </Text> <View style={styles.titleRight}> {isProcessing ? ( diff --git a/src/view/com/modals/ContentFilteringSettings.tsx b/src/view/com/modals/ContentFilteringSettings.tsx index 30b465562..1256bd420 100644 --- a/src/view/com/modals/ContentFilteringSettings.tsx +++ b/src/view/com/modals/ContentFilteringSettings.tsx @@ -7,23 +7,66 @@ import {useStores} from 'state/index' import {LabelPreference} from 'state/models/ui/preferences' import {s, colors, gradients} from 'lib/styles' import {Text} from '../util/text/Text' +import {TextLink} from '../util/Link' +import {ToggleButton} from '../util/forms/ToggleButton' import {usePalette} from 'lib/hooks/usePalette' import {CONFIGURABLE_LABEL_GROUPS} from 'lib/labeling/const' -import {isDesktopWeb} from 'platform/detection' +import {isDesktopWeb, isIOS} from 'platform/detection' +import * as Toast from '../util/Toast' export const snapPoints = ['90%'] -export function Component({}: {}) { +export const Component = observer(({}: {}) => { const store = useStores() const pal = usePalette('default') + + React.useEffect(() => { + store.preferences.sync() + }, [store]) + + const onToggleAdultContent = React.useCallback(async () => { + if (isIOS) { + return + } + try { + await store.preferences.setAdultContentEnabled( + !store.preferences.adultContentEnabled, + ) + } catch (e) { + Toast.show('There was an issue syncing your preferences with the server') + store.log.error('Failed to update preferences with server', {e}) + } + }, [store]) + const onPressDone = React.useCallback(() => { store.shell.closeModal() }, [store]) return ( - <View testID="contentModerationModal" style={[pal.view, styles.container]}> - <Text style={[pal.text, styles.title]}>Content Moderation</Text> + <View testID="contentFilteringModal" style={[pal.view, styles.container]}> + <Text style={[pal.text, styles.title]}>Content Filtering</Text> <ScrollView style={styles.scrollContainer}> + <View style={s.mb10}> + {isIOS ? ( + <Text type="md" style={pal.textLight}> + Adult content can only be enabled via the Web at{' '} + <TextLink + style={pal.link} + href="https://staging.bsky.app" + text="staging.bsky.app" + /> + . + </Text> + ) : ( + <ToggleButton + type="default-light" + label="Enable Adult Content" + isSelected={store.preferences.adultContentEnabled} + onPress={onToggleAdultContent} + style={styles.toggleBtn} + /> + )} + </View> <ContentLabelPref group="nsfw" disabled={!store.preferences.adultContentEnabled} @@ -50,7 +93,7 @@ export function Component({}: {}) { testID="sendReportBtn" onPress={onPressDone} accessibilityRole="button" - accessibilityLabel="Confirm content moderation settings" + accessibilityLabel="Done" accessibilityHint=""> <LinearGradient colors={[gradients.blueLight.start, gradients.blueLight.end]} @@ -63,7 +106,7 @@ export function Component({}: {}) { </View> </View> ) -} +}) // TODO: Refactor this component to pass labels down to each tab const ContentLabelPref = observer( @@ -76,6 +119,21 @@ const ContentLabelPref = observer( }) => { const store = useStores() const pal = usePalette('default') + + const onChange = React.useCallback( + async (v: LabelPreference) => { + try { + await store.preferences.setContentLabelPref(group, v) + } catch (e) { + Toast.show( + 'There was an issue syncing your preferences with the server', + ) + store.log.error('Failed to update preferences with server', {e}) + } + }, + [store, group], + ) + return ( <View style={[styles.contentLabelPref, pal.border]}> <View style={s.flex1}> @@ -95,7 +153,7 @@ const ContentLabelPref = observer( ) : ( <SelectGroup current={store.preferences.contentLabels[group]} - onChange={v => store.preferences.setContentLabelPref(group, v)} + onChange={onChange} group={group} /> )} @@ -250,4 +308,7 @@ const styles = StyleSheet.create({ padding: 14, backgroundColor: colors.gray1, }, + toggleBtn: { + paddingHorizontal: 0, + }, }) diff --git a/src/view/com/modals/CreateOrEditMuteList.tsx b/src/view/com/modals/CreateOrEditMuteList.tsx new file mode 100644 index 000000000..0c13f243a --- /dev/null +++ b/src/view/com/modals/CreateOrEditMuteList.tsx @@ -0,0 +1,279 @@ +import React, {useState, useCallback} from 'react' +import * as Toast from '../util/Toast' +import { + ActivityIndicator, + KeyboardAvoidingView, + ScrollView, + StyleSheet, + TextInput, + TouchableOpacity, + View, +} from 'react-native' +import LinearGradient from 'react-native-linear-gradient' +import {Image as RNImage} from 'react-native-image-crop-picker' +import {Text} from '../util/text/Text' +import {ErrorMessage} from '../util/error/ErrorMessage' +import {useStores} from 'state/index' +import {ListModel} from 'state/models/content/list' +import {s, colors, gradients} from 'lib/styles' +import {enforceLen} from 'lib/strings/helpers' +import {compressIfNeeded} from 'lib/media/manip' +import {UserAvatar} from '../util/UserAvatar' +import {usePalette} from 'lib/hooks/usePalette' +import {useTheme} from 'lib/ThemeContext' +import {useAnalytics} from 'lib/analytics' +import {cleanError, isNetworkError} from 'lib/strings/errors' +import {isDesktopWeb} from 'platform/detection' + +const MAX_NAME = 64 // todo +const MAX_DESCRIPTION = 300 // todo + +export const snapPoints = ['fullscreen'] + +export function Component({ + onSave, + list, +}: { + onSave?: (uri: string) => void + list?: ListModel +}) { + const store = useStores() + const [error, setError] = useState<string>('') + const pal = usePalette('default') + const theme = useTheme() + const {track} = useAnalytics() + + const [isProcessing, setProcessing] = useState<boolean>(false) + const [name, setName] = useState<string>(list?.list.name || '') + const [description, setDescription] = useState<string>( + list?.list.description || '', + ) + const [avatar, setAvatar] = useState<string | undefined>(list?.list.avatar) + const [newAvatar, setNewAvatar] = useState<RNImage | undefined | null>() + + const onPressCancel = useCallback(() => { + store.shell.closeModal() + }, [store]) + + const onSelectNewAvatar = useCallback( + async (img: RNImage | null) => { + if (!img) { + setNewAvatar(null) + setAvatar(null) + return + } + track('CreateMuteList:AvatarSelected') + try { + const finalImg = await compressIfNeeded(img, 1000000) + setNewAvatar(finalImg) + setAvatar(finalImg.path) + } catch (e: any) { + setError(cleanError(e)) + } + }, + [track, setNewAvatar, setAvatar, setError], + ) + + const onPressSave = useCallback(async () => { + track('CreateMuteList:Save') + const nameTrimmed = name.trim() + if (!nameTrimmed) { + setError('Name is required') + return + } + setProcessing(true) + if (error) { + setError('') + } + try { + if (list) { + await list.updateMetadata({ + name: nameTrimmed, + description: description.trim(), + avatar: newAvatar, + }) + Toast.show('Mute list updated') + onSave?.(list.uri) + } else { + const res = await ListModel.createModList(store, { + name, + description, + avatar: newAvatar, + }) + Toast.show('Mute list created') + onSave?.(res.uri) + } + store.shell.closeModal() + } catch (e: any) { + if (isNetworkError(e)) { + setError( + 'Failed to create the mute list. Check your internet connection and try again.', + ) + } else { + setError(cleanError(e)) + } + } + setProcessing(false) + }, [ + track, + setProcessing, + setError, + error, + onSave, + store, + name, + description, + newAvatar, + list, + ]) + + return ( + <KeyboardAvoidingView behavior="height"> + <ScrollView + style={[pal.view, styles.container]} + testID="createOrEditMuteListModal"> + <Text style={[styles.title, pal.text]}> + {list ? 'Edit Mute List' : 'New Mute List'} + </Text> + {error !== '' && ( + <View style={styles.errorContainer}> + <ErrorMessage message={error} /> + </View> + )} + <Text style={[styles.label, pal.text]}>List Avatar</Text> + <View style={[styles.avi, {borderColor: pal.colors.background}]}> + <UserAvatar + size={80} + avatar={avatar} + onSelectNewAvatar={onSelectNewAvatar} + /> + </View> + <View style={styles.form}> + <View> + <Text style={[styles.label, pal.text]} nativeID="list-name"> + List Name + </Text> + <TextInput + testID="editNameInput" + style={[styles.textInput, pal.border, pal.text]} + placeholder="e.g. Spammers" + placeholderTextColor={colors.gray4} + value={name} + onChangeText={v => setName(enforceLen(v, MAX_NAME))} + accessible={true} + accessibilityLabel="Name" + accessibilityHint="" + accessibilityLabelledBy="list-name" + /> + </View> + <View style={s.pb10}> + <Text style={[styles.label, pal.text]} nativeID="list-description"> + Description + </Text> + <TextInput + testID="editDescriptionInput" + style={[styles.textArea, pal.border, pal.text]} + placeholder="e.g. Users that repeatedly reply with ads." + placeholderTextColor={colors.gray4} + keyboardAppearance={theme.colorScheme} + multiline + value={description} + onChangeText={v => setDescription(enforceLen(v, MAX_DESCRIPTION))} + accessible={true} + accessibilityLabel="Description" + accessibilityHint="" + accessibilityLabelledBy="list-description" + /> + </View> + {isProcessing ? ( + <View style={[styles.btn, s.mt10, {backgroundColor: colors.gray2}]}> + <ActivityIndicator /> + </View> + ) : ( + <TouchableOpacity + testID="saveBtn" + style={s.mt10} + onPress={onPressSave} + accessibilityRole="button" + accessibilityLabel="Save" + accessibilityHint="Creates the mute list"> + <LinearGradient + colors={[gradients.blueLight.start, gradients.blueLight.end]} + start={{x: 0, y: 0}} + end={{x: 1, y: 1}} + style={[styles.btn]}> + <Text style={[s.white, s.bold]}>Save</Text> + </LinearGradient> + </TouchableOpacity> + )} + <TouchableOpacity + testID="cancelBtn" + style={s.mt5} + onPress={onPressCancel} + accessibilityRole="button" + accessibilityLabel="Cancel" + accessibilityHint="" + onAccessibilityEscape={onPressCancel}> + <View style={[styles.btn]}> + <Text style={[s.black, s.bold, pal.text]}>Cancel</Text> + </View> + </TouchableOpacity> + </View> + </ScrollView> + </KeyboardAvoidingView> + ) +} + +const styles = StyleSheet.create({ + container: { + paddingHorizontal: isDesktopWeb ? 0 : 16, + }, + title: { + textAlign: 'center', + fontWeight: 'bold', + fontSize: 24, + marginBottom: 18, + }, + label: { + fontWeight: 'bold', + paddingHorizontal: 4, + paddingBottom: 4, + marginTop: 20, + }, + form: { + paddingHorizontal: 6, + }, + textInput: { + borderWidth: 1, + borderRadius: 6, + paddingHorizontal: 14, + paddingVertical: 10, + fontSize: 16, + }, + textArea: { + borderWidth: 1, + borderRadius: 6, + paddingHorizontal: 12, + paddingTop: 10, + fontSize: 16, + height: 100, + textAlignVertical: 'top', + }, + btn: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + width: '100%', + borderRadius: 32, + padding: 10, + marginBottom: 10, + }, + avi: { + width: 84, + height: 84, + borderWidth: 2, + borderRadius: 42, + marginTop: 4, + }, + errorContainer: {marginTop: 20}, +}) diff --git a/src/view/com/modals/EditImage.tsx b/src/view/com/modals/EditImage.tsx index 4a5d9bfde..eab472a78 100644 --- a/src/view/com/modals/EditImage.tsx +++ b/src/view/com/modals/EditImage.tsx @@ -18,148 +18,114 @@ import {Slider} from '@miblanchard/react-native-slider' import {MaterialIcons} from '@expo/vector-icons' import {observer} from 'mobx-react-lite' import {getKeys} from 'lib/type-assertions' +import {isDesktopWeb} from 'platform/detection' export const snapPoints = ['80%'] +const RATIOS = { + '4:3': { + Icon: RectWideIcon, + }, + '1:1': { + Icon: SquareIcon, + }, + '3:4': { + Icon: RectTallIcon, + }, + None: { + label: 'None', + Icon: MaterialIcons, + name: 'do-not-disturb-alt', + }, +} as const + +type AspectRatio = keyof typeof RATIOS + interface Props { image: ImageModel gallery: GalleryModel } -// This is only used for desktop web export const Component = observer(function ({image, gallery}: Props) { const pal = usePalette('default') - const store = useStores() - const {shell} = store const theme = useTheme() - const winDim = useWindowDimensions() + const store = useStores() + const windowDimensions = useWindowDimensions() - const [altText, setAltText] = useState(image.altText) - const [aspectRatio, setAspectRatio] = useState<AspectRatio>( - image.aspectRatio ?? 'None', - ) - const [flipHorizontal, setFlipHorizontal] = useState<boolean>( - image.flipHorizontal ?? false, - ) - const [flipVertical, setFlipVertical] = useState<boolean>( - image.flipVertical ?? false, - ) + const { + aspectRatio, + // rotate = 0 + } = image.attributes - // TODO: doesn't seem to be working correctly with crop - // const [rotation, setRotation] = useState(image.rotation ?? 0) - const [scale, setScale] = useState<number>(image.scale ?? 1) - const [position, setPosition] = useState<Position>() - const [isEditing, setIsEditing] = useState(false) const editorRef = useRef<ImageEditor>(null) - - const imgEditorStyles = useMemo(() => { - const dim = Math.min(425, winDim.width - 24) - return {width: dim, height: dim} - }, [winDim.width]) - - const manipulationAttributes = useMemo( - () => ({ - // TODO: doesn't seem to be working correctly with crop - // ...(rotation !== undefined ? {rotate: rotation} : {}), - ...(flipHorizontal !== undefined ? {flipHorizontal} : {}), - ...(flipVertical !== undefined ? {flipVertical} : {}), - }), - [flipHorizontal, flipVertical], - ) - - useEffect(() => { - const manipulateImage = async () => { - await image.manipulate(manipulationAttributes) - } - - manipulateImage() - }, [image, manipulationAttributes]) - - const ratios = useMemo( - () => - ({ - '4:3': { - hint: 'Sets image aspect ratio to wide', - Icon: RectWideIcon, - }, - '1:1': { - hint: 'Sets image aspect ratio to square', - Icon: SquareIcon, - }, - '3:4': { - hint: 'Sets image aspect ratio to tall', - Icon: RectTallIcon, - }, - None: { - label: 'None', - hint: 'Sets image aspect ratio to tall', - Icon: MaterialIcons, - name: 'do-not-disturb-alt', - }, - } as const), - [], + const [scale, setScale] = useState<number>(image.attributes.scale ?? 1) + const [position, setPosition] = useState<Position | undefined>( + image.attributes.position, ) - - type AspectRatio = keyof typeof ratios + const [altText, setAltText] = useState('') const onFlipHorizontal = useCallback(() => { - setFlipHorizontal(!flipHorizontal) - image.manipulate({flipHorizontal}) - }, [flipHorizontal, image]) + image.flipHorizontal() + }, [image]) const onFlipVertical = useCallback(() => { - setFlipVertical(!flipVertical) - image.manipulate({flipVertical}) - }, [flipVertical, image]) + image.flipVertical() + }, [image]) + + // const onSetRotate = useCallback( + // (direction: 'left' | 'right') => { + // const rotation = (rotate + 90 * (direction === 'left' ? -1 : 1)) % 360 + // image.setRotate(rotation) + // }, + // [rotate, image], + // ) + + const onSetRatio = useCallback( + (ratio: AspectRatio) => { + image.setRatio(ratio) + }, + [image], + ) const adjustments = useMemo( - () => - [ - // { - // name: 'rotate-left', - // label: 'Rotate left', - // hint: 'Rotate image left', - // onPress: () => { - // const rotate = (rotation - 90) % 360 - // setRotation(rotate) - // image.manipulate({rotate}) - // }, - // }, - // { - // name: 'rotate-right', - // label: 'Rotate right', - // hint: 'Rotate image right', - // onPress: () => { - // const rotate = (rotation + 90) % 360 - // setRotation(rotate) - // image.manipulate({rotate}) - // }, - // }, - { - name: 'flip', - label: 'Flip horizontal', - hint: 'Flip image horizontally', - onPress: onFlipHorizontal, - }, - { - name: 'flip', - label: 'Flip vertically', - hint: 'Flip image vertically', - onPress: onFlipVertical, - }, - ] as const, + () => [ + // { + // name: 'rotate-left' as const, + // label: 'Rotate left', + // onPress: () => { + // onSetRotate('left') + // }, + // }, + // { + // name: 'rotate-right' as const, + // label: 'Rotate right', + // onPress: () => { + // onSetRotate('right') + // }, + // }, + { + name: 'flip' as const, + label: 'Flip horizontal', + onPress: onFlipHorizontal, + }, + { + name: 'flip' as const, + label: 'Flip vertically', + onPress: onFlipVertical, + }, + ], [onFlipHorizontal, onFlipVertical], ) useEffect(() => { image.prev = image.compressed - setIsEditing(true) + image.prevAttributes = image.attributes + image.resetCompressed() }, [image]) const onCloseModal = useCallback(() => { - shell.closeModal() - setIsEditing(false) - }, [shell]) + store.shell.closeModal() + }, [store.shell]) const onPressCancel = useCallback(async () => { await gallery.previous(image) @@ -184,25 +150,12 @@ export const Component = observer(function ({image, gallery}: Props) { ...(position !== undefined ? {position} : {}), } : {}), - ...manipulationAttributes, - aspectRatio, }) - image.prevAttributes = manipulationAttributes + image.prev = image.compressed + image.prevAttributes = image.attributes onCloseModal() - }, [ - altText, - aspectRatio, - image, - manipulationAttributes, - position, - scale, - onCloseModal, - ]) - - const onPressRatio = useCallback((as: AspectRatio) => { - setAspectRatio(as) - }, []) + }, [altText, image, position, scale, onCloseModal]) const getLabelIconSize = useCallback((as: AspectRatio) => { switch (as) { @@ -220,40 +173,55 @@ export const Component = observer(function ({image, gallery}: Props) { return null } - const {width, height} = image.getDisplayDimensions( - aspectRatio, - imgEditorStyles.width, - ) + const computedWidth = + windowDimensions.width > 500 ? 410 : windowDimensions.width - 80 + const sideLength = isDesktopWeb ? 300 : computedWidth + + const dimensions = image.getDisplayDimensions(aspectRatio, sideLength) + const imgContainerStyles = {width: sideLength, height: sideLength} + + const imgControlStyles = { + alignItems: 'center' as const, + flexDirection: isDesktopWeb ? ('row' as const) : ('column' as const), + gap: isDesktopWeb ? 5 : 0, + } return ( <View testID="editImageModal" style={[pal.view, styles.container, s.flex1]}> <Text style={[styles.title, pal.text]}>Edit image</Text> - <View> - <View style={[styles.imgContainer, imgEditorStyles, pal.borderDark]}> - <ImageEditor - ref={editorRef} - style={styles.imgEditor} - image={isEditing ? image.compressed.path : image.path} - width={width} - height={height} - scale={scale} - border={0} - position={position} - onPositionChange={setPosition} + <View style={[styles.gap18, s.flexRow]}> + <View> + <View + style={[styles.imgContainer, pal.borderDark, imgContainerStyles]}> + <ImageEditor + ref={editorRef} + style={styles.imgEditor} + image={image.compressed.path} + scale={scale} + border={0} + position={position} + onPositionChange={setPosition} + {...dimensions} + /> + </View> + <Slider + value={scale} + onValueChange={(v: number | number[]) => + setScale(Array.isArray(v) ? v[0] : v) + } + minimumValue={1} + maximumValue={3} /> </View> - <Slider - value={scale} - onValueChange={(v: number | number[]) => - setScale(Array.isArray(v) ? v[0] : v) - } - minimumValue={1} - maximumValue={3} - /> - <View style={[s.flexRow, styles.gap18]}> - <View style={styles.imgControls}> - {getKeys(ratios).map(ratio => { - const {hint, Icon, ...props} = ratios[ratio] + <View> + {isDesktopWeb ? ( + <Text type="sm-bold" style={pal.text}> + Ratios + </Text> + ) : null} + <View style={imgControlStyles}> + {getKeys(RATIOS).map(ratio => { + const {Icon, ...props} = RATIOS[ratio] const labelIconSize = getLabelIconSize(ratio) const isSelected = aspectRatio === ratio @@ -261,10 +229,10 @@ export const Component = observer(function ({image, gallery}: Props) { <Pressable key={ratio} onPress={() => { - onPressRatio(ratio) + onSetRatio(ratio) }} accessibilityLabel={ratio} - accessibilityHint={hint}> + accessibilityHint=""> <Icon size={labelIconSize} style={[styles.imgControl, isSelected ? s.blue3 : pal.text]} @@ -281,18 +249,22 @@ export const Component = observer(function ({image, gallery}: Props) { ) })} </View> - <View style={[styles.verticalSep, pal.border]} /> - <View style={styles.imgControls}> - {adjustments.map(({label, hint, name, onPress}) => ( + {isDesktopWeb ? ( + <Text type="sm-bold" style={[pal.text, styles.subsection]}> + Transformations + </Text> + ) : null} + <View style={imgControlStyles}> + {adjustments.map(({label, name, onPress}) => ( <Pressable key={label} onPress={onPress} accessibilityLabel={label} - accessibilityHint={hint} + accessibilityHint="" style={styles.flipBtn}> <MaterialIcons name={name} - size={label.startsWith('Flip') ? 22 : 24} + size={label?.startsWith('Flip') ? 22 : 24} style={[ pal.text, label === 'Flip vertically' @@ -305,7 +277,10 @@ export const Component = observer(function ({image, gallery}: Props) { </View> </View> </View> - <View style={[styles.gap18]}> + <View style={[styles.gap18, styles.bottomSection, pal.border]}> + <Text type="sm-bold" style={pal.text} nativeID="alt-text"> + Accessibility + </Text> <TextInput testID="altTextImageInput" style={[styles.textArea, pal.border, pal.text]} @@ -313,11 +288,9 @@ export const Component = observer(function ({image, gallery}: Props) { multiline value={altText} onChangeText={text => setAltText(enforceLen(text, MAX_ALT_TEXT))} - placeholder="Image description" - placeholderTextColor={pal.colors.textLight} - accessibilityLabel="Image alt text" - accessibilityHint="Sets image alt text for screenreaders" - accessibilityLabelledBy="imageAltText" + accessibilityLabel="Alt text" + accessibilityHint="" + accessibilityLabelledBy="alt-text" /> </View> <View style={styles.btns}> @@ -345,30 +318,16 @@ export const Component = observer(function ({image, gallery}: Props) { const styles = StyleSheet.create({ container: { gap: 18, - paddingVertical: 18, - paddingHorizontal: 12, + paddingHorizontal: isDesktopWeb ? undefined : 16, height: '100%', width: '100%', }, - gap18: { - gap: 18, - }, - + subsection: {marginTop: 12}, + gap18: {gap: 18}, title: { fontWeight: 'bold', fontSize: 24, }, - - textArea: { - borderWidth: 1, - borderRadius: 6, - paddingTop: 10, - paddingHorizontal: 12, - fontSize: 16, - height: 100, - textAlignVertical: 'top', - }, - btns: { flexDirection: 'row', alignItems: 'center', @@ -379,28 +338,12 @@ const styles = StyleSheet.create({ paddingVertical: 8, paddingHorizontal: 24, }, - - verticalSep: { - borderLeftWidth: 1, - }, - - imgControls: { - flexDirection: 'row', - gap: 5, - }, imgControl: { display: 'flex', alignItems: 'center', justifyContent: 'center', height: 40, }, - flipVertical: { - transform: [{rotate: '90deg'}], - }, - flipBtn: { - paddingHorizontal: 4, - paddingVertical: 8, - }, imgEditor: { maxWidth: '100%', }, @@ -408,11 +351,29 @@ const styles = StyleSheet.create({ display: 'flex', alignItems: 'center', justifyContent: 'center', - height: 425, - width: 425, borderWidth: 1, - borderRadius: 8, borderStyle: 'solid', - overflow: 'hidden', + marginBottom: 4, + }, + flipVertical: { + transform: [{rotate: '90deg'}], + }, + flipBtn: { + paddingHorizontal: 4, + paddingVertical: 8, + }, + textArea: { + borderWidth: 1, + borderRadius: 6, + paddingTop: 10, + paddingHorizontal: 12, + fontSize: 16, + height: 100, + textAlignVertical: 'top', + maxHeight: isDesktopWeb ? undefined : 50, + }, + bottomSection: { + borderTopWidth: 1, + paddingTop: 18, }, }) diff --git a/src/view/com/modals/EditProfile.tsx b/src/view/com/modals/EditProfile.tsx index c26592fa9..f37a0f71a 100644 --- a/src/view/com/modals/EditProfile.tsx +++ b/src/view/com/modals/EditProfile.tsx @@ -65,7 +65,7 @@ export function Component({ } const onSelectNewAvatar = useCallback( async (img: RNImage | null) => { - if (!img) { + if (img === null) { setNewUserAvatar(null) setUserAvatar(null) return @@ -81,6 +81,7 @@ export function Component({ }, [track, setNewUserAvatar, setUserAvatar, setError], ) + const onSelectNewBanner = useCallback( async (img: RNImage | null) => { if (!img) { @@ -99,6 +100,7 @@ export function Component({ }, [track, setNewUserBanner, setUserBanner, setError], ) + const onPressSave = useCallback(async () => { track('EditProfile:Save') setProcessing(true) diff --git a/src/view/com/modals/InviteCodes.tsx b/src/view/com/modals/InviteCodes.tsx index 52d6fa46a..b3fe9dd3f 100644 --- a/src/view/com/modals/InviteCodes.tsx +++ b/src/view/com/modals/InviteCodes.tsx @@ -57,7 +57,7 @@ export function Component({}: {}) { code works once! </Text> <Text type="sm" style={[styles.description, pal.textLight]}> - ( You'll receive one invite code every two weeks. ) + (You'll receive one invite code every two weeks.) </Text> <ScrollView style={[styles.scrollContainer, pal.border]}> {store.me.invites.map((invite, i) => ( diff --git a/src/view/com/modals/ListAddRemoveUser.tsx b/src/view/com/modals/ListAddRemoveUser.tsx new file mode 100644 index 000000000..91fe67c17 --- /dev/null +++ b/src/view/com/modals/ListAddRemoveUser.tsx @@ -0,0 +1,253 @@ +import React, {useCallback} from 'react' +import {observer} from 'mobx-react-lite' +import {Pressable, StyleSheet, View} from 'react-native' +import {AppBskyGraphDefs as GraphDefs} from '@atproto/api' +import { + FontAwesomeIcon, + FontAwesomeIconStyle, +} from '@fortawesome/react-native-fontawesome' +import {Text} from '../util/text/Text' +import {UserAvatar} from '../util/UserAvatar' +import {ListsList} from '../lists/ListsList' +import {ListsListModel} from 'state/models/lists/lists-list' +import {ListMembershipModel} from 'state/models/content/list-membership' +import {EmptyStateWithButton} from '../util/EmptyStateWithButton' +import {Button} from '../util/forms/Button' +import * as Toast from '../util/Toast' +import {useStores} from 'state/index' +import {sanitizeDisplayName} from 'lib/strings/display-names' +import {s} from 'lib/styles' +import {usePalette} from 'lib/hooks/usePalette' +import {isDesktopWeb, isAndroid} from 'platform/detection' + +export const snapPoints = ['fullscreen'] + +export const Component = observer( + ({ + subject, + displayName, + onUpdate, + }: { + subject: string + displayName: string + onUpdate?: () => void + }) => { + const store = useStores() + const pal = usePalette('default') + const palPrimary = usePalette('primary') + const palInverted = usePalette('inverted') + const [selected, setSelected] = React.useState([]) + + const listsList: ListsListModel = React.useMemo( + () => new ListsListModel(store, store.me.did), + [store], + ) + const memberships: ListMembershipModel = React.useMemo( + () => new ListMembershipModel(store, subject), + [store, subject], + ) + React.useEffect(() => { + listsList.refresh() + memberships.fetch().then( + () => { + setSelected(memberships.memberships.map(m => m.value.list)) + }, + err => { + store.log.error('Failed to fetch memberships', {err}) + }, + ) + }, [memberships, listsList, store, setSelected]) + + const onPressCancel = useCallback(() => { + store.shell.closeModal() + }, [store]) + + const onPressSave = useCallback(async () => { + try { + await memberships.updateTo(selected) + } catch (err) { + store.log.error('Failed to update memberships', {err}) + return + } + Toast.show('Lists updated') + onUpdate?.() + store.shell.closeModal() + }, [store, selected, memberships, onUpdate]) + + const onPressNewMuteList = useCallback(() => { + store.shell.openModal({ + name: 'create-or-edit-mute-list', + onSave: (_uri: string) => { + listsList.refresh() + }, + }) + }, [store, listsList]) + + const onToggleSelected = useCallback( + (uri: string) => { + if (selected.includes(uri)) { + setSelected(selected.filter(uri2 => uri2 !== uri)) + } else { + setSelected([...selected, uri]) + } + }, + [selected, setSelected], + ) + + const renderItem = useCallback( + (list: GraphDefs.ListView) => { + const isSelected = selected.includes(list.uri) + return ( + <Pressable + testID={`toggleBtn-${list.name}`} + style={[styles.listItem, pal.border]} + accessibilityLabel={`${isSelected ? 'Remove from' : 'Add to'} ${ + list.name + }`} + accessibilityHint="" + onPress={() => onToggleSelected(list.uri)}> + <View style={styles.listItemAvi}> + <UserAvatar size={40} avatar={list.avatar} /> + </View> + <View style={styles.listItemContent}> + <Text + type="lg" + style={[s.bold, pal.text]} + numberOfLines={1} + lineHeight={1.2}> + {sanitizeDisplayName(list.name)} + </Text> + <Text type="md" style={[pal.textLight]} numberOfLines={1}> + {list.purpose === 'app.bsky.graph.defs#modlist' && 'Mute list'}{' '} + by{' '} + {list.creator.did === store.me.did + ? 'you' + : `@${list.creator.handle}`} + </Text> + </View> + <View + style={ + isSelected + ? [styles.checkbox, palPrimary.border, palPrimary.view] + : [styles.checkbox, pal.borderDark] + }> + {isSelected && ( + <FontAwesomeIcon + icon="check" + style={palInverted.text as FontAwesomeIconStyle} + /> + )} + </View> + </Pressable> + ) + }, + [pal, palPrimary, palInverted, onToggleSelected, selected, store.me.did], + ) + + const renderEmptyState = React.useCallback(() => { + return ( + <EmptyStateWithButton + icon="users-slash" + message="You can subscribe to mute lists to automatically mute all of the users they include. Mute lists are public but your subscription to a mute list is private." + buttonLabel="New Mute List" + onPress={onPressNewMuteList} + /> + ) + }, [onPressNewMuteList]) + + return ( + <View testID="listAddRemoveUserModal" style={s.hContentRegion}> + <Text style={[styles.title, pal.text]}>Add {displayName} to lists</Text> + <ListsList + listsList={listsList} + showAddBtns + onPressCreateNew={onPressNewMuteList} + renderItem={renderItem} + renderEmptyState={renderEmptyState} + style={[styles.list, pal.border]} + /> + <View style={[styles.btns, pal.border]}> + <Button + testID="cancelBtn" + type="default" + onPress={onPressCancel} + style={styles.footerBtn} + accessibilityLabel="Cancel" + accessibilityHint="" + onAccessibilityEscape={onPressCancel} + label="Cancel" + /> + <Button + testID="saveBtn" + type="primary" + onPress={onPressSave} + style={styles.footerBtn} + accessibilityLabel="Save changes" + accessibilityHint="" + onAccessibilityEscape={onPressSave} + label="Save Changes" + /> + </View> + </View> + ) + }, +) + +const styles = StyleSheet.create({ + container: { + paddingHorizontal: isDesktopWeb ? 0 : 16, + }, + title: { + textAlign: 'center', + fontWeight: 'bold', + fontSize: 24, + marginBottom: 10, + }, + list: { + flex: 1, + borderTopWidth: 1, + }, + btns: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 10, + paddingTop: 10, + paddingBottom: isAndroid ? 10 : 0, + borderTopWidth: 1, + }, + footerBtn: { + paddingHorizontal: 24, + paddingVertical: 12, + }, + + listItem: { + flexDirection: 'row', + alignItems: 'center', + borderTopWidth: 1, + paddingHorizontal: 14, + paddingVertical: 10, + }, + listItemAvi: { + width: 54, + paddingLeft: 4, + paddingTop: 8, + paddingBottom: 10, + }, + listItemContent: { + flex: 1, + paddingRight: 10, + paddingTop: 10, + paddingBottom: 10, + }, + checkbox: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + borderWidth: 1, + width: 24, + height: 24, + borderRadius: 6, + marginRight: 8, + }, +}) diff --git a/src/view/com/modals/Modal.tsx b/src/view/com/modals/Modal.tsx index 18b7ae4c4..08ee74b02 100644 --- a/src/view/com/modals/Modal.tsx +++ b/src/view/com/modals/Modal.tsx @@ -12,6 +12,8 @@ import * as EditProfileModal from './EditProfile' import * as ServerInputModal from './ServerInput' import * as ReportPostModal from './ReportPost' import * as RepostModal from './Repost' +import * as CreateOrEditMuteListModal from './CreateOrEditMuteList' +import * as ListAddRemoveUserModal from './ListAddRemoveUser' import * as AltImageModal from './AltImage' import * as ReportAccountModal from './ReportAccount' import * as DeleteAccountModal from './DeleteAccount' @@ -66,6 +68,12 @@ export const ModalsContainer = observer(function ModalsContainer() { } else if (activeModal?.name === 'report-account') { snapPoints = ReportAccountModal.snapPoints element = <ReportAccountModal.Component {...activeModal} /> + } else if (activeModal?.name === 'create-or-edit-mute-list') { + snapPoints = CreateOrEditMuteListModal.snapPoints + element = <CreateOrEditMuteListModal.Component {...activeModal} /> + } else if (activeModal?.name === 'list-add-remove-user') { + snapPoints = ListAddRemoveUserModal.snapPoints + element = <ListAddRemoveUserModal.Component {...activeModal} /> } else if (activeModal?.name === 'delete-account') { snapPoints = DeleteAccountModal.snapPoints element = <DeleteAccountModal.Component /> diff --git a/src/view/com/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx index c9f2c4952..f2cf72a03 100644 --- a/src/view/com/modals/Modal.web.tsx +++ b/src/view/com/modals/Modal.web.tsx @@ -11,6 +11,8 @@ import * as EditProfileModal from './EditProfile' import * as ServerInputModal from './ServerInput' import * as ReportPostModal from './ReportPost' import * as ReportAccountModal from './ReportAccount' +import * as CreateOrEditMuteListModal from './CreateOrEditMuteList' +import * as ListAddRemoveUserModal from './ListAddRemoveUser' import * as DeleteAccountModal from './DeleteAccount' import * as RepostModal from './Repost' import * as CropImageModal from './crop-image/CropImage.web' @@ -69,6 +71,10 @@ function Modal({modal}: {modal: ModalIface}) { element = <ReportPostModal.Component {...modal} /> } else if (modal.name === 'report-account') { element = <ReportAccountModal.Component {...modal} /> + } else if (modal.name === 'create-or-edit-mute-list') { + element = <CreateOrEditMuteListModal.Component {...modal} /> + } else if (modal.name === 'list-add-remove-user') { + element = <ListAddRemoveUserModal.Component {...modal} /> } else if (modal.name === 'crop-image') { element = <CropImageModal.Component {...modal} /> } else if (modal.name === 'delete-account') { diff --git a/src/view/com/modals/ReportPost.tsx b/src/view/com/modals/ReportPost.tsx index 0695eed8e..ec75dc4c2 100644 --- a/src/view/com/modals/ReportPost.tsx +++ b/src/view/com/modals/ReportPost.tsx @@ -19,7 +19,7 @@ import {usePalette} from 'lib/hooks/usePalette' const DMCA_LINK = 'https://bsky.app/support/copyright' -export const snapPoints = [500] +export const snapPoints = [550] export function Component({ postUri, @@ -73,6 +73,19 @@ export function Component({ ), }, { + key: ComAtprotoModerationDefs.REASONRUDE, + label: ( + <View> + <Text style={pal.text} type="md-bold"> + Anti-Social Behavior + </Text> + <Text style={pal.textLight}> + Harassment, trolling, or intolerance + </Text> + </View> + ), + }, + { key: ComAtprotoModerationDefs.REASONVIOLATION, label: ( <View> diff --git a/src/view/com/pager/TabBar.tsx b/src/view/com/pager/TabBar.tsx index 6ef9d74e3..9294b6026 100644 --- a/src/view/com/pager/TabBar.tsx +++ b/src/view/com/pager/TabBar.tsx @@ -77,7 +77,7 @@ export function TabBar({ ], ) - const onLayout = () => { + const onLayout = React.useCallback(() => { const promises = [] for (let i = 0; i < items.length; i++) { promises.push( @@ -98,14 +98,17 @@ export function TabBar({ Promise.all(promises).then((layouts: Layout[]) => { setItemLayouts(layouts) }) - } + }, [containerRef, itemRefs, setItemLayouts, items.length]) - const onPressItem = (index: number) => { - onSelect?.(index) - if (index === selectedPage) { - onPressSelected?.() - } - } + const onPressItem = React.useCallback( + (index: number) => { + onSelect?.(index) + if (index === selectedPage) { + onPressSelected?.() + } + }, + [onSelect, onPressSelected, selectedPage], + ) return ( <View diff --git a/src/view/com/post-thread/PostThread.tsx b/src/view/com/post-thread/PostThread.tsx index b3da0b01b..610b96507 100644 --- a/src/view/com/post-thread/PostThread.tsx +++ b/src/view/com/post-thread/PostThread.tsx @@ -24,8 +24,10 @@ import {Text} from '../util/text/Text' import {s} from 'lib/styles' import {isDesktopWeb, isMobileWeb} from 'platform/detection' import {usePalette} from 'lib/hooks/usePalette' +import {useSetTitle} from 'lib/hooks/useSetTitle' import {useNavigation} from '@react-navigation/native' import {NavigationProp} from 'lib/routes/types' +import {sanitizeDisplayName} from 'lib/strings/display-names' const REPLY_PROMPT = {_reactKey: '__reply__', _isHighlightedPost: false} const DELETED = {_reactKey: '__deleted__', _isHighlightedPost: false} @@ -59,6 +61,13 @@ export const PostThread = observer(function PostThread({ } return [] }, [view.thread]) + useSetTitle( + view.thread?.postRecord && + `${sanitizeDisplayName( + view.thread.post.author.displayName || + `@${view.thread.post.author.handle}`, + )}: "${view.thread?.postRecord?.text}"`, + ) // events // = diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx index 563a3ead6..084e30a25 100644 --- a/src/view/com/post-thread/PostThreadItem.tsx +++ b/src/view/com/post-thread/PostThreadItem.tsx @@ -21,7 +21,7 @@ import {pluralize} from 'lib/strings/helpers' import {useStores} from 'state/index' import {PostMeta} from '../util/PostMeta' import {PostEmbeds} from '../util/post-embeds' -import {PostCtrls} from '../util/PostCtrls' +import {PostCtrls} from '../util/post-ctrls/PostCtrls' import {PostHider} from '../util/moderation/PostHider' import {ContentHider} from '../util/moderation/ContentHider' import {ImageHider} from '../util/moderation/ImageHider' diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx index 0b49995fe..614c5ea77 100644 --- a/src/view/com/post/Post.tsx +++ b/src/view/com/post/Post.tsx @@ -20,7 +20,7 @@ import {Link} from '../util/Link' import {UserInfoText} from '../util/UserInfoText' import {PostMeta} from '../util/PostMeta' import {PostEmbeds} from '../util/post-embeds' -import {PostCtrls} from '../util/PostCtrls' +import {PostCtrls} from '../util/post-ctrls/PostCtrls' import {PostHider} from '../util/moderation/PostHider' import {ContentHider} from '../util/moderation/ContentHider' import {ImageHider} from '../util/moderation/ImageHider' diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx index b4708cf53..fa6131d61 100644 --- a/src/view/com/posts/FeedItem.tsx +++ b/src/view/com/posts/FeedItem.tsx @@ -8,11 +8,12 @@ import { FontAwesomeIconStyle, } from '@fortawesome/react-native-fontawesome' import {PostsFeedItemModel} from 'state/models/feeds/posts' +import {ModerationBehaviorCode} from 'lib/labeling/types' import {Link, DesktopWebTextLink} from '../util/Link' import {Text} from '../util/text/Text' import {UserInfoText} from '../util/UserInfoText' import {PostMeta} from '../util/PostMeta' -import {PostCtrls} from '../util/PostCtrls' +import {PostCtrls} from '../util/post-ctrls/PostCtrls' import {PostEmbeds} from '../util/post-embeds' import {PostHider} from '../util/moderation/PostHider' import {ContentHider} from '../util/moderation/ContentHider' @@ -31,13 +32,14 @@ export const FeedItem = observer(function ({ isThreadChild, isThreadParent, showFollowBtn, + ignoreMuteFor, }: { item: PostsFeedItemModel isThreadChild?: boolean isThreadParent?: boolean showReplyLine?: boolean showFollowBtn?: boolean - ignoreMuteFor?: string // NOTE currently disabled, will be addressed in the next PR -prf + ignoreMuteFor?: string }) { const store = useStores() const pal = usePalette('default') @@ -142,12 +144,22 @@ export const FeedItem = observer(function ({ isThreadParent ? styles.outerNoBottom : undefined, ] + // moderation override + let moderation = item.moderation.list + if ( + ignoreMuteFor === item.post.author.did && + moderation.isMute && + !moderation.noOverride + ) { + moderation = {behavior: ModerationBehaviorCode.Show} + } + return ( <PostHider testID={`feedItem-by-${item.post.author.handle}`} style={outerStyles} href={itemHref} - moderation={item.moderation.list}> + moderation={moderation}> {isThreadChild && ( <View style={[styles.topReplyLine, {borderColor: pal.colors.replyLine}]} @@ -237,7 +249,7 @@ export const FeedItem = observer(function ({ </View> )} <ContentHider - moderation={item.moderation.list} + moderation={moderation} containerStyle={styles.contentHider}> {item.richText?.text ? ( <View style={styles.postTextContainer}> diff --git a/src/view/com/posts/FeedSlice.tsx b/src/view/com/posts/FeedSlice.tsx index 5a191ac10..824fd0c4b 100644 --- a/src/view/com/posts/FeedSlice.tsx +++ b/src/view/com/posts/FeedSlice.tsx @@ -19,7 +19,9 @@ export function FeedSlice({ ignoreMuteFor?: string }) { if (slice.moderation.list.behavior === ModerationBehaviorCode.Hide) { - return null + if (!ignoreMuteFor && !slice.moderation.list.noOverride) { + return null + } } if (slice.isThread && slice.items.length > 3) { const last = slice.items.length - 1 diff --git a/src/view/com/profile/ProfileCard.tsx b/src/view/com/profile/ProfileCard.tsx index 12d631833..42c4edef5 100644 --- a/src/view/com/profile/ProfileCard.tsx +++ b/src/view/com/profile/ProfileCard.tsx @@ -32,7 +32,7 @@ export const ProfileCard = observer( noBorder?: boolean followers?: AppBskyActorDefs.ProfileView[] | undefined overrideModeration?: boolean - renderButton?: () => JSX.Element + renderButton?: (profile: AppBskyActorDefs.ProfileViewBasic) => JSX.Element }) => { const store = useStores() const pal = usePalette('default') @@ -92,7 +92,7 @@ export const ProfileCard = observer( )} </View> {renderButton ? ( - <View style={styles.layoutButton}>{renderButton()}</View> + <View style={styles.layoutButton}>{renderButton(profile)}</View> ) : undefined} </View> {profile.description ? ( diff --git a/src/view/com/profile/ProfileHeader.tsx b/src/view/com/profile/ProfileHeader.tsx index dee788aff..3b9ca67c9 100644 --- a/src/view/com/profile/ProfileHeader.tsx +++ b/src/view/com/profile/ProfileHeader.tsx @@ -23,6 +23,7 @@ import {DropdownButton, DropdownItem} from '../util/forms/DropdownButton' import * as Toast from '../util/Toast' import {LoadingPlaceholder} from '../util/LoadingPlaceholder' import {Text} from '../util/text/Text' +import {TextLink} from '../util/Link' import {RichText} from '../util/text/RichText' import {UserAvatar} from '../util/UserAvatar' import {UserBanner} from '../util/UserBanner' @@ -30,6 +31,7 @@ import {ProfileHeaderWarnings} from '../util/moderation/ProfileHeaderWarnings' import {usePalette} from 'lib/hooks/usePalette' import {useAnalytics} from 'lib/analytics' import {NavigationProp} from 'lib/routes/types' +import {listUriToHref} from 'lib/strings/url-helpers' import {isDesktopWeb, isNative} from 'platform/detection' import {FollowState} from 'state/models/cache/my-follows' import {shareUrl} from 'lib/sharing' @@ -146,12 +148,21 @@ const ProfileHeaderLoaded = observer( navigation.push('ProfileFollows', {name: view.handle}) }, [track, navigation, view]) - const onPressShare = React.useCallback(async () => { + const onPressShare = React.useCallback(() => { track('ProfileHeader:ShareButtonClicked') const url = toShareUrl(`/profile/${view.handle}`) shareUrl(url) }, [track, view]) + const onPressAddRemoveLists = React.useCallback(() => { + track('ProfileHeader:AddToListsButtonClicked') + store.shell.openModal({ + name: 'list-add-remove-user', + subject: view.did, + displayName: view.displayName || view.handle, + }) + }, [track, view, store]) + const onPressMuteAccount = React.useCallback(async () => { track('ProfileHeader:MuteAccountButtonClicked') try { @@ -233,6 +244,11 @@ const ProfileHeaderLoaded = observer( label: 'Share', onPress: onPressShare, }, + { + testID: 'profileHeaderDropdownListAddRemoveBtn', + label: 'Add to Lists', + onPress: onPressAddRemoveLists, + }, ] if (!isMe) { items.push({sep: true}) @@ -269,6 +285,7 @@ const ProfileHeaderLoaded = observer( onPressUnblockAccount, onPressBlockAccount, onPressReportAccount, + onPressAddRemoveLists, ]) const blockHide = !isMe && (view.viewer.blocking || view.viewer.blockedBy) @@ -422,31 +439,42 @@ const ProfileHeaderLoaded = observer( {view.viewer.blocking ? ( <View testID="profileHeaderBlockedNotice" - style={[styles.moderationNotice, pal.view, pal.border]}> - <FontAwesomeIcon icon="ban" style={[pal.text, s.mr5]} /> - <Text type="md" style={[s.mr2, pal.text]}> + style={[styles.moderationNotice, pal.viewLight]}> + <FontAwesomeIcon icon="ban" style={[pal.text]} /> + <Text type="lg-medium" style={pal.text}> Account blocked </Text> </View> ) : view.viewer.muted ? ( <View testID="profileHeaderMutedNotice" - style={[styles.moderationNotice, pal.view, pal.border]}> + style={[styles.moderationNotice, pal.viewLight]}> <FontAwesomeIcon icon={['far', 'eye-slash']} - style={[pal.text, s.mr5]} + style={[pal.text]} /> - <Text type="md" style={[s.mr2, pal.text]}> - Account muted + <Text type="lg-medium" style={pal.text}> + Account muted{' '} + {view.viewer.mutedByList && ( + <Text type="lg-medium" style={pal.text}> + by{' '} + <TextLink + type="lg-medium" + style={pal.link} + href={listUriToHref(view.viewer.mutedByList.uri)} + text={view.viewer.mutedByList.name} + /> + </Text> + )} </Text> </View> ) : undefined} {view.viewer.blockedBy && ( <View testID="profileHeaderBlockedNotice" - style={[styles.moderationNotice, pal.view, pal.border]}> - <FontAwesomeIcon icon="ban" style={[pal.text, s.mr5]} /> - <Text type="md" style={[s.mr2, pal.text]}> + style={[styles.moderationNotice, pal.viewLight]}> + <FontAwesomeIcon icon="ban" style={[pal.text]} /> + <Text type="lg-medium" style={pal.text}> This account has blocked you </Text> </View> @@ -595,10 +623,10 @@ const styles = StyleSheet.create({ moderationNotice: { flexDirection: 'row', alignItems: 'center', - borderWidth: 1, borderRadius: 8, - paddingHorizontal: 12, - paddingVertical: 10, + paddingHorizontal: 16, + paddingVertical: 14, + gap: 8, }, br40: {borderRadius: 40}, diff --git a/src/view/com/util/BlurView.web.tsx b/src/view/com/util/BlurView.web.tsx index efcf40b9c..5267e6ade 100644 --- a/src/view/com/util/BlurView.web.tsx +++ b/src/view/com/util/BlurView.web.tsx @@ -14,7 +14,8 @@ export const BlurView = ({ ...props }: React.PropsWithChildren<BlurViewProps>) => { // @ts-ignore using an RNW-specific attribute here -prf - style = addStyle(style, {backdropFilter: `blur(${blurAmount || 10}px`}) + let blur = `blur(${blurAmount || 10}px` + style = addStyle(style, {backdropFilter: blur, WebkitBackdropFilter: blur}) if (blurType === 'dark') { style = addStyle(style, styles.dark) } else { diff --git a/src/view/com/util/EmptyState.tsx b/src/view/com/util/EmptyState.tsx index 2b2c4e657..a495fcd3f 100644 --- a/src/view/com/util/EmptyState.tsx +++ b/src/view/com/util/EmptyState.tsx @@ -10,17 +10,19 @@ import {UserGroupIcon} from 'lib/icons' import {usePalette} from 'lib/hooks/usePalette' export function EmptyState({ + testID, icon, message, style, }: { + testID?: string icon: IconProp | 'user-group' message: string style?: StyleProp<ViewStyle> }) { const pal = usePalette('default') return ( - <View style={[styles.container, style]}> + <View testID={testID} style={[styles.container, style]}> <View style={styles.iconContainer}> {icon === 'user-group' ? ( <UserGroupIcon size="64" style={styles.icon} /> diff --git a/src/view/com/util/EmptyStateWithButton.tsx b/src/view/com/util/EmptyStateWithButton.tsx new file mode 100644 index 000000000..008ca2bdb --- /dev/null +++ b/src/view/com/util/EmptyStateWithButton.tsx @@ -0,0 +1,88 @@ +import React from 'react' +import {StyleSheet, View} from 'react-native' +import { + FontAwesomeIcon, + FontAwesomeIconStyle, +} from '@fortawesome/react-native-fontawesome' +import {IconProp} from '@fortawesome/fontawesome-svg-core' +import {Text} from './text/Text' +import {Button} from './forms/Button' +import {usePalette} from 'lib/hooks/usePalette' +import {s} from 'lib/styles' + +interface Props { + testID?: string + icon: IconProp + message: string + buttonLabel: string + onPress: () => void +} + +export function EmptyStateWithButton(props: Props) { + const pal = usePalette('default') + const palInverted = usePalette('inverted') + + return ( + <View testID={props.testID} style={styles.container}> + <View style={styles.iconContainer}> + <FontAwesomeIcon + icon={props.icon} + style={[styles.icon, pal.text]} + size={62} + /> + </View> + <Text type="xl-medium" style={[s.textCenter, pal.text]}> + {props.message} + </Text> + <View style={styles.btns}> + <Button + testID={props.testID ? `${props.testID}-button` : undefined} + type="inverted" + style={styles.btn} + onPress={props.onPress}> + <FontAwesomeIcon + icon="plus" + style={palInverted.text as FontAwesomeIconStyle} + size={14} + /> + <Text type="lg-medium" style={palInverted.text}> + {props.buttonLabel} + </Text> + </Button> + </View> + </View> + ) +} +const styles = StyleSheet.create({ + container: { + height: '100%', + paddingVertical: 40, + paddingHorizontal: 30, + }, + iconContainer: { + marginBottom: 16, + }, + icon: { + marginLeft: 'auto', + marginRight: 'auto', + }, + btns: { + flexDirection: 'row', + justifyContent: 'center', + }, + btn: { + gap: 10, + marginVertical: 20, + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 14, + paddingHorizontal: 24, + borderRadius: 30, + }, + notice: { + borderRadius: 12, + paddingHorizontal: 12, + paddingVertical: 10, + marginHorizontal: 30, + }, +}) diff --git a/src/view/com/util/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx index a2e607e47..f3679326f 100644 --- a/src/view/com/util/UserAvatar.tsx +++ b/src/view/com/util/UserAvatar.tsx @@ -66,6 +66,7 @@ export function UserAvatar({ if (!(await requestCameraAccessIfNeeded())) { return } + onSelectNewAvatar?.( await openCamera(store, { width: 1000, @@ -83,20 +84,21 @@ export function UserAvatar({ if (!(await requestPhotoAccessIfNeeded())) { return } + const items = await openPicker(store, { + aspect: [1, 1], + }) + const item = items[0] + + const croppedImage = await openCropper(store, { mediaType: 'photo', - multiple: false, + cropperCircleOverlay: true, + height: item.height, + width: item.width, + path: item.path, }) - onSelectNewAvatar?.( - await openCropper(store, { - mediaType: 'photo', - path: items[0].path, - width: 1000, - height: 1000, - cropperCircleOverlay: true, - }), - ) + onSelectNewAvatar?.(croppedImage) }, }, { diff --git a/src/view/com/util/UserBanner.tsx b/src/view/com/util/UserBanner.tsx index 51cfbccbb..6e08be505 100644 --- a/src/view/com/util/UserBanner.tsx +++ b/src/view/com/util/UserBanner.tsx @@ -55,10 +55,8 @@ export function UserBanner({ if (!(await requestPhotoAccessIfNeeded())) { return } - const items = await openPicker(store, { - mediaType: 'photo', - multiple: false, - }) + const items = await openPicker(store) + onSelectNewBanner?.( await openCropper(store, { mediaType: 'photo', diff --git a/src/view/com/util/ViewHeader.tsx b/src/view/com/util/ViewHeader.tsx index 7f5b5b7c2..97802394e 100644 --- a/src/view/com/util/ViewHeader.tsx +++ b/src/view/com/util/ViewHeader.tsx @@ -20,11 +20,13 @@ export const ViewHeader = observer(function ({ canGoBack, hideOnScroll, showOnDesktop, + renderButton, }: { title: string canGoBack?: boolean hideOnScroll?: boolean showOnDesktop?: boolean + renderButton?: () => JSX.Element }) { const pal = usePalette('default') const store = useStores() @@ -46,7 +48,7 @@ export const ViewHeader = observer(function ({ if (isDesktopWeb) { if (showOnDesktop) { - return <DesktopWebHeader title={title} /> + return <DesktopWebHeader title={title} renderButton={renderButton} /> } return null } else { @@ -79,13 +81,23 @@ export const ViewHeader = observer(function ({ {title} </Text> </View> - <View style={canGoBack ? styles.backBtn : styles.backBtnWide} /> + {renderButton ? ( + renderButton() + ) : ( + <View style={canGoBack ? styles.backBtn : styles.backBtnWide} /> + )} </Container> ) } }) -function DesktopWebHeader({title}: {title: string}) { +function DesktopWebHeader({ + title, + renderButton, +}: { + title: string + renderButton?: () => JSX.Element +}) { const pal = usePalette('default') return ( <CenteredView style={[styles.header, styles.desktopHeader, pal.border]}> @@ -94,6 +106,7 @@ function DesktopWebHeader({title}: {title: string}) { {title} </Text> </View> + {renderButton?.()} </CenteredView> ) } diff --git a/src/view/com/util/Views.web.tsx b/src/view/com/util/Views.web.tsx index 804192a37..9d6501d54 100644 --- a/src/view/com/util/Views.web.tsx +++ b/src/view/com/util/Views.web.tsx @@ -22,7 +22,7 @@ import { View, ViewProps, } from 'react-native' -import {addStyle, colors} from 'lib/styles' +import {addStyle} from 'lib/styles' import {usePalette} from 'lib/hooks/usePalette' interface AddedProps { @@ -124,12 +124,6 @@ const styles = StyleSheet.create({ marginLeft: 'auto', marginRight: 'auto', }, - containerLight: { - backgroundColor: colors.gray1, - }, - containerDark: { - backgroundColor: colors.gray7, - }, fixedHeight: { height: '100vh', }, diff --git a/src/view/com/util/forms/Button.tsx b/src/view/com/util/forms/Button.tsx index 1c9b1cf51..6a5f19f99 100644 --- a/src/view/com/util/forms/Button.tsx +++ b/src/view/com/util/forms/Button.tsx @@ -38,6 +38,7 @@ export function Button({ accessibilityLabel, accessibilityHint, accessibilityLabelledBy, + onAccessibilityEscape, }: React.PropsWithChildren<{ type?: ButtonType label?: string @@ -48,6 +49,7 @@ export function Button({ accessibilityLabel?: string accessibilityHint?: string accessibilityLabelledBy?: string + onAccessibilityEscape?: () => void }>) { const theme = useTheme() const typeOuterStyle = choose<ViewStyle, Record<ButtonType, ViewStyle>>( @@ -126,6 +128,7 @@ export function Button({ }, }, ) + const onPressWrapped = React.useCallback( (event: Event) => { event.stopPropagation() @@ -134,15 +137,30 @@ export function Button({ }, [onPress], ) + + const getStyle = React.useCallback( + state => { + const arr = [typeOuterStyle, styles.outer, style] + if (state.pressed) { + arr.push({opacity: 0.6}) + } else if (state.hovered) { + arr.push({opacity: 0.8}) + } + return arr + }, + [typeOuterStyle, style], + ) + return ( <Pressable - style={[typeOuterStyle, styles.outer, style]} + style={getStyle} onPress={onPressWrapped} testID={testID} accessibilityRole="button" accessibilityLabel={accessibilityLabel} accessibilityHint={accessibilityHint} - accessibilityLabelledBy={accessibilityLabelledBy}> + accessibilityLabelledBy={accessibilityLabelledBy} + onAccessibilityEscape={onAccessibilityEscape}> {label ? ( <Text type="button" style={[typeLabelStyle, labelStyle]}> {label} diff --git a/src/view/com/util/forms/DropdownButton.tsx b/src/view/com/util/forms/DropdownButton.tsx index 04346d91f..36ef1f409 100644 --- a/src/view/com/util/forms/DropdownButton.tsx +++ b/src/view/com/util/forms/DropdownButton.tsx @@ -209,7 +209,7 @@ export function PostDropdownBtn({ }, }, {sep: true}, - { + !isAuthor && { testID: 'postDropdownReportBtn', icon: 'circle-exclamation', label: 'Report post', @@ -339,7 +339,9 @@ const DropdownItems = ({ color={pal.text.color as string} /> )} - <Text style={[styles.label, pal.text]}>{item.label}</Text> + <Text style={[styles.label, pal.text]} numberOfLines={1}> + {item.label} + </Text> </TouchableOpacity> ) } else if (isSep(item)) { diff --git a/src/view/com/util/images/Gallery.tsx b/src/view/com/util/images/Gallery.tsx index 1a29b4530..723db289c 100644 --- a/src/view/com/util/images/Gallery.tsx +++ b/src/view/com/util/images/Gallery.tsx @@ -63,6 +63,5 @@ const styles = StyleSheet.create({ position: 'absolute', left: 6, bottom: 6, - width: 46, }, }) diff --git a/src/view/com/util/PostCtrls.tsx b/src/view/com/util/post-ctrls/PostCtrls.tsx index 3be6b59f1..9980e9de0 100644 --- a/src/view/com/util/PostCtrls.tsx +++ b/src/view/com/util/post-ctrls/PostCtrls.tsx @@ -1,4 +1,4 @@ -import React from 'react' +import React, {useCallback} from 'react' import { StyleProp, StyleSheet, @@ -18,18 +18,14 @@ import ReactNativeHapticFeedback, { // TriggerableAnimated, // TriggerableAnimatedRef, // } from './anim/TriggerableAnimated' -import {Text} from './text/Text' -import {PostDropdownBtn} from './forms/DropdownButton' -import { - HeartIcon, - HeartIconSolid, - RepostIcon, - CommentBottomArrow, -} from 'lib/icons' +import {Text} from '../text/Text' +import {PostDropdownBtn} from '../forms/DropdownButton' +import {HeartIcon, HeartIconSolid, CommentBottomArrow} from 'lib/icons' import {s, colors} from 'lib/styles' import {useTheme} from 'lib/ThemeContext' import {useStores} from 'state/index' -import {isIOS} from 'platform/detection' +import {isIOS, isNative} from 'platform/detection' +import {RepostButton} from './RepostButton' interface PostCtrlsOpts { itemUri: string @@ -112,10 +108,12 @@ export function PostCtrls(opts: PostCtrlsOpts) { // DISABLED see #135 // const repostRef = React.useRef<TriggerableAnimatedRef | null>(null) // const likeRef = React.useRef<TriggerableAnimatedRef | null>(null) - const onRepost = () => { + const onRepost = useCallback(() => { store.shell.closeModal() if (!opts.isReposted) { - ReactNativeHapticFeedback.trigger(hapticImpact) + if (isNative) { + ReactNativeHapticFeedback.trigger(hapticImpact) + } opts.onPressToggleRepost().catch(_e => undefined) // DISABLED see #135 // repostRef.current?.trigger( @@ -128,9 +126,9 @@ export function PostCtrls(opts: PostCtrlsOpts) { } else { opts.onPressToggleRepost().catch(_e => undefined) } - } + }, [opts, store.shell]) - const onQuote = () => { + const onQuote = useCallback(() => { store.shell.closeModal() store.shell.openComposer({ quote: { @@ -141,17 +139,18 @@ export function PostCtrls(opts: PostCtrlsOpts) { indexedAt: opts.indexedAt, }, }) - ReactNativeHapticFeedback.trigger(hapticImpact) - } - const onPressToggleRepostWrapper = () => { - store.shell.openModal({ - name: 'repost', - onRepost: onRepost, - onQuote: onQuote, - isReposted: opts.isReposted, - }) - } + if (isNative) { + ReactNativeHapticFeedback.trigger(hapticImpact) + } + }, [ + opts.author, + opts.indexedAt, + opts.itemCid, + opts.itemUri, + opts.text, + store.shell, + ]) const onPressToggleLikeWrapper = async () => { if (!opts.isLiked) { @@ -181,7 +180,7 @@ export function PostCtrls(opts: PostCtrlsOpts) { onPress={opts.onPressReply} accessibilityRole="button" accessibilityLabel="Reply" - accessibilityHint="Opens reply composer"> + accessibilityHint="reply composer"> <CommentBottomArrow style={[defaultCtrlColor, opts.big ? s.mt2 : styles.mt1]} strokeWidth={3} @@ -193,39 +192,7 @@ export function PostCtrls(opts: PostCtrlsOpts) { </Text> ) : undefined} </TouchableOpacity> - <TouchableOpacity - testID="repostBtn" - hitSlop={HITSLOP} - onPress={onPressToggleRepostWrapper} - style={styles.ctrl} - accessibilityRole="button" - accessibilityLabel={opts.isReposted ? 'Undo repost' : 'Repost'} - accessibilityHint={ - opts.isReposted - ? `Remove your repost of ${opts.author}'s post` - : `Repost or quote post ${opts.author}'s post` - }> - <RepostIcon - style={ - opts.isReposted - ? (styles.ctrlIconReposted as StyleProp<ViewStyle>) - : defaultCtrlColor - } - strokeWidth={2.4} - size={opts.big ? 24 : 20} - /> - {typeof opts.repostCount !== 'undefined' ? ( - <Text - testID="repostCount" - style={ - opts.isReposted - ? [s.bold, s.green3, s.f15, s.ml5] - : [defaultCtrlColor, s.f15, s.ml5] - }> - {opts.repostCount} - </Text> - ) : undefined} - </TouchableOpacity> + <RepostButton {...opts} onRepost={onRepost} onQuote={onQuote} /> <TouchableOpacity testID="likeBtn" style={styles.ctrl} @@ -234,9 +201,7 @@ export function PostCtrls(opts: PostCtrlsOpts) { accessibilityRole="button" accessibilityLabel={opts.isLiked ? 'Unlike' : 'Like'} accessibilityHint={ - opts.isReposted - ? `Removes like from ${opts.author}'s post` - : `Like ${opts.author}'s post` + opts.isReposted ? `Removes like from the post` : `Like the post` }> {opts.isLiked ? ( <HeartIconSolid @@ -309,9 +274,6 @@ const styles = StyleSheet.create({ padding: 5, margin: -5, }, - ctrlIconReposted: { - color: colors.green3, - }, ctrlIconLiked: { color: colors.red3, }, diff --git a/src/view/com/util/post-ctrls/RepostButton.tsx b/src/view/com/util/post-ctrls/RepostButton.tsx new file mode 100644 index 000000000..e6de4cb19 --- /dev/null +++ b/src/view/com/util/post-ctrls/RepostButton.tsx @@ -0,0 +1,95 @@ +import React, {useCallback} from 'react' +import {StyleProp, StyleSheet, TouchableOpacity, ViewStyle} from 'react-native' +import {RepostIcon} from 'lib/icons' +import {s, colors} from 'lib/styles' +import {useTheme} from 'lib/ThemeContext' +import {Text} from '../text/Text' +import {useStores} from 'state/index' + +const HITSLOP = {top: 5, left: 5, bottom: 5, right: 5} + +interface Props { + isReposted: boolean + repostCount?: number + big?: boolean + onRepost: () => void + onQuote: () => void +} + +export const RepostButton = ({ + isReposted, + repostCount, + big, + onRepost, + onQuote, +}: Props) => { + const store = useStores() + const theme = useTheme() + + const defaultControlColor = React.useMemo( + () => ({ + color: theme.palette.default.postCtrl, + }), + [theme], + ) + + const onPressToggleRepostWrapper = useCallback(() => { + store.shell.openModal({ + name: 'repost', + onRepost: onRepost, + onQuote: onQuote, + isReposted, + }) + }, [onRepost, onQuote, isReposted, store.shell]) + + return ( + <TouchableOpacity + testID="repostBtn" + hitSlop={HITSLOP} + onPress={onPressToggleRepostWrapper} + style={styles.control} + accessibilityRole="button" + accessibilityLabel={isReposted ? 'Undo repost' : 'Repost'} + accessibilityHint={ + isReposted + ? `Remove your repost of the post` + : `Repost or quote post the post` + }> + <RepostIcon + style={ + isReposted + ? (styles.reposted as StyleProp<ViewStyle>) + : defaultControlColor + } + strokeWidth={2.4} + size={big ? 24 : 20} + /> + {typeof repostCount !== 'undefined' ? ( + <Text + testID="repostCount" + style={ + isReposted + ? [s.bold, s.green3, s.f15, s.ml5] + : [defaultControlColor, s.f15, s.ml5] + }> + {repostCount} + </Text> + ) : undefined} + </TouchableOpacity> + ) +} + +const styles = StyleSheet.create({ + control: { + flexDirection: 'row', + alignItems: 'center', + padding: 5, + margin: -5, + }, + reposted: { + color: colors.green3, + }, + repostCount: { + color: 'currentColor', + }, +}) diff --git a/src/view/com/util/post-ctrls/RepostButton.web.tsx b/src/view/com/util/post-ctrls/RepostButton.web.tsx new file mode 100644 index 000000000..66cc0d123 --- /dev/null +++ b/src/view/com/util/post-ctrls/RepostButton.web.tsx @@ -0,0 +1,86 @@ +import React, {useMemo} from 'react' +import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native' +import {RepostIcon} from 'lib/icons' +import {DropdownButton} from '../forms/DropdownButton' +import {colors} from 'lib/styles' +import {useTheme} from 'lib/ThemeContext' +import {Text} from '../text/Text' + +interface Props { + isReposted: boolean + repostCount?: number + big?: boolean + onRepost: () => void + onQuote: () => void +} + +export const RepostButton = ({ + isReposted, + repostCount, + big, + onRepost, + onQuote, +}: Props) => { + const theme = useTheme() + + const defaultControlColor = React.useMemo( + () => ({ + color: theme.palette.default.postCtrl, + }), + [theme], + ) + + const items = useMemo( + () => [ + { + label: isReposted ? 'Undo repost' : 'Repost', + icon: 'retweet' as const, + onPress: onRepost, + }, + {label: 'Quote post', icon: 'quote-left' as const, onPress: onQuote}, + ], + [isReposted, onRepost, onQuote], + ) + + return ( + <DropdownButton + type="bare" + items={items} + bottomOffset={4} + openToRight + rightOffset={-40}> + <View + style={[ + styles.control, + (isReposted + ? styles.reposted + : defaultControlColor) as StyleProp<ViewStyle>, + ]}> + <RepostIcon strokeWidth={2.4} size={big ? 24 : 20} /> + {typeof repostCount !== 'undefined' ? ( + <Text + testID="repostCount" + type={isReposted ? 'md-bold' : 'md-medium'} + style={styles.repostCount}> + {repostCount ?? 0} + </Text> + ) : undefined} + </View> + </DropdownButton> + ) +} + +const styles = StyleSheet.create({ + control: { + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + gap: 4, + }, + reposted: { + color: colors.green3, + }, + repostCount: { + color: 'currentColor', + }, +}) diff --git a/src/view/com/util/post-embeds/index.tsx b/src/view/com/util/post-embeds/index.tsx index 621bd7c0f..328b9305b 100644 --- a/src/view/com/util/post-embeds/index.tsx +++ b/src/view/com/util/post-embeds/index.tsx @@ -210,6 +210,5 @@ const styles = StyleSheet.create({ position: 'absolute', left: 6, bottom: 6, - width: 46, }, }) diff --git a/src/view/index.ts b/src/view/index.ts index 9c8d023dd..84fc3f315 100644 --- a/src/view/index.ts +++ b/src/view/index.ts @@ -39,6 +39,8 @@ import {faEye} from '@fortawesome/free-solid-svg-icons/faEye' import {faEyeSlash as farEyeSlash} from '@fortawesome/free-regular-svg-icons/faEyeSlash' import {faGear} from '@fortawesome/free-solid-svg-icons/faGear' import {faGlobe} from '@fortawesome/free-solid-svg-icons/faGlobe' +import {faHand} from '@fortawesome/free-solid-svg-icons/faHand' +import {faHand as farHand} from '@fortawesome/free-regular-svg-icons/faHand' import {faHeart} from '@fortawesome/free-regular-svg-icons/faHeart' import {faHeart as fasHeart} from '@fortawesome/free-solid-svg-icons/faHeart' import {faHouse} from '@fortawesome/free-solid-svg-icons/faHouse' @@ -47,6 +49,7 @@ import {faImage} from '@fortawesome/free-solid-svg-icons/faImage' import {faInfo} from '@fortawesome/free-solid-svg-icons/faInfo' import {faLanguage} from '@fortawesome/free-solid-svg-icons/faLanguage' import {faLink} from '@fortawesome/free-solid-svg-icons/faLink' +import {faListUl} from '@fortawesome/free-solid-svg-icons/faListUl' import {faLock} from '@fortawesome/free-solid-svg-icons/faLock' import {faMagnifyingGlass} from '@fortawesome/free-solid-svg-icons/faMagnifyingGlass' import {faMessage} from '@fortawesome/free-regular-svg-icons/faMessage' @@ -68,8 +71,10 @@ import {faRss} from '@fortawesome/free-solid-svg-icons/faRss' import {faUser} from '@fortawesome/free-regular-svg-icons/faUser' import {faUsers} from '@fortawesome/free-solid-svg-icons/faUsers' import {faUserCheck} from '@fortawesome/free-solid-svg-icons/faUserCheck' +import {faUserSlash} from '@fortawesome/free-solid-svg-icons/faUserSlash' import {faUserPlus} from '@fortawesome/free-solid-svg-icons/faUserPlus' import {faUserXmark} from '@fortawesome/free-solid-svg-icons/faUserXmark' +import {faUsersSlash} from '@fortawesome/free-solid-svg-icons/faUsersSlash' import {faTicket} from '@fortawesome/free-solid-svg-icons/faTicket' import {faTrashCan} from '@fortawesome/free-regular-svg-icons/faTrashCan' import {faX} from '@fortawesome/free-solid-svg-icons/faX' @@ -119,6 +124,8 @@ export function setup() { farEyeSlash, faGear, faGlobe, + faHand, + farHand, faHeart, fasHeart, faHouse, @@ -127,6 +134,7 @@ export function setup() { faInfo, faLanguage, faLink, + faListUl, faLock, faMagnifyingGlass, faMessage, @@ -148,8 +156,10 @@ export function setup() { faUser, faUsers, faUserCheck, + faUserSlash, faUserPlus, faUserXmark, + faUsersSlash, faTicket, faTrashCan, faThumbtack, diff --git a/src/view/screens/AppPasswords.tsx b/src/view/screens/AppPasswords.tsx index cb1896b4a..3adbf09df 100644 --- a/src/view/screens/AppPasswords.tsx +++ b/src/view/screens/AppPasswords.tsx @@ -140,8 +140,8 @@ function AppPasswordsHeader() { pal.text, isDesktopWeb && styles.descriptionDesktop, ]}> - These passwords can be used to log onto Bluesky in other apps without - giving them full access to your account or your password. + Use app passwords to login to other Bluesky clients without giving full + access to your account or password. </Text> </> ) @@ -289,5 +289,6 @@ const styles = StyleSheet.create({ trashIcon: { color: 'red', + minWidth: 16, }, }) diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx index 3db9a4378..1457478d5 100644 --- a/src/view/screens/Home.tsx +++ b/src/view/screens/Home.tsx @@ -62,7 +62,7 @@ export const HomeScreen = withAuthRequired( setSelectedPage(index) store.shell.setIsDrawerSwipeDisabled(index > 0) }, - [store], + [store, setSelectedPage], ) const onPressSelected = React.useCallback(() => { diff --git a/src/view/screens/Moderation.tsx b/src/view/screens/Moderation.tsx new file mode 100644 index 000000000..4c52301cb --- /dev/null +++ b/src/view/screens/Moderation.tsx @@ -0,0 +1,137 @@ +import React from 'react' +import {StyleSheet, TouchableOpacity, View} from 'react-native' +import {useFocusEffect} from '@react-navigation/native' +import { + FontAwesomeIcon, + FontAwesomeIconStyle, +} from '@fortawesome/react-native-fontawesome' +import {observer} from 'mobx-react-lite' +import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' +import {withAuthRequired} from 'view/com/auth/withAuthRequired' +import {useStores} from 'state/index' +import {s} from 'lib/styles' +import {CenteredView} from '../com/util/Views' +import {ViewHeader} from '../com/util/ViewHeader' +import {Link} from '../com/util/Link' +import {Text} from '../com/util/text/Text' +import {usePalette} from 'lib/hooks/usePalette' +import {useAnalytics} from 'lib/analytics' +import {isDesktopWeb} from 'platform/detection' + +type Props = NativeStackScreenProps<CommonNavigatorParams, 'Moderation'> +export const ModerationScreen = withAuthRequired( + observer(function Moderation({}: Props) { + const pal = usePalette('default') + const store = useStores() + const {screen, track} = useAnalytics() + + useFocusEffect( + React.useCallback(() => { + screen('Moderation') + store.shell.setMinimalShellMode(false) + }, [screen, store]), + ) + + const onPressContentFiltering = React.useCallback(() => { + track('Moderation:ContentfilteringButtonClicked') + store.shell.openModal({name: 'content-filtering-settings'}) + }, [track, store]) + + return ( + <CenteredView + style={[ + s.hContentRegion, + pal.border, + isDesktopWeb ? styles.desktopContainer : pal.viewLight, + ]} + testID="moderationScreen"> + <ViewHeader title="Moderation" showOnDesktop /> + <View style={styles.spacer} /> + <TouchableOpacity + testID="contentFilteringBtn" + style={[styles.linkCard, pal.view]} + onPress={onPressContentFiltering} + accessibilityRole="tab" + accessibilityHint="Content filtering" + accessibilityLabel=""> + <View style={[styles.iconContainer, pal.btn]}> + <FontAwesomeIcon + icon="eye" + style={pal.text as FontAwesomeIconStyle} + /> + </View> + <Text type="lg" style={pal.text}> + Content filtering + </Text> + </TouchableOpacity> + <Link + testID="mutelistsBtn" + style={[styles.linkCard, pal.view]} + href="/moderation/mute-lists"> + <View style={[styles.iconContainer, pal.btn]}> + <FontAwesomeIcon + icon="users-slash" + style={pal.text as FontAwesomeIconStyle} + /> + </View> + <Text type="lg" style={pal.text}> + Mute lists + </Text> + </Link> + <Link + testID="mutedAccountsBtn" + style={[styles.linkCard, pal.view]} + href="/moderation/muted-accounts"> + <View style={[styles.iconContainer, pal.btn]}> + <FontAwesomeIcon + icon="user-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]} + href="/moderation/blocked-accounts"> + <View style={[styles.iconContainer, pal.btn]}> + <FontAwesomeIcon + icon="ban" + style={pal.text as FontAwesomeIconStyle} + /> + </View> + <Text type="lg" style={pal.text}> + Blocked accounts + </Text> + </Link> + </CenteredView> + ) + }), +) + +const styles = StyleSheet.create({ + desktopContainer: { + borderLeftWidth: 1, + borderRightWidth: 1, + }, + spacer: { + height: 6, + }, + linkCard: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 12, + paddingHorizontal: 18, + marginBottom: 1, + }, + iconContainer: { + alignItems: 'center', + justifyContent: 'center', + width: 40, + height: 40, + borderRadius: 30, + marginRight: 12, + }, +}) diff --git a/src/view/screens/BlockedAccounts.tsx b/src/view/screens/ModerationBlockedAccounts.tsx index 195068510..cd506d630 100644 --- a/src/view/screens/BlockedAccounts.tsx +++ b/src/view/screens/ModerationBlockedAccounts.tsx @@ -22,8 +22,11 @@ import {ViewHeader} from '../com/util/ViewHeader' import {CenteredView} from 'view/com/util/Views' import {ProfileCard} from 'view/com/profile/ProfileCard' -type Props = NativeStackScreenProps<CommonNavigatorParams, 'BlockedAccounts'> -export const BlockedAccounts = withAuthRequired( +type Props = NativeStackScreenProps< + CommonNavigatorParams, + 'ModerationBlockedAccounts' +> +export const ModerationBlockedAccounts = withAuthRequired( observer(({}: Props) => { const pal = usePalette('default') const store = useStores() diff --git a/src/view/screens/ModerationMuteLists.tsx b/src/view/screens/ModerationMuteLists.tsx new file mode 100644 index 000000000..0b81f432f --- /dev/null +++ b/src/view/screens/ModerationMuteLists.tsx @@ -0,0 +1,122 @@ +import React from 'react' +import {StyleSheet} from 'react-native' +import {useFocusEffect, useNavigation} from '@react-navigation/native' +import { + FontAwesomeIcon, + FontAwesomeIconStyle, +} from '@fortawesome/react-native-fontawesome' +import {AtUri} from '@atproto/api' +import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' +import {withAuthRequired} from 'view/com/auth/withAuthRequired' +import {EmptyStateWithButton} from 'view/com/util/EmptyStateWithButton' +import {useStores} from 'state/index' +import {ListsListModel} from 'state/models/lists/lists-list' +import {ListsList} from 'view/com/lists/ListsList' +import {Button} from 'view/com/util/forms/Button' +import {NavigationProp} from 'lib/routes/types' +import {usePalette} from 'lib/hooks/usePalette' +import {CenteredView} from 'view/com/util/Views' +import {ViewHeader} from 'view/com/util/ViewHeader' +import {isDesktopWeb} from 'platform/detection' + +type Props = NativeStackScreenProps< + CommonNavigatorParams, + 'ModerationMuteLists' +> +export const ModerationMuteListsScreen = withAuthRequired(({}: Props) => { + const pal = usePalette('default') + const store = useStores() + const navigation = useNavigation<NavigationProp>() + + const mutelists: ListsListModel = React.useMemo( + () => new ListsListModel(store, 'my-modlists'), + [store], + ) + + useFocusEffect( + React.useCallback(() => { + store.shell.setMinimalShellMode(false) + mutelists.refresh() + }, [store, mutelists]), + ) + + const onPressNewMuteList = React.useCallback(() => { + store.shell.openModal({ + name: 'create-or-edit-mute-list', + onSave: (uri: string) => { + try { + const urip = new AtUri(uri) + navigation.navigate('ProfileList', { + name: urip.hostname, + rkey: urip.rkey, + }) + } catch {} + }, + }) + }, [store, navigation]) + + const renderEmptyState = React.useCallback(() => { + return ( + <EmptyStateWithButton + testID="emptyMuteLists" + icon="users-slash" + message="You can subscribe to mute lists to automatically mute all of the users they include. Mute lists are public but your subscription to a mute list is private." + buttonLabel="New Mute List" + onPress={onPressNewMuteList} + /> + ) + }, [onPressNewMuteList]) + + const renderHeaderButton = React.useCallback( + () => ( + <Button + type="primary-light" + onPress={onPressNewMuteList} + style={styles.createBtn}> + <FontAwesomeIcon + icon="plus" + style={pal.link as FontAwesomeIconStyle} + size={18} + /> + </Button> + ), + [onPressNewMuteList, pal], + ) + + return ( + <CenteredView + style={[ + styles.container, + isDesktopWeb && styles.containerDesktop, + pal.view, + pal.border, + ]} + testID="moderationMutelistsScreen"> + <ViewHeader + title="Mute Lists" + showOnDesktop + renderButton={renderHeaderButton} + /> + <ListsList + listsList={mutelists} + showAddBtns={isDesktopWeb} + renderEmptyState={renderEmptyState} + onPressCreateNew={onPressNewMuteList} + /> + </CenteredView> + ) +}) + +const styles = StyleSheet.create({ + container: { + flex: 1, + paddingBottom: isDesktopWeb ? 0 : 100, + }, + containerDesktop: { + borderLeftWidth: 1, + borderRightWidth: 1, + }, + createBtn: { + width: 40, + }, +}) diff --git a/src/view/screens/MutedAccounts.tsx b/src/view/screens/ModerationMutedAccounts.tsx index 8f3d1f8a5..22b8c0d33 100644 --- a/src/view/screens/MutedAccounts.tsx +++ b/src/view/screens/ModerationMutedAccounts.tsx @@ -22,8 +22,11 @@ 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( +type Props = NativeStackScreenProps< + CommonNavigatorParams, + 'ModerationMutedAccounts' +> +export const ModerationMutedAccounts = withAuthRequired( observer(({}: Props) => { const pal = usePalette('default') const store = useStores() diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx index 68aa97b66..9c8dd458c 100644 --- a/src/view/screens/Profile.tsx +++ b/src/view/screens/Profile.tsx @@ -7,12 +7,16 @@ import {withAuthRequired} from 'view/com/auth/withAuthRequired' import {ViewSelector} from '../com/util/ViewSelector' import {CenteredView} from '../com/util/Views' import {ScreenHider} from 'view/com/util/moderation/ScreenHider' -import {ProfileUiModel} from 'state/models/ui/profile' +import {ProfileUiModel, Sections} from 'state/models/ui/profile' import {useStores} from 'state/index' import {PostsFeedSliceModel} from 'state/models/feeds/posts' import {ProfileHeader} from '../com/profile/ProfileHeader' import {FeedSlice} from '../com/posts/FeedSlice' -import {PostFeedLoadingPlaceholder} from '../com/util/LoadingPlaceholder' +import {ListCard} from 'view/com/lists/ListCard' +import { + PostFeedLoadingPlaceholder, + ProfileCardFeedLoadingPlaceholder, +} from '../com/util/LoadingPlaceholder' import {ErrorScreen} from '../com/util/error/ErrorScreen' import {ErrorMessage} from '../com/util/error/ErrorMessage' import {EmptyState} from '../com/util/EmptyState' @@ -23,6 +27,8 @@ import {useAnalytics} from 'lib/analytics' import {ComposeIcon2} from 'lib/icons' import AlgoItem from 'view/com/algos/AlgoItem' import {AlgoItemModel} from 'state/models/feeds/algo/algo-item' +import {useSetTitle} from 'lib/hooks/useSetTitle' +import {combinedDisplayName} from 'lib/strings/display-names' type Props = NativeStackScreenProps<CommonNavigatorParams, 'Profile'> export const ProfileScreen = withAuthRequired( @@ -39,6 +45,7 @@ export const ProfileScreen = withAuthRequired( () => new ProfileUiModel(store, {user: route.params.name}), [route.params.name, store], ) + useSetTitle(combinedDisplayName(uiState.profile)) useFocusEffect( React.useCallback(() => { @@ -113,53 +120,87 @@ export const ProfileScreen = withAuthRequired( }, [uiState.showLoadingMoreFooter]) const renderItem = React.useCallback( (item: any) => { - if (item === ProfileUiModel.END_ITEM) { - return <Text style={styles.endItem}>- end of feed -</Text> - } else if (item === ProfileUiModel.LOADING_ITEM) { - return <PostFeedLoadingPlaceholder /> - } else if (item._reactKey === '__error__') { - if (uiState.feed.isBlocking) { + if (uiState.selectedView === Sections.Lists) { + if (item === ProfileUiModel.LOADING_ITEM) { + return <ProfileCardFeedLoadingPlaceholder /> + } else if (item._reactKey === '__error__') { + return ( + <View style={s.p5}> + <ErrorMessage + message={item.error} + onPressTryAgain={onPressTryAgain} + /> + </View> + ) + } else if (item === ProfileUiModel.EMPTY_ITEM) { return ( <EmptyState - icon="ban" - message="Posts hidden" + testID="listsEmpty" + icon="list-ul" + message="No lists yet!" style={styles.emptyState} /> ) + } else { + return <ListCard testID={`list-${item.name}`} list={item} /> } - if (uiState.feed.isBlockedBy) { + } else { + if (item === ProfileUiModel.END_ITEM) { + return <Text style={styles.endItem}>- end of feed -</Text> + } else if (item === ProfileUiModel.LOADING_ITEM) { + return <PostFeedLoadingPlaceholder /> + } else if (item._reactKey === '__error__') { + if (uiState.feed.isBlocking) { + return ( + <EmptyState + icon="ban" + message="Posts hidden" + style={styles.emptyState} + /> + ) + } + if (uiState.feed.isBlockedBy) { + return ( + <EmptyState + icon="ban" + message="Posts hidden" + style={styles.emptyState} + /> + ) + } + return ( + <View style={s.p5}> + <ErrorMessage + message={item.error} + onPressTryAgain={onPressTryAgain} + /> + </View> + ) + } else if (item === ProfileUiModel.EMPTY_ITEM) { return ( <EmptyState - icon="ban" - message="Posts hidden" + icon={['far', 'message']} + message="No posts yet!" style={styles.emptyState} /> ) + } else if (item instanceof PostsFeedSliceModel) { + return ( + <FeedSlice slice={item} ignoreMuteFor={uiState.profile.did} /> + ) + } else if (item instanceof AlgoItemModel) { + return <AlgoItem item={item} /> } - return ( - <View style={s.p5}> - <ErrorMessage - message={item.error} - onPressTryAgain={onPressTryAgain} - /> - </View> - ) - } else if (item === ProfileUiModel.EMPTY_ITEM) { - return ( - <EmptyState - icon={['far', 'message']} - message="No posts yet!" - style={styles.emptyState} - /> - ) - } else if (item instanceof PostsFeedSliceModel) { - return <FeedSlice slice={item} ignoreMuteFor={uiState.profile.did} /> - } else if (item instanceof AlgoItemModel) { - return <AlgoItem item={item} /> } return <View /> }, - [onPressTryAgain, uiState], + [ + onPressTryAgain, + uiState.selectedView, + uiState.profile.did, + uiState.feed.isBlocking, + uiState.feed.isBlockedBy, + ], ) return ( diff --git a/src/view/screens/ProfileList.tsx b/src/view/screens/ProfileList.tsx new file mode 100644 index 000000000..01f27bae1 --- /dev/null +++ b/src/view/screens/ProfileList.tsx @@ -0,0 +1,177 @@ +import React from 'react' +import {StyleSheet, View} from 'react-native' +import {useFocusEffect} from '@react-navigation/native' +import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' +import {useNavigation} from '@react-navigation/native' +import {observer} from 'mobx-react-lite' +import {withAuthRequired} from 'view/com/auth/withAuthRequired' +import {ViewHeader} from 'view/com/util/ViewHeader' +import {CenteredView} from 'view/com/util/Views' +import {ListItems} from 'view/com/lists/ListItems' +import {EmptyState} from 'view/com/util/EmptyState' +import {Button} from 'view/com/util/forms/Button' +import * as Toast from 'view/com/util/Toast' +import {ListModel} from 'state/models/content/list' +import {useStores} from 'state/index' +import {usePalette} from 'lib/hooks/usePalette' +import {useSetTitle} from 'lib/hooks/useSetTitle' +import {NavigationProp} from 'lib/routes/types' +import {isDesktopWeb} from 'platform/detection' + +type Props = NativeStackScreenProps<CommonNavigatorParams, 'ProfileList'> +export const ProfileListScreen = withAuthRequired( + observer(({route}: Props) => { + const store = useStores() + const navigation = useNavigation<NavigationProp>() + const pal = usePalette('default') + const {name, rkey} = route.params + + const list: ListModel = React.useMemo(() => { + const model = new ListModel( + store, + `at://${name}/app.bsky.graph.list/${rkey}`, + ) + return model + }, [store, name, rkey]) + useSetTitle(list.list?.name) + + useFocusEffect( + React.useCallback(() => { + store.shell.setMinimalShellMode(false) + list.loadMore(true) + }, [store, list]), + ) + + const onToggleSubscribed = React.useCallback(async () => { + try { + if (list.list?.viewer?.muted) { + await list.unsubscribe() + } else { + await list.subscribe() + } + } catch (err) { + Toast.show( + 'There was an an issue updating your subscription, please check your internet connection and try again.', + ) + store.log.error('Failed up update subscription', {err}) + } + }, [store, list]) + + const onPressEditList = React.useCallback(() => { + store.shell.openModal({ + name: 'create-or-edit-mute-list', + list, + onSave() { + list.refresh() + }, + }) + }, [store, list]) + + const onPressDeleteList = React.useCallback(() => { + store.shell.openModal({ + name: 'confirm', + title: 'Delete List', + message: 'Are you sure?', + async onPressConfirm() { + await list.delete() + if (navigation.canGoBack()) { + navigation.goBack() + } else { + navigation.navigate('Home') + } + }, + }) + }, [store, list, navigation]) + + const renderEmptyState = React.useCallback(() => { + return <EmptyState icon="users-slash" message="This list is empty!" /> + }, []) + + const renderHeaderBtn = React.useCallback(() => { + return ( + <View style={styles.headerBtns}> + {list?.isOwner && ( + <Button + type="default" + label="Delete List" + testID="deleteListBtn" + accessibilityLabel="Delete list" + accessibilityHint="" + onPress={onPressDeleteList} + /> + )} + {list?.isOwner && ( + <Button + type="default" + label="Edit List" + testID="editListBtn" + accessibilityLabel="Edit list" + accessibilityHint="" + onPress={onPressEditList} + /> + )} + {list.list?.viewer?.muted ? ( + <Button + type="inverted" + label="Unsubscribe" + testID="unsubscribeListBtn" + accessibilityLabel="Unsubscribe from list" + accessibilityHint="" + onPress={onToggleSubscribed} + /> + ) : ( + <Button + type="primary" + label="Subscribe & Mute" + testID="subscribeListBtn" + accessibilityLabel="Subscribe to this list" + accessibilityHint="Mutes the users included in this list" + onPress={onToggleSubscribed} + /> + )} + </View> + ) + }, [ + list?.isOwner, + list.list?.viewer?.muted, + onPressDeleteList, + onPressEditList, + onToggleSubscribed, + ]) + + return ( + <CenteredView + style={[ + styles.container, + isDesktopWeb && styles.containerDesktop, + pal.view, + pal.border, + ]} + testID="moderationMutelistsScreen"> + <ViewHeader title="" renderButton={renderHeaderBtn} /> + <ListItems + list={list} + renderEmptyState={renderEmptyState} + onToggleSubscribed={onToggleSubscribed} + onPressEditList={onPressEditList} + onPressDeleteList={onPressDeleteList} + /> + </CenteredView> + ) + }), +) + +const styles = StyleSheet.create({ + headerBtns: { + flexDirection: 'row', + gap: 8, + }, + container: { + flex: 1, + paddingBottom: isDesktopWeb ? 0 : 100, + }, + containerDesktop: { + borderLeftWidth: 1, + borderRightWidth: 1, + }, +}) diff --git a/src/view/screens/SearchMobile.tsx b/src/view/screens/SearchMobile.tsx index 6152038d3..f9b4864b2 100644 --- a/src/view/screens/SearchMobile.tsx +++ b/src/view/screens/SearchMobile.tsx @@ -121,7 +121,7 @@ export const SearchScreen = withAuthRequired( <TouchableWithoutFeedback onPress={onPress} accessible={false}> <View style={[pal.view, styles.container]}> <HeaderWithInput - isInputFocused={true} + isInputFocused={isInputFocused} query={query} setIsInputFocused={setIsInputFocused} onChangeQuery={onChangeQuery} diff --git a/src/view/screens/Settings.tsx b/src/view/screens/Settings.tsx index 0c8ac3f1f..a919f11b0 100644 --- a/src/view/screens/Settings.tsx +++ b/src/view/screens/Settings.tsx @@ -127,11 +127,6 @@ export const SettingsScreen = withAuthRequired( store.shell.openModal({name: 'invite-codes'}) }, [track, store]) - const onPressContentFiltering = React.useCallback(() => { - track('Settings:ContentfilteringButtonClicked') - store.shell.openModal({name: 'content-filtering-settings'}) - }, [track, store]) - const onPressContentLanguages = React.useCallback(() => { track('Settings:ContentlanguagesButtonClicked') store.shell.openModal({name: 'content-languages-settings'}) @@ -252,7 +247,9 @@ export const SettingsScreen = withAuthRequired( Add account </Text> </TouchableOpacity> + <View style={styles.spacer20} /> + <Text type="xl-bold" style={[pal.text, styles.heading]}> Invite a friend </Text> @@ -287,9 +284,6 @@ export const SettingsScreen = withAuthRequired( <View style={styles.spacer20} /> - <Text type="xl-bold" style={[pal.text, styles.heading]}> - Moderation - </Text> <Link testID="bookmarkedAlgosBtn" style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]} @@ -306,51 +300,7 @@ export const SettingsScreen = withAuthRequired( Custom Algorithms </Text> </Link> - <TouchableOpacity - testID="contentFilteringBtn" - style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]} - onPress={isSwitching ? undefined : onPressContentFiltering} - accessibilityHint="Content moderation" - accessibilityLabel="Opens configurable content moderation settings"> - <View style={[styles.iconContainer, pal.btn]}> - <FontAwesomeIcon - icon="eye" - style={pal.text as FontAwesomeIconStyle} - /> - </View> - <Text type="lg" style={pal.text}> - Content moderation - </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"> - <View style={[styles.iconContainer, pal.btn]}> - <FontAwesomeIcon - icon="ban" - style={pal.text as FontAwesomeIconStyle} - /> - </View> - <Text type="lg" style={pal.text}> - Blocked accounts - </Text> - </Link> - <View style={styles.spacer20} /> + <Text type="xl-bold" style={[pal.text, styles.heading]}> Advanced </Text> @@ -398,8 +348,8 @@ export const SettingsScreen = withAuthRequired( style={pal.text as FontAwesomeIconStyle} /> </View> - <Text type="lg" style={pal.text}> - Change my handle + <Text type="lg" style={pal.text} numberOfLines={1}> + Change handle </Text> </TouchableOpacity> <View style={styles.spacer20} /> diff --git a/src/view/shell/Drawer.tsx b/src/view/shell/Drawer.tsx index bdd64807e..2ecdbaab2 100644 --- a/src/view/shell/Drawer.tsx +++ b/src/view/shell/Drawer.tsx @@ -28,6 +28,7 @@ import { MagnifyingGlassIcon2Solid, MoonIcon, UserIconSolid, + HandIcon, } from 'lib/icons' import {UserAvatar} from 'view/com/util/UserAvatar' import {Text} from 'view/com/util/text/Text' @@ -94,6 +95,12 @@ export const DrawerContent = observer(() => { onPressTab('MyProfile') }, [onPressTab]) + const onPressModeration = React.useCallback(() => { + track('Menu:ItemClicked', {url: 'Moderation'}) + navigation.navigate('Moderation') + store.shell.closeDrawer() + }, [navigation, track, store.shell]) + const onPressSettings = React.useCallback(() => { track('Menu:ItemClicked', {url: 'Settings'}) navigation.navigate('Settings') @@ -215,13 +222,30 @@ export const DrawerContent = observer(() => { } label="Notifications" accessibilityLabel="Notifications" - accessibilityHint={`${store.me.notifications.unreadCountLabel} unread`} + accessibilityHint={ + notifications.unreadCountLabel === '' + ? '' + : `${notifications.unreadCountLabel} unread` + } count={notifications.unreadCountLabel} bold={isAtNotifications} onPress={onPressNotifications} /> <MenuItem icon={ + <HandIcon + strokeWidth={5} + style={pal.text as FontAwesomeIconStyle} + size={24} + /> + } + label="Moderation" + accessibilityLabel="Moderation" + accessibilityHint="" + onPress={onPressModeration} + /> + <MenuItem + icon={ isAtMyProfile ? ( <UserIconSolid style={pal.text as StyleProp<ViewStyle>} @@ -404,6 +428,7 @@ const styles = StyleSheet.create({ flex: 1, paddingTop: 20, paddingBottom: 50, + maxWidth: 300, }, viewDarkMode: { backgroundColor: '#1B1919', diff --git a/src/view/shell/bottom-bar/BottomBar.tsx b/src/view/shell/bottom-bar/BottomBar.tsx index c11a0128c..ef9499f9f 100644 --- a/src/view/shell/bottom-bar/BottomBar.tsx +++ b/src/view/shell/bottom-bar/BottomBar.tsx @@ -38,6 +38,7 @@ export const BottomBar = observer(({navigation}: BottomTabBarProps) => { useNavigationTabState() const {footerMinimalShellTransform} = useMinimalShellMode() + const {notifications} = store.me const onPressTab = React.useCallback( (tab: string) => { @@ -138,11 +139,15 @@ export const BottomBar = observer(({navigation}: BottomTabBarProps) => { ) } onPress={onPressNotifications} - notificationCount={store.me.notifications.unreadCountLabel} + notificationCount={notifications.unreadCountLabel} accessible={true} accessibilityRole="tab" accessibilityLabel="Notifications" - accessibilityHint={`${store.me.notifications.unreadCountLabel} unread`} + accessibilityHint={ + notifications.unreadCountLabel === '' + ? '' + : `${notifications.unreadCountLabel} unread` + } /> <Btn testID="bottomBarProfileBtn" diff --git a/src/view/shell/desktop/LeftNav.tsx b/src/view/shell/desktop/LeftNav.tsx index ca6330304..ba48dd2ae 100644 --- a/src/view/shell/desktop/LeftNav.tsx +++ b/src/view/shell/desktop/LeftNav.tsx @@ -29,6 +29,7 @@ import { CogIcon, CogIconSolid, ComposeIcon2, + HandIcon, } from 'lib/icons' import {getCurrentRoute, isTab, isStateAtTabRoot} from 'lib/routes/helpers' import {NavigationProp} from 'lib/routes/types' @@ -203,6 +204,24 @@ export const DesktopLeftNav = observer(function DesktopLeftNav() { } label="Notifications" /> + <NavItem + href="/moderation" + icon={ + <HandIcon + strokeWidth={5.5} + style={pal.text as FontAwesomeIconStyle} + size={24} + /> + } + iconFilled={ + <FontAwesomeIcon + icon="hand" + style={pal.text as FontAwesomeIconStyle} + size={20} + /> + } + label="Moderation" + /> {store.session.hasSession && ( <NavItem href={`/profile/${store.me.handle}`} @@ -279,7 +298,7 @@ const styles = StyleSheet.create({ flexDirection: 'row', alignItems: 'center', justifyContent: 'center', - width: 138, + width: 140, borderRadius: 24, paddingVertical: 10, paddingHorizontal: 16, diff --git a/src/view/shell/desktop/RightNav.tsx b/src/view/shell/desktop/RightNav.tsx index d6663ce3d..4fae64fef 100644 --- a/src/view/shell/desktop/RightNav.tsx +++ b/src/view/shell/desktop/RightNav.tsx @@ -11,13 +11,14 @@ import {s} from 'lib/styles' import {useStores} from 'state/index' import {pluralize} from 'lib/strings/helpers' import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle' -import {MoonIcon} from 'lib/icons' +import {MoonIcon, SunIcon} from 'lib/icons' import {formatCount} from 'view/com/util/numeric/format' export const DesktopRightNav = observer(function DesktopRightNav() { const store = useStores() const pal = usePalette('default') const mode = useColorSchemeStyle('Light', 'Dark') + const otherMode = mode === 'Dark' ? 'Light' : 'Dark' const onDarkmodePress = React.useCallback(() => { store.shell.setDarkMode(!store.shell.darkMode) @@ -71,10 +72,14 @@ export const DesktopRightNav = observer(function DesktopRightNav() { : 'Sets display to dark mode' }> <View style={[pal.viewLight, styles.darkModeToggleIcon]}> - <MoonIcon size={18} style={pal.textLight} /> + {mode === 'Dark' ? ( + <SunIcon size={18} style={pal.textLight} /> + ) : ( + <MoonIcon size={18} style={pal.textLight} /> + )} </View> <Text type="sm" style={pal.textLight}> - {mode} mode + {otherMode} mode </Text> </TouchableOpacity> </View> diff --git a/src/view/shell/index.web.tsx b/src/view/shell/index.web.tsx index 349376436..68ce370ed 100644 --- a/src/view/shell/index.web.tsx +++ b/src/view/shell/index.web.tsx @@ -1,4 +1,4 @@ -import React from 'react' +import React, {useEffect} from 'react' import {observer} from 'mobx-react-lite' import {View, StyleSheet, TouchableOpacity} from 'react-native' import {useStores} from 'state/index' @@ -14,11 +14,21 @@ import {RoutesContainer, FlatNavigator} from '../../Navigation' import {DrawerContent} from './Drawer' import {useWebMediaQueries} from '../../lib/hooks/useWebMediaQueries' import {BottomBarWeb} from './bottom-bar/BottomBarWeb' +import {useNavigation} from '@react-navigation/native' +import {NavigationProp} from 'lib/routes/types' const ShellInner = observer(() => { const store = useStores() const {isDesktop} = useWebMediaQueries() + const navigator = useNavigation<NavigationProp>() + + useEffect(() => { + navigator.addListener('state', () => { + store.shell.closeAnyActiveElement() + }) + }, [navigator, store.shell]) + return ( <> <View style={s.hContentRegion}> |