Merge branch 'main' into custom-algos

This commit is contained in:
Paul Frazee 2023-05-17 12:30:54 -05:00
commit 7aa1d9010e
99 changed files with 4234 additions and 716 deletions

View file

@ -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

View file

@ -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`,

View file

@ -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])
}

View file

@ -320,6 +320,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({
@ -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>
)
}

View file

@ -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,

View file

@ -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
}

View file

@ -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 {

View file

@ -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 & {

View file

@ -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}`
}

View file

@ -0,0 +1,4 @@
export function bskyTitle(page: string, unreadCountLabel?: string) {
const unreadPrefix = unreadCountLabel ? `(${unreadCountLabel}) ` : ''
return `${unreadPrefix}${page} - Bluesky`
}

View file

@ -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 {

View file

@ -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',

View file

@ -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)
}
}
}
}

View file

@ -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})),
)
}
}

View file

@ -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) ||

View file

@ -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

View file

@ -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:

View file

@ -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) ||

View file

@ -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
}

View file

@ -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)
}),
)
}
}

View file

@ -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
}
}

View file

@ -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)
}

View file

@ -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): {

View file

@ -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() {

View file

@ -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

View file

@ -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,
},
})

View file

@ -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>
)
})
) : (

View file

@ -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

View file

@ -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,
},
})

View file

@ -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},
})

View file

@ -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},
})

View file

@ -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 ? (

View file

@ -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,
},
})

View file

@ -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},
})

View file

@ -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],
const [scale, setScale] = useState<number>(image.attributes.scale ?? 1)
const [position, setPosition] = useState<Position | undefined>(
image.attributes.position,
)
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),
[],
)
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,
},
})

View file

@ -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)

View file

@ -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) => (

View file

@ -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,
},
})

View file

@ -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 />

View file

@ -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') {

View file

@ -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,
@ -72,6 +72,19 @@ export function Component({
</View>
),
},
{
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 file

@ -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

View file

@ -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
// =

View file

@ -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'

View file

@ -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'

View file

@ -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}>

View file

@ -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

View file

@ -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 ? (

View file

@ -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},

View file

@ -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 {

View file

@ -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} />

View file

@ -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,
},
})

View file

@ -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)
},
},
{

View file

@ -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',

View file

@ -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>
)
}

View file

@ -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',
},

View file

@ -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}

View file

@ -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)) {

View file

@ -63,6 +63,5 @@ const styles = StyleSheet.create({
position: 'absolute',
left: 6,
bottom: 6,
width: 46,
},
})

View file

@ -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,
},

View file

@ -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',
},
})

View file

@ -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',
},
})

View file

@ -210,6 +210,5 @@ const styles = StyleSheet.create({
position: 'absolute',
left: 6,
bottom: 6,
width: 46,
},
})

View file

@ -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,

View file

@ -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,
},
})

View file

@ -62,7 +62,7 @@ export const HomeScreen = withAuthRequired(
setSelectedPage(index)
store.shell.setIsDrawerSwipeDisabled(index > 0)
},
[store],
[store, setSelectedPage],
)
const onPressSelected = React.useCallback(() => {

View file

@ -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,
},
})

View file

@ -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()

View file

@ -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,
},
})

View file

@ -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()

View file

@ -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 (

View file

@ -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,
},
})

View file

@ -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}

View file

@ -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} />

View file

@ -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,11 +222,28 @@ 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 ? (
@ -404,6 +428,7 @@ const styles = StyleSheet.create({
flex: 1,
paddingTop: 20,
paddingBottom: 50,
maxWidth: 300,
},
viewDarkMode: {
backgroundColor: '#1B1919',

View file

@ -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"

View file

@ -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,

View file

@ -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>

View file

@ -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}>