diff options
Diffstat (limited to 'src')
88 files changed, 2979 insertions, 1780 deletions
diff --git a/src/Navigation.tsx b/src/Navigation.tsx index 06cce0f00..48bab182d 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -125,7 +125,10 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) { <Stack.Screen name="Profile" component={ProfileScreen} - options={({route}) => ({title: title(`@${route.params.name}`)})} + options={({route}) => ({ + title: title(`@${route.params.name}`), + animation: 'none', + })} /> <Stack.Screen name="ProfileFollowers" diff --git a/src/lib/api/feed-manip.ts b/src/lib/api/feed-manip.ts index f4bf6cdff..472289b40 100644 --- a/src/lib/api/feed-manip.ts +++ b/src/lib/api/feed-manip.ts @@ -17,6 +17,12 @@ export class FeedViewPostsSlice { constructor(public items: FeedViewPost[] = []) {} + get _reactKey() { + return `slice-${this.items[0].post.uri}-${ + this.items[0].reason?.indexedAt || this.items[0].post.indexedAt + }` + } + get uri() { if (this.isFlattenedReply) { return this.items[1].post.uri diff --git a/src/lib/api/hack-add-deleted-embed.ts b/src/lib/api/hack-add-deleted-embed.ts new file mode 100644 index 000000000..59aad21a2 --- /dev/null +++ b/src/lib/api/hack-add-deleted-embed.ts @@ -0,0 +1,24 @@ +import { + AppBskyFeedDefs, + AppBskyFeedPost, + ComAtprotoRepoStrongRef, +} from '@atproto/api' + +/** + * HACK + * The server doesnt seem to be correctly giving the notFound view yet + * so I'm adding it manually for now + * -prf + */ +export function hackAddDeletedEmbed(post: AppBskyFeedDefs.PostView) { + const record = post.record as AppBskyFeedPost.Record + if (record.embed?.$type === 'app.bsky.embed.record' && !post.embed) { + post.embed = { + $type: 'app.bsky.embed.record#view', + record: { + $type: 'app.bsky.embed.record#viewNotFound', + uri: (record.embed.record as ComAtprotoRepoStrongRef.Main).uri, + }, + } + } +} diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index 458ef7baa..4ecd32046 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -4,6 +4,7 @@ import { AppBskyEmbedRecord, AppBskyEmbedRecordWithMedia, AppBskyRichtextFacet, + ComAtprotoLabelDefs, ComAtprotoRepoUploadBlob, RichText, } from '@atproto/api' @@ -13,6 +14,7 @@ import {isNetworkError} from 'lib/strings/errors' import {LinkMeta} from '../link-meta/link-meta' import {isWeb} from 'platform/detection' import {ImageModel} from 'state/models/media/image' +import {shortenLinks} from 'lib/strings/rich-text-manip' export interface ExternalEmbedDraft { uri: string @@ -29,10 +31,24 @@ export async function resolveName(store: RootStoreModel, didOrHandle: string) { if (didOrHandle.startsWith('did:')) { return didOrHandle } - const res = await store.agent.resolveHandle({ - handle: didOrHandle, - }) - return res.data.did + + // we run the resolution always to ensure freshness + const promise = store.agent + .resolveHandle({ + handle: didOrHandle, + }) + .then(res => { + store.handleResolutions.cache.set(didOrHandle, res.data.did) + return res.data.did + }) + + // but we can return immediately if it's cached + const cached = store.handleResolutions.cache.get(didOrHandle) + if (cached) { + return cached + } + + return promise } export async function uploadBlob( @@ -63,6 +79,7 @@ interface PostOpts { } extLink?: ExternalEmbedDraft images?: ImageModel[] + labels?: string[] knownHandles?: Set<string> onStateChange?: (state: string) => void langs?: string[] @@ -76,7 +93,7 @@ export async function post(store: RootStoreModel, opts: PostOpts) { | AppBskyEmbedRecordWithMedia.Main | undefined let reply - const rt = new RichText( + let rt = new RichText( {text: opts.rawText.trim()}, { cleanNewlines: true, @@ -85,6 +102,7 @@ export async function post(store: RootStoreModel, opts: PostOpts) { opts.onStateChange?.('Processing...') await rt.detectFacets(store.agent) + rt = shortenLinks(rt) // filter out any mention facets that didn't map to a user rt.facets = rt.facets?.filter(facet => { @@ -220,6 +238,15 @@ export async function post(store: RootStoreModel, opts: PostOpts) { } } + // set labels + let labels: ComAtprotoLabelDefs.SelfLabels | undefined + if (opts.labels?.length) { + labels = { + $type: 'com.atproto.label.defs#selfLabels', + values: opts.labels.map(val => ({val})), + } + } + // add top 3 languages from user preferences if langs is provided let langs = opts.langs if (opts.langs) { @@ -234,6 +261,7 @@ export async function post(store: RootStoreModel, opts: PostOpts) { reply, embed, langs, + labels, }) } catch (e: any) { console.error(`Failed to create post: ${e.toString()}`) diff --git a/src/lib/icons.tsx b/src/lib/icons.tsx index 4ea3b4d65..233f8a473 100644 --- a/src/lib/icons.tsx +++ b/src/lib/icons.tsx @@ -957,3 +957,41 @@ export function SatelliteDishIcon({ </Svg> ) } + +// Copyright (c) 2020 Refactoring UI Inc. +// https://github.com/tailwindlabs/heroicons/blob/master/LICENSE +export function ShieldExclamation({ + style, + size, + strokeWidth = 1.5, +}: { + style?: StyleProp<TextStyle> + size?: string | number + strokeWidth?: number +}) { + let color = 'currentColor' + if ( + style && + typeof style === 'object' && + 'color' in style && + typeof style.color === 'string' + ) { + color = style.color + } + return ( + <Svg + width={size} + height={size} + fill="none" + viewBox="0 0 24 24" + strokeWidth={strokeWidth || 1.5} + stroke={color} + style={style}> + <Path + strokeLinecap="round" + strokeLinejoin="round" + d="M12 9v3.75m0-10.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.75c0 5.592 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.57-.598-3.75h-.152c-3.196 0-6.1-1.249-8.25-3.286zm0 13.036h.008v.008H12v-.008z" + /> + </Svg> + ) +} diff --git a/src/lib/moderation.ts b/src/lib/moderation.ts new file mode 100644 index 000000000..aadee0e74 --- /dev/null +++ b/src/lib/moderation.ts @@ -0,0 +1,107 @@ +import {ModerationCause, ProfileModeration} from '@atproto/api' + +export interface ModerationCauseDescription { + name: string + description: string +} + +export function describeModerationCause( + cause: ModerationCause | undefined, + context: 'account' | 'content', +): ModerationCauseDescription { + if (!cause) { + return { + name: 'Content Warning', + description: + 'Moderator has chosen to set a general warning on the content.', + } + } + if (cause.type === 'blocking') { + return { + name: 'User Blocked', + description: 'You have blocked this user. You cannot view their content.', + } + } + if (cause.type === 'blocked-by') { + return { + name: 'User Blocking You', + description: 'This user has blocked you. You cannot view their content.', + } + } + if (cause.type === 'block-other') { + return { + name: 'Content Not Available', + description: + 'This content is not available because one of the users involved has blocked the other.', + } + } + if (cause.type === 'muted') { + if (cause.source.type === 'list') { + return { + name: + context === 'account' + ? `Muted by "${cause.source.list.name}"` + : `Post by muted user ("${cause.source.list.name}")`, + description: 'You have muted this user', + } + } else { + return { + name: context === 'account' ? 'Muted User' : 'Post by muted user', + description: 'You have muted this user', + } + } + } + return cause.labelDef.strings[context].en +} + +export function getProfileModerationCauses( + moderation: ProfileModeration, +): ModerationCause[] { + /* + Gather everything on profile and account that blurs or alerts + */ + return [ + moderation.decisions.profile.cause, + ...moderation.decisions.profile.additionalCauses, + moderation.decisions.account.cause, + ...moderation.decisions.account.additionalCauses, + ].filter(cause => { + if (!cause) { + return false + } + if (cause?.type === 'label') { + if ( + cause.labelDef.onwarn === 'blur' || + cause.labelDef.onwarn === 'alert' + ) { + return true + } else { + return false + } + } + return true + }) as ModerationCause[] +} + +export function isCauseALabelOnUri( + cause: ModerationCause | undefined, + uri: string, +): boolean { + if (cause?.type !== 'label') { + return false + } + return cause.label.uri === uri +} + +export function getModerationCauseKey(cause: ModerationCause): string { + const source = + cause.source.type === 'labeler' + ? cause.source.labeler.did + : cause.source.type === 'list' + ? cause.source.list.uri + : 'user' + if (cause.type === 'label') { + return `label:${cause.label.val}:${source}` + } + return `${cause.type}:${source}` +} diff --git a/src/lib/sentry.ts b/src/lib/sentry.ts index c5d1d3eb6..5448415ff 100644 --- a/src/lib/sentry.ts +++ b/src/lib/sentry.ts @@ -15,6 +15,11 @@ Sentry.init({ environment: __DEV__ ? 'development' : 'production', // Set the environment enableAutoPerformanceTracking: true, // Enable auto performance tracking tracesSampleRate: 0.5, // Set tracesSampleRate to 1.0 to capture 100% of transactions for performance monitoring. // TODO: this might be too much in production + _experiments: { + // The sampling rate for profiling is relative to TracesSampleRate. + // In this case, we'll capture profiles for 50% of transactions. + profilesSampleRate: 0.5, + }, integrations: isNative ? [ new Sentry.Native.ReactNativeTracing({ diff --git a/src/lib/strings/display-names.ts b/src/lib/strings/display-names.ts index b98153732..29b7c3b50 100644 --- a/src/lib/strings/display-names.ts +++ b/src/lib/strings/display-names.ts @@ -1,10 +1,19 @@ +import {ModerationUI} from '@atproto/api' +import {describeModerationCause} from '../moderation' + // \u2705 = ✅ // \u2713 = ✓ // \u2714 = ✔ // \u2611 = ☑ const CHECK_MARKS_RE = /[\u2705\u2713\u2714\u2611]/gu -export function sanitizeDisplayName(str: string): string { +export function sanitizeDisplayName( + str: string, + moderation?: ModerationUI, +): string { + if (moderation?.blur) { + return `âš ${describeModerationCause(moderation.cause, 'account').name}` + } if (typeof str === 'string') { return str.replace(CHECK_MARKS_RE, '').trim() } diff --git a/src/lib/strings/handles.ts b/src/lib/strings/handles.ts index 3c01d9345..6ce462435 100644 --- a/src/lib/strings/handles.ts +++ b/src/lib/strings/handles.ts @@ -3,7 +3,7 @@ export function makeValidHandle(str: string): string { str = str.slice(0, 20) } str = str.toLowerCase() - return str.replace(/^[^a-z]+/g, '').replace(/[^a-z0-9-]/g, '') + return str.replace(/^[^a-z0-9]+/g, '').replace(/[^a-z0-9-]/g, '') } export function createFullHandle(name: string, domain: string): string { diff --git a/src/lib/strings/rich-text-manip.ts b/src/lib/strings/rich-text-manip.ts new file mode 100644 index 000000000..d9cd8c071 --- /dev/null +++ b/src/lib/strings/rich-text-manip.ts @@ -0,0 +1,34 @@ +import {RichText, UnicodeString} from '@atproto/api' +import {toShortUrl} from './url-helpers' + +export function shortenLinks(rt: RichText): RichText { + if (!rt.facets?.length) { + return rt + } + rt = rt.clone() + // enumerate the link facets + if (rt.facets) { + for (const facet of rt.facets) { + const isLink = !!facet.features.find( + f => f.$type === 'app.bsky.richtext.facet#link', + ) + if (!isLink) { + continue + } + + // extract and shorten the URL + const {byteStart, byteEnd} = facet.index + const url = rt.unicodeText.slice(byteStart, byteEnd) + const shortened = new UnicodeString(toShortUrl(url)) + + // insert the shorten URL + rt.insert(byteStart, shortened.utf16) + // update the facet to cover the new shortened URL + facet.index.byteStart = byteStart + facet.index.byteEnd = byteStart + shortened.length + // remove the old URL + rt.delete(byteStart + shortened.length, byteEnd + shortened.length) + } + } + return rt +} diff --git a/src/lib/strings/url-helpers.ts b/src/lib/strings/url-helpers.ts index 105c631bf..b509aad01 100644 --- a/src/lib/strings/url-helpers.ts +++ b/src/lib/strings/url-helpers.ts @@ -30,7 +30,7 @@ export function toNiceDomain(url: string): string { if (`https://${urlp.host}` === PROD_SERVICE) { return 'Bluesky Social' } - return urlp.host + return urlp.host ? urlp.host : url } catch (e) { return url } @@ -42,15 +42,12 @@ export function toShortUrl(url: string): string { if (urlp.protocol !== 'http:' && urlp.protocol !== 'https:') { return url } - const shortened = - urlp.host + - (urlp.pathname === '/' ? '' : urlp.pathname) + - urlp.search + - urlp.hash - if (shortened.length > 30) { - return shortened.slice(0, 27) + '...' + const path = + (urlp.pathname === '/' ? '' : urlp.pathname) + urlp.search + urlp.hash + if (path.length > 15) { + return urlp.host + path.slice(0, 13) + '...' } - return shortened ? shortened : url + return urlp.host + path } catch (e) { return url } diff --git a/src/locale/helpers.ts b/src/locale/helpers.ts index bce4e6590..6e6e3f08c 100644 --- a/src/locale/helpers.ts +++ b/src/locale/helpers.ts @@ -79,8 +79,6 @@ export function isPostInLanguage( return bcp47Match.basicFilter(lang, targetLangs).length > 0 } -export function getTranslatorLink(lang: string, text: string): string { - return encodeURI( - `https://translate.google.com/?sl=auto&tl=${lang}&text=${text}`, - ) +export function getTranslatorLink(text: string): string { + return encodeURI(`https://translate.google.com/?sl=auto&text=${text}`) } diff --git a/src/state/models/cache/handle-resolutions.ts b/src/state/models/cache/handle-resolutions.ts new file mode 100644 index 000000000..2e2b69661 --- /dev/null +++ b/src/state/models/cache/handle-resolutions.ts @@ -0,0 +1,5 @@ +import {LRUMap} from 'lru_map' + +export class HandleResolutionsCache { + cache: LRUMap<string, string> = new LRUMap(500) +} diff --git a/src/state/models/cache/posts.ts b/src/state/models/cache/posts.ts new file mode 100644 index 000000000..d3632f436 --- /dev/null +++ b/src/state/models/cache/posts.ts @@ -0,0 +1,70 @@ +import {LRUMap} from 'lru_map' +import {RootStoreModel} from '../root-store' +import { + AppBskyFeedDefs, + AppBskyEmbedRecord, + AppBskyEmbedRecordWithMedia, + AppBskyFeedPost, +} from '@atproto/api' + +type PostView = AppBskyFeedDefs.PostView + +export class PostsCache { + cache: LRUMap<string, PostView> = new LRUMap(500) + + constructor(public rootStore: RootStoreModel) {} + + set(uri: string, postView: PostView) { + this.cache.set(uri, postView) + if (postView.author.handle) { + this.rootStore.handleResolutions.cache.set( + postView.author.handle, + postView.author.did, + ) + } + } + + fromFeedItem(feedItem: AppBskyFeedDefs.FeedViewPost) { + this.set(feedItem.post.uri, feedItem.post) + if ( + feedItem.reply?.parent && + AppBskyFeedDefs.isPostView(feedItem.reply?.parent) + ) { + this.set(feedItem.reply.parent.uri, feedItem.reply.parent) + } + const embed = feedItem.post.embed + if ( + AppBskyEmbedRecord.isView(embed) && + AppBskyEmbedRecord.isViewRecord(embed.record) && + AppBskyFeedPost.isRecord(embed.record.value) && + AppBskyFeedPost.validateRecord(embed.record.value).success + ) { + this.set(embed.record.uri, embedViewToPostView(embed.record)) + } + if ( + AppBskyEmbedRecordWithMedia.isView(embed) && + AppBskyEmbedRecord.isViewRecord(embed.record?.record) && + AppBskyFeedPost.isRecord(embed.record.record.value) && + AppBskyFeedPost.validateRecord(embed.record.record.value).success + ) { + this.set( + embed.record.record.uri, + embedViewToPostView(embed.record.record), + ) + } + } +} + +function embedViewToPostView( + embedView: AppBskyEmbedRecord.ViewRecord, +): PostView { + return { + $type: 'app.bsky.feed.post#view', + uri: embedView.uri, + cid: embedView.cid, + author: embedView.author, + record: embedView.value, + indexedAt: embedView.indexedAt, + labels: embedView.labels, + } +} diff --git a/src/state/models/cache/profiles-view.ts b/src/state/models/cache/profiles-view.ts index b4bd70db5..e5a9be587 100644 --- a/src/state/models/cache/profiles-view.ts +++ b/src/state/models/cache/profiles-view.ts @@ -45,8 +45,6 @@ export class ProfilesCache { } overwrite(did: string, res: GetProfile.Response) { - if (this.cache.has(did)) { - this.cache.set(did, res) - } + this.cache.set(did, res) } } diff --git a/src/state/models/content/list.ts b/src/state/models/content/list.ts index 2498cf581..5d4ffb4fa 100644 --- a/src/state/models/content/list.ts +++ b/src/state/models/content/list.ts @@ -306,7 +306,7 @@ export class ListModel { this.hasMore = !!this.loadMoreCursor this.list = res.data.list this.items = this.items.concat( - res.data.items.map(item => ({...item, _reactKey: item.subject})), + res.data.items.map(item => ({...item, _reactKey: item.subject.did})), ) } } diff --git a/src/state/models/content/post-thread-item.ts b/src/state/models/content/post-thread-item.ts index 14aa607ed..942f3acc8 100644 --- a/src/state/models/content/post-thread-item.ts +++ b/src/state/models/content/post-thread-item.ts @@ -3,9 +3,9 @@ import { AppBskyFeedPost as FeedPost, AppBskyFeedDefs, RichText, + PostModeration, } from '@atproto/api' import {RootStoreModel} from '../root-store' -import {PostLabelInfo, PostModeration} from 'lib/labeling/types' import {PostsFeedItemModel} from '../feeds/post' type PostView = AppBskyFeedDefs.PostView @@ -67,10 +67,6 @@ export class PostThreadItemModel { return this.data.isThreadMuted } - get labelInfo(): PostLabelInfo { - return this.data.labelInfo - } - get moderation(): PostModeration { return this.data.moderation } @@ -111,7 +107,7 @@ export class PostThreadItemModel { const itemModel = new PostThreadItemModel(this.rootStore, item) itemModel._depth = this._depth + 1 itemModel._showParentReplyLine = - itemModel.parentUri !== highlightedPostUri && replies.length === 0 + itemModel.parentUri !== highlightedPostUri if (item.replies?.length) { itemModel._showChildReplyLine = true itemModel.assignTreeModels(item, highlightedPostUri, false, true) diff --git a/src/state/models/content/post-thread.ts b/src/state/models/content/post-thread.ts index 0a67c783e..85ed13cb4 100644 --- a/src/state/models/content/post-thread.ts +++ b/src/state/models/content/post-thread.ts @@ -2,6 +2,7 @@ import {makeAutoObservable, runInAction} from 'mobx' import { AppBskyFeedGetPostThread as GetPostThread, AppBskyFeedDefs, + PostModeration, } from '@atproto/api' import {AtUri} from '@atproto/api' import {RootStoreModel} from '../root-store' @@ -12,6 +13,8 @@ import {PostThreadItemModel} from './post-thread-item' export class PostThreadModel { // state isLoading = false + isLoadingFromCache = false + isFromCache = false isRefreshing = false hasLoaded = false error = '' @@ -20,7 +23,7 @@ export class PostThreadModel { params: GetPostThread.QueryParams // data - thread?: PostThreadItemModel + thread?: PostThreadItemModel | null = null isBlocked = false constructor( @@ -52,7 +55,7 @@ export class PostThreadModel { } get hasContent() { - return typeof this.thread !== 'undefined' + return !!this.thread } get hasError() { @@ -82,10 +85,16 @@ export class PostThreadModel { if (!this.resolvedUri) { await this._resolveUri() } + if (this.hasContent) { await this.update() } else { - await this._load() + const precache = this.rootStore.posts.cache.get(this.resolvedUri) + if (precache) { + await this._loadPrecached(precache) + } else { + await this._load() + } } } @@ -169,6 +178,37 @@ export class PostThreadModel { }) } + async _loadPrecached(precache: AppBskyFeedDefs.PostView) { + // start with the cached version + this.isLoadingFromCache = true + this.isFromCache = true + this._replaceAll({ + success: true, + headers: {}, + data: { + thread: { + post: precache, + }, + }, + }) + this._xIdle() + + // then update in the background + try { + const res = await this.rootStore.agent.getPostThread( + Object.assign({}, this.params, {uri: this.resolvedUri}), + ) + this._replaceAll(res) + } catch (e: any) { + console.log(e) + this._xIdle(e) + } finally { + runInAction(() => { + this.isLoadingFromCache = false + }) + } + } + async _load(isRefreshing = false) { if (this.hasLoaded && !isRefreshing) { return @@ -192,7 +232,6 @@ export class PostThreadModel { return } pruneReplies(res.data.thread) - sortThread(res.data.thread) const thread = new PostThreadItemModel( this.rootStore, res.data.thread as AppBskyFeedDefs.ThreadViewPost, @@ -202,6 +241,7 @@ export class PostThreadModel { res.data.thread as AppBskyFeedDefs.ThreadViewPost, thread.uri, ) + sortThread(thread) this.thread = thread } } @@ -223,24 +263,28 @@ function pruneReplies(post: MaybePost) { } } -function sortThread(post: MaybePost) { - if (post.notFound) { +type MaybeThreadItem = + | PostThreadItemModel + | AppBskyFeedDefs.NotFoundPost + | AppBskyFeedDefs.BlockedPost +function sortThread(item: MaybeThreadItem) { + if ('notFound' in item) { return } - post = post as AppBskyFeedDefs.ThreadViewPost - if (post.replies) { - post.replies.sort((a: MaybePost, b: MaybePost) => { - post = post as AppBskyFeedDefs.ThreadViewPost - if (a.notFound) { + item = item as PostThreadItemModel + if (item.replies) { + item.replies.sort((a: MaybeThreadItem, b: MaybeThreadItem) => { + if ('notFound' in a && a.notFound) { return 1 } - if (b.notFound) { + if ('notFound' in b && b.notFound) { return -1 } - a = a as AppBskyFeedDefs.ThreadViewPost - b = b as AppBskyFeedDefs.ThreadViewPost - const aIsByOp = a.post.author.did === post.post.author.did - const bIsByOp = b.post.author.did === post.post.author.did + item = item as PostThreadItemModel + a = a as PostThreadItemModel + b = b as PostThreadItemModel + const aIsByOp = a.post.author.did === item.post.author.did + const bIsByOp = b.post.author.did === item.post.author.did if (aIsByOp && bIsByOp) { return a.post.indexedAt.localeCompare(b.post.indexedAt) // oldest } else if (aIsByOp) { @@ -248,8 +292,31 @@ function sortThread(post: MaybePost) { } else if (bIsByOp) { return 1 // op's own reply } + // put moderated content down at the bottom + if (modScore(a.moderation) !== modScore(b.moderation)) { + return modScore(a.moderation) - modScore(b.moderation) + } return b.post.indexedAt.localeCompare(a.post.indexedAt) // newest }) - post.replies.forEach(reply => sortThread(reply)) + item.replies.forEach(reply => sortThread(reply)) + } +} + +function modScore(mod: PostModeration): number { + if (mod.content.blur && mod.content.noOverride) { + return 5 + } + if (mod.content.blur) { + return 4 + } + if (mod.content.alert) { + return 3 + } + if (mod.embed.blur && mod.embed.noOverride) { + return 2 + } + if (mod.embed.blur) { + return 1 } + return 0 } diff --git a/src/state/models/content/profile.ts b/src/state/models/content/profile.ts index 34b2ea28e..26fa6008c 100644 --- a/src/state/models/content/profile.ts +++ b/src/state/models/content/profile.ts @@ -6,18 +6,14 @@ import { AppBskyActorGetProfile as GetProfile, AppBskyActorProfile, RichText, + moderateProfile, + ProfileModeration, } from '@atproto/api' import {RootStoreModel} from '../root-store' import * as apilib from 'lib/api/index' import {cleanError} from 'lib/strings/errors' import {FollowState} from '../cache/my-follows' import {Image as RNImage} from 'react-native-image-crop-picker' -import {ProfileLabelInfo, ProfileModeration} from 'lib/labeling/types' -import { - getProfileModeration, - filterAccountLabels, - filterProfileLabels, -} from 'lib/labeling/helpers' import {track} from 'lib/analytics/analytics' export class ProfileViewerModel { @@ -26,7 +22,8 @@ export class ProfileViewerModel { following?: string followedBy?: string blockedBy?: boolean - blocking?: string + blocking?: string; + [key: string]: unknown constructor() { makeAutoObservable(this) @@ -53,7 +50,8 @@ export class ProfileModel { followsCount: number = 0 postsCount: number = 0 labels?: ComAtprotoLabelDefs.Label[] = undefined - viewer = new ProfileViewerModel() + viewer = new ProfileViewerModel(); + [key: string]: unknown // added data descriptionRichText?: RichText = new RichText({text: ''}) @@ -85,25 +83,20 @@ export class ProfileModel { return this.hasLoaded && !this.hasContent } - get labelInfo(): ProfileLabelInfo { - return { - accountLabels: filterAccountLabels(this.labels), - profileLabels: filterProfileLabels(this.labels), - isMuted: this.viewer?.muted || false, - isBlocking: !!this.viewer?.blocking || false, - isBlockedBy: !!this.viewer?.blockedBy || false, - } - } - get moderation(): ProfileModeration { - return getProfileModeration(this.rootStore, this.labelInfo) + return moderateProfile(this, this.rootStore.preferences.moderationOpts) } // public api // = async setup() { - await this._load() + const precache = await this.rootStore.profiles.cache.get(this.params.actor) + if (precache) { + await this._loadWithCache(precache) + } else { + await this._load() + } } async refresh() { @@ -252,7 +245,13 @@ export class ProfileModel { this._xLoading(isRefreshing) try { const res = await this.rootStore.agent.getProfile(this.params) - this.rootStore.profiles.overwrite(this.params.actor, res) // cache invalidation + this.rootStore.profiles.overwrite(this.params.actor, res) + if (res.data.handle) { + this.rootStore.handleResolutions.cache.set( + res.data.handle, + res.data.did, + ) + } this._replaceAll(res) await this._createRichText() this._xIdle() @@ -261,6 +260,23 @@ export class ProfileModel { } } + async _loadWithCache(precache: GetProfile.Response) { + // use cached value + this._replaceAll(precache) + await this._createRichText() + this._xIdle() + + // fetch latest + try { + const res = await this.rootStore.agent.getProfile(this.params) + this.rootStore.profiles.overwrite(this.params.actor, res) // cache invalidation + this._replaceAll(res) + await this._createRichText() + } catch (e: any) { + this._xIdle(e) + } + } + _replaceAll(res: GetProfile.Response) { this.did = res.data.did this.handle = res.data.handle diff --git a/src/state/models/discovery/foafs.ts b/src/state/models/discovery/foafs.ts index 4b25ed4af..580145f65 100644 --- a/src/state/models/discovery/foafs.ts +++ b/src/state/models/discovery/foafs.ts @@ -1,6 +1,7 @@ import { AppBskyActorDefs, AppBskyGraphGetFollows as GetFollows, + moderateProfile, } from '@atproto/api' import {makeAutoObservable, runInAction} from 'mobx' import sampleSize from 'lodash.samplesize' @@ -52,6 +53,13 @@ export class FoafsModel { cursor, limit: 100, }) + res.data.follows = res.data.follows.filter( + profile => + !moderateProfile( + profile, + this.rootStore.preferences.moderationOpts, + ).account.filter, + ) this.rootStore.me.follows.hydrateProfiles(res.data.follows) if (!res.data.cursor) { break @@ -97,11 +105,24 @@ export class FoafsModel { const profile = profiles.data.profiles[i] const source = this.sources[i] if (res.status === 'fulfilled' && profile) { - // filter out users already followed by the user or that *is* the user + // filter out inappropriate suggestions res.value.data.follows = res.value.data.follows.filter(follow => { - return ( - follow.did !== this.rootStore.me.did && !follow.viewer?.following - ) + const viewer = follow.viewer + if (viewer) { + if ( + viewer.following || + viewer.muted || + viewer.mutedByList || + viewer.blockedBy || + viewer.blocking + ) { + return false + } + } + if (follow.did === this.rootStore.me.did) { + return false + } + return true }) runInAction(() => { diff --git a/src/state/models/discovery/suggested-actors.ts b/src/state/models/discovery/suggested-actors.ts index 50faae614..0b3d36952 100644 --- a/src/state/models/discovery/suggested-actors.ts +++ b/src/state/models/discovery/suggested-actors.ts @@ -1,5 +1,5 @@ import {makeAutoObservable, runInAction} from 'mobx' -import {AppBskyActorDefs} from '@atproto/api' +import {AppBskyActorDefs, moderateProfile} from '@atproto/api' import {RootStoreModel} from '../root-store' import {cleanError} from 'lib/strings/errors' import {bundleAsync} from 'lib/async/bundle' @@ -69,7 +69,12 @@ export class SuggestedActorsModel { limit: 25, cursor: this.loadMoreCursor, }) - const {actors, cursor} = res.data + let {actors, cursor} = res.data + actors = actors.filter( + actor => + !moderateProfile(actor, this.rootStore.preferences.moderationOpts) + .account.filter, + ) this.rootStore.me.follows.hydrateProfiles(actors) runInAction(() => { @@ -80,8 +85,17 @@ export class SuggestedActorsModel { this.hasMore = !!cursor this.suggestions = this.suggestions.concat( actors.filter(actor => { - if (actor.viewer?.following) { - return false + const viewer = actor.viewer + if (viewer) { + if ( + viewer.following || + viewer.muted || + viewer.mutedByList || + viewer.blockedBy || + viewer.blocking + ) { + return false + } } if (actor.did === this.rootStore.me.did) { return false diff --git a/src/state/models/feeds/notifications.ts b/src/state/models/feeds/notifications.ts index 05e2ef0db..50a411379 100644 --- a/src/state/models/feeds/notifications.ts +++ b/src/state/models/feeds/notifications.ts @@ -8,6 +8,8 @@ import { AppBskyFeedLike, AppBskyGraphFollow, ComAtprotoLabelDefs, + moderatePost, + moderateProfile, } from '@atproto/api' import AwaitLock from 'await-lock' import chunk from 'lodash.chunk' @@ -15,24 +17,12 @@ import {bundleAsync} from 'lib/async/bundle' import {RootStoreModel} from '../root-store' import {PostThreadModel} from '../content/post-thread' import {cleanError} from 'lib/strings/errors' -import { - PostLabelInfo, - PostModeration, - ModerationBehaviorCode, -} from 'lib/labeling/types' -import { - getPostModeration, - filterAccountLabels, - filterProfileLabels, -} from 'lib/labeling/helpers' const GROUPABLE_REASONS = ['like', 'repost', 'follow'] const PAGE_SIZE = 30 const MS_1HR = 1e3 * 60 * 60 const MS_2DAY = MS_1HR * 48 -let _idCounter = 0 - export const MAX_VISIBLE_NOTIFS = 30 export interface GroupedNotification extends ListNotifications.Notification { @@ -100,27 +90,19 @@ export class NotificationsFeedItemModel { } } - get labelInfo(): PostLabelInfo { - const addedInfo = this.additionalPost?.thread?.labelInfo - return { - postLabels: (this.labels || []).concat(addedInfo?.postLabels || []), - accountLabels: filterAccountLabels(this.author.labels).concat( - addedInfo?.accountLabels || [], - ), - profileLabels: filterProfileLabels(this.author.labels).concat( - 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: - !!this.author.viewer?.blockedBy || addedInfo?.isBlockedBy || false, + get shouldFilter(): boolean { + if (this.additionalPost?.thread) { + const postMod = moderatePost( + this.additionalPost.thread.data.post, + this.rootStore.preferences.moderationOpts, + ) + return postMod.content.filter || false } - } - - get moderation(): PostModeration { - return getPostModeration(this.rootStore, this.labelInfo) + const profileMod = moderateProfile( + this.author, + this.rootStore.preferences.moderationOpts, + ) + return profileMod.account.filter || false } get numUnreadInGroup(): number { @@ -259,6 +241,12 @@ export class NotificationsFeedModel { loadMoreError = '' hasMore = true loadMoreCursor?: string + + /** + * The last time notifications were seen. Refers to either the + * user's machine clock or the value of the `indexedAt` property on their + * latest notification, whichever was greater at the time of viewing. + */ lastSync?: Date // used to linearize async modifications to state @@ -345,9 +333,6 @@ export class NotificationsFeedModel { limit: PAGE_SIZE, }) await this._replaceAll(res) - runInAction(() => { - this.lastSync = new Date() - }) this._setQueued(undefined) this._countUnread() this._xIdle() @@ -503,7 +488,9 @@ export class NotificationsFeedModel { const postsRes = await this.rootStore.agent.app.bsky.feed.getPosts({ uris: [addedUri], }) - notif.setAdditionalData(postsRes.data.posts[0]) + const post = postsRes.data.posts[0] + notif.setAdditionalData(post) + this.rootStore.posts.set(post.uri, post) } const filtered = this._filterNotifications([notif]) return filtered[0] @@ -539,9 +526,17 @@ export class NotificationsFeedModel { // = async _replaceAll(res: ListNotifications.Response) { - if (res.data.notifications[0]) { - this.mostRecentNotificationUri = res.data.notifications[0].uri + const latest = res.data.notifications[0] + + if (latest) { + const now = new Date() + const lastIndexed = new Date(latest.indexedAt) + const nowOrLastIndexed = now > lastIndexed ? now : lastIndexed + + this.mostRecentNotificationUri = latest.uri + this.lastSync = nowOrLastIndexed } + return this._appendAll(res, true) } @@ -563,8 +558,7 @@ export class NotificationsFeedModel { ): NotificationsFeedItemModel[] { return items .filter(item => { - const hideByLabel = - item.moderation.list.behavior === ModerationBehaviorCode.Hide + const hideByLabel = item.shouldFilter let mutedThread = !!( item.reasonSubjectRootUri && this.rootStore.mutedThreads.uris.has(item.reasonSubjectRootUri) @@ -588,7 +582,7 @@ export class NotificationsFeedModel { for (const item of items) { const itemModel = new NotificationsFeedItemModel( this.rootStore, - `item-${_idCounter++}`, + `notification-${item.uri}`, item, ) const uri = itemModel.additionalDataUri @@ -611,6 +605,7 @@ export class NotificationsFeedModel { ), ) for (const post of postsChunks.flat()) { + this.rootStore.posts.set(post.uri, post) const models = addedPostMap.get(post.uri) if (models?.length) { for (const model of models) { diff --git a/src/state/models/feeds/post.ts b/src/state/models/feeds/post.ts index 47039c72a..ae4f29105 100644 --- a/src/state/models/feeds/post.ts +++ b/src/state/models/feeds/post.ts @@ -3,21 +3,13 @@ import { AppBskyFeedPost as FeedPost, AppBskyFeedDefs, RichText, + moderatePost, + PostModeration, } from '@atproto/api' import {RootStoreModel} from '../root-store' import {updateDataOptimistically} from 'lib/async/revertible' -import {PostLabelInfo, PostModeration} from 'lib/labeling/types' -import { - getEmbedLabels, - getEmbedMuted, - getEmbedMutedByList, - getEmbedBlocking, - getEmbedBlockedBy, - filterAccountLabels, - filterProfileLabels, - getPostModeration, -} from 'lib/labeling/helpers' import {track} from 'lib/analytics/analytics' +import {hackAddDeletedEmbed} from 'lib/api/hack-add-deleted-embed' type FeedViewPost = AppBskyFeedDefs.FeedViewPost type ReasonRepost = AppBskyFeedDefs.ReasonRepost @@ -36,14 +28,15 @@ export class PostsFeedItemModel { constructor( public rootStore: RootStoreModel, - reactKey: string, + _reactKey: string, v: FeedViewPost, ) { - this._reactKey = reactKey + this._reactKey = _reactKey this.post = v.post if (FeedPost.isRecord(this.post.record)) { const valid = FeedPost.validateRecord(this.post.record) if (valid.success) { + hackAddDeletedEmbed(this.post) this.postRecord = this.post.record this.richText = new RichText(this.postRecord, {cleanNewlines: true}) } else { @@ -86,33 +79,8 @@ export class PostsFeedItemModel { return this.rootStore.mutedThreads.uris.has(this.rootUri) } - get labelInfo(): PostLabelInfo { - return { - postLabels: (this.post.labels || []).concat( - getEmbedLabels(this.post.embed), - ), - accountLabels: filterAccountLabels(this.post.author.labels), - profileLabels: filterProfileLabels(this.post.author.labels), - isMuted: - 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) || - false, - isBlockedBy: - !!this.post.author.viewer?.blockedBy || - getEmbedBlockedBy(this.post.embed) || - false, - } - } - get moderation(): PostModeration { - return getPostModeration(this.rootStore, this.labelInfo) + return moderatePost(this.post, this.rootStore.preferences.moderationOpts) } copy(v: FeedViewPost) { diff --git a/src/state/models/feeds/posts-slice.ts b/src/state/models/feeds/posts-slice.ts index 239bc5b6a..16e4eef15 100644 --- a/src/state/models/feeds/posts-slice.ts +++ b/src/state/models/feeds/posts-slice.ts @@ -1,11 +1,8 @@ import {makeAutoObservable} from 'mobx' import {RootStoreModel} from '../root-store' import {FeedViewPostsSlice} from 'lib/api/feed-manip' -import {mergePostModerations} from 'lib/labeling/helpers' import {PostsFeedItemModel} from './post' -let _idCounter = 0 - export class PostsFeedSliceModel { // ui state _reactKey: string = '' @@ -13,15 +10,15 @@ export class PostsFeedSliceModel { // data items: PostsFeedItemModel[] = [] - constructor( - public rootStore: RootStoreModel, - reactKey: string, - slice: FeedViewPostsSlice, - ) { - this._reactKey = reactKey - for (const item of slice.items) { + constructor(public rootStore: RootStoreModel, slice: FeedViewPostsSlice) { + this._reactKey = slice._reactKey + for (let i = 0; i < slice.items.length; i++) { this.items.push( - new PostsFeedItemModel(rootStore, `slice-${_idCounter++}`, item), + new PostsFeedItemModel( + rootStore, + `${this._reactKey} - ${i}`, + slice.items[i], + ), ) } makeAutoObservable(this, {rootStore: false}) @@ -55,7 +52,20 @@ export class PostsFeedSliceModel { } get moderation() { - return mergePostModerations(this.items.map(item => item.moderation)) + // prefer the most stringent item + const topItem = this.items.find(item => item.moderation.content.filter) + if (topItem) { + return topItem.moderation + } + // otherwise just use the first one + return this.items[0].moderation + } + + shouldFilter(ignoreFilterForDid: string | undefined): boolean { + const mods = this.items + .filter(item => item.post.author.did !== ignoreFilterForDid) + .map(item => item.moderation) + return !!mods.find(mod => mod.content.filter) } containsUri(uri: string) { diff --git a/src/state/models/feeds/posts.ts b/src/state/models/feeds/posts.ts index 4e6633d38..6facc27ad 100644 --- a/src/state/models/feeds/posts.ts +++ b/src/state/models/feeds/posts.ts @@ -8,12 +8,11 @@ import AwaitLock from 'await-lock' import {bundleAsync} from 'lib/async/bundle' import {RootStoreModel} from '../root-store' import {cleanError} from 'lib/strings/errors' -import {FeedTuner, FeedViewPostsSlice} from 'lib/api/feed-manip' +import {FeedTuner} from 'lib/api/feed-manip' import {PostsFeedSliceModel} from './posts-slice' import {track} from 'lib/analytics/analytics' const PAGE_SIZE = 30 -let _idCounter = 0 type QueryParams = | GetTimeline.QueryParams @@ -75,24 +74,6 @@ export class PostsFeedModel { return this.hasLoaded && !this.hasContent } - get nonReplyFeed() { - if (this.feedType === 'author') { - return this.slices.filter(slice => { - const params = this.params as GetAuthorFeed.QueryParams - const item = slice.rootItem - const isRepost = - item?.reasonRepost?.by?.handle === params.actor || - item?.reasonRepost?.by?.did === params.actor - const allow = - !item.postRecord?.reply || // not a reply - isRepost // but allow if it's a repost - return allow - }) - } else { - return this.slices - } - } - setHasNewLatest(v: boolean) { this.hasNewLatest = v } @@ -282,31 +263,26 @@ export class PostsFeedModel { return } const res = await this._getFeed({limit: 1}) - this.setHasNewLatest(res.data.feed[0]?.post.uri !== this.pollCursor) + if (res.data.feed[0]) { + const slices = this.tuner.tune(res.data.feed, this.feedTuners) + if (slices[0]) { + const sliceModel = new PostsFeedSliceModel(this.rootStore, slices[0]) + if (sliceModel.moderation.content.filter) { + return + } + this.setHasNewLatest(sliceModel.uri !== this.pollCursor) + } + } } /** - * Fetches the given post and adds it to the top - * Used by the composer to add their new posts + * Updates the UI after the user has created a post */ - async addPostToTop(uri: string) { + onPostCreated() { if (!this.slices.length) { return this.refresh() - } - try { - const res = await this.rootStore.agent.app.bsky.feed.getPosts({ - uris: [uri], - }) - const toPrepend = new PostsFeedSliceModel( - this.rootStore, - uri, - new FeedViewPostsSlice(res.data.posts.map(post => ({post}))), - ) - runInAction(() => { - this.slices = [toPrepend].concat(this.slices) - }) - } catch (e) { - this.rootStore.log.error('Failed to load post to prepend', {e}) + } else { + this.setHasNewLatest(true) } } @@ -374,16 +350,15 @@ export class PostsFeedModel { this.rootStore.me.follows.hydrateProfiles( res.data.feed.map(item => item.post.author), ) + for (const item of res.data.feed) { + this.rootStore.posts.fromFeedItem(item) + } const slices = this.tuner.tune(res.data.feed, this.feedTuners) const toAppend: PostsFeedSliceModel[] = [] for (const slice of slices) { - const sliceModel = new PostsFeedSliceModel( - this.rootStore, - `item-${_idCounter++}`, - slice, - ) + const sliceModel = new PostsFeedSliceModel(this.rootStore, slice) toAppend.push(sliceModel) } runInAction(() => { @@ -405,6 +380,7 @@ export class PostsFeedModel { res: GetTimeline.Response | GetAuthorFeed.Response | GetCustomFeed.Response, ) { for (const item of res.data.feed) { + this.rootStore.posts.fromFeedItem(item) const existingSlice = this.slices.find(slice => slice.containsUri(item.post.uri), ) diff --git a/src/state/models/me.ts b/src/state/models/me.ts index 59d79f056..186e61cf6 100644 --- a/src/state/models/me.ts +++ b/src/state/models/me.ts @@ -52,6 +52,8 @@ export class MeModel { this.mainFeed.clear() this.notifications.clear() this.follows.clear() + this.rootStore.profiles.cache.clear() + this.rootStore.posts.cache.clear() this.did = '' this.handle = '' this.displayName = '' @@ -104,7 +106,6 @@ export class MeModel { this.rootStore.log.debug('MeModel:load', {hasSession: sess.hasSession}) if (sess.hasSession) { this.did = sess.currentSession?.did || '' - this.handle = sess.currentSession?.handle || '' await this.fetchProfile() this.mainFeed.clear() /* dont await */ this.mainFeed.setup().catch(e => { @@ -144,6 +145,7 @@ export class MeModel { this.displayName = profile.data.displayName || '' this.description = profile.data.description || '' this.avatar = profile.data.avatar || '' + this.handle = profile.data.handle || '' this.followsCount = profile.data.followsCount this.followersCount = profile.data.followersCount } else { diff --git a/src/state/models/media/image.ts b/src/state/models/media/image.ts index e524c49de..dd5b36170 100644 --- a/src/state/models/media/image.ts +++ b/src/state/models/media/image.ts @@ -120,8 +120,8 @@ export class ImageModel implements Omit<RNImage, 'size'> { } } - async setAltText(altText: string) { - this.altText = altText + setAltText(altText: string) { + this.altText = altText.trim() } // Only compress prior to upload diff --git a/src/state/models/root-store.ts b/src/state/models/root-store.ts index d76ea07c9..6ced8090a 100644 --- a/src/state/models/root-store.ts +++ b/src/state/models/root-store.ts @@ -12,7 +12,9 @@ import {isObj, hasProp} from 'lib/type-guards' import {LogModel} from './log' import {SessionModel} from './session' import {ShellUiModel} from './ui/shell' +import {HandleResolutionsCache} from './cache/handle-resolutions' import {ProfilesCache} from './cache/profiles-view' +import {PostsCache} from './cache/posts' import {LinkMetasCache} from './cache/link-metas' import {NotificationsFeedItemModel} from './feeds/notifications' import {MeModel} from './me' @@ -45,7 +47,9 @@ export class RootStoreModel { preferences = new PreferencesModel(this) me = new MeModel(this) invitedUsers = new InvitedUsers(this) + handleResolutions = new HandleResolutionsCache() profiles = new ProfilesCache(this) + posts = new PostsCache(this) linkMetas = new LinkMetasCache(this) imageSizes = new ImageSizesCache() mutedThreads = new MutedThreads() diff --git a/src/state/models/ui/preferences.ts b/src/state/models/ui/preferences.ts index e1c0b1f71..23668a3dc 100644 --- a/src/state/models/ui/preferences.ts +++ b/src/state/models/ui/preferences.ts @@ -1,9 +1,14 @@ import {makeAutoObservable, runInAction} from 'mobx' +import {LabelPreference as APILabelPreference} from '@atproto/api' import AwaitLock from 'await-lock' import isEqual from 'lodash.isequal' import {isObj, hasProp} from 'lib/type-guards' import {RootStoreModel} from '../root-store' -import {ComAtprotoLabelDefs, AppBskyActorDefs} from '@atproto/api' +import { + ComAtprotoLabelDefs, + AppBskyActorDefs, + ModerationOpts, +} from '@atproto/api' import {LabelValGroup} from 'lib/labeling/types' import {getLabelValueGroup} from 'lib/labeling/helpers' import { @@ -16,7 +21,8 @@ import {DEFAULT_FEEDS} from 'lib/constants' import {isIOS, deviceLocales} from 'platform/detection' import {LANGUAGES} from '../../../locale/languages' -export type LabelPreference = 'show' | 'warn' | 'hide' +// TEMP we need to permanently convert 'show' to 'ignore', for now we manually convert -prf +export type LabelPreference = APILabelPreference | 'show' const LABEL_GROUPS = [ 'nsfw', 'nudity', @@ -408,6 +414,44 @@ export class PreferencesModel { return res } + get moderationOpts(): ModerationOpts { + return { + userDid: this.rootStore.session.currentSession?.did || '', + adultContentEnabled: this.adultContentEnabled, + labels: { + // TEMP translate old settings until this UI can be migrated -prf + porn: tempfixLabelPref(this.contentLabels.nsfw), + sexual: tempfixLabelPref(this.contentLabels.suggestive), + nudity: tempfixLabelPref(this.contentLabels.nudity), + nsfl: tempfixLabelPref(this.contentLabels.gore), + corpse: tempfixLabelPref(this.contentLabels.gore), + gore: tempfixLabelPref(this.contentLabels.gore), + torture: tempfixLabelPref(this.contentLabels.gore), + 'self-harm': tempfixLabelPref(this.contentLabels.gore), + 'intolerant-race': tempfixLabelPref(this.contentLabels.hate), + 'intolerant-gender': tempfixLabelPref(this.contentLabels.hate), + 'intolerant-sexual-orientation': tempfixLabelPref( + this.contentLabels.hate, + ), + 'intolerant-religion': tempfixLabelPref(this.contentLabels.hate), + intolerant: tempfixLabelPref(this.contentLabels.hate), + 'icon-intolerant': tempfixLabelPref(this.contentLabels.hate), + spam: tempfixLabelPref(this.contentLabels.spam), + impersonation: tempfixLabelPref(this.contentLabels.impersonation), + scam: 'warn', + }, + labelers: [ + { + labeler: { + did: '', + displayName: 'Bluesky Social', + }, + labels: {}, + }, + ], + } + } + async setSavedFeeds(saved: string[], pinned: string[]) { const oldSaved = this.savedFeeds const oldPinned = this.pinnedFeeds @@ -485,3 +529,11 @@ export class PreferencesModel { this.requireAltTextEnabled = !this.requireAltTextEnabled } } + +// TEMP we need to permanently convert 'show' to 'ignore', for now we manually convert -prf +function tempfixLabelPref(pref: LabelPreference): APILabelPreference { + if (pref === 'show') { + return 'ignore' + } + return pref +} diff --git a/src/state/models/ui/profile.ts b/src/state/models/ui/profile.ts index a0249d768..9dae09ec5 100644 --- a/src/state/models/ui/profile.ts +++ b/src/state/models/ui/profile.ts @@ -6,8 +6,9 @@ import {ActorFeedsModel} from '../lists/actor-feeds' import {ListsListModel} from '../lists/lists-list' export enum Sections { - Posts = 'Posts', + PostsNoReplies = 'Posts', PostsWithReplies = 'Posts & replies', + PostsWithMedia = 'Media', CustomAlgorithms = 'Feeds', Lists = 'Lists', } @@ -46,6 +47,7 @@ export class ProfileUiModel { this.feed = new PostsFeedModel(rootStore, 'author', { actor: params.user, limit: 10, + filter: 'posts_no_replies', }) this.algos = new ActorFeedsModel(rootStore, {actor: params.user}) this.lists = new ListsListModel(rootStore, params.user) @@ -53,8 +55,9 @@ export class ProfileUiModel { get currentView(): PostsFeedModel | ActorFeedsModel | ListsListModel { if ( - this.selectedView === Sections.Posts || - this.selectedView === Sections.PostsWithReplies + this.selectedView === Sections.PostsNoReplies || + this.selectedView === Sections.PostsWithReplies || + this.selectedView === Sections.PostsWithMedia ) { return this.feed } else if (this.selectedView === Sections.Lists) { @@ -76,7 +79,11 @@ export class ProfileUiModel { } get selectorItems() { - const items = [Sections.Posts, Sections.PostsWithReplies] + const items = [ + Sections.PostsNoReplies, + Sections.PostsWithReplies, + Sections.PostsWithMedia, + ] if (this.algos.hasLoaded && !this.algos.isEmpty) { items.push(Sections.CustomAlgorithms) } @@ -90,7 +97,7 @@ export class ProfileUiModel { // If, for whatever reason, the selected view index is not available, default back to posts // This can happen when the user was focused on a view but performed an action that caused // the view to disappear (e.g. deleting the last list in their list of lists https://imgflip.com/i/7txu1y) - return this.selectorItems[this.selectedViewIndex] || Sections.Posts + return this.selectorItems[this.selectedViewIndex] || Sections.PostsNoReplies } get uiItems() { @@ -107,26 +114,25 @@ export class ProfileUiModel { }, ]) } else { - // not loading, no error, show content if ( - this.selectedView === Sections.Posts || + this.selectedView === Sections.PostsNoReplies || this.selectedView === Sections.PostsWithReplies || - this.selectedView === Sections.CustomAlgorithms + this.selectedView === Sections.PostsWithMedia ) { if (this.feed.hasContent) { - if (this.selectedView === Sections.CustomAlgorithms) { - arr = this.algos.feeds - } else if (this.selectedView === Sections.Posts) { - arr = this.feed.nonReplyFeed - } else { - arr = this.feed.slices.slice() - } + arr = this.feed.slices.slice() if (!this.feed.hasMore) { arr = arr.concat([ProfileUiModel.END_ITEM]) } } else if (this.feed.isEmpty) { arr = arr.concat([ProfileUiModel.EMPTY_ITEM]) } + } else if (this.selectedView === Sections.CustomAlgorithms) { + if (this.algos.hasContent) { + arr = this.algos.feeds + } else if (this.algos.isEmpty) { + arr = arr.concat([ProfileUiModel.EMPTY_ITEM]) + } } else if (this.selectedView === Sections.Lists) { if (this.lists.hasContent) { arr = this.lists.lists @@ -143,8 +149,9 @@ export class ProfileUiModel { get showLoadingMoreFooter() { if ( - this.selectedView === Sections.Posts || - this.selectedView === Sections.PostsWithReplies + this.selectedView === Sections.PostsNoReplies || + this.selectedView === Sections.PostsWithReplies || + this.selectedView === Sections.PostsWithMedia ) { return this.feed.hasContent && this.feed.hasMore && this.feed.isLoading } else if (this.selectedView === Sections.Lists) { @@ -157,7 +164,27 @@ export class ProfileUiModel { // = setSelectedViewIndex(index: number) { + // ViewSelector fires onSelectView on mount + if (index === this.selectedViewIndex) return + this.selectedViewIndex = index + + let filter = 'posts_no_replies' + if (this.selectedView === Sections.PostsWithReplies) { + filter = 'posts_with_replies' + } else if (this.selectedView === Sections.PostsWithMedia) { + filter = 'posts_with_media' + } + + this.feed = new PostsFeedModel(this.rootStore, 'author', { + actor: this.params.user, + limit: 10, + filter, + }) + + if (this.currentView instanceof PostsFeedModel) { + this.feed.setup() + } } async setup() { diff --git a/src/state/models/ui/shell.ts b/src/state/models/ui/shell.ts index e33a34acf..92d028c79 100644 --- a/src/state/models/ui/shell.ts +++ b/src/state/models/ui/shell.ts @@ -1,4 +1,4 @@ -import {AppBskyEmbedRecord} from '@atproto/api' +import {AppBskyEmbedRecord, ModerationUI} from '@atproto/api' import {RootStoreModel} from '../root-store' import {makeAutoObservable, runInAction} from 'mobx' import {ProfileModel} from '../content/profile' @@ -42,16 +42,21 @@ export interface ServerInputModal { onSelect: (url: string) => void } -export interface ReportPostModal { - name: 'report-post' - postUri: string - postCid: string +export interface ModerationDetailsModal { + name: 'moderation-details' + context: 'account' | 'content' + moderation: ModerationUI } -export interface ReportAccountModal { - name: 'report-account' - did: string -} +export type ReportModal = { + name: 'report' +} & ( + | { + uri: string + cid: string + } + | {did: string} +) export interface CreateOrEditMuteListModal { name: 'create-or-edit-mute-list' @@ -94,6 +99,13 @@ export interface RepostModal { isReposted: boolean } +export interface SelfLabelModal { + name: 'self-label' + labels: string[] + hasMedia: boolean + onChange: (labels: string[]) => void +} + export interface ChangeHandleModal { name: 'change-handle' onChanged: () => void @@ -146,8 +158,8 @@ export type Modal = | PreferencesHomeFeed // Moderation - | ReportAccountModal - | ReportPostModal + | ModerationDetailsModal + | ReportModal | CreateOrEditMuteListModal | ListAddRemoveUserModal @@ -157,6 +169,7 @@ export type Modal = | EditImageModal | ServerInputModal | RepostModal + | SelfLabelModal // Bluesky access | WaitlistModal diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 0fae996ff..ecfef3ecd 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -32,6 +32,8 @@ import {s, colors, gradients} from 'lib/styles' import {sanitizeDisplayName} from 'lib/strings/display-names' import {sanitizeHandle} from 'lib/strings/handles' import {cleanError} from 'lib/strings/errors' +import {shortenLinks} from 'lib/strings/rich-text-manip' +import {toShortUrl} from 'lib/strings/url-helpers' import {SelectPhotoBtn} from './photos/SelectPhotoBtn' import {OpenCameraBtn} from './photos/OpenCameraBtn' import {usePalette} from 'lib/hooks/usePalette' @@ -41,6 +43,7 @@ import {isDesktopWeb, isAndroid, isIOS} from 'platform/detection' import {GalleryModel} from 'state/models/media/gallery' import {Gallery} from './photos/Gallery' import {MAX_GRAPHEME_LENGTH} from 'lib/constants' +import {LabelsBtn} from './labels/LabelsBtn' import {SelectLangBtn} from './select-language/SelectLangBtn' type Props = ComposerOpts & { @@ -62,11 +65,14 @@ export const ComposePost = observer(function ComposePost({ const [processingState, setProcessingState] = useState('') const [error, setError] = useState('') const [richtext, setRichText] = useState(new RichText({text: ''})) - const graphemeLength = useMemo(() => richtext.graphemeLength, [richtext]) + const graphemeLength = useMemo(() => { + return shortenLinks(richtext).graphemeLength + }, [richtext]) const [quote, setQuote] = useState<ComposerOpts['quote'] | undefined>( initQuote, ) const {extLink, setExtLink} = useExternalLinkFetch({setQuote}) + const [labels, setLabels] = useState<string[]>([]) const [suggestedLinks, setSuggestedLinks] = useState<Set<string>>(new Set()) const gallery = useMemo(() => new GalleryModel(store), [store]) @@ -145,76 +151,59 @@ export const ComposePost = observer(function ComposePost({ [gallery, track], ) - const onPressPublish = useCallback( - async (rt: RichText) => { - if (isProcessing || rt.graphemeLength > MAX_GRAPHEME_LENGTH) { - return - } - if (store.preferences.requireAltTextEnabled && gallery.needsAltText) { - return - } + const onPressPublish = async () => { + if (isProcessing || graphemeLength > MAX_GRAPHEME_LENGTH) { + return + } + if (store.preferences.requireAltTextEnabled && gallery.needsAltText) { + return + } - setError('') + setError('') - if (rt.text.trim().length === 0 && gallery.isEmpty) { - setError('Did you want to say anything?') - return - } + if (richtext.text.trim().length === 0 && gallery.isEmpty) { + setError('Did you want to say anything?') + return + } - setIsProcessing(true) + setIsProcessing(true) - let createdPost - try { - createdPost = await apilib.post(store, { - rawText: rt.text, - replyTo: replyTo?.uri, - images: gallery.images, - quote: quote, - extLink: extLink, - onStateChange: setProcessingState, - knownHandles: autocompleteView.knownHandles, - langs: store.preferences.postLanguages, - }) - } catch (e: any) { - if (extLink) { - setExtLink({ - ...extLink, - isLoading: true, - localThumb: undefined, - } as apilib.ExternalEmbedDraft) - } - setError(cleanError(e.message)) - setIsProcessing(false) - return - } finally { - track('Create Post', { - imageCount: gallery.size, - }) - if (replyTo && replyTo.uri) track('Post:Reply') - } - if (!replyTo) { - await store.me.mainFeed.addPostToTop(createdPost.uri) + try { + await apilib.post(store, { + rawText: richtext.text, + replyTo: replyTo?.uri, + images: gallery.images, + quote, + extLink, + labels, + onStateChange: setProcessingState, + knownHandles: autocompleteView.knownHandles, + langs: store.preferences.postLanguages, + }) + } catch (e: any) { + if (extLink) { + setExtLink({ + ...extLink, + isLoading: true, + localThumb: undefined, + } as apilib.ExternalEmbedDraft) } - onPost?.() - onClose() - Toast.show(`Your ${replyTo ? 'reply' : 'post'} has been published`) - }, - [ - isProcessing, - setError, - setIsProcessing, - replyTo, - autocompleteView.knownHandles, - extLink, - onClose, - onPost, - quote, - setExtLink, - store, - track, - gallery, - ], - ) + setError(cleanError(e.message)) + setIsProcessing(false) + return + } finally { + track('Create Post', { + imageCount: gallery.size, + }) + if (replyTo && replyTo.uri) track('Post:Reply') + } + if (!replyTo) { + store.me.mainFeed.onPostCreated() + } + onPost?.() + onClose() + Toast.show(`Your ${replyTo ? 'reply' : 'post'} has been published`) + } const canPost = useMemo( () => @@ -229,6 +218,7 @@ export const ComposePost = observer(function ComposePost({ const selectTextInputPlaceholder = replyTo ? 'Write your reply' : `What's up?` const canSelectImages = useMemo(() => gallery.size < 4, [gallery.size]) + const hasMedia = gallery.size > 0 || Boolean(extLink) return ( <KeyboardAvoidingView @@ -247,6 +237,7 @@ export const ComposePost = observer(function ComposePost({ <Text style={[pal.link, s.f18]}>Cancel</Text> </TouchableOpacity> <View style={s.flex1} /> + <LabelsBtn labels={labels} onChange={setLabels} hasMedia={hasMedia} /> {isProcessing ? ( <View style={styles.postBtn}> <ActivityIndicator /> @@ -254,9 +245,7 @@ export const ComposePost = observer(function ComposePost({ ) : canPost ? ( <TouchableOpacity testID="composerPublishBtn" - onPress={() => { - onPressPublish(richtext) - }} + onPress={onPressPublish} accessibilityRole="button" accessibilityLabel={replyTo ? 'Publish reply' : 'Publish post'} accessibilityHint={ @@ -366,20 +355,23 @@ export const ComposePost = observer(function ComposePost({ </ScrollView> {!extLink && suggestedLinks.size > 0 ? ( <View style={s.mb5}> - {Array.from(suggestedLinks).map(url => ( - <TouchableOpacity - key={`suggested-${url}`} - testID="addLinkCardBtn" - style={[pal.borderDark, styles.addExtLinkBtn]} - onPress={() => onPressAddLinkCard(url)} - accessibilityRole="button" - accessibilityLabel="Add link card" - accessibilityHint={`Creates a card with a thumbnail. The card links to ${url}`}> - <Text style={pal.text}> - Add link card: <Text style={pal.link}>{url}</Text> - </Text> - </TouchableOpacity> - ))} + {Array.from(suggestedLinks) + .slice(0, 3) + .map(url => ( + <TouchableOpacity + key={`suggested-${url}`} + testID="addLinkCardBtn" + style={[pal.borderDark, styles.addExtLinkBtn]} + onPress={() => onPressAddLinkCard(url)} + accessibilityRole="button" + accessibilityLabel="Add link card" + accessibilityHint={`Creates a card with a thumbnail. The card links to ${url}`}> + <Text style={pal.text}> + Add link card:{' '} + <Text style={pal.link}>{toShortUrl(url)}</Text> + </Text> + </TouchableOpacity> + ))} </View> ) : null} <View style={[pal.border, styles.bottomBar]}> @@ -408,7 +400,7 @@ const styles = StyleSheet.create({ flexDirection: 'row', alignItems: 'center', paddingTop: isDesktopWeb ? 10 : undefined, - paddingBottom: 10, + paddingBottom: isDesktopWeb ? 10 : 4, paddingHorizontal: 20, height: 55, }, diff --git a/src/view/com/composer/labels/LabelsBtn.tsx b/src/view/com/composer/labels/LabelsBtn.tsx new file mode 100644 index 000000000..96908d47f --- /dev/null +++ b/src/view/com/composer/labels/LabelsBtn.tsx @@ -0,0 +1,64 @@ +import React from 'react' +import {Keyboard, StyleSheet} from 'react-native' +import {observer} from 'mobx-react-lite' +import {Button} from 'view/com/util/forms/Button' +import {usePalette} from 'lib/hooks/usePalette' +import {useStores} from 'state/index' +import {ShieldExclamation} from 'lib/icons' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {FontAwesomeIconStyle} from '@fortawesome/react-native-fontawesome' +import {isNative} from 'platform/detection' + +export const LabelsBtn = observer(function LabelsBtn({ + labels, + hasMedia, + onChange, +}: { + labels: string[] + hasMedia: boolean + onChange: (v: string[]) => void +}) { + const pal = usePalette('default') + const store = useStores() + + return ( + <Button + type="default-light" + testID="labelsBtn" + style={[styles.button, !hasMedia && styles.dimmed]} + accessibilityLabel="Content warnings" + accessibilityHint="" + onPress={() => { + if (isNative) { + if (Keyboard.isVisible()) { + Keyboard.dismiss() + } + } + store.shell.openModal({name: 'self-label', labels, hasMedia, onChange}) + }}> + <ShieldExclamation style={pal.link} size={26} /> + {labels.length > 0 ? ( + <FontAwesomeIcon + icon="check" + size={16} + style={pal.link as FontAwesomeIconStyle} + /> + ) : null} + </Button> + ) +}) + +const styles = StyleSheet.create({ + button: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 14, + marginRight: 4, + }, + dimmed: { + opacity: 0.4, + }, + label: { + maxWidth: 100, + }, +}) diff --git a/src/view/com/composer/text-input/TextInput.web.tsx b/src/view/com/composer/text-input/TextInput.web.tsx index 245c17b9c..f64880e15 100644 --- a/src/view/com/composer/text-input/TextInput.web.tsx +++ b/src/view/com/composer/text-input/TextInput.web.tsx @@ -1,6 +1,7 @@ import React from 'react' import {StyleSheet, View} from 'react-native' import {RichText} from '@atproto/api' +import EventEmitter from 'eventemitter3' import {useEditor, EditorContent, JSONContent} from '@tiptap/react' import {Document} from '@tiptap/extension-document' import History from '@tiptap/extension-history' @@ -53,6 +54,22 @@ export const TextInput = React.forwardRef( 'ProseMirror-dark', ) + // we use a memoized emitter to propagate events out of tiptap + // without triggering re-runs of the useEditor hook + const emitter = React.useMemo(() => new EventEmitter(), []) + React.useEffect(() => { + emitter.addListener('publish', onPressPublish) + return () => { + emitter.removeListener('publish', onPressPublish) + } + }, [emitter, onPressPublish]) + React.useEffect(() => { + emitter.addListener('photo-pasted', onPhotoPasted) + return () => { + emitter.removeListener('photo-pasted', onPhotoPasted) + } + }, [emitter, onPhotoPasted]) + const editor = useEditor( { extensions: [ @@ -60,6 +77,7 @@ export const TextInput = React.forwardRef( Link.configure({ protocols: ['http', 'https'], autolink: true, + linkOnPaste: false, }), Mention.configure({ HTMLAttributes: { @@ -86,16 +104,13 @@ export const TextInput = React.forwardRef( return } - getImageFromUri(items, onPhotoPasted) + getImageFromUri(items, (uri: string) => { + emitter.emit('photo-pasted', uri) + }) }, handleKeyDown: (_, event) => { if ((event.metaKey || event.ctrlKey) && event.code === 'Enter') { - // Workaround relying on previous state from `setRichText` to - // get the updated text content during editor initialization - setRichText((state: RichText) => { - onPressPublish(state) - return state - }) + emitter.emit('publish') } }, }, @@ -107,6 +122,7 @@ export const TextInput = React.forwardRef( const json = editorProp.getJSON() const newRt = new RichText({text: editorJsonToText(json).trim()}) + newRt.detectFacetsWithoutResolution() setRichText(newRt) const newSuggestedLinks = new Set(editorJsonToLinks(json)) @@ -115,7 +131,7 @@ export const TextInput = React.forwardRef( } }, }, - [modeClass], + [modeClass, emitter], ) React.useImperativeHandle(ref, () => ({ diff --git a/src/view/com/feeds/CustomFeed.tsx b/src/view/com/feeds/CustomFeed.tsx index 79f1dd74d..264c2d982 100644 --- a/src/view/com/feeds/CustomFeed.tsx +++ b/src/view/com/feeds/CustomFeed.tsx @@ -69,6 +69,7 @@ export const CustomFeed = observer( return ( <TouchableOpacity + testID={`feed-${item.displayName}`} accessibilityRole="button" style={[styles.container, pal.border, style]} onPress={() => { diff --git a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx index b900f9afe..f5e858209 100644 --- a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx +++ b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx @@ -17,6 +17,7 @@ import { NativeSyntheticEvent, NativeMethodsMixin, } from 'react-native' +import {Image} from 'expo-image' import useImageDimensions from '../../hooks/useImageDimensions' import usePanResponder from '../../hooks/usePanResponder' @@ -41,6 +42,8 @@ type Props = { doubleTapToZoomEnabled?: boolean } +const AnimatedImage = Animated.createAnimatedComponent(Image) + const ImageItem = ({ imageSrc, onZoom, @@ -128,7 +131,7 @@ const ImageItem = ({ onScroll, onScrollEndDrag, })}> - <Animated.Image + <AnimatedImage {...panHandlers} source={imageSrc} style={imageStylesWithOpacity} diff --git a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx index ebf0b1d28..a6b98009a 100644 --- a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx +++ b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx @@ -18,6 +18,7 @@ import { NativeSyntheticEvent, TouchableWithoutFeedback, } from 'react-native' +import {Image} from 'expo-image' import useDoubleTapToZoom from '../../hooks/useDoubleTapToZoom' import useImageDimensions from '../../hooks/useImageDimensions' @@ -42,6 +43,8 @@ type Props = { doubleTapToZoomEnabled?: boolean } +const AnimatedImage = Animated.createAnimatedComponent(Image) + const ImageItem = ({ imageSrc, onZoom, @@ -131,7 +134,7 @@ const ImageItem = ({ accessibilityRole="image" accessibilityLabel={imageSrc.alt} accessibilityHint=""> - <Animated.Image + <AnimatedImage source={imageSrc} style={imageStylesWithOpacity} onLoad={() => setLoaded(true)} diff --git a/src/view/com/lists/ListActions.tsx b/src/view/com/lists/ListActions.tsx index ee5a2afcb..353338198 100644 --- a/src/view/com/lists/ListActions.tsx +++ b/src/view/com/lists/ListActions.tsx @@ -11,6 +11,7 @@ export const ListActions = ({ isOwner, onPressDeleteList, onPressShareList, + onPressReportList, reversed = false, // Default value of reversed is false }: { isOwner: boolean @@ -19,6 +20,7 @@ export const ListActions = ({ onPressEditList?: () => void onPressDeleteList?: () => void onPressShareList?: () => void + onPressReportList?: () => void reversed?: boolean // New optional prop }) => { const pal = usePalette('default') @@ -64,6 +66,17 @@ export const ListActions = ({ onPress={onPressShareList}> <FontAwesomeIcon icon={'share'} style={[pal.text]} /> </Button>, + !isOwner && ( + <Button + key="reportListBtn" + testID="reportListBtn" + type="default" + accessibilityLabel="Report list" + accessibilityHint="" + onPress={onPressReportList}> + <FontAwesomeIcon icon={'circle-exclamation'} style={[pal.text]} /> + </Button> + ), ] // If reversed is true, reverse the array to reverse the order of the buttons diff --git a/src/view/com/lists/ListItems.tsx b/src/view/com/lists/ListItems.tsx index 188518ea5..7f2173d78 100644 --- a/src/view/com/lists/ListItems.tsx +++ b/src/view/com/lists/ListItems.tsx @@ -45,6 +45,7 @@ export const ListItems = observer( onPressEditList, onPressDeleteList, onPressShareList, + onPressReportList, renderEmptyState, testID, headerOffset = 0, @@ -57,6 +58,7 @@ export const ListItems = observer( onPressEditList: () => void onPressDeleteList: () => void onPressShareList: () => void + onPressReportList: () => void renderEmptyState?: () => JSX.Element testID?: string headerOffset?: number @@ -169,6 +171,7 @@ export const ListItems = observer( onPressEditList={onPressEditList} onPressDeleteList={onPressDeleteList} onPressShareList={onPressShareList} + onPressReportList={onPressReportList} /> ) : null } else if (item === ERROR_ITEM) { @@ -208,6 +211,7 @@ export const ListItems = observer( onPressEditList, onPressDeleteList, onPressShareList, + onPressReportList, onPressTryAgain, onPressRetryLoadMore, ], @@ -267,6 +271,7 @@ const ListHeader = observer( onPressEditList, onPressDeleteList, onPressShareList, + onPressReportList, }: { list: AppBskyGraphDefs.ListView isOwner: boolean @@ -274,6 +279,7 @@ const ListHeader = observer( onPressEditList: () => void onPressDeleteList: () => void onPressShareList: () => void + onPressReportList: () => void }) => { const pal = usePalette('default') const store = useStores() @@ -300,6 +306,7 @@ const ListHeader = observer( <TextLink text={sanitizeHandle(list.creator.handle, '@')} href={makeProfileLink(list.creator)} + style={pal.textLight} /> )} </Text> @@ -319,6 +326,7 @@ const ListHeader = observer( onPressEditList={onPressEditList} onToggleSubscribed={onToggleSubscribed} onPressShareList={onPressShareList} + onPressReportList={onPressReportList} /> )} </View> diff --git a/src/view/com/lists/ListsList.tsx b/src/view/com/lists/ListsList.tsx index 2b6f74c2b..fb07ee0b8 100644 --- a/src/view/com/lists/ListsList.tsx +++ b/src/view/com/lists/ListsList.tsx @@ -1,6 +1,5 @@ import React, {MutableRefObject} from 'react' import { - ActivityIndicator, RefreshControl, StyleProp, StyleSheet, @@ -166,18 +165,6 @@ export const ListsList = observer( ], ) - const Footer = React.useCallback( - () => - listsList.isLoading ? ( - <View style={styles.feedFooter}> - <ActivityIndicator /> - </View> - ) : ( - <View /> - ), - [listsList], - ) - return ( <View testID={testID} style={style}> {data.length > 0 && ( @@ -187,7 +174,6 @@ export const ListsList = observer( data={data} keyExtractor={item => item._reactKey} renderItem={renderItemInner} - ListFooterComponent={Footer} refreshControl={ <RefreshControl refreshing={isRefreshing} diff --git a/src/view/com/modals/ChangeHandle.tsx b/src/view/com/modals/ChangeHandle.tsx index a6010906c..0b9707622 100644 --- a/src/view/com/modals/ChangeHandle.tsx +++ b/src/view/com/modals/ChangeHandle.tsx @@ -493,7 +493,9 @@ function CustomHandleForm({ <ActivityIndicator color="white" /> ) : ( <Text type="xl-medium" style={[s.white, s.textCenter]}> - {canSave ? `Update to ${handle}` : 'Verify DNS Record'} + {canSave + ? `Update to ${handle}` + : `Verify ${isDNSForm ? 'DNS Record' : 'Text File'}`} </Text> )} </Button> diff --git a/src/view/com/modals/InviteCodes.tsx b/src/view/com/modals/InviteCodes.tsx index b3fe9dd3f..d46579f09 100644 --- a/src/view/com/modals/InviteCodes.tsx +++ b/src/view/com/modals/InviteCodes.tsx @@ -53,11 +53,7 @@ export function Component({}: {}) { Invite a Friend </Text> <Text type="lg" style={[styles.description, pal.text]}> - Send these invites to your friends so they can create an account. Each - code works once! - </Text> - <Text type="sm" style={[styles.description, pal.textLight]}> - (You'll receive one invite code every two weeks.) + Each code works once. You'll receive more invite codes periodically. </Text> <ScrollView style={[styles.scrollContainer, pal.border]}> {store.me.invites.map((invite, i) => ( diff --git a/src/view/com/modals/ListAddRemoveUser.tsx b/src/view/com/modals/ListAddRemoveUser.tsx index 0f001f911..bfb7e4dc0 100644 --- a/src/view/com/modals/ListAddRemoveUser.tsx +++ b/src/view/com/modals/ListAddRemoveUser.tsx @@ -1,6 +1,6 @@ import React, {useCallback} from 'react' import {observer} from 'mobx-react-lite' -import {Pressable, StyleSheet, View} from 'react-native' +import {Pressable, StyleSheet, View, ActivityIndicator} from 'react-native' import {AppBskyGraphDefs as GraphDefs} from '@atproto/api' import { FontAwesomeIcon, @@ -42,6 +42,7 @@ export const Component = observer( string[] >([]) const [selected, setSelected] = React.useState<string[]>([]) + const [membershipsLoaded, setMembershipsLoaded] = React.useState(false) const listsList: ListsListModel = React.useMemo( () => new ListsListModel(store, store.me.did), @@ -58,12 +59,13 @@ export const Component = observer( const ids = memberships.memberships.map(m => m.value.list) setOriginalSelections(ids) setSelected(ids) + setMembershipsLoaded(true) }, err => { store.log.error('Failed to fetch memberships', {err}) }, ) - }, [memberships, listsList, store, setSelected]) + }, [memberships, listsList, store, setSelected, setMembershipsLoaded]) const onPressCancel = useCallback(() => { store.shell.closeModal() @@ -107,11 +109,16 @@ export const Component = observer( return ( <Pressable testID={`toggleBtn-${list.name}`} - style={[styles.listItem, pal.border]} + style={[ + styles.listItem, + pal.border, + {opacity: membershipsLoaded ? 1 : 0.5}, + ]} accessibilityLabel={`${isSelected ? 'Remove from' : 'Add to'} ${ list.name }`} accessibilityHint="" + disabled={!membershipsLoaded} onPress={() => onToggleSelected(list.uri)}> <View style={styles.listItemAvi}> <UserAvatar size={40} avatar={list.avatar} /> @@ -132,23 +139,33 @@ export const Component = observer( : sanitizeHandle(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> + {membershipsLoaded && ( + <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], + [ + pal, + palPrimary, + palInverted, + onToggleSelected, + selected, + store.me.did, + membershipsLoaded, + ], ) const renderEmptyState = React.useCallback(() => { @@ -200,6 +217,12 @@ export const Component = observer( label="Save Changes" /> )} + + {(listsList.isLoading || !membershipsLoaded) && ( + <View style={styles.loadingContainer}> + <ActivityIndicator /> + </View> + )} </View> </View> ) @@ -221,6 +244,7 @@ const styles = StyleSheet.create({ borderTopWidth: 1, }, btns: { + position: 'relative', flexDirection: 'row', alignItems: 'center', justifyContent: 'center', @@ -263,4 +287,11 @@ const styles = StyleSheet.create({ borderRadius: 6, marginRight: 8, }, + loadingContainer: { + position: 'absolute', + top: 10, + right: 0, + bottom: 0, + justifyContent: 'center', + }, }) diff --git a/src/view/com/modals/Modal.tsx b/src/view/com/modals/Modal.tsx index 525df7ba1..efd06412d 100644 --- a/src/view/com/modals/Modal.tsx +++ b/src/view/com/modals/Modal.tsx @@ -6,18 +6,20 @@ import BottomSheet from '@gorhom/bottom-sheet' import {useStores} from 'state/index' import {createCustomBackdrop} from '../util/BottomSheetCustomBackdrop' import {usePalette} from 'lib/hooks/usePalette' +import {navigate} from '../../../Navigation' +import once from 'lodash.once' import * as ConfirmModal from './Confirm' import * as EditProfileModal from './EditProfile' import * as ProfilePreviewModal from './ProfilePreview' import * as ServerInputModal from './ServerInput' -import * as ReportPostModal from './report/ReportPost' import * as RepostModal from './Repost' +import * as SelfLabelModal from './SelfLabel' import * as CreateOrEditMuteListModal from './CreateOrEditMuteList' import * as ListAddRemoveUserModal from './ListAddRemoveUser' import * as AltImageModal from './AltImage' import * as EditImageModal from './AltImage' -import * as ReportAccountModal from './report/ReportAccount' +import * as ReportModal from './report/Modal' import * as DeleteAccountModal from './DeleteAccount' import * as ChangeHandleModal from './ChangeHandle' import * as WaitlistModal from './Waitlist' @@ -28,6 +30,7 @@ import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguages import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings' import * as PreferencesHomeFeed from './PreferencesHomeFeed' import * as OnboardingModal from './OnboardingModal' +import * as ModerationDetailsModal from './ModerationDetails' const DEFAULT_SNAPPOINTS = ['90%'] @@ -35,9 +38,25 @@ export const ModalsContainer = observer(function ModalsContainer() { const store = useStores() const bottomSheetRef = useRef<BottomSheet>(null) const pal = usePalette('default') + + const activeModal = + store.shell.activeModals[store.shell.activeModals.length - 1] + + const navigateOnce = once(navigate) + + const onBottomSheetAnimate = (fromIndex: number, toIndex: number) => { + if (activeModal?.name === 'profile-preview' && toIndex === 1) { + // begin loading the profile screen behind the scenes + navigateOnce('Profile', {name: activeModal.did}) + } + } const onBottomSheetChange = (snapPoint: number) => { if (snapPoint === -1) { store.shell.closeModal() + } else if (activeModal?.name === 'profile-preview' && snapPoint === 1) { + // ensure we navigate to Profile and close the modal + navigateOnce('Profile', {name: activeModal.did}) + store.shell.closeModal() } } const onClose = () => { @@ -45,9 +64,6 @@ export const ModalsContainer = observer(function ModalsContainer() { store.shell.closeModal() } - const activeModal = - store.shell.activeModals[store.shell.activeModals.length - 1] - useEffect(() => { if (store.shell.isModalActive) { bottomSheetRef.current?.expand() @@ -70,12 +86,9 @@ export const ModalsContainer = observer(function ModalsContainer() { } else if (activeModal?.name === 'server-input') { snapPoints = ServerInputModal.snapPoints element = <ServerInputModal.Component {...activeModal} /> - } else if (activeModal?.name === 'report-post') { - snapPoints = ReportPostModal.snapPoints - element = <ReportPostModal.Component {...activeModal} /> - } else if (activeModal?.name === 'report-account') { - snapPoints = ReportAccountModal.snapPoints - element = <ReportAccountModal.Component {...activeModal} /> + } else if (activeModal?.name === 'report') { + snapPoints = ReportModal.snapPoints + element = <ReportModal.Component {...activeModal} /> } else if (activeModal?.name === 'create-or-edit-mute-list') { snapPoints = CreateOrEditMuteListModal.snapPoints element = <CreateOrEditMuteListModal.Component {...activeModal} /> @@ -88,6 +101,9 @@ export const ModalsContainer = observer(function ModalsContainer() { } else if (activeModal?.name === 'repost') { snapPoints = RepostModal.snapPoints element = <RepostModal.Component {...activeModal} /> + } else if (activeModal?.name === 'self-label') { + snapPoints = SelfLabelModal.snapPoints + element = <SelfLabelModal.Component {...activeModal} /> } else if (activeModal?.name === 'alt-text-image') { snapPoints = AltImageModal.snapPoints element = <AltImageModal.Component {...activeModal} /> @@ -121,6 +137,9 @@ export const ModalsContainer = observer(function ModalsContainer() { } else if (activeModal?.name === 'onboarding') { snapPoints = OnboardingModal.snapPoints element = <OnboardingModal.Component /> + } else if (activeModal?.name === 'moderation-details') { + snapPoints = ModerationDetailsModal.snapPoints + element = <ModerationDetailsModal.Component {...activeModal} /> } else { return null } @@ -146,6 +165,7 @@ export const ModalsContainer = observer(function ModalsContainer() { } handleIndicatorStyle={{backgroundColor: pal.text.color}} handleStyle={[styles.handle, pal.view]} + onAnimate={onBottomSheetAnimate} onChange={onBottomSheetChange}> {element} </BottomSheet> diff --git a/src/view/com/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx index 39cdbd868..0e28b1618 100644 --- a/src/view/com/modals/Modal.web.tsx +++ b/src/view/com/modals/Modal.web.tsx @@ -10,12 +10,12 @@ import * as ConfirmModal from './Confirm' import * as EditProfileModal from './EditProfile' import * as ProfilePreviewModal from './ProfilePreview' import * as ServerInputModal from './ServerInput' -import * as ReportPostModal from './report/ReportPost' -import * as ReportAccountModal from './report/ReportAccount' +import * as ReportModal from './report/Modal' import * as CreateOrEditMuteListModal from './CreateOrEditMuteList' import * as ListAddRemoveUserModal from './ListAddRemoveUser' import * as DeleteAccountModal from './DeleteAccount' import * as RepostModal from './Repost' +import * as SelfLabelModal from './SelfLabel' import * as CropImageModal from './crop-image/CropImage.web' import * as AltTextImageModal from './AltImage' import * as EditImageModal from './EditImage' @@ -27,6 +27,7 @@ import * as ContentFilteringSettingsModal from './ContentFilteringSettings' import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings' import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings' import * as OnboardingModal from './OnboardingModal' +import * as ModerationDetailsModal from './ModerationDetails' import * as PreferencesHomeFeed from './PreferencesHomeFeed' @@ -74,10 +75,8 @@ function Modal({modal}: {modal: ModalIface}) { element = <ProfilePreviewModal.Component {...modal} /> } else if (modal.name === 'server-input') { element = <ServerInputModal.Component {...modal} /> - } else if (modal.name === 'report-post') { - element = <ReportPostModal.Component {...modal} /> - } else if (modal.name === 'report-account') { - element = <ReportAccountModal.Component {...modal} /> + } else if (modal.name === 'report') { + element = <ReportModal.Component {...modal} /> } else if (modal.name === 'create-or-edit-mute-list') { element = <CreateOrEditMuteListModal.Component {...modal} /> } else if (modal.name === 'list-add-remove-user') { @@ -88,6 +87,8 @@ function Modal({modal}: {modal: ModalIface}) { element = <DeleteAccountModal.Component /> } else if (modal.name === 'repost') { element = <RepostModal.Component {...modal} /> + } else if (modal.name === 'self-label') { + element = <SelfLabelModal.Component {...modal} /> } else if (modal.name === 'change-handle') { element = <ChangeHandleModal.Component {...modal} /> } else if (modal.name === 'waitlist') { @@ -110,6 +111,8 @@ function Modal({modal}: {modal: ModalIface}) { element = <PreferencesHomeFeed.Component /> } else if (modal.name === 'onboarding') { element = <OnboardingModal.Component /> + } else if (modal.name === 'moderation-details') { + element = <ModerationDetailsModal.Component {...modal} /> } else { return null } diff --git a/src/view/com/modals/ModerationDetails.tsx b/src/view/com/modals/ModerationDetails.tsx new file mode 100644 index 000000000..b0e68e61b --- /dev/null +++ b/src/view/com/modals/ModerationDetails.tsx @@ -0,0 +1,105 @@ +import React from 'react' +import {StyleSheet, View} from 'react-native' +import {ModerationUI} from '@atproto/api' +import {useStores} from 'state/index' +import {s} from 'lib/styles' +import {Text} from '../util/text/Text' +import {TextLink} from '../util/Link' +import {usePalette} from 'lib/hooks/usePalette' +import {isDesktopWeb} from 'platform/detection' +import {listUriToHref} from 'lib/strings/url-helpers' +import {Button} from '../util/forms/Button' + +export const snapPoints = [300] + +export function Component({ + context, + moderation, +}: { + context: 'account' | 'content' + moderation: ModerationUI +}) { + const store = useStores() + const pal = usePalette('default') + + let name + let description + if (!moderation.cause) { + name = 'Content Warning' + description = + 'Moderator has chosen to set a general warning on the content.' + } else if (moderation.cause.type === 'blocking') { + name = 'User Blocked' + description = 'You have blocked this user. You cannot view their content.' + } else if (moderation.cause.type === 'blocked-by') { + name = 'User Blocks You' + description = 'This user has blocked you. You cannot view their content.' + } else if (moderation.cause.type === 'block-other') { + name = 'Content Not Available' + description = + 'This content is not available because one of the users involved has blocked the other.' + } else if (moderation.cause.type === 'muted') { + if (moderation.cause.source.type === 'list') { + const list = moderation.cause.source.list + name = <>Account Muted by List</> + description = ( + <> + This user is included the{' '} + <TextLink + type="2xl" + href={listUriToHref(list.uri)} + text={list.name} + style={pal.link} + />{' '} + list which you have muted. + </> + ) + } else { + name = 'Account Muted' + description = 'You have muted this user.' + } + } else { + name = moderation.cause.labelDef.strings[context].en.name + description = moderation.cause.labelDef.strings[context].en.description + } + + return ( + <View testID="moderationDetailsModal" style={[styles.container, pal.view]}> + <Text type="title-xl" style={[pal.text, styles.title]}> + {name} + </Text> + <Text type="2xl" style={[pal.text, styles.description]}> + {description} + </Text> + <View style={s.flex1} /> + <Button + type="primary" + style={styles.btn} + onPress={() => store.shell.closeModal()}> + <Text type="button-lg" style={[pal.textLight, s.textCenter, s.white]}> + Okay + </Text> + </Button> + </View> + ) +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + paddingHorizontal: isDesktopWeb ? 0 : 14, + }, + title: { + textAlign: 'center', + fontWeight: 'bold', + marginBottom: 12, + }, + description: { + textAlign: 'center', + }, + btn: { + paddingVertical: 14, + marginTop: isDesktopWeb ? 40 : 0, + marginBottom: isDesktopWeb ? 0 : 40, + }, +}) diff --git a/src/view/com/modals/ProfilePreview.tsx b/src/view/com/modals/ProfilePreview.tsx index d3267644b..4efe81225 100644 --- a/src/view/com/modals/ProfilePreview.tsx +++ b/src/view/com/modals/ProfilePreview.tsx @@ -1,63 +1,56 @@ -import React, {useState, useEffect, useCallback} from 'react' -import {StyleSheet, View} from 'react-native' +import React, {useState, useEffect} from 'react' +import {ActivityIndicator, StyleSheet, View} from 'react-native' import {observer} from 'mobx-react-lite' -import {useNavigation, StackActions} from '@react-navigation/native' -import {Text} from '../util/text/Text' +import {ThemedText} from '../util/text/ThemedText' import {useStores} from 'state/index' import {ProfileModel} from 'state/models/content/profile' import {usePalette} from 'lib/hooks/usePalette' import {useAnalytics} from 'lib/analytics/analytics' import {ProfileHeader} from '../profile/ProfileHeader' -import {Button} from '../util/forms/Button' -import {NavigationProp} from 'lib/routes/types' +import {InfoCircleIcon} from 'lib/icons' +import {useNavigationState} from '@react-navigation/native' +import {isIOS} from 'platform/detection' +import {s} from 'lib/styles' -export const snapPoints = [560] +export const snapPoints = [520, '100%'] export const Component = observer(({did}: {did: string}) => { const store = useStores() const pal = usePalette('default') - const palInverted = usePalette('inverted') - const navigation = useNavigation<NavigationProp>() const [model] = useState(new ProfileModel(store, {actor: did})) const {screen} = useAnalytics() + // track the navigator state to detect if a page-load occurred + const navState = useNavigationState(s => s) + const [initNavState] = useState(navState) + const isLoading = initNavState !== navState + useEffect(() => { screen('Profile:Preview') model.setup() }, [model, screen]) - const onPressViewProfile = useCallback(() => { - navigation.dispatch(StackActions.push('Profile', {name: model.handle})) - store.shell.closeModal() - }, [navigation, store, model]) - return ( - <View style={pal.view}> - <View style={styles.headerWrapper}> + <View style={[pal.view, s.flex1]}> + <View + style={[ + styles.headerWrapper, + isLoading && isIOS && styles.headerPositionAdjust, + ]}> <ProfileHeader view={model} hideBackButton onRefreshAll={() => {}} /> </View> - <View style={[styles.buttonsContainer, pal.view]}> - <View style={styles.buttons}> - <Button - type="inverted" - style={[styles.button, styles.buttonWide]} - onPress={onPressViewProfile} - accessibilityLabel="View profile" - accessibilityHint=""> - <Text type="button-lg" style={palInverted.text}> - View Profile - </Text> - </Button> - <Button - type="default" - style={styles.button} - onPress={() => store.shell.closeModal()} - accessibilityLabel="Close this preview" - accessibilityHint=""> - <Text type="button-lg" style={pal.text}> - Close - </Text> - </Button> + <View style={[styles.hintWrapper, pal.view]}> + <View style={styles.hint}> + {isLoading ? ( + <ActivityIndicator /> + ) : ( + <> + <InfoCircleIcon size={21} style={pal.textLight} /> + <ThemedText type="xl" fg="light"> + Swipe up to see more + </ThemedText> + </> + )} </View> </View> </View> @@ -68,22 +61,18 @@ const styles = StyleSheet.create({ headerWrapper: { height: 440, }, - buttonsContainer: { - height: 120, + headerPositionAdjust: { + // HACK align the header for the profilescreen transition -prf + paddingTop: 23, }, - buttons: { - flexDirection: 'row', - gap: 8, - paddingHorizontal: 14, - paddingTop: 16, + hintWrapper: { + height: 80, }, - button: { - flex: 2, + hint: { flexDirection: 'row', justifyContent: 'center', - paddingVertical: 12, - }, - buttonWide: { - flex: 3, + gap: 8, + paddingHorizontal: 14, + borderRadius: 6, }, }) diff --git a/src/view/com/modals/SelfLabel.tsx b/src/view/com/modals/SelfLabel.tsx new file mode 100644 index 000000000..42863fd33 --- /dev/null +++ b/src/view/com/modals/SelfLabel.tsx @@ -0,0 +1,191 @@ +import React, {useState} from 'react' +import {StyleSheet, TouchableOpacity, View} from 'react-native' +import {observer} from 'mobx-react-lite' +import {Text} from '../util/text/Text' +import {useStores} from 'state/index' +import {s, colors} from 'lib/styles' +import {usePalette} from 'lib/hooks/usePalette' +import {isDesktopWeb} from 'platform/detection' +import {Button} from '../util/forms/Button' +import {SelectableBtn} from '../util/forms/SelectableBtn' +import {ScrollView} from 'view/com/modals/util' + +const ADULT_CONTENT_LABELS = ['sexual', 'nudity', 'porn'] + +export const snapPoints = ['50%'] + +export const Component = observer(function Component({ + labels, + hasMedia, + onChange, +}: { + labels: string[] + hasMedia: boolean + onChange: (labels: string[]) => void +}) { + const pal = usePalette('default') + const store = useStores() + const [selected, setSelected] = useState(labels) + + const toggleAdultLabel = (label: string) => { + const hadLabel = selected.includes(label) + const stripped = selected.filter(l => !ADULT_CONTENT_LABELS.includes(l)) + const final = !hadLabel ? stripped.concat([label]) : stripped + setSelected(final) + onChange(final) + } + + const removeAdultLabel = () => { + const final = selected.filter(l => !ADULT_CONTENT_LABELS.includes(l)) + setSelected(final) + onChange(final) + } + + const hasAdultSelection = + selected.includes('sexual') || + selected.includes('nudity') || + selected.includes('porn') + return ( + <View testID="selfLabelModal" style={[pal.view, styles.container]}> + <View style={styles.titleSection}> + <Text type="title-lg" style={[pal.text, styles.title]}> + Add a content warning + </Text> + </View> + + <ScrollView> + <View style={[styles.section, pal.border, {borderBottomWidth: 1}]}> + <View + style={{ + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingBottom: 8, + }}> + <Text type="title" style={pal.text}> + Adult Content + </Text> + {hasAdultSelection ? ( + <Button + type="default-light" + onPress={removeAdultLabel} + style={{paddingTop: 0, paddingBottom: 0, paddingRight: 0}}> + <Text type="md" style={pal.link}> + Remove + </Text> + </Button> + ) : null} + </View> + {hasMedia ? ( + <> + <View style={s.flexRow}> + <SelectableBtn + testID="sexualLabelBtn" + selected={selected.includes('sexual')} + left + label="Suggestive" + onSelect={() => toggleAdultLabel('sexual')} + accessibilityHint="" + style={s.flex1} + /> + <SelectableBtn + testID="nudityLabelBtn" + selected={selected.includes('nudity')} + label="Nudity" + onSelect={() => toggleAdultLabel('nudity')} + accessibilityHint="" + style={s.flex1} + /> + <SelectableBtn + testID="pornLabelBtn" + selected={selected.includes('porn')} + label="Porn" + right + onSelect={() => toggleAdultLabel('porn')} + accessibilityHint="" + style={s.flex1} + /> + </View> + + <Text style={[pal.text, styles.adultExplainer]}> + {selected.includes('sexual') ? ( + <>Pictures meant for adults.</> + ) : selected.includes('nudity') ? ( + <>Artistic or non-erotic nudity.</> + ) : selected.includes('porn') ? ( + <>Sexual activity or erotic nudity.</> + ) : ( + <>If none are selected, suitable for all ages.</> + )} + </Text> + </> + ) : ( + <View> + <Text style={[pal.textLight]}> + <Text type="md-bold" style={[pal.textLight]}> + Not Applicable + </Text> + . This warning is only available for posts with media attached. + </Text> + </View> + )} + </View> + </ScrollView> + + <View style={[styles.btnContainer, pal.borderDark]}> + <TouchableOpacity + testID="confirmBtn" + onPress={() => { + store.shell.closeModal() + }} + style={styles.btn} + accessibilityRole="button" + accessibilityLabel="Confirm" + accessibilityHint=""> + <Text style={[s.white, s.bold, s.f18]}>Done</Text> + </TouchableOpacity> + </View> + </View> + ) +}) + +const styles = StyleSheet.create({ + container: { + flex: 1, + paddingBottom: isDesktopWeb ? 0 : 40, + }, + titleSection: { + paddingTop: isDesktopWeb ? 0 : 4, + paddingBottom: isDesktopWeb ? 14 : 10, + }, + title: { + textAlign: 'center', + fontWeight: '600', + marginBottom: 5, + }, + description: { + textAlign: 'center', + paddingHorizontal: 32, + }, + section: { + borderTopWidth: 1, + paddingVertical: 20, + paddingHorizontal: isDesktopWeb ? 0 : 20, + }, + adultExplainer: { + paddingLeft: 5, + paddingTop: 10, + }, + btn: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + borderRadius: 32, + padding: 14, + backgroundColor: colors.blue3, + }, + btnContainer: { + paddingTop: 20, + paddingHorizontal: 20, + }, +}) diff --git a/src/view/com/modals/report/ReportPost.tsx b/src/view/com/modals/report/Modal.tsx index 34ec8c2f2..f386b110d 100644 --- a/src/view/com/modals/report/ReportPost.tsx +++ b/src/view/com/modals/report/Modal.tsx @@ -1,10 +1,9 @@ import React, {useState, useMemo} from 'react' import {Linking, StyleSheet, TouchableOpacity, View} from 'react-native' import {ScrollView} from 'react-native-gesture-handler' -import {ComAtprotoModerationDefs} from '@atproto/api' +import {AtUri} from '@atproto/api' import {useStores} from 'state/index' import {s} from 'lib/styles' -import {RadioGroup, RadioGroupItem} from '../../util/forms/RadioGroup' import {Text} from '../../util/text/Text' import * as Toast from '../../util/Toast' import {ErrorMessage} from '../../util/error/ErrorMessage' @@ -12,25 +11,43 @@ import {cleanError} from 'lib/strings/errors' import {usePalette} from 'lib/hooks/usePalette' import {SendReportButton} from './SendReportButton' import {InputIssueDetails} from './InputIssueDetails' +import {ReportReasonOptions} from './ReasonOptions' +import {CollectionId} from './types' const DMCA_LINK = 'https://bsky.app/support/copyright' export const snapPoints = [575] -export function Component({ - postUri, - postCid, -}: { - postUri: string - postCid: string -}) { +const CollectionNames = { + [CollectionId.FeedGenerator]: 'Feed', + [CollectionId.Profile]: 'Profile', + [CollectionId.List]: 'List', + [CollectionId.Post]: 'Post', +} + +type ReportComponentProps = + | { + uri: string + cid: string + } + | { + did: string + } + +export function Component(content: ReportComponentProps) { const store = useStores() const pal = usePalette('default') const [isProcessing, setIsProcessing] = useState(false) - const [showTextInput, setShowTextInput] = useState(false) + const [showDetailsInput, setShowDetailsInput] = useState(false) const [error, setError] = useState<string>() const [issue, setIssue] = useState<string>() const [details, setDetails] = useState<string>() + const isAccountReport = 'did' in content + const subjectKey = isAccountReport ? content.did : content.uri + const atUri = useMemo( + () => (!isAccountReport ? new AtUri(subjectKey) : null), + [isAccountReport, subjectKey], + ) const submitReport = async () => { setError('') @@ -43,12 +60,14 @@ export function Component({ Linking.openURL(DMCA_LINK) return } + const $type = !isAccountReport + ? 'com.atproto.repo.strongRef' + : 'com.atproto.admin.defs#repoRef' await store.agent.createModerationReport({ reasonType: issue, subject: { - $type: 'com.atproto.repo.strongRef', - uri: postUri, - cid: postCid, + $type, + ...content, }, reason: details, }) @@ -63,13 +82,13 @@ export function Component({ } const goBack = () => { - setShowTextInput(false) + setShowDetailsInput(false) } return ( - <ScrollView testID="reportPostModal" style={[s.flex1, pal.view]}> + <ScrollView testID="reportModal" style={[s.flex1, pal.view]}> <View style={styles.container}> - {showTextInput ? ( + {showDetailsInput ? ( <InputIssueDetails details={details} setDetails={setDetails} @@ -79,12 +98,13 @@ export function Component({ /> ) : ( <SelectIssue - setShowTextInput={setShowTextInput} + setShowDetailsInput={setShowDetailsInput} error={error} issue={issue} setIssue={setIssue} submitReport={submitReport} isProcessing={isProcessing} + atUri={atUri} /> )} </View> @@ -92,128 +112,59 @@ export function Component({ ) } +// If no atUri is passed, that means the reporting collection is account +const getCollectionNameForReport = (atUri: AtUri | null) => { + if (!atUri) return 'Account' + // Generic fallback for any collection being reported + return CollectionNames[atUri.collection as CollectionId] || 'Content' +} + const SelectIssue = ({ error, - setShowTextInput, + setShowDetailsInput, issue, setIssue, submitReport, isProcessing, + atUri, }: { error: string | undefined - setShowTextInput: (v: boolean) => void + setShowDetailsInput: (v: boolean) => void issue: string | undefined setIssue: (v: string) => void submitReport: () => void isProcessing: boolean + atUri: AtUri | null }) => { const pal = usePalette('default') - const ITEMS: RadioGroupItem[] = useMemo( - () => [ - { - key: ComAtprotoModerationDefs.REASONSPAM, - label: ( - <View> - <Text style={pal.text} type="md-bold"> - Spam - </Text> - <Text style={pal.textLight}>Excessive mentions or replies</Text> - </View> - ), - }, - { - key: ComAtprotoModerationDefs.REASONSEXUAL, - label: ( - <View> - <Text style={pal.text} type="md-bold"> - Unwanted Sexual Content - </Text> - <Text style={pal.textLight}> - Nudity or pornography not labeled as such - </Text> - </View> - ), - }, - { - key: '__copyright__', - label: ( - <View> - <Text style={pal.text} type="md-bold"> - Copyright Violation - </Text> - <Text style={pal.textLight}>Contains copyrighted material</Text> - </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> - <Text style={pal.text} type="md-bold"> - Illegal and Urgent - </Text> - <Text style={pal.textLight}> - Glaring violations of law or terms of service - </Text> - </View> - ), - }, - { - key: ComAtprotoModerationDefs.REASONOTHER, - label: ( - <View> - <Text style={pal.text} type="md-bold"> - Other - </Text> - <Text style={pal.textLight}> - An issue not included in these options - </Text> - </View> - ), - }, - ], - [pal], - ) - + const collectionName = getCollectionNameForReport(atUri) const onSelectIssue = (v: string) => setIssue(v) const goToDetails = () => { if (issue === '__copyright__') { Linking.openURL(DMCA_LINK) return } - setShowTextInput(true) + setShowDetailsInput(true) } return ( <> - <Text style={[pal.text, styles.title]}>Report post</Text> + <Text style={[pal.text, styles.title]}>Report {collectionName}</Text> <Text style={[pal.textLight, styles.description]}> - What is the issue with this post? + What is the issue with this {collectionName}? </Text> - <RadioGroup - testID="reportPostRadios" - items={ITEMS} - onSelect={onSelectIssue} + <ReportReasonOptions + atUri={atUri} + selectedIssue={issue} + onSelectIssue={onSelectIssue} /> {error ? ( <View style={s.mt10}> <ErrorMessage message={error} /> </View> ) : undefined} - {issue ? ( + {/* If no atUri is present, the report would be for account in which case, we allow sending without specifying a reason */} + {issue || !atUri ? ( <> <SendReportButton onPress={submitReport} diff --git a/src/view/com/modals/report/ReasonOptions.tsx b/src/view/com/modals/report/ReasonOptions.tsx new file mode 100644 index 000000000..23b49b664 --- /dev/null +++ b/src/view/com/modals/report/ReasonOptions.tsx @@ -0,0 +1,123 @@ +import {View} from 'react-native' +import React, {useMemo} from 'react' +import {AtUri, ComAtprotoModerationDefs} from '@atproto/api' + +import {Text} from '../../util/text/Text' +import {UsePaletteValue, usePalette} from 'lib/hooks/usePalette' +import {RadioGroup, RadioGroupItem} from 'view/com/util/forms/RadioGroup' +import {CollectionId} from './types' + +type ReasonMap = Record<string, {title: string; description: string}> +const CommonReasons = { + [ComAtprotoModerationDefs.REASONRUDE]: { + title: 'Anti-Social Behavior', + description: 'Harassment, trolling, or intolerance', + }, + [ComAtprotoModerationDefs.REASONVIOLATION]: { + title: 'Illegal and Urgent', + description: 'Glaring violations of law or terms of service', + }, + [ComAtprotoModerationDefs.REASONOTHER]: { + title: 'Other', + description: 'An issue not included in these options', + }, +} +const CollectionToReasonsMap: Record<string, ReasonMap> = { + [CollectionId.Post]: { + [ComAtprotoModerationDefs.REASONSPAM]: { + title: 'Spam', + description: 'Excessive mentions or replies', + }, + [ComAtprotoModerationDefs.REASONSEXUAL]: { + title: 'Unwanted Sexual Content', + description: 'Nudity or pornography not labeled as such', + }, + __copyright__: { + title: 'Copyright Violation', + description: 'Contains copyrighted material', + }, + ...CommonReasons, + }, + [CollectionId.List]: { + ...CommonReasons, + [ComAtprotoModerationDefs.REASONVIOLATION]: { + title: 'Name or Description Violates Community Standards', + description: 'Terms used violate community standards', + }, + }, +} +const AccountReportReasons = { + [ComAtprotoModerationDefs.REASONMISLEADING]: { + title: 'Misleading Account', + description: 'Impersonation or false claims about identity or affiliation', + }, + [ComAtprotoModerationDefs.REASONSPAM]: { + title: 'Frequently Posts Unwanted Content', + description: 'Spam; excessive mentions or replies', + }, + [ComAtprotoModerationDefs.REASONVIOLATION]: { + title: 'Name or Description Violates Community Standards', + description: 'Terms used violate community standards', + }, +} + +const Option = ({ + pal, + title, + description, +}: { + pal: UsePaletteValue + description: string + title: string +}) => { + return ( + <View> + <Text style={pal.text} type="md-bold"> + {title} + </Text> + <Text style={pal.textLight}>{description}</Text> + </View> + ) +} + +// This is mostly just content copy without almost any logic +// so this may grow over time and it makes sense to split it up into its own file +// to keep it separate from the actual reporting modal logic +const useReportRadioOptions = (pal: UsePaletteValue, atUri: AtUri | null) => + useMemo(() => { + let items: ReasonMap = {...CommonReasons} + // If no atUri is passed, that means the reporting collection is account + if (!atUri) { + items = {...AccountReportReasons} + } + + if (atUri?.collection && CollectionToReasonsMap[atUri.collection]) { + items = {...CollectionToReasonsMap[atUri.collection]} + } + + return Object.entries(items).map(([key, {title, description}]) => ({ + key, + label: <Option pal={pal} title={title} description={description} />, + })) + }, [pal, atUri]) + +export const ReportReasonOptions = ({ + atUri, + selectedIssue, + onSelectIssue, +}: { + atUri: AtUri | null + selectedIssue?: string + onSelectIssue: (key: string) => void +}) => { + const pal = usePalette('default') + const ITEMS: RadioGroupItem[] = useReportRadioOptions(pal, atUri) + return ( + <RadioGroup + items={ITEMS} + onSelect={onSelectIssue} + testID="reportReasonRadios" + initialSelection={selectedIssue} + /> + ) +} diff --git a/src/view/com/modals/report/ReportAccount.tsx b/src/view/com/modals/report/ReportAccount.tsx deleted file mode 100644 index b53c54caa..000000000 --- a/src/view/com/modals/report/ReportAccount.tsx +++ /dev/null @@ -1,197 +0,0 @@ -import React, {useState, useMemo} from 'react' -import {TouchableOpacity, StyleSheet, View} from 'react-native' -import {ScrollView} from 'react-native-gesture-handler' -import {ComAtprotoModerationDefs} from '@atproto/api' -import {useStores} from 'state/index' -import {s} from 'lib/styles' -import {RadioGroup, RadioGroupItem} from '../../util/forms/RadioGroup' -import {Text} from '../../util/text/Text' -import * as Toast from '../../util/Toast' -import {ErrorMessage} from '../../util/error/ErrorMessage' -import {cleanError} from 'lib/strings/errors' -import {usePalette} from 'lib/hooks/usePalette' -import {isDesktopWeb} from 'platform/detection' -import {SendReportButton} from './SendReportButton' -import {InputIssueDetails} from './InputIssueDetails' - -export const snapPoints = [500] - -export function Component({did}: {did: string}) { - const store = useStores() - const pal = usePalette('default') - const [isProcessing, setIsProcessing] = useState(false) - const [error, setError] = useState<string>() - const [issue, setIssue] = useState<string>() - const onSelectIssue = (v: string) => setIssue(v) - const [details, setDetails] = useState<string>() - const [showDetailsInput, setShowDetailsInput] = useState(false) - - const onPress = async () => { - setError('') - if (!issue) { - return - } - setIsProcessing(true) - try { - await store.agent.com.atproto.moderation.createReport({ - reasonType: issue, - subject: { - $type: 'com.atproto.admin.defs#repoRef', - did, - }, - reason: details, - }) - Toast.show("Thank you for your report! We'll look into it promptly.") - store.shell.closeModal() - return - } catch (e: any) { - setError(cleanError(e)) - setIsProcessing(false) - } - } - const goBack = () => { - setShowDetailsInput(false) - } - const goToDetails = () => { - setShowDetailsInput(true) - } - - return ( - <ScrollView - testID="reportAccountModal" - style={[styles.container, pal.view]}> - {showDetailsInput ? ( - <InputIssueDetails - submitReport={onPress} - setDetails={setDetails} - details={details} - isProcessing={isProcessing} - goBack={goBack} - /> - ) : ( - <SelectIssue - onPress={onPress} - onSelectIssue={onSelectIssue} - error={error} - isProcessing={isProcessing} - goToDetails={goToDetails} - /> - )} - </ScrollView> - ) -} - -const SelectIssue = ({ - onPress, - onSelectIssue, - error, - isProcessing, - goToDetails, -}: { - onPress: () => void - onSelectIssue: (v: string) => void - error: string | undefined - isProcessing: boolean - goToDetails: () => void -}) => { - const pal = usePalette('default') - const ITEMS: RadioGroupItem[] = useMemo( - () => [ - { - key: ComAtprotoModerationDefs.REASONMISLEADING, - label: ( - <View> - <Text style={pal.text} type="md-bold"> - Misleading Account - </Text> - <Text style={pal.textLight}> - Impersonation or false claims about identity or affiliation - </Text> - </View> - ), - }, - { - key: ComAtprotoModerationDefs.REASONSPAM, - label: ( - <View> - <Text style={pal.text} type="md-bold"> - Frequently Posts Unwanted Content - </Text> - <Text style={pal.textLight}> - Spam; excessive mentions or replies - </Text> - </View> - ), - }, - { - key: ComAtprotoModerationDefs.REASONVIOLATION, - label: ( - <View> - <Text style={pal.text} type="md-bold"> - Name or Description Violates Community Standards - </Text> - <Text style={pal.textLight}> - Terms used violate community standards - </Text> - </View> - ), - }, - ], - [pal], - ) - return ( - <> - <Text type="title-xl" style={[pal.text, styles.title]}> - Report Account - </Text> - <Text type="xl" style={[pal.text, styles.description]}> - What is the issue with this account? - </Text> - <RadioGroup - testID="reportAccountRadios" - items={ITEMS} - onSelect={onSelectIssue} - /> - <Text type="sm" style={[pal.text, styles.description, s.pt10]}> - For other issues, please report specific posts. - </Text> - {error ? ( - <View style={s.mt10}> - <ErrorMessage message={error} /> - </View> - ) : undefined} - <SendReportButton onPress={onPress} isProcessing={isProcessing} /> - <TouchableOpacity - testID="addDetailsBtn" - style={styles.addDetailsBtn} - onPress={goToDetails} - accessibilityRole="button" - accessibilityLabel="Add details" - accessibilityHint="Add more details to your report"> - <Text style={[s.f18, pal.link]}>Add details to report</Text> - </TouchableOpacity> - </> - ) -} - -const styles = StyleSheet.create({ - container: { - flex: 1, - paddingHorizontal: isDesktopWeb ? 0 : 10, - }, - title: { - textAlign: 'center', - fontWeight: 'bold', - marginBottom: 12, - }, - description: { - textAlign: 'center', - paddingHorizontal: 22, - marginBottom: 10, - }, - addDetailsBtn: { - padding: 14, - alignSelf: 'center', - marginBottom: 40, - }, -}) diff --git a/src/view/com/modals/report/types.ts b/src/view/com/modals/report/types.ts new file mode 100644 index 000000000..ca947ecbd --- /dev/null +++ b/src/view/com/modals/report/types.ts @@ -0,0 +1,8 @@ +// TODO: ATM, @atproto/api does not export ids but it does have these listed at @atproto/api/client/lexicons +// once we start exporting the ids from the @atproto/ap package, replace these hardcoded ones +export enum CollectionId { + FeedGenerator = 'app.bsky.feed.generator', + Profile = 'app.bsky.actor.profile', + List = 'app.bsky.graph.list', + Post = 'app.bsky.feed.post', +} diff --git a/src/view/com/notifications/FeedItem.tsx b/src/view/com/notifications/FeedItem.tsx index 7b9f0715b..7b07bb30f 100644 --- a/src/view/com/notifications/FeedItem.tsx +++ b/src/view/com/notifications/FeedItem.tsx @@ -7,7 +7,11 @@ import { StyleSheet, View, } from 'react-native' -import {AppBskyEmbedImages} from '@atproto/api' +import { + AppBskyEmbedImages, + ProfileModeration, + moderateProfile, +} from '@atproto/api' import {AtUri} from '@atproto/api' import { FontAwesomeIcon, @@ -31,11 +35,6 @@ import {Link, TextLink} from '../util/Link' import {useStores} from 'state/index' import {usePalette} from 'lib/hooks/usePalette' import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' -import { - getProfileViewBasicLabelInfo, - getProfileModeration, -} from 'lib/labeling/helpers' -import {ProfileModeration} from 'lib/labeling/types' import {formatCount} from '../util/numeric/format' import {makeProfileLink} from 'lib/routes/links' @@ -99,9 +98,9 @@ export const FeedItem = observer(function ({ handle: item.author.handle, displayName: item.author.displayName, avatar: item.author.avatar, - moderation: getProfileModeration( - store, - getProfileViewBasicLabelInfo(item.author), + moderation: moderateProfile( + item.author, + store.preferences.moderationOpts, ), }, ...(item.additional?.map(({author}) => { @@ -111,10 +110,7 @@ export const FeedItem = observer(function ({ handle: author.handle, displayName: author.displayName, avatar: author.avatar, - moderation: getProfileModeration( - store, - getProfileViewBasicLabelInfo(author), - ), + moderation: moderateProfile(author, store.preferences.moderationOpts), } }) || []), ] @@ -175,7 +171,7 @@ export const FeedItem = observer(function ({ action = `liked your custom feed '${new AtUri(item.subjectUri).rkey}'` icon = 'HeartIconSolid' iconStyle = [ - s.red3 as FontAwesomeIconStyle, + s.likeColor as FontAwesomeIconStyle, {position: 'relative', top: -4}, ] } else { diff --git a/src/view/com/post-thread/PostThread.tsx b/src/view/com/post-thread/PostThread.tsx index 51f63dbb3..399e47006 100644 --- a/src/view/com/post-thread/PostThread.tsx +++ b/src/view/com/post-thread/PostThread.tsx @@ -20,25 +20,37 @@ import {ComposePrompt} from '../composer/Prompt' import {ErrorMessage} from '../util/error/ErrorMessage' import {Text} from '../util/text/Text' import {s} from 'lib/styles' -import {isDesktopWeb, isMobileWeb} from 'platform/detection' +import {isIOS, 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 MAINTAIN_VISIBLE_CONTENT_POSITION = {minIndexForVisible: 0} + +const PARENT_SPINNER = { + _reactKey: '__parent_spinner__', + _isHighlightedPost: false, +} const REPLY_PROMPT = {_reactKey: '__reply__', _isHighlightedPost: false} const DELETED = {_reactKey: '__deleted__', _isHighlightedPost: false} const BLOCKED = {_reactKey: '__blocked__', _isHighlightedPost: false} +const CHILD_SPINNER = { + _reactKey: '__child_spinner__', + _isHighlightedPost: false, +} const BOTTOM_COMPONENT = { _reactKey: '__bottom_component__', _isHighlightedPost: false, } type YieldedItem = | PostThreadItemModel + | typeof PARENT_SPINNER | typeof REPLY_PROMPT | typeof DELETED | typeof BLOCKED + | typeof PARENT_SPINNER export const PostThread = observer(function PostThread({ uri, @@ -51,14 +63,24 @@ export const PostThread = observer(function PostThread({ }) { const pal = usePalette('default') const ref = useRef<FlatList>(null) + const hasScrolledIntoView = useRef<boolean>(false) const [isRefreshing, setIsRefreshing] = React.useState(false) const navigation = useNavigation<NavigationProp>() const posts = React.useMemo(() => { if (view.thread) { - return Array.from(flattenThread(view.thread)).concat([BOTTOM_COMPONENT]) + const arr = Array.from(flattenThread(view.thread)) + if (view.isLoadingFromCache) { + if (view.thread?.postRecord?.reply) { + arr.unshift(PARENT_SPINNER) + } + arr.push(CHILD_SPINNER) + } else { + arr.push(BOTTOM_COMPONENT) + } + return arr } return [] - }, [view.thread]) + }, [view.isLoadingFromCache, view.thread]) useSetTitle( view.thread?.postRecord && `${sanitizeDisplayName( @@ -80,17 +102,37 @@ export const PostThread = observer(function PostThread({ setIsRefreshing(false) }, [view, setIsRefreshing]) - const onLayout = React.useCallback(() => { + const onContentSizeChange = React.useCallback(() => { + // only run once + if (hasScrolledIntoView.current) { + return + } + + // wait for loading to finish + if ( + !view.hasContent || + (view.isFromCache && view.isLoadingFromCache) || + view.isLoading + ) { + return + } + const index = posts.findIndex(post => post._isHighlightedPost) if (index !== -1) { ref.current?.scrollToIndex({ index, animated: false, - viewOffset: 40, + viewPosition: 0, }) + hasScrolledIntoView.current = true } - }, [posts, ref]) - + }, [ + posts, + view.hasContent, + view.isFromCache, + view.isLoadingFromCache, + view.isLoading, + ]) const onScrollToIndexFailed = React.useCallback( (info: { index: number @@ -115,7 +157,13 @@ export const PostThread = observer(function PostThread({ const renderItem = React.useCallback( ({item}: {item: YieldedItem}) => { - if (item === REPLY_PROMPT) { + if (item === PARENT_SPINNER) { + return ( + <View style={styles.parentSpinner}> + <ActivityIndicator /> + </View> + ) + } else if (item === REPLY_PROMPT) { return <ComposePrompt onPressCompose={onPressReply} /> } else if (item === DELETED) { return ( @@ -150,6 +198,12 @@ export const PostThread = observer(function PostThread({ ]} /> ) + } else if (item === CHILD_SPINNER) { + return ( + <View style={styles.childSpinner}> + <ActivityIndicator /> + </View> + ) } else if (item instanceof PostThreadItemModel) { return <PostThreadItem item={item} onPostReply={onRefresh} /> } @@ -247,6 +301,11 @@ export const PostThread = observer(function PostThread({ ref={ref} data={posts} initialNumToRender={posts.length} + maintainVisibleContentPosition={ + isIOS && view.isFromCache + ? MAINTAIN_VISIBLE_CONTENT_POSITION + : undefined + } keyExtractor={item => item._reactKey} renderItem={renderItem} refreshControl={ @@ -257,10 +316,12 @@ export const PostThread = observer(function PostThread({ titleColor={pal.colors.text} /> } - onLayout={onLayout} + onContentSizeChange={ + isIOS && view.isFromCache ? undefined : onContentSizeChange + } onScrollToIndexFailed={onScrollToIndexFailed} style={s.hContentRegion} - contentContainerStyle={s.contentContainerExtra} + contentContainerStyle={styles.contentContainerExtra} /> ) }) @@ -307,10 +368,17 @@ const styles = StyleSheet.create({ paddingHorizontal: 18, paddingVertical: 18, }, + parentSpinner: { + paddingVertical: 10, + }, + childSpinner: {}, bottomBorder: { borderBottomWidth: 1, }, bottomSpacer: { - height: 200, + height: 400, + }, + contentContainerExtra: { + paddingBottom: 500, }, }) diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx index edf8d7749..8a56012f0 100644 --- a/src/view/com/post-thread/PostThreadItem.tsx +++ b/src/view/com/post-thread/PostThreadItem.tsx @@ -26,15 +26,14 @@ import {PostEmbeds} from '../util/post-embeds' 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' +import {PostAlerts} from '../util/moderation/PostAlerts' import {PostSandboxWarning} from '../util/PostSandboxWarning' import {ErrorMessage} from '../util/error/ErrorMessage' import {usePalette} from 'lib/hooks/usePalette' import {formatCount} from '../util/numeric/format' import {TimeElapsed} from 'view/com/util/TimeElapsed' import {makeProfileLink} from 'lib/routes/links' - -const PARENT_REPLY_LINE_LENGTH = 8 +import {isDesktopWeb} from 'platform/detection' export const PostThreadItem = observer(function PostThreadItem({ item, @@ -69,8 +68,7 @@ export const PostThreadItem = observer(function PostThreadItem({ }, [item.post.uri, item.post.author]) const repostsTitle = 'Reposts of this post' - const primaryLanguage = store.preferences.contentLanguages[0] || 'en' - const translatorUrl = getTranslatorLink(primaryLanguage, record?.text || '') + const translatorUrl = getTranslatorLink(record?.text || '') const needsTranslation = useMemo( () => store.preferences.contentLanguages.length > 0 && @@ -159,159 +157,197 @@ export const PostThreadItem = observer(function PostThreadItem({ if (item._isHighlightedPost) { return ( - <PostHider - testID={`postThreadItem-by-${item.post.author.handle}`} - style={[styles.outer, styles.outerHighlighted, pal.border, pal.view]} - moderation={item.moderation.thread}> - <PostSandboxWarning /> - <View style={styles.layout}> - <View style={styles.layoutAvi}> - <PreviewableUserAvatar - size={52} - did={item.post.author.did} - handle={item.post.author.handle} - avatar={item.post.author.avatar} - moderation={item.moderation.avatar} - /> + <> + {item.rootUri !== item.uri && ( + <View style={{paddingLeft: 18, flexDirection: 'row', height: 16}}> + <View style={{width: 52}}> + <View + style={[ + styles.replyLine, + { + flexGrow: 1, + backgroundColor: pal.colors.replyLine, + }, + ]} + /> + </View> </View> - <View style={styles.layoutContent}> - <View style={[styles.meta, styles.metaExpandedLine1]}> - <View style={[s.flexRow]}> + )} + + <Link + testID={`postThreadItem-by-${item.post.author.handle}`} + style={[styles.outer, styles.outerHighlighted, pal.border, pal.view]} + noFeedback + accessible={false}> + <PostSandboxWarning /> + <View style={styles.layout}> + <View style={[styles.layoutAvi, {paddingBottom: 8}]}> + <PreviewableUserAvatar + size={52} + did={item.post.author.did} + handle={item.post.author.handle} + avatar={item.post.author.avatar} + moderation={item.moderation.avatar} + /> + </View> + <View style={styles.layoutContent}> + <View style={[styles.meta, styles.metaExpandedLine1]}> + <View style={[s.flexRow]}> + <Link + style={styles.metaItem} + href={authorHref} + title={authorTitle}> + <Text + type="xl-bold" + style={[pal.text]} + numberOfLines={1} + lineHeight={1.2}> + {sanitizeDisplayName( + item.post.author.displayName || + sanitizeHandle(item.post.author.handle), + )} + </Text> + </Link> + <Text type="md" style={[styles.metaItem, pal.textLight]}> + · + <TimeElapsed timestamp={item.post.indexedAt}> + {({timeElapsed}) => <>{timeElapsed}</>} + </TimeElapsed> + </Text> + </View> + </View> + <View style={styles.meta}> <Link style={styles.metaItem} href={authorHref} title={authorTitle}> - <Text - type="xl-bold" - style={[pal.text]} - numberOfLines={1} - lineHeight={1.2}> - {sanitizeDisplayName( - item.post.author.displayName || - sanitizeHandle(item.post.author.handle), - )} + <Text type="md" style={[pal.textLight]} numberOfLines={1}> + {sanitizeHandle(item.post.author.handle, '@')} </Text> </Link> - <Text type="md" style={[styles.metaItem, pal.textLight]}> - · - <TimeElapsed timestamp={item.post.indexedAt}> - {({timeElapsed}) => <>{timeElapsed}</>} - </TimeElapsed> - </Text> - </View> - <View style={s.flex1} /> - <PostDropdownBtn - testID="postDropdownBtn" - itemUri={itemUri} - itemCid={itemCid} - itemHref={itemHref} - itemTitle={itemTitle} - isAuthor={item.post.author.did === store.me.did} - isThreadMuted={item.isThreadMuted} - onCopyPostText={onCopyPostText} - onOpenTranslate={onOpenTranslate} - onToggleThreadMute={onToggleThreadMute} - onDeletePost={onDeletePost} - /> - </View> - <View style={styles.meta}> - <Link - style={styles.metaItem} - href={authorHref} - title={authorTitle}> - <Text type="md" style={[pal.textLight]} numberOfLines={1}> - {sanitizeHandle(item.post.author.handle, '@')} - </Text> - </Link> - </View> - </View> - </View> - <View style={[s.pl10, s.pr10, s.pb10]}> - <ContentHider moderation={item.moderation.view}> - {item.richText?.text ? ( - <View - style={[ - styles.postTextContainer, - styles.postTextLargeContainer, - ]}> - <RichText - type="post-text-lg" - richText={item.richText} - lineHeight={1.3} - style={s.flex1} - /> </View> - ) : undefined} - <ImageHider moderation={item.moderation.view} style={s.mb10}> - <PostEmbeds embed={item.post.embed} style={s.mb10} /> - </ImageHider> - </ContentHider> - <ExpandedPostDetails - post={item.post} - translatorUrl={translatorUrl} - needsTranslation={needsTranslation} - /> - {hasEngagement ? ( - <View style={[styles.expandedInfo, pal.border]}> - {item.post.repostCount ? ( - <Link - style={styles.expandedInfoItem} - href={repostsHref} - title={repostsTitle}> - <Text testID="repostCount" type="lg" style={pal.textLight}> - <Text type="xl-bold" style={pal.text}> - {formatCount(item.post.repostCount)} - </Text>{' '} - {pluralize(item.post.repostCount, 'repost')} - </Text> - </Link> - ) : ( - <></> - )} - {item.post.likeCount ? ( - <Link - style={styles.expandedInfoItem} - href={likesHref} - title={likesTitle}> - <Text testID="likeCount" type="lg" style={pal.textLight}> - <Text type="xl-bold" style={pal.text}> - {formatCount(item.post.likeCount)} - </Text>{' '} - {pluralize(item.post.likeCount, 'like')} - </Text> - </Link> - ) : ( - <></> - )} </View> - ) : ( - <></> - )} - <View style={[s.pl10, s.pb5]}> - <PostCtrls - big + <PostDropdownBtn + testID="postDropdownBtn" itemUri={itemUri} itemCid={itemCid} itemHref={itemHref} itemTitle={itemTitle} - author={item.post.author} - text={item.richText?.text || record.text} - indexedAt={item.post.indexedAt} isAuthor={item.post.author.did === store.me.did} - isReposted={!!item.post.viewer?.repost} - isLiked={!!item.post.viewer?.like} isThreadMuted={item.isThreadMuted} - onPressReply={onPressReply} - onPressToggleRepost={onPressToggleRepost} - onPressToggleLike={onPressToggleLike} onCopyPostText={onCopyPostText} onOpenTranslate={onOpenTranslate} onToggleThreadMute={onToggleThreadMute} onDeletePost={onDeletePost} + style={{ + paddingVertical: 6, + paddingHorizontal: 10, + marginLeft: 'auto', + width: 40, + }} + /> + </View> + <View style={[s.pl10, s.pr10, s.pb10]}> + <ContentHider + moderation={item.moderation.content} + ignoreMute + style={styles.contentHider} + childContainerStyle={styles.contentHiderChild}> + <PostAlerts + moderation={item.moderation.content} + includeMute + style={styles.alert} + /> + {item.richText?.text ? ( + <View + style={[ + styles.postTextContainer, + styles.postTextLargeContainer, + ]}> + <RichText + type="post-text-lg" + richText={item.richText} + lineHeight={1.3} + style={s.flex1} + /> + </View> + ) : undefined} + {item.post.embed && ( + <ContentHider moderation={item.moderation.embed} style={s.mb10}> + <PostEmbeds + embed={item.post.embed} + moderation={item.moderation.embed} + /> + </ContentHider> + )} + </ContentHider> + <ExpandedPostDetails + post={item.post} + translatorUrl={translatorUrl} + needsTranslation={needsTranslation} /> + {hasEngagement ? ( + <View style={[styles.expandedInfo, pal.border]}> + {item.post.repostCount ? ( + <Link + style={styles.expandedInfoItem} + href={repostsHref} + title={repostsTitle}> + <Text testID="repostCount" type="lg" style={pal.textLight}> + <Text type="xl-bold" style={pal.text}> + {formatCount(item.post.repostCount)} + </Text>{' '} + {pluralize(item.post.repostCount, 'repost')} + </Text> + </Link> + ) : ( + <></> + )} + {item.post.likeCount ? ( + <Link + style={styles.expandedInfoItem} + href={likesHref} + title={likesTitle}> + <Text testID="likeCount" type="lg" style={pal.textLight}> + <Text type="xl-bold" style={pal.text}> + {formatCount(item.post.likeCount)} + </Text>{' '} + {pluralize(item.post.likeCount, 'like')} + </Text> + </Link> + ) : ( + <></> + )} + </View> + ) : ( + <></> + )} + <View style={[s.pl10, s.pb5]}> + <PostCtrls + big + itemUri={itemUri} + itemCid={itemCid} + itemHref={itemHref} + itemTitle={itemTitle} + author={item.post.author} + text={item.richText?.text || record.text} + indexedAt={item.post.indexedAt} + isAuthor={item.post.author.did === store.me.did} + isReposted={!!item.post.viewer?.repost} + isLiked={!!item.post.viewer?.like} + isThreadMuted={item.isThreadMuted} + onPressReply={onPressReply} + onPressToggleRepost={onPressToggleRepost} + onPressToggleLike={onPressToggleLike} + onCopyPostText={onCopyPostText} + onOpenTranslate={onOpenTranslate} + onToggleThreadMute={onToggleThreadMute} + onDeletePost={onDeletePost} + /> + </View> </View> - </View> - </PostHider> + </Link> + </> ) } else { return ( @@ -324,26 +360,36 @@ export const PostThreadItem = observer(function PostThreadItem({ pal.border, pal.view, item._showParentReplyLine && styles.noTopBorder, + !item._showChildReplyLine && {borderBottomWidth: 1}, ]} - moderation={item.moderation.thread}> - {item._showParentReplyLine && ( - <View - style={[ - styles.parentReplyLine, - {borderColor: pal.colors.replyLine}, - ]} - /> - )} - {item._showChildReplyLine && ( - <View - style={[ - styles.childReplyLine, - {borderColor: pal.colors.replyLine}, - ]} - /> - )} + moderation={item.moderation.content}> <PostSandboxWarning /> - <View style={styles.layout}> + + <View + style={{flexDirection: 'row', gap: 10, paddingLeft: 8, height: 16}}> + <View style={{width: 52}}> + {item._showParentReplyLine && ( + <View + style={[ + styles.replyLine, + { + flexGrow: 1, + backgroundColor: pal.colors.replyLine, + marginBottom: 4, + }, + ]} + /> + )} + </View> + </View> + + <View + style={[ + styles.layout, + { + paddingBottom: item._showChildReplyLine ? 0 : 8, + }, + ]}> <View style={styles.layoutAvi}> <PreviewableUserAvatar size={52} @@ -352,7 +398,21 @@ export const PostThreadItem = observer(function PostThreadItem({ avatar={item.post.author.avatar} moderation={item.moderation.avatar} /> + + {item._showChildReplyLine && ( + <View + style={[ + styles.replyLine, + { + flexGrow: 1, + backgroundColor: pal.colors.replyLine, + marginTop: 4, + }, + ]} + /> + )} </View> + <View style={styles.layoutContent}> <PostMeta author={item.post.author} @@ -360,32 +420,39 @@ export const PostThreadItem = observer(function PostThreadItem({ timestamp={item.post.indexedAt} postHref={itemHref} /> - <ContentHider - moderation={item.moderation.thread} - containerStyle={styles.contentHider}> - {item.richText?.text ? ( - <View style={styles.postTextContainer}> - <RichText - type="post-text" - richText={item.richText} - style={[pal.text, s.flex1]} - lineHeight={1.3} - /> - </View> - ) : undefined} - <ImageHider style={s.mb10} moderation={item.moderation.thread}> - <PostEmbeds embed={item.post.embed} style={s.mb10} /> - </ImageHider> - {needsTranslation && ( - <View style={[pal.borderDark, styles.translateLink]}> - <Link href={translatorUrl} title="Translate"> - <Text type="sm" style={pal.link}> - Translate this post - </Text> - </Link> - </View> - )} - </ContentHider> + <PostAlerts + moderation={item.moderation.content} + style={styles.alert} + /> + {item.richText?.text ? ( + <View style={styles.postTextContainer}> + <RichText + type="post-text" + richText={item.richText} + style={[pal.text, s.flex1]} + lineHeight={1.3} + /> + </View> + ) : undefined} + {item.post.embed && ( + <ContentHider + style={styles.contentHider} + moderation={item.moderation.embed}> + <PostEmbeds + embed={item.post.embed} + moderation={item.moderation.embed} + /> + </ContentHider> + )} + {needsTranslation && ( + <View style={[pal.borderDark, styles.translateLink]}> + <Link href={translatorUrl} title="Translate"> + <Text type="sm" style={pal.link}> + Translate this post + </Text> + </Link> + </View> + )} <PostCtrls itemUri={itemUri} itemCid={itemCid} @@ -416,7 +483,7 @@ export const PostThreadItem = observer(function PostThreadItem({ <Link style={[ styles.loadMore, - {borderTopColor: pal.colors.border}, + {borderBottomColor: pal.colors.border}, pal.view, ]} href={itemHref} @@ -466,41 +533,22 @@ const styles = StyleSheet.create({ paddingLeft: 10, }, outerHighlighted: { - paddingTop: 2, - paddingLeft: 6, - paddingRight: 6, + paddingTop: 16, + paddingLeft: 10, + paddingRight: 10, }, noTopBorder: { borderTopWidth: 0, }, - parentReplyLine: { - position: 'absolute', - left: 44, - top: -1 * PARENT_REPLY_LINE_LENGTH + 6, - height: PARENT_REPLY_LINE_LENGTH, - borderLeftWidth: 2, - }, - childReplyLine: { - position: 'absolute', - left: 44, - top: 65, - bottom: 0, - borderLeftWidth: 2, - }, layout: { flexDirection: 'row', + gap: 10, + paddingLeft: 8, }, - layoutAvi: { - paddingLeft: 10, - paddingTop: 10, - paddingBottom: 10, - marginRight: 10, - }, + layoutAvi: {}, layoutContent: { flex: 1, paddingRight: 10, - paddingTop: 10, - paddingBottom: 10, }, meta: { flexDirection: 'row', @@ -513,7 +561,10 @@ const styles = StyleSheet.create({ }, metaItem: { paddingRight: 5, - maxWidth: 240, + maxWidth: isDesktopWeb ? 380 : 220, + }, + alert: { + marginBottom: 6, }, postTextContainer: { flexDirection: 'row', @@ -521,7 +572,6 @@ const styles = StyleSheet.create({ flexWrap: 'wrap', paddingBottom: 8, paddingRight: 10, - minHeight: 36, }, postTextLargeContainer: { paddingHorizontal: 0, @@ -531,7 +581,10 @@ const styles = StyleSheet.create({ marginBottom: 6, }, contentHider: { - marginTop: 4, + marginBottom: 6, + }, + contentHiderChild: { + marginTop: 6, }, expandedInfo: { flexDirection: 'row', @@ -547,10 +600,14 @@ const styles = StyleSheet.create({ loadMore: { flexDirection: 'row', justifyContent: 'space-between', - borderTopWidth: 1, + borderBottomWidth: 1, paddingLeft: 80, paddingRight: 20, - paddingVertical: 10, - marginBottom: 8, + paddingVertical: 12, + }, + replyLine: { + width: 2, + marginLeft: 'auto', + marginRight: 'auto', }, }) diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx index ac5e7d20b..673ddefcf 100644 --- a/src/view/com/post/Post.tsx +++ b/src/view/com/post/Post.tsx @@ -19,9 +19,8 @@ import {UserInfoText} from '../util/UserInfoText' import {PostMeta} from '../util/PostMeta' import {PostEmbeds} from '../util/post-embeds' 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' +import {PostAlerts} from '../util/moderation/PostAlerts' import {Text} from '../util/text/Text' import {RichText} from '../util/text/RichText' import * as Toast from '../util/Toast' @@ -134,8 +133,7 @@ const PostLoaded = observer( replyAuthorDid = urip.hostname } - const primaryLanguage = store.preferences.contentLanguages[0] || 'en' - const translatorUrl = getTranslatorLink(primaryLanguage, record?.text || '') + const translatorUrl = getTranslatorLink(record?.text || '') const needsTranslation = useMemo( () => store.preferences.contentLanguages.length > 0 && @@ -206,10 +204,7 @@ const PostLoaded = observer( }, [item, setDeleted, store]) return ( - <PostHider - href={itemHref} - style={[styles.outer, pal.view, pal.border, style]} - moderation={item.moderation.list}> + <Link href={itemHref} style={[styles.outer, pal.view, pal.border, style]}> {showReplyLine && <View style={styles.replyLine} />} <View style={styles.layout}> <View style={styles.layoutAvi}> @@ -251,8 +246,13 @@ const PostLoaded = observer( </View> )} <ContentHider - moderation={item.moderation.list} - containerStyle={styles.contentHider}> + moderation={item.moderation.content} + style={styles.contentHider} + childContainerStyle={styles.contentHiderChild}> + <PostAlerts + moderation={item.moderation.content} + style={styles.alert} + /> {item.richText?.text ? ( <View style={styles.postTextContainer}> <RichText @@ -264,9 +264,16 @@ const PostLoaded = observer( /> </View> ) : undefined} - <ImageHider moderation={item.moderation.list} style={s.mb10}> - <PostEmbeds embed={item.post.embed} style={s.mb10} /> - </ImageHider> + {item.post.embed ? ( + <ContentHider + moderation={item.moderation.embed} + style={styles.contentHider}> + <PostEmbeds + embed={item.post.embed} + moderation={item.moderation.embed} + /> + </ContentHider> + ) : null} {needsTranslation && ( <View style={[pal.borderDark, styles.translateLink]}> <Link href={translatorUrl} title="Translate"> @@ -302,15 +309,17 @@ const PostLoaded = observer( /> </View> </View> - </PostHider> + </Link> ) }, ) const styles = StyleSheet.create({ outer: { - padding: 10, + paddingTop: 10, paddingRight: 15, + paddingBottom: 5, + paddingLeft: 10, borderTopWidth: 1, }, layout: { @@ -323,11 +332,13 @@ const styles = StyleSheet.create({ layoutContent: { flex: 1, }, + alert: { + marginBottom: 6, + }, postTextContainer: { flexDirection: 'row', alignItems: 'center', flexWrap: 'wrap', - paddingBottom: 8, }, translateLink: { marginBottom: 12, @@ -341,6 +352,9 @@ const styles = StyleSheet.create({ borderLeftColor: colors.gray2, }, contentHider: { - marginTop: 4, + marginBottom: 2, + }, + contentHiderChild: { + marginTop: 6, }, }) diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx index 75c321145..e1212f32c 100644 --- a/src/view/com/posts/FeedItem.tsx +++ b/src/view/com/posts/FeedItem.tsx @@ -8,16 +8,14 @@ import { FontAwesomeIconStyle, } from '@fortawesome/react-native-fontawesome' import {PostsFeedItemModel} from 'state/models/feeds/post' -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/post-ctrls/PostCtrls' import {PostEmbeds} from '../util/post-embeds' -import {PostHider} from '../util/moderation/PostHider' import {ContentHider} from '../util/moderation/ContentHider' -import {ImageHider} from '../util/moderation/ImageHider' +import {PostAlerts} from '../util/moderation/PostAlerts' import {RichText} from '../util/text/RichText' import {PostSandboxWarning} from '../util/PostSandboxWarning' import * as Toast from '../util/Toast' @@ -34,14 +32,14 @@ import {makeProfileLink} from 'lib/routes/links' export const FeedItem = observer(function ({ item, isThreadChild, + isThreadLastChild, isThreadParent, - ignoreMuteFor, }: { item: PostsFeedItemModel isThreadChild?: boolean + isThreadLastChild?: boolean isThreadParent?: boolean showReplyLine?: boolean - ignoreMuteFor?: string }) { const store = useStores() const pal = usePalette('default') @@ -62,8 +60,7 @@ export const FeedItem = observer(function ({ const urip = new AtUri(record.reply.parent?.uri || record.reply.root.uri) return urip.hostname }, [record?.reply]) - const primaryLanguage = store.preferences.contentLanguages[0] || 'en' - const translatorUrl = getTranslatorLink(primaryLanguage, record?.text || '') + const translatorUrl = getTranslatorLink(record?.text || '') const needsTranslation = useMemo( () => store.preferences.contentLanguages.length > 0 && @@ -138,80 +135,86 @@ export const FeedItem = observer(function ({ ) }, [track, item, setDeleted, store]) - const isSmallTop = isThreadChild const outerStyles = [ styles.outer, pal.view, - {borderColor: pal.colors.border}, - isSmallTop ? styles.outerSmallTop : undefined, - isThreadParent ? styles.outerNoBottom : undefined, + { + borderColor: pal.colors.border, + paddingBottom: + isThreadLastChild || (!isThreadChild && !isThreadParent) + ? 6 + : undefined, + }, + isThreadChild ? styles.outerSmallTop : undefined, ] - // moderation override - let moderation = item.moderation.list - if ( - ignoreMuteFor === item.post.author.did && - moderation.isMute && - !moderation.noOverride - ) { - moderation = {behavior: ModerationBehaviorCode.Show} - } - if (!record || deleted) { return <View /> } return ( - <PostHider + <Link testID={`feedItem-by-${item.post.author.handle}`} style={outerStyles} href={itemHref} - moderation={moderation}> - {isThreadChild && ( - <View - style={[styles.topReplyLine, {borderColor: pal.colors.replyLine}]} - /> - )} - {isThreadParent && ( - <View - style={[styles.bottomReplyLine, {borderColor: pal.colors.replyLine}]} - /> - )} - {item.reasonRepost && ( - <Link - style={styles.includeReason} - href={makeProfileLink(item.reasonRepost.by)} - title={sanitizeDisplayName( - item.reasonRepost.by.displayName || item.reasonRepost.by.handle, - )}> - <FontAwesomeIcon - icon="retweet" - style={[ - styles.includeReasonIcon, - {color: pal.colors.textLight} as FontAwesomeIconStyle, - ]} - /> - <Text - type="sm-bold" - style={pal.textLight} - lineHeight={1.2} - numberOfLines={1}> - Reposted by{' '} - <DesktopWebTextLink - type="sm-bold" - style={pal.textLight} - lineHeight={1.2} - numberOfLines={1} - text={sanitizeDisplayName( - item.reasonRepost.by.displayName || - sanitizeHandle(item.reasonRepost.by.handle), - )} - href={makeProfileLink(item.reasonRepost.by)} - /> - </Text> - </Link> - )} + noFeedback + accessible={false}> <PostSandboxWarning /> + + <View style={{flexDirection: 'row', gap: 10, paddingLeft: 8}}> + <View style={{width: 52}}> + {isThreadChild && ( + <View + style={[ + styles.replyLine, + { + flexGrow: 1, + backgroundColor: pal.colors.replyLine, + marginBottom: 4, + }, + ]} + /> + )} + </View> + + <View style={{paddingTop: 12}}> + {item.reasonRepost && ( + <Link + style={styles.includeReason} + href={makeProfileLink(item.reasonRepost.by)} + title={sanitizeDisplayName( + item.reasonRepost.by.displayName || item.reasonRepost.by.handle, + )}> + <FontAwesomeIcon + icon="retweet" + style={[ + styles.includeReasonIcon, + {color: pal.colors.textLight} as FontAwesomeIconStyle, + ]} + /> + <Text + type="sm-bold" + style={pal.textLight} + lineHeight={1.2} + numberOfLines={1}> + Reposted by{' '} + <DesktopWebTextLink + type="sm-bold" + style={pal.textLight} + lineHeight={1.2} + numberOfLines={1} + text={sanitizeDisplayName( + item.reasonRepost.by.displayName || + sanitizeHandle(item.reasonRepost.by.handle), + )} + href={makeProfileLink(item.reasonRepost.by)} + /> + </Text> + </Link> + )} + </View> + </View> + <View style={styles.layout}> <View style={styles.layoutAvi}> <PreviewableUserAvatar @@ -221,6 +224,18 @@ export const FeedItem = observer(function ({ avatar={item.post.author.avatar} moderation={item.moderation.avatar} /> + {isThreadParent && ( + <View + style={[ + styles.replyLine, + { + flexGrow: 1, + backgroundColor: pal.colors.replyLine, + marginTop: 4, + }, + ]} + /> + )} </View> <View style={styles.layoutContent}> <PostMeta @@ -255,8 +270,14 @@ export const FeedItem = observer(function ({ </View> )} <ContentHider - moderation={moderation} - containerStyle={styles.contentHider}> + testID="contentHider-post" + moderation={item.moderation.content} + ignoreMute + childContainerStyle={styles.contentHiderChild}> + <PostAlerts + moderation={item.moderation.content} + style={styles.alert} + /> {item.richText?.text ? ( <View style={styles.postTextContainer}> <RichText @@ -267,9 +288,17 @@ export const FeedItem = observer(function ({ /> </View> ) : undefined} - <ImageHider moderation={item.moderation.list} style={styles.embed}> - <PostEmbeds embed={item.post.embed} style={styles.embed} /> - </ImageHider> + {item.post.embed ? ( + <ContentHider + testID="contentHider-embed" + moderation={item.moderation.embed} + style={styles.embed}> + <PostEmbeds + embed={item.post.embed} + moderation={item.moderation.embed} + /> + </ContentHider> + ) : null} {needsTranslation && ( <View style={[pal.borderDark, styles.translateLink]}> <Link href={translatorUrl} title="Translate"> @@ -281,7 +310,6 @@ export const FeedItem = observer(function ({ )} </ContentHider> <PostCtrls - style={styles.ctrls} itemUri={itemUri} itemCid={itemCid} itemHref={itemHref} @@ -306,43 +334,29 @@ export const FeedItem = observer(function ({ /> </View> </View> - </PostHider> + </Link> ) }) const styles = StyleSheet.create({ outer: { borderTopWidth: 1, - padding: 10, + paddingLeft: 10, paddingRight: 15, - paddingBottom: 8, }, outerSmallTop: { borderTopWidth: 0, }, - outerNoBottom: { - paddingBottom: 2, - }, - topReplyLine: { - position: 'absolute', - left: 42, - top: 0, - height: 6, - borderLeftWidth: 2, - }, - bottomReplyLine: { - position: 'absolute', - left: 42, - top: 72, - bottom: 0, - borderLeftWidth: 2, + replyLine: { + width: 2, + marginLeft: 'auto', + marginRight: 'auto', }, includeReason: { flexDirection: 'row', - paddingLeft: 50, - paddingRight: 20, marginTop: 2, - marginBottom: 2, + marginBottom: 4, + marginLeft: -20, }, includeReasonIcon: { marginRight: 4, @@ -358,14 +372,18 @@ const styles = StyleSheet.create({ layoutContent: { flex: 1, }, + alert: { + marginTop: 6, + marginBottom: 6, + }, postTextContainer: { flexDirection: 'row', alignItems: 'center', flexWrap: 'wrap', paddingBottom: 4, }, - contentHider: { - marginTop: 4, + contentHiderChild: { + marginTop: 6, }, embed: { marginBottom: 6, @@ -373,7 +391,4 @@ const styles = StyleSheet.create({ translateLink: { marginBottom: 6, }, - ctrls: { - marginTop: 4, - }, }) diff --git a/src/view/com/posts/FeedSlice.tsx b/src/view/com/posts/FeedSlice.tsx index b73d4a99d..6fc169db9 100644 --- a/src/view/com/posts/FeedSlice.tsx +++ b/src/view/com/posts/FeedSlice.tsx @@ -1,5 +1,6 @@ import React from 'react' import {StyleSheet, View} from 'react-native' +import {observer} from 'mobx-react-lite' import {PostsFeedSliceModel} from 'state/models/feeds/posts-slice' import {AtUri} from '@atproto/api' import {Link} from '../util/Link' @@ -7,65 +8,65 @@ import {Text} from '../util/text/Text' import Svg, {Circle, Line} from 'react-native-svg' import {FeedItem} from './FeedItem' import {usePalette} from 'lib/hooks/usePalette' -import {ModerationBehaviorCode} from 'lib/labeling/types' import {makeProfileLink} from 'lib/routes/links' -export function FeedSlice({ - slice, - ignoreMuteFor, -}: { - slice: PostsFeedSliceModel - ignoreMuteFor?: string -}) { - if (slice.moderation.list.behavior === ModerationBehaviorCode.Hide) { - if (!ignoreMuteFor && !slice.moderation.list.noOverride) { +export const FeedSlice = observer( + ({ + slice, + ignoreFilterFor, + }: { + slice: PostsFeedSliceModel + ignoreFilterFor?: string + }) => { + if (slice.shouldFilter(ignoreFilterFor)) { return null } - } - if (slice.isThread && slice.items.length > 3) { - const last = slice.items.length - 1 + + if (slice.isThread && slice.items.length > 3) { + const last = slice.items.length - 1 + return ( + <> + <FeedItem + key={slice.items[0]._reactKey} + item={slice.items[0]} + isThreadParent={slice.isThreadParentAt(0)} + isThreadChild={slice.isThreadChildAt(0)} + /> + <FeedItem + key={slice.items[1]._reactKey} + item={slice.items[1]} + isThreadParent={slice.isThreadParentAt(1)} + isThreadChild={slice.isThreadChildAt(1)} + /> + <ViewFullThread slice={slice} /> + <FeedItem + key={slice.items[last]._reactKey} + item={slice.items[last]} + isThreadParent={slice.isThreadParentAt(last)} + isThreadChild={slice.isThreadChildAt(last)} + isThreadLastChild + /> + </> + ) + } + return ( <> - <FeedItem - key={slice.items[0]._reactKey} - item={slice.items[0]} - isThreadParent={slice.isThreadParentAt(0)} - isThreadChild={slice.isThreadChildAt(0)} - ignoreMuteFor={ignoreMuteFor} - /> - <FeedItem - key={slice.items[1]._reactKey} - item={slice.items[1]} - isThreadParent={slice.isThreadParentAt(1)} - isThreadChild={slice.isThreadChildAt(1)} - ignoreMuteFor={ignoreMuteFor} - /> - <ViewFullThread slice={slice} /> - <FeedItem - key={slice.items[last]._reactKey} - item={slice.items[last]} - isThreadParent={slice.isThreadParentAt(last)} - isThreadChild={slice.isThreadChildAt(last)} - ignoreMuteFor={ignoreMuteFor} - /> + {slice.items.map((item, i) => ( + <FeedItem + key={item._reactKey} + item={item} + isThreadParent={slice.isThreadParentAt(i)} + isThreadChild={slice.isThreadChildAt(i)} + isThreadLastChild={ + slice.isThreadChildAt(i) && slice.items.length === i + 1 + } + /> + ))} </> ) - } - - return ( - <> - {slice.items.map((item, i) => ( - <FeedItem - key={item._reactKey} - item={item} - isThreadParent={slice.isThreadParentAt(i)} - isThreadChild={slice.isThreadChildAt(i)} - ignoreMuteFor={ignoreMuteFor} - /> - ))} - </> - ) -} + }, +) function ViewFullThread({slice}: {slice: PostsFeedSliceModel}) { const pal = usePalette('default') @@ -75,23 +76,28 @@ function ViewFullThread({slice}: {slice: PostsFeedSliceModel}) { }, [slice.rootItem.post.uri, slice.rootItem.post.author]) return ( - <Link style={[pal.view, styles.viewFullThread]} href={itemHref} noFeedback> + <Link + style={[pal.view, styles.viewFullThread]} + href={itemHref} + asAnchor + noFeedback> <View style={styles.viewFullThreadDots}> - <Svg width="4" height="30"> + <Svg width="4" height="40"> <Line x1="2" y1="0" x2="2" - y2="8" + y2="15" stroke={pal.colors.replyLine} strokeWidth="2" /> - <Circle cx="2" cy="16" r="1.5" fill={pal.colors.replyLineDot} /> <Circle cx="2" cy="22" r="1.5" fill={pal.colors.replyLineDot} /> <Circle cx="2" cy="28" r="1.5" fill={pal.colors.replyLineDot} /> + <Circle cx="2" cy="34" r="1.5" fill={pal.colors.replyLineDot} /> </Svg> </View> - <Text type="md" style={pal.link}> + + <Text type="md" style={[pal.link, {paddingTop: 18, paddingBottom: 4}]}> View full thread </Text> </Link> @@ -100,13 +106,12 @@ function ViewFullThread({slice}: {slice: PostsFeedSliceModel}) { const styles = StyleSheet.create({ viewFullThread: { - paddingTop: 14, - paddingBottom: 6, - paddingLeft: 80, + flexDirection: 'row', + gap: 10, + paddingLeft: 18, }, viewFullThreadDots: { - position: 'absolute', - left: 41, - top: 0, + width: 52, + alignItems: 'center', }, }) diff --git a/src/view/com/profile/ProfileCard.tsx b/src/view/com/profile/ProfileCard.tsx index 946e0f2ab..771785ee9 100644 --- a/src/view/com/profile/ProfileCard.tsx +++ b/src/view/com/profile/ProfileCard.tsx @@ -1,7 +1,11 @@ import * as React from 'react' import {StyleSheet, View} from 'react-native' import {observer} from 'mobx-react-lite' -import {AppBskyActorDefs} from '@atproto/api' +import { + AppBskyActorDefs, + moderateProfile, + ProfileModeration, +} from '@atproto/api' import {Link} from '../util/Link' import {Text} from '../util/text/Text' import {UserAvatar} from '../util/UserAvatar' @@ -11,12 +15,12 @@ import {useStores} from 'state/index' import {FollowButton} from './FollowButton' import {sanitizeDisplayName} from 'lib/strings/display-names' import {sanitizeHandle} from 'lib/strings/handles' -import { - getProfileViewBasicLabelInfo, - getProfileModeration, -} from 'lib/labeling/helpers' -import {ModerationBehaviorCode} from 'lib/labeling/types' import {makeProfileLink} from 'lib/routes/links' +import { + describeModerationCause, + getProfileModerationCauses, + getModerationCauseKey, +} from 'lib/moderation' export const ProfileCard = observer( ({ @@ -25,7 +29,6 @@ export const ProfileCard = observer( noBg, noBorder, followers, - overrideModeration, renderButton, }: { testID?: string @@ -33,7 +36,6 @@ export const ProfileCard = observer( noBg?: boolean noBorder?: boolean followers?: AppBskyActorDefs.ProfileView[] | undefined - overrideModeration?: boolean renderButton?: ( profile: AppBskyActorDefs.ProfileViewBasic, ) => React.ReactNode @@ -41,18 +43,11 @@ export const ProfileCard = observer( const store = useStores() const pal = usePalette('default') - const moderation = getProfileModeration( - store, - getProfileViewBasicLabelInfo(profile), + const moderation = moderateProfile( + profile, + store.preferences.moderationOpts, ) - if ( - moderation.list.behavior === ModerationBehaviorCode.Hide && - !overrideModeration - ) { - return null - } - return ( <Link testID={testID} @@ -82,20 +77,17 @@ export const ProfileCard = observer( lineHeight={1.2}> {sanitizeDisplayName( profile.displayName || sanitizeHandle(profile.handle), + moderation.profile, )} </Text> <Text type="md" style={[pal.textLight]} numberOfLines={1}> {sanitizeHandle(profile.handle, '@')} </Text> - {!!profile.viewer?.followedBy && ( - <View style={s.flexRow}> - <View style={[s.mt5, pal.btn, styles.pill]}> - <Text type="xs" style={pal.text}> - Follows You - </Text> - </View> - </View> - )} + <ProfileCardPills + followedBy={!!profile.viewer?.followedBy} + moderation={moderation} + /> + {!!profile.viewer?.followedBy && <View style={s.flexRow} />} </View> {renderButton ? ( <View style={styles.layoutButton}>{renderButton(profile)}</View> @@ -114,6 +106,46 @@ export const ProfileCard = observer( }, ) +function ProfileCardPills({ + followedBy, + moderation, +}: { + followedBy: boolean + moderation: ProfileModeration +}) { + const pal = usePalette('default') + + const causes = getProfileModerationCauses(moderation) + if (!followedBy && !causes.length) { + return null + } + + return ( + <View style={styles.pills}> + {followedBy && ( + <View style={[s.mt5, pal.btn, styles.pill]}> + <Text type="xs" style={pal.text}> + Follows You + </Text> + </View> + )} + {causes.map(cause => { + const desc = describeModerationCause(cause, 'account') + return ( + <View + style={[s.mt5, pal.btn, styles.pill]} + key={getModerationCauseKey(cause)}> + <Text type="xs" style={pal.text}> + {cause?.type === 'label' ? 'âš ' : ''} + {desc.name} + </Text> + </View> + ) + })} + </View> + ) +} + const FollowersList = observer( ({followers}: {followers?: AppBskyActorDefs.ProfileView[] | undefined}) => { const store = useStores() @@ -125,9 +157,9 @@ const FollowersList = observer( const followersWithMods = followers .map(f => ({ f, - mod: getProfileModeration(store, getProfileViewBasicLabelInfo(f)), + mod: moderateProfile(f, store.preferences.moderationOpts), })) - .filter(({mod}) => mod.list.behavior !== ModerationBehaviorCode.Hide) + .filter(({mod}) => !mod.account.filter) return ( <View style={styles.followedBy}> @@ -218,6 +250,12 @@ const styles = StyleSheet.create({ paddingRight: 10, paddingBottom: 10, }, + pills: { + flexDirection: 'row', + flexWrap: 'wrap', + columnGap: 6, + rowGap: 2, + }, pill: { borderRadius: 4, paddingHorizontal: 6, diff --git a/src/view/com/profile/ProfileHeader.tsx b/src/view/com/profile/ProfileHeader.tsx index a372f0d81..dd3fb530e 100644 --- a/src/view/com/profile/ProfileHeader.tsx +++ b/src/view/com/profile/ProfileHeader.tsx @@ -21,15 +21,13 @@ import * as Toast from '../util/Toast' import {LoadingPlaceholder} from '../util/LoadingPlaceholder' import {Text} from '../util/text/Text' import {ThemedText} from '../util/text/ThemedText' -import {TextLink} from '../util/Link' import {RichText} from '../util/text/RichText' import {UserAvatar} from '../util/UserAvatar' import {UserBanner} from '../util/UserBanner' -import {ProfileHeaderWarnings} from '../util/moderation/ProfileHeaderWarnings' +import {ProfileHeaderAlerts} from '../util/moderation/ProfileHeaderAlerts' import {usePalette} from 'lib/hooks/usePalette' import {useAnalytics} from 'lib/analytics/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' @@ -116,7 +114,10 @@ const ProfileHeaderLoaded = observer( }, [navigation]) const onPressAvi = React.useCallback(() => { - if (view.avatar) { + if ( + view.avatar && + !(view.moderation.avatar.blur && view.moderation.avatar.noOverride) + ) { store.shell.openLightbox(new ProfileImageLightbox(view)) } }, [store, view]) @@ -244,7 +245,7 @@ const ProfileHeaderLoaded = observer( const onPressReportAccount = React.useCallback(() => { track('ProfileHeader:ReportAccountButtonClicked') store.shell.openModal({ - name: 'report-account', + name: 'report', did: view.did, }) }, [track, store, view]) @@ -434,6 +435,7 @@ const ProfileHeaderLoaded = observer( style={[pal.text, styles.title]}> {sanitizeDisplayName( view.displayName || sanitizeHandle(view.handle), + view.moderation.profile, )} </Text> </View> @@ -494,7 +496,9 @@ const ProfileHeaderLoaded = observer( </Text> </Text> </View> - {view.descriptionRichText ? ( + {view.description && + view.descriptionRichText && + !view.moderation.profile.blur ? ( <RichText testID="profileHeaderDescription" style={[styles.description, pal.text]} @@ -504,52 +508,7 @@ const ProfileHeaderLoaded = observer( ) : undefined} </> )} - <ProfileHeaderWarnings moderation={view.moderation.view} /> - <View style={styles.moderationLines}> - {view.viewer.blocking ? ( - <View - testID="profileHeaderBlockedNotice" - 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.viewLight]}> - <FontAwesomeIcon - icon={['far', 'eye-slash']} - style={[pal.text]} - /> - <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.viewLight]}> - <FontAwesomeIcon icon="ban" style={[pal.text]} /> - <Text type="lg-medium" style={pal.text}> - This account has blocked you - </Text> - </View> - )} - </View> + <ProfileHeaderAlerts moderation={view.moderation} /> </View> {!isDesktopWeb && !hideBackButton && ( <TouchableWithoutFeedback @@ -693,19 +652,6 @@ const styles = StyleSheet.create({ paddingVertical: 2, }, - moderationLines: { - gap: 6, - }, - - moderationNotice: { - flexDirection: 'row', - alignItems: 'center', - borderRadius: 8, - paddingHorizontal: 16, - paddingVertical: 14, - gap: 8, - }, - br40: {borderRadius: 40}, br50: {borderRadius: 50}, }) diff --git a/src/view/com/util/PostMeta.tsx b/src/view/com/util/PostMeta.tsx index 2ce499765..bf21ff0d1 100644 --- a/src/view/com/util/PostMeta.tsx +++ b/src/view/com/util/PostMeta.tsx @@ -91,7 +91,7 @@ export const PostMeta = observer(function (opts: PostMetaOpts) { const styles = StyleSheet.create({ metaOneLine: { flexDirection: 'row', - alignItems: 'baseline', + alignItems: isAndroid ? 'center' : 'baseline', paddingBottom: 2, gap: 4, }, diff --git a/src/view/com/util/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx index d999ffb31..0f34f75aa 100644 --- a/src/view/com/util/UserAvatar.tsx +++ b/src/view/com/util/UserAvatar.tsx @@ -3,6 +3,7 @@ import {StyleSheet, View} from 'react-native' import Svg, {Circle, Rect, Path} from 'react-native-svg' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {HighPriorityImage} from 'view/com/util/images/Image' +import {ModerationUI} from '@atproto/api' import {openCamera, openCropper, openPicker} from '../../../lib/media/picker' import { usePhotoLibraryPermission, @@ -13,7 +14,6 @@ import {colors} from 'lib/styles' import {usePalette} from 'lib/hooks/usePalette' import {isWeb, isAndroid} from 'platform/detection' import {Image as RNImage} from 'react-native-image-crop-picker' -import {AvatarModeration} from 'lib/labeling/types' import {UserPreviewLink} from './UserPreviewLink' import {DropdownItem, NativeDropdown} from './forms/NativeDropdown' @@ -23,7 +23,7 @@ interface BaseUserAvatarProps { type?: Type size: number avatar?: string | null - moderation?: AvatarModeration + moderation?: ModerationUI } interface UserAvatarProps extends BaseUserAvatarProps { @@ -213,20 +213,20 @@ export function UserAvatar({ ], ) - const warning = useMemo(() => { - if (!moderation?.warn) { + const alert = useMemo(() => { + if (!moderation?.alert) { return null } return ( - <View style={[styles.warningIconContainer, pal.view]}> + <View style={[styles.alertIconContainer, pal.view]}> <FontAwesomeIcon icon="exclamation-circle" - style={styles.warningIcon} + style={styles.alertIcon} size={Math.floor(size / 3)} /> </View> ) - }, [moderation?.warn, size, pal]) + }, [moderation?.alert, size, pal]) // onSelectNewAvatar is only passed as prop on the EditProfile component return onSelectNewAvatar ? ( @@ -259,12 +259,12 @@ export function UserAvatar({ source={{uri: avatar}} blurRadius={moderation?.blur ? BLUR_AMOUNT : 0} /> - {warning} + {alert} </View> ) : ( <View style={{width: size, height: size}}> <DefaultAvatar type={type} size={size} /> - {warning} + {alert} </View> ) } @@ -289,13 +289,13 @@ const styles = StyleSheet.create({ justifyContent: 'center', backgroundColor: colors.gray5, }, - warningIconContainer: { + alertIconContainer: { position: 'absolute', right: 0, bottom: 0, borderRadius: 100, }, - warningIcon: { + alertIcon: { color: colors.red3, }, }) diff --git a/src/view/com/util/UserBanner.tsx b/src/view/com/util/UserBanner.tsx index b7e91b5dd..7c5c583c2 100644 --- a/src/view/com/util/UserBanner.tsx +++ b/src/view/com/util/UserBanner.tsx @@ -1,6 +1,7 @@ import React, {useMemo} from 'react' import {StyleSheet, View} from 'react-native' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {ModerationUI} from '@atproto/api' import {Image} from 'expo-image' import {colors} from 'lib/styles' import {openCamera, openCropper, openPicker} from '../../../lib/media/picker' @@ -10,7 +11,6 @@ import { useCameraPermission, } from 'lib/hooks/usePermissions' import {usePalette} from 'lib/hooks/usePalette' -import {AvatarModeration} from 'lib/labeling/types' import {isWeb, isAndroid} from 'platform/detection' import {Image as RNImage} from 'react-native-image-crop-picker' import {NativeDropdown, DropdownItem} from './forms/NativeDropdown' @@ -21,7 +21,7 @@ export function UserBanner({ onSelectNewBanner, }: { banner?: string | null - moderation?: AvatarModeration + moderation?: ModerationUI onSelectNewBanner?: (img: RNImage | null) => void }) { const store = useStores() diff --git a/src/view/com/util/UserPreviewLink.tsx b/src/view/com/util/UserPreviewLink.tsx index 7eedbc2d4..f43f9e80b 100644 --- a/src/view/com/util/UserPreviewLink.tsx +++ b/src/view/com/util/UserPreviewLink.tsx @@ -2,7 +2,7 @@ import React from 'react' import {Pressable, StyleProp, ViewStyle} from 'react-native' import {useStores} from 'state/index' import {Link} from './Link' -import {isDesktopWeb} from 'platform/detection' +import {isWeb} from 'platform/detection' import {makeProfileLink} from 'lib/routes/links' interface UserPreviewLinkProps { @@ -15,7 +15,7 @@ export function UserPreviewLink( ) { const store = useStores() - if (isDesktopWeb) { + if (isWeb) { return ( <Link href={makeProfileLink(props)} diff --git a/src/view/com/util/ViewSelector.tsx b/src/view/com/util/ViewSelector.tsx index e2f47ba89..a25ca4d8e 100644 --- a/src/view/com/util/ViewSelector.tsx +++ b/src/view/com/util/ViewSelector.tsx @@ -1,5 +1,11 @@ import React, {useEffect, useState} from 'react' -import {Pressable, RefreshControl, StyleSheet, View} from 'react-native' +import { + Pressable, + RefreshControl, + StyleSheet, + View, + ScrollView, +} from 'react-native' import {FlatList} from './Views' import {OnScrollCb} from 'lib/hooks/useOnMainScroll' import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle' @@ -140,6 +146,8 @@ export function Selector({ items: string[] onSelect?: (index: number) => void }) { + const [height, setHeight] = useState(0) + const pal = usePalette('default') const borderColor = useColorSchemeStyle( {borderColor: colors.black}, @@ -151,37 +159,56 @@ export function Selector({ } return ( - <View style={[pal.view, styles.outer]}> - {items.map((item, i) => { - const selected = i === selectedIndex - return ( - <Pressable - testID={`selector-${i}`} - key={item} - onPress={() => onPressItem(i)} - accessibilityLabel={item} - accessibilityHint={`Selects ${item}`} - // TODO: Modify the component API such that lint fails - // at the invocation site as well - > - <View - style={[ - styles.item, - selected && styles.itemSelected, - borderColor, - ]}> - <Text - style={ - selected - ? [styles.labelSelected, pal.text] - : [styles.label, pal.textLight] - }> - {item} - </Text> - </View> - </Pressable> - ) - })} + <View + style={{ + width: '100%', + position: 'relative', + overflow: 'hidden', + height, + backgroundColor: pal.colors.background, + }}> + <ScrollView + horizontal + showsHorizontalScrollIndicator={false} + style={{position: 'absolute'}}> + <View + style={[pal.view, styles.outer]} + onLayout={e => { + const {height} = e.nativeEvent.layout + setHeight(height || 60) + }}> + {items.map((item, i) => { + const selected = i === selectedIndex + return ( + <Pressable + testID={`selector-${i}`} + key={item} + onPress={() => onPressItem(i)} + accessibilityLabel={item} + accessibilityHint={`Selects ${item}`} + // TODO: Modify the component API such that lint fails + // at the invocation site as well + > + <View + style={[ + styles.item, + selected && styles.itemSelected, + borderColor, + ]}> + <Text + style={ + selected + ? [styles.labelSelected, pal.text] + : [styles.label, pal.textLight] + }> + {item} + </Text> + </View> + </Pressable> + ) + })} + </View> + </ScrollView> </View> ) } diff --git a/src/view/com/util/forms/NativeDropdown.tsx b/src/view/com/util/forms/NativeDropdown.tsx index 9e6fcaa44..082285064 100644 --- a/src/view/com/util/forms/NativeDropdown.tsx +++ b/src/view/com/util/forms/NativeDropdown.tsx @@ -60,7 +60,6 @@ export const DropdownMenuTrigger = DropdownMenu.create( icon="ellipsis" size={20} color={defaultCtrlColor} - style={styles.ellipsis} /> )} </View> @@ -252,9 +251,6 @@ const styles = StyleSheet.create({ height: 1, marginVertical: 4, }, - ellipsis: { - padding: isWeb ? 0 : 10, - }, content: { backgroundColor: '#f0f0f0', borderRadius: 8, diff --git a/src/view/com/util/forms/PostDropdownBtn.tsx b/src/view/com/util/forms/PostDropdownBtn.tsx index 27a1f20d0..969deb3ac 100644 --- a/src/view/com/util/forms/PostDropdownBtn.tsx +++ b/src/view/com/util/forms/PostDropdownBtn.tsx @@ -1,6 +1,9 @@ import React from 'react' +import {StyleProp, View, ViewStyle} from 'react-native' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {toShareUrl} from 'lib/strings/url-helpers' import {useStores} from 'state/index' +import {useTheme} from 'lib/ThemeContext' import {shareUrl} from 'lib/sharing' import { NativeDropdown, @@ -19,6 +22,7 @@ export function PostDropdownBtn({ onOpenTranslate, onToggleThreadMute, onDeletePost, + style, }: { testID: string itemUri: string @@ -31,8 +35,11 @@ export function PostDropdownBtn({ onOpenTranslate: () => void onToggleThreadMute: () => void onDeletePost: () => void + style?: StyleProp<ViewStyle> }) { const store = useStores() + const theme = useTheme() + const defaultCtrlColor = theme.palette.default.postCtrl const dropdownItems: NativeDropdownItem[] = [ { @@ -102,9 +109,9 @@ export function PostDropdownBtn({ label: 'Report post', onPress() { store.shell.openModal({ - name: 'report-post', - postUri: itemUri, - postCid: itemCid, + name: 'report', + uri: itemUri, + cid: itemCid, }) }, testID: 'postDropdownReportBtn', @@ -146,8 +153,11 @@ export function PostDropdownBtn({ testID={testID} items={dropdownItems} accessibilityLabel="More post options" - accessibilityHint="" - /> + accessibilityHint=""> + <View style={style}> + <FontAwesomeIcon icon="ellipsis" size={20} color={defaultCtrlColor} /> + </View> + </NativeDropdown> </EventStopper> ) } diff --git a/src/view/com/util/forms/SelectableBtn.tsx b/src/view/com/util/forms/SelectableBtn.tsx index 503c49b2f..4b494264e 100644 --- a/src/view/com/util/forms/SelectableBtn.tsx +++ b/src/view/com/util/forms/SelectableBtn.tsx @@ -5,6 +5,7 @@ import {usePalette} from 'lib/hooks/usePalette' import {isDesktopWeb} from 'platform/detection' interface SelectableBtnProps { + testID?: string selected: boolean label: string left?: boolean @@ -15,6 +16,7 @@ interface SelectableBtnProps { } export function SelectableBtn({ + testID, selected, label, left, @@ -25,12 +27,15 @@ export function SelectableBtn({ }: SelectableBtnProps) { const pal = usePalette('default') const palPrimary = usePalette('inverted') + const needsWidthStyles = !style || !('width' in style || 'flex' in style) return ( <Pressable + testID={testID} style={[ - styles.selectableBtn, - left && styles.selectableBtnLeft, - right && styles.selectableBtnRight, + styles.btn, + needsWidthStyles && styles.btnWidth, + left && styles.btnLeft, + right && styles.btnRight, pal.border, selected ? palPrimary.view : pal.view, style, @@ -45,9 +50,7 @@ export function SelectableBtn({ } const styles = StyleSheet.create({ - selectableBtn: { - flex: isDesktopWeb ? undefined : 1, - width: isDesktopWeb ? 100 : undefined, + btn: { flexDirection: 'row', justifyContent: 'center', borderWidth: 1, @@ -55,12 +58,16 @@ const styles = StyleSheet.create({ paddingHorizontal: 10, paddingVertical: 10, }, - selectableBtnLeft: { + btnWidth: { + flex: isDesktopWeb ? undefined : 1, + width: isDesktopWeb ? 100 : undefined, + }, + btnLeft: { borderTopLeftRadius: 8, borderBottomLeftRadius: 8, borderLeftWidth: 1, }, - selectableBtnRight: { + btnRight: { borderTopRightRadius: 8, borderBottomRightRadius: 8, }, diff --git a/src/view/com/util/moderation/ContentHider.tsx b/src/view/com/util/moderation/ContentHider.tsx index ac5c8395d..853f7840c 100644 --- a/src/view/com/util/moderation/ContentHider.tsx +++ b/src/view/com/util/moderation/ContentHider.tsx @@ -1,36 +1,32 @@ import React from 'react' import {Pressable, StyleProp, StyleSheet, View, ViewStyle} from 'react-native' import {usePalette} from 'lib/hooks/usePalette' +import {ModerationUI} from '@atproto/api' import {Text} from '../text/Text' -import {addStyle} from 'lib/styles' -import {ModerationBehavior, ModerationBehaviorCode} from 'lib/labeling/types' +import {ShieldExclamation} from 'lib/icons' +import {describeModerationCause} from 'lib/moderation' +import {useStores} from 'state/index' +import {isDesktopWeb} from 'platform/detection' export function ContentHider({ testID, moderation, + ignoreMute, style, - containerStyle, + childContainerStyle, children, }: React.PropsWithChildren<{ testID?: string - moderation: ModerationBehavior + moderation: ModerationUI + ignoreMute?: boolean style?: StyleProp<ViewStyle> - containerStyle?: StyleProp<ViewStyle> + childContainerStyle?: StyleProp<ViewStyle> }>) { + const store = useStores() const pal = usePalette('default') const [override, setOverride] = React.useState(false) - const onPressShow = React.useCallback(() => { - setOverride(true) - }, [setOverride]) - const onPressHide = React.useCallback(() => { - setOverride(false) - }, [setOverride]) - if ( - moderation.behavior === ModerationBehaviorCode.Show || - moderation.behavior === ModerationBehaviorCode.Warn || - moderation.behavior === ModerationBehaviorCode.WarnImages - ) { + if (!moderation.blur || (ignoreMute && moderation.cause?.type === 'muted')) { return ( <View testID={testID} style={style}> {children} @@ -38,73 +34,72 @@ export function ContentHider({ ) } - if (moderation.behavior === ModerationBehaviorCode.Hide) { - return null - } - + const desc = describeModerationCause(moderation.cause, 'content') return ( - <View style={[styles.container, pal.view, pal.border, containerStyle]}> + <View testID={testID} style={style}> <Pressable - onPress={override ? onPressHide : onPressShow} - accessibilityLabel={override ? 'Hide post' : 'Show post'} - // TODO: The text labelling should be split up so controls have unique roles - accessibilityHint={ - override - ? 'Re-hide post' - : 'Shows post hidden based on your moderation settings' - } + onPress={() => { + if (!moderation.noOverride) { + setOverride(v => !v) + } else { + store.shell.openModal({ + name: 'moderation-details', + context: 'content', + moderation, + }) + } + }} + accessibilityRole="button" + accessibilityHint={override ? 'Hide the content' : 'Show the content'} + accessibilityLabel="" style={[ - styles.description, - pal.viewLight, - override && styles.descriptionOpen, + styles.cover, + moderation.noOverride + ? {borderWidth: 1, borderColor: pal.colors.borderDark} + : pal.viewLight, ]}> - <Text type="md" style={pal.textLight}> - {moderation.reason || 'Content warning'} + <Pressable + onPress={() => { + store.shell.openModal({ + name: 'moderation-details', + context: 'content', + moderation, + }) + }} + accessibilityRole="button" + accessibilityLabel="Learn more about this warning" + accessibilityHint=""> + <ShieldExclamation size={18} style={pal.text} /> + </Pressable> + <Text type="lg" style={pal.text}> + {desc.name} </Text> - <View style={styles.showBtn}> - <Text type="md-medium" style={pal.link}> - {override ? 'Hide' : 'Show'} - </Text> - </View> - </Pressable> - {override && ( - <View style={[styles.childrenContainer, pal.border]}> - <View testID={testID} style={addStyle(style, styles.child)}> - {children} + {!moderation.noOverride && ( + <View style={styles.showBtn}> + <Text type="xl" style={pal.link}> + {override ? 'Hide' : 'Show'} + </Text> </View> - </View> - )} + )} + </Pressable> + {override && <View style={childContainerStyle}>{children}</View>} </View> ) } const styles = StyleSheet.create({ - container: { - marginBottom: 10, - borderWidth: 1, - borderRadius: 12, - }, - description: { + cover: { flexDirection: 'row', alignItems: 'center', + gap: 4, + borderRadius: 8, + marginTop: 4, paddingVertical: 14, paddingLeft: 14, - paddingRight: 18, - borderRadius: 12, - }, - descriptionOpen: { - borderBottomLeftRadius: 0, - borderBottomRightRadius: 0, - }, - icon: { - marginRight: 10, + paddingRight: isDesktopWeb ? 18 : 22, }, showBtn: { marginLeft: 'auto', + alignSelf: 'center', }, - childrenContainer: { - paddingHorizontal: 12, - paddingTop: 8, - }, - child: {}, }) diff --git a/src/view/com/util/moderation/ImageHider.tsx b/src/view/com/util/moderation/ImageHider.tsx deleted file mode 100644 index 40c9d0a21..000000000 --- a/src/view/com/util/moderation/ImageHider.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import React from 'react' -import {Pressable, StyleProp, StyleSheet, View, ViewStyle} from 'react-native' -import {usePalette} from 'lib/hooks/usePalette' -import {Text} from '../text/Text' -import {ModerationBehavior, ModerationBehaviorCode} from 'lib/labeling/types' -import {isDesktopWeb} from 'platform/detection' -import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {FontAwesomeIconStyle} from '@fortawesome/react-native-fontawesome' - -export function ImageHider({ - testID, - moderation, - style, - children, -}: React.PropsWithChildren<{ - testID?: string - moderation: ModerationBehavior - style?: StyleProp<ViewStyle> -}>) { - const pal = usePalette('default') - const [override, setOverride] = React.useState(false) - const onPressToggle = React.useCallback(() => { - setOverride(v => !v) - }, [setOverride]) - - if (moderation.behavior === ModerationBehaviorCode.Hide) { - return null - } - - if (moderation.behavior !== ModerationBehaviorCode.WarnImages) { - return ( - <View testID={testID} style={style}> - {children} - </View> - ) - } - - return ( - <View testID={testID} style={style}> - <View style={[styles.cover, pal.viewLight]}> - <Pressable - onPress={onPressToggle} - style={[styles.toggleBtn]} - accessibilityLabel="Show image" - accessibilityHint=""> - <FontAwesomeIcon - icon={override ? 'eye' : ['far', 'eye-slash']} - size={24} - style={pal.text as FontAwesomeIconStyle} - /> - <Text type="lg" style={pal.text}> - {moderation.reason || 'Content warning'} - </Text> - <View style={styles.flex1} /> - <Text type="xl-bold" style={pal.link}> - {override ? 'Hide' : 'Show'} - </Text> - </Pressable> - </View> - {override && children} - </View> - ) -} - -const styles = StyleSheet.create({ - cover: { - borderRadius: 8, - marginTop: 4, - }, - toggleBtn: { - flexDirection: 'row', - gap: 8, - alignItems: 'center', - paddingHorizontal: isDesktopWeb ? 24 : 20, - paddingVertical: isDesktopWeb ? 20 : 18, - }, - flex1: { - flex: 1, - }, -}) diff --git a/src/view/com/util/moderation/PostAlerts.tsx b/src/view/com/util/moderation/PostAlerts.tsx new file mode 100644 index 000000000..8a6cbbb85 --- /dev/null +++ b/src/view/com/util/moderation/PostAlerts.tsx @@ -0,0 +1,68 @@ +import React from 'react' +import {Pressable, StyleProp, StyleSheet, ViewStyle} from 'react-native' +import {ModerationUI} from '@atproto/api' +import {Text} from '../text/Text' +import {usePalette} from 'lib/hooks/usePalette' +import {ShieldExclamation} from 'lib/icons' +import {describeModerationCause} from 'lib/moderation' +import {useStores} from 'state/index' + +export function PostAlerts({ + moderation, + includeMute, + style, +}: { + moderation: ModerationUI + includeMute?: boolean + style?: StyleProp<ViewStyle> +}) { + const store = useStores() + const pal = usePalette('default') + + const shouldAlert = + !!moderation.cause && + (moderation.alert || + (includeMute && moderation.blur && moderation.cause?.type === 'muted')) + if (!shouldAlert) { + return null + } + + const desc = describeModerationCause(moderation.cause, 'content') + return ( + <Pressable + onPress={() => { + store.shell.openModal({ + name: 'moderation-details', + context: 'content', + moderation, + }) + }} + accessibilityRole="button" + accessibilityLabel="Learn more about this warning" + accessibilityHint="" + style={[styles.container, pal.viewLight, style]}> + <ShieldExclamation style={pal.text} size={16} /> + <Text type="lg" style={pal.text}> + {desc.name} + </Text> + <Text type="lg" style={[pal.link, styles.learnMoreBtn]}> + Learn More + </Text> + </Pressable> + ) +} + +const styles = StyleSheet.create({ + container: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + paddingVertical: 8, + paddingLeft: 14, + paddingHorizontal: 16, + borderRadius: 8, + }, + learnMoreBtn: { + marginLeft: 'auto', + }, +}) diff --git a/src/view/com/util/moderation/PostHider.tsx b/src/view/com/util/moderation/PostHider.tsx index f2b6dbddd..2a52561d4 100644 --- a/src/view/com/util/moderation/PostHider.tsx +++ b/src/view/com/util/moderation/PostHider.tsx @@ -1,17 +1,20 @@ import React, {ComponentProps} from 'react' -import {StyleSheet, TouchableOpacity, View} from 'react-native' -import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {StyleSheet, Pressable, View} from 'react-native' +import {ModerationUI} from '@atproto/api' import {usePalette} from 'lib/hooks/usePalette' import {Link} from '../Link' import {Text} from '../text/Text' import {addStyle} from 'lib/styles' -import {ModerationBehaviorCode, ModerationBehavior} from 'lib/labeling/types' +import {describeModerationCause} from 'lib/moderation' +import {ShieldExclamation} from 'lib/icons' +import {useStores} from 'state/index' +import {isDesktopWeb} from 'platform/detection' interface Props extends ComponentProps<typeof Link> { // testID?: string // href?: string // style: StyleProp<ViewStyle> - moderation: ModerationBehavior + moderation: ModerationUI } export function PostHider({ @@ -22,60 +25,71 @@ export function PostHider({ children, ...props }: Props) { + const store = useStores() const pal = usePalette('default') const [override, setOverride] = React.useState(false) - const bg = override ? pal.viewLight : pal.view - if (moderation.behavior === ModerationBehaviorCode.Hide) { - return null - } - - if (moderation.behavior === ModerationBehaviorCode.Warn) { + if (!moderation.blur) { return ( - <> - <View style={[styles.description, bg, pal.border]}> - <FontAwesomeIcon - icon={['far', 'eye-slash']} - style={[styles.icon, pal.text]} - /> - <Text type="md" style={pal.textLight}> - {moderation.reason || 'Content warning'} - </Text> - <TouchableOpacity - style={styles.showBtn} - onPress={() => setOverride(v => !v)} - accessibilityRole="button"> - <Text type="md" style={pal.link}> - {override ? 'Hide' : 'Show'} post - </Text> - </TouchableOpacity> - </View> - {override && ( - <View style={[styles.childrenContainer, pal.border, bg]}> - <Link - testID={testID} - style={addStyle(style, styles.child)} - href={href} - noFeedback> - {children} - </Link> - </View> - )} - </> + <Link + testID={testID} + style={style} + href={href} + noFeedback + accessible={false} + {...props}> + {children} + </Link> ) } - // NOTE: any further label enforcement should occur in ContentContainer + const desc = describeModerationCause(moderation.cause, 'content') return ( - <Link - testID={testID} - style={style} - href={href} - noFeedback - accessible={false} - {...props}> - {children} - </Link> + <> + <Pressable + onPress={() => { + if (!moderation.noOverride) { + setOverride(v => !v) + } + }} + accessibilityRole="button" + accessibilityHint={override ? 'Hide the content' : 'Show the content'} + accessibilityLabel="" + style={[styles.description, pal.viewLight]}> + <Pressable + onPress={() => { + store.shell.openModal({ + name: 'moderation-details', + context: 'content', + moderation, + }) + }} + accessibilityRole="button" + accessibilityLabel="Learn more about this warning" + accessibilityHint=""> + <ShieldExclamation size={18} style={pal.text} /> + </Pressable> + <Text type="lg" style={pal.text}> + {desc.name} + </Text> + {!moderation.noOverride && ( + <Text type="xl" style={[styles.showBtn, pal.link]}> + {override ? 'Hide' : 'Show'} + </Text> + )} + </Pressable> + {override && ( + <View style={[styles.childrenContainer, pal.border, pal.viewLight]}> + <Link + testID={testID} + style={addStyle(style, styles.child)} + href={href} + noFeedback> + {children} + </Link> + </View> + )} + </> ) } @@ -83,22 +97,23 @@ const styles = StyleSheet.create({ description: { flexDirection: 'row', alignItems: 'center', + gap: 4, paddingVertical: 14, - paddingHorizontal: 18, - borderTopWidth: 1, - }, - icon: { - marginRight: 10, + paddingLeft: 18, + paddingRight: isDesktopWeb ? 18 : 22, + marginTop: 1, }, showBtn: { marginLeft: 'auto', + alignSelf: 'center', }, childrenContainer: { - paddingHorizontal: 6, + paddingHorizontal: 4, paddingBottom: 6, }, child: { - borderWidth: 1, - borderRadius: 12, + borderWidth: 0, + borderTopWidth: 0, + borderRadius: 8, }, }) diff --git a/src/view/com/util/moderation/ProfileHeaderAlerts.tsx b/src/view/com/util/moderation/ProfileHeaderAlerts.tsx new file mode 100644 index 000000000..b7781e06d --- /dev/null +++ b/src/view/com/util/moderation/ProfileHeaderAlerts.tsx @@ -0,0 +1,76 @@ +import React from 'react' +import {Pressable, StyleProp, StyleSheet, View, ViewStyle} from 'react-native' +import {ProfileModeration} from '@atproto/api' +import {Text} from '../text/Text' +import {usePalette} from 'lib/hooks/usePalette' +import {ShieldExclamation} from 'lib/icons' +import { + describeModerationCause, + getProfileModerationCauses, +} from 'lib/moderation' +import {useStores} from 'state/index' + +export function ProfileHeaderAlerts({ + moderation, + style, +}: { + moderation: ProfileModeration + style?: StyleProp<ViewStyle> +}) { + const store = useStores() + const pal = usePalette('default') + + const causes = getProfileModerationCauses(moderation) + if (!causes.length) { + return null + } + + return ( + <View style={styles.grid}> + {causes.map(cause => { + const desc = describeModerationCause(cause, 'account') + return ( + <Pressable + testID="profileHeaderAlert" + key={desc.name} + onPress={() => { + store.shell.openModal({ + name: 'moderation-details', + context: 'content', + moderation: {cause}, + }) + }} + accessibilityRole="button" + accessibilityLabel="Learn more about this warning" + accessibilityHint="" + style={[styles.container, pal.viewLight, style]}> + <ShieldExclamation style={pal.text} size={24} /> + <Text type="lg" style={pal.text}> + {desc.name} + </Text> + <Text type="lg" style={[pal.link, styles.learnMoreBtn]}> + Learn More + </Text> + </Pressable> + ) + })} + </View> + ) +} + +const styles = StyleSheet.create({ + grid: { + gap: 4, + }, + container: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + paddingVertical: 12, + paddingHorizontal: 16, + borderRadius: 8, + }, + learnMoreBtn: { + marginLeft: 'auto', + }, +}) diff --git a/src/view/com/util/moderation/ProfileHeaderWarnings.tsx b/src/view/com/util/moderation/ProfileHeaderWarnings.tsx deleted file mode 100644 index 7a1a8e295..000000000 --- a/src/view/com/util/moderation/ProfileHeaderWarnings.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import React from 'react' -import {StyleSheet, View} from 'react-native' -import { - FontAwesomeIcon, - FontAwesomeIconStyle, -} from '@fortawesome/react-native-fontawesome' -import {Text} from '../text/Text' -import {usePalette} from 'lib/hooks/usePalette' -import {ModerationBehavior, ModerationBehaviorCode} from 'lib/labeling/types' - -export function ProfileHeaderWarnings({ - moderation, -}: { - moderation: ModerationBehavior -}) { - const palErr = usePalette('error') - if (moderation.behavior === ModerationBehaviorCode.Show) { - return null - } - return ( - <View style={[styles.container, palErr.border, palErr.view]}> - <FontAwesomeIcon - icon="circle-exclamation" - style={palErr.text as FontAwesomeIconStyle} - size={20} - /> - <Text style={palErr.text}> - This account has been flagged: {moderation.reason} - </Text> - </View> - ) -} - -const styles = StyleSheet.create({ - container: { - flexDirection: 'row', - alignItems: 'center', - gap: 10, - borderWidth: 1, - borderRadius: 6, - paddingHorizontal: 10, - paddingVertical: 8, - }, -}) diff --git a/src/view/com/util/moderation/ScreenHider.tsx b/src/view/com/util/moderation/ScreenHider.tsx index 2e7b07e1a..b76b1101c 100644 --- a/src/view/com/util/moderation/ScreenHider.tsx +++ b/src/view/com/util/moderation/ScreenHider.tsx @@ -1,16 +1,24 @@ import React from 'react' -import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native' +import { + TouchableWithoutFeedback, + StyleProp, + StyleSheet, + View, + ViewStyle, +} from 'react-native' import { FontAwesomeIcon, FontAwesomeIconStyle, } from '@fortawesome/react-native-fontawesome' import {useNavigation} from '@react-navigation/native' +import {ModerationUI} from '@atproto/api' import {usePalette} from 'lib/hooks/usePalette' import {NavigationProp} from 'lib/routes/types' import {Text} from '../text/Text' import {Button} from '../forms/Button' import {isDesktopWeb} from 'platform/detection' -import {ModerationBehaviorCode, ModerationBehavior} from 'lib/labeling/types' +import {describeModerationCause} from 'lib/moderation' +import {useStores} from 'state/index' export function ScreenHider({ testID, @@ -22,24 +30,17 @@ export function ScreenHider({ }: React.PropsWithChildren<{ testID?: string screenDescription: string - moderation: ModerationBehavior + moderation: ModerationUI style?: StyleProp<ViewStyle> containerStyle?: StyleProp<ViewStyle> }>) { + const store = useStores() const pal = usePalette('default') const palInverted = usePalette('inverted') const [override, setOverride] = React.useState(false) const navigation = useNavigation<NavigationProp>() - const onPressBack = React.useCallback(() => { - if (navigation.canGoBack()) { - navigation.goBack() - } else { - navigation.navigate('Home') - } - }, [navigation]) - - if (moderation.behavior !== ModerationBehaviorCode.Hide || override) { + if (!moderation.blur || override) { return ( <View testID={testID} style={style}> {children} @@ -47,6 +48,7 @@ export function ScreenHider({ ) } + const desc = describeModerationCause(moderation.cause, 'account') return ( <View style={[styles.container, pal.view, containerStyle]}> <View style={styles.iconContainer}> @@ -63,11 +65,38 @@ export function ScreenHider({ </Text> <Text type="2xl" style={[styles.description, pal.textLight]}> This {screenDescription} has been flagged:{' '} - {moderation.reason || 'Content warning'} + <Text type="2xl-medium" style={pal.text}> + {desc.name} + </Text> + .{' '} + <TouchableWithoutFeedback + onPress={() => { + store.shell.openModal({ + name: 'moderation-details', + context: 'account', + moderation, + }) + }} + accessibilityRole="button" + accessibilityLabel="Learn more about this warning" + accessibilityHint=""> + <Text type="2xl" style={pal.link}> + Learn More + </Text> + </TouchableWithoutFeedback> </Text> {!isDesktopWeb && <View style={styles.spacer} />} <View style={styles.btnContainer}> - <Button type="inverted" onPress={onPressBack} style={styles.btn}> + <Button + type="inverted" + onPress={() => { + if (navigation.canGoBack()) { + navigation.goBack() + } else { + navigation.navigate('Home') + } + }} + style={styles.btn}> <Text type="button-lg" style={pal.textInverted}> Go back </Text> diff --git a/src/view/com/util/post-ctrls/PostCtrls.tsx b/src/view/com/util/post-ctrls/PostCtrls.tsx index 672e02693..c71100df0 100644 --- a/src/view/com/util/post-ctrls/PostCtrls.tsx +++ b/src/view/com/util/post-ctrls/PostCtrls.tsx @@ -6,11 +6,6 @@ import { View, ViewStyle, } from 'react-native' -// DISABLED see #135 -// import { -// TriggerableAnimated, -// TriggerableAnimatedRef, -// } from './anim/TriggerableAnimated' import {Text} from '../text/Text' import {PostDropdownBtn} from '../forms/PostDropdownBtn' import {HeartIcon, HeartIconSolid, CommentBottomArrow} from 'lib/icons' @@ -20,7 +15,6 @@ import {useTheme} from 'lib/ThemeContext' import {useStores} from 'state/index' import {RepostButton} from './RepostButton' import {Haptics} from 'lib/haptics' -import {createHitslop} from 'lib/constants' interface PostCtrlsOpts { itemUri: string @@ -53,44 +47,6 @@ interface PostCtrlsOpts { onDeletePost: () => void } -const HITSLOP = createHitslop(5) - -// DISABLED see #135 -/* -function ctrlAnimStart(interp: Animated.Value) { - return Animated.sequence([ - Animated.timing(interp, { - toValue: 1, - duration: 250, - useNativeDriver: true, - }), - Animated.delay(50), - Animated.timing(interp, { - toValue: 0, - duration: 20, - useNativeDriver: true, - }), - ]) -} - -function ctrlAnimStyle(interp: Animated.Value) { - return { - transform: [ - { - scale: interp.interpolate({ - inputRange: [0, 1.0], - outputRange: [1.0, 4.0], - }), - }, - ], - opacity: interp.interpolate({ - inputRange: [0, 1.0], - outputRange: [1.0, 0.0], - }), - } -} -*/ - export function PostCtrls(opts: PostCtrlsOpts) { const store = useStores() const theme = useTheme() @@ -100,22 +56,11 @@ export function PostCtrls(opts: PostCtrlsOpts) { }), [theme], ) as StyleProp<ViewStyle> - // DISABLED see #135 - // const repostRef = React.useRef<TriggerableAnimatedRef | null>(null) - // const likeRef = React.useRef<TriggerableAnimatedRef | null>(null) const onRepost = useCallback(() => { store.shell.closeModal() if (!opts.isReposted) { Haptics.default() opts.onPressToggleRepost().catch(_e => undefined) - // DISABLED see #135 - // repostRef.current?.trigger( - // {start: ctrlAnimStart, style: ctrlAnimStyle}, - // async () => { - // await opts.onPressToggleRepost().catch(_e => undefined) - // setRepostMod(0) - // }, - // ) } else { opts.onPressToggleRepost().catch(_e => undefined) } @@ -146,18 +91,8 @@ export function PostCtrls(opts: PostCtrlsOpts) { if (!opts.isLiked) { Haptics.default() await opts.onPressToggleLike().catch(_e => undefined) - // DISABLED see #135 - // likeRef.current?.trigger( - // {start: ctrlAnimStart, style: ctrlAnimStyle}, - // async () => { - // await opts.onPressToggleLike().catch(_e => undefined) - // setLikeMod(0) - // }, - // ) - // setIsLikedPressed(false) } else { await opts.onPressToggleLike().catch(_e => undefined) - // setIsLikedPressed(false) } } @@ -165,8 +100,7 @@ export function PostCtrls(opts: PostCtrlsOpts) { <View style={[styles.ctrls, opts.style]}> <TouchableOpacity testID="replyBtn" - style={styles.ctrl} - hitSlop={HITSLOP} + style={[styles.ctrl, !opts.big && styles.ctrlPad, {paddingLeft: 0}]} onPress={opts.onPressReply} accessibilityRole="button" accessibilityLabel={`Reply (${opts.replyCount} ${ @@ -187,8 +121,7 @@ export function PostCtrls(opts: PostCtrlsOpts) { <RepostButton {...opts} onRepost={onRepost} onQuote={onQuote} /> <TouchableOpacity testID="likeBtn" - style={styles.ctrl} - hitSlop={HITSLOP} + style={[styles.ctrl, !opts.big && styles.ctrlPad]} onPress={onPressToggleLikeWrapper} accessibilityRole="button" accessibilityLabel={`${opts.isLiked ? 'Unlike' : 'Like'} (${ @@ -232,6 +165,7 @@ export function PostCtrls(opts: PostCtrlsOpts) { onOpenTranslate={opts.onOpenTranslate} onToggleThreadMute={opts.onToggleThreadMute} onDeletePost={opts.onDeletePost} + style={styles.ctrlPad} /> )} {/* used for adding pad to the right side */} @@ -248,8 +182,12 @@ const styles = StyleSheet.create({ ctrl: { flexDirection: 'row', alignItems: 'center', - padding: 5, - margin: -5, + }, + ctrlPad: { + paddingTop: 5, + paddingBottom: 5, + paddingLeft: 5, + paddingRight: 5, }, ctrlIconLiked: { color: colors.like, diff --git a/src/view/com/util/post-ctrls/RepostButton.tsx b/src/view/com/util/post-ctrls/RepostButton.tsx index 5fe62aefe..374d06515 100644 --- a/src/view/com/util/post-ctrls/RepostButton.tsx +++ b/src/view/com/util/post-ctrls/RepostButton.tsx @@ -6,9 +6,6 @@ import {useTheme} from 'lib/ThemeContext' import {Text} from '../text/Text' import {pluralize} from 'lib/strings/helpers' import {useStores} from 'state/index' -import {createHitslop} from 'lib/constants' - -const HITSLOP = createHitslop(5) interface Props { isReposted: boolean @@ -47,9 +44,8 @@ export const RepostButton = ({ return ( <TouchableOpacity testID="repostBtn" - hitSlop={HITSLOP} onPress={onPressToggleRepostWrapper} - style={styles.control} + style={[styles.control, !big && styles.controlPad]} accessibilityRole="button" accessibilityLabel={`${ isReposted ? 'Undo repost' : 'Repost' @@ -83,8 +79,9 @@ const styles = StyleSheet.create({ control: { flexDirection: 'row', alignItems: 'center', + }, + controlPad: { padding: 5, - margin: -5, }, reposted: { color: colors.green3, diff --git a/src/view/com/util/post-ctrls/RepostButton.web.tsx b/src/view/com/util/post-ctrls/RepostButton.web.tsx index 4d2a3fcdd..eab6e2fef 100644 --- a/src/view/com/util/post-ctrls/RepostButton.web.tsx +++ b/src/view/com/util/post-ctrls/RepostButton.web.tsx @@ -52,6 +52,7 @@ export const RepostButton = ({ <View style={[ styles.control, + !big && styles.controlPad, (isReposted ? styles.reposted : defaultControlColor) as StyleProp<ViewStyle>, @@ -77,6 +78,9 @@ const styles = StyleSheet.create({ alignItems: 'center', gap: 4, }, + controlPad: { + padding: 5, + }, reposted: { color: colors.green3, }, diff --git a/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx b/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx index a4cbb3e29..81f1ca560 100644 --- a/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx +++ b/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx @@ -1,9 +1,11 @@ import React from 'react' +import {Image} from 'expo-image' import {Text} from '../text/Text' -import {AutoSizedImage} from '../images/AutoSizedImage' import {StyleSheet, View} from 'react-native' import {usePalette} from 'lib/hooks/usePalette' import {AppBskyEmbedExternal} from '@atproto/api' +import {isDesktopWeb} from 'platform/detection' +import {toNiceDomain} from 'lib/strings/url-helpers' export const ExternalLinkEmbed = ({ link, @@ -14,44 +16,71 @@ export const ExternalLinkEmbed = ({ }) => { const pal = usePalette('default') return ( - <> + <View style={styles.extContainer}> {link.thumb ? ( - <AutoSizedImage uri={link.thumb} style={styles.extImage}> + <View style={styles.extImageContainer}> + <Image + style={styles.extImage} + source={{uri: link.thumb}} + accessibilityIgnoresInvertColors + /> {imageChild} - </AutoSizedImage> + </View> ) : undefined} <View style={styles.extInner}> - <Text type="md-bold" numberOfLines={2} style={[pal.text]}> - {link.title || link.uri} - </Text> <Text type="sm" numberOfLines={1} style={[pal.textLight, styles.extUri]}> - {link.uri} + {toNiceDomain(link.uri)} + </Text> + <Text + type="lg-bold" + numberOfLines={isDesktopWeb ? 2 : 4} + style={[pal.text]}> + {link.title || link.uri} </Text> {link.description ? ( <Text - type="sm" - numberOfLines={2} + type="md" + numberOfLines={isDesktopWeb ? 2 : 4} style={[pal.text, styles.extDescription]}> {link.description} </Text> ) : undefined} </View> - </> + </View> ) } const styles = StyleSheet.create({ + extContainer: { + flexDirection: isDesktopWeb ? 'row' : 'column', + }, extInner: { - padding: 10, + paddingHorizontal: isDesktopWeb ? 14 : 10, + paddingTop: 8, + paddingBottom: 10, + flex: isDesktopWeb ? 1 : undefined, }, + extImageContainer: isDesktopWeb + ? { + borderTopLeftRadius: 6, + borderBottomLeftRadius: 6, + width: 120, + aspectRatio: 1, + overflow: 'hidden', + } + : { + borderTopLeftRadius: 6, + borderTopRightRadius: 6, + width: '100%', + height: 200, + overflow: 'hidden', + }, extImage: { - borderTopLeftRadius: 6, - borderTopRightRadius: 6, width: '100%', - maxHeight: 200, + height: 200, }, extUri: { marginTop: 2, diff --git a/src/view/com/util/post-embeds/QuoteEmbed.tsx b/src/view/com/util/post-embeds/QuoteEmbed.tsx index 4995562ac..f82b5b7df 100644 --- a/src/view/com/util/post-embeds/QuoteEmbed.tsx +++ b/src/view/com/util/post-embeds/QuoteEmbed.tsx @@ -1,6 +1,12 @@ import React from 'react' -import {StyleProp, StyleSheet, ViewStyle} from 'react-native' -import {AppBskyEmbedImages, AppBskyEmbedRecordWithMedia} from '@atproto/api' +import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native' +import { + AppBskyEmbedRecord, + AppBskyFeedPost, + AppBskyEmbedImages, + AppBskyEmbedRecordWithMedia, + ModerationUI, +} from '@atproto/api' import {AtUri} from '@atproto/api' import {PostMeta} from '../PostMeta' import {Link} from '../Link' @@ -8,13 +14,68 @@ import {Text} from '../text/Text' import {usePalette} from 'lib/hooks/usePalette' import {ComposerOptsQuote} from 'state/models/ui/shell' import {PostEmbeds} from '.' +import {PostAlerts} from '../moderation/PostAlerts' import {makeProfileLink} from 'lib/routes/links' +import {InfoCircleIcon} from 'lib/icons' + +export function MaybeQuoteEmbed({ + embed, + moderation, + style, +}: { + embed: AppBskyEmbedRecord.View + moderation: ModerationUI + style?: StyleProp<ViewStyle> +}) { + const pal = usePalette('default') + if ( + AppBskyEmbedRecord.isViewRecord(embed.record) && + AppBskyFeedPost.isRecord(embed.record.value) && + AppBskyFeedPost.validateRecord(embed.record.value).success + ) { + return ( + <QuoteEmbed + quote={{ + author: embed.record.author, + cid: embed.record.cid, + uri: embed.record.uri, + indexedAt: embed.record.indexedAt, + text: embed.record.value.text, + embeds: embed.record.embeds, + }} + moderation={moderation} + style={style} + /> + ) + } else if (AppBskyEmbedRecord.isViewBlocked(embed.record)) { + return ( + <View style={[styles.errorContainer, pal.borderDark]}> + <InfoCircleIcon size={18} style={pal.text} /> + <Text type="lg" style={pal.text}> + Blocked + </Text> + </View> + ) + } else if (AppBskyEmbedRecord.isViewNotFound(embed.record)) { + return ( + <View style={[styles.errorContainer, pal.borderDark]}> + <InfoCircleIcon size={18} style={pal.text} /> + <Text type="lg" style={pal.text}> + Deleted + </Text> + </View> + ) + } + return null +} export function QuoteEmbed({ quote, + moderation, style, }: { quote: ComposerOptsQuote + moderation?: ModerationUI style?: StyleProp<ViewStyle> }) { const pal = usePalette('default') @@ -46,16 +107,19 @@ export function QuoteEmbed({ postHref={itemHref} timestamp={quote.indexedAt} /> + {moderation ? ( + <PostAlerts moderation={moderation} style={styles.alert} /> + ) : null} {!isEmpty ? ( <Text type="post-text" style={pal.text} numberOfLines={6}> {quote.text} </Text> ) : null} {AppBskyEmbedImages.isView(imagesEmbed) && ( - <PostEmbeds embed={imagesEmbed} /> + <PostEmbeds embed={imagesEmbed} moderation={{}} /> )} {AppBskyEmbedRecordWithMedia.isView(imagesEmbed) && ( - <PostEmbeds embed={imagesEmbed.media} /> + <PostEmbeds embed={imagesEmbed.media} moderation={{}} /> )} </Link> ) @@ -76,4 +140,17 @@ const styles = StyleSheet.create({ paddingLeft: 13, paddingRight: 8, }, + errorContainer: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + borderRadius: 8, + marginTop: 8, + paddingVertical: 14, + paddingHorizontal: 14, + borderWidth: 1, + }, + alert: { + marginBottom: 6, + }, }) diff --git a/src/view/com/util/post-embeds/index.tsx b/src/view/com/util/post-embeds/index.tsx index 7ffebff54..5d0090434 100644 --- a/src/view/com/util/post-embeds/index.tsx +++ b/src/view/com/util/post-embeds/index.tsx @@ -4,17 +4,18 @@ import { StyleProp, View, ViewStyle, - Image as RNImage, Text, + InteractionManager, } from 'react-native' +import {Image} from 'expo-image' import { AppBskyEmbedImages, AppBskyEmbedExternal, AppBskyEmbedRecord, AppBskyEmbedRecordWithMedia, - AppBskyFeedPost, AppBskyFeedDefs, AppBskyGraphDefs, + ModerationUI, } from '@atproto/api' import {Link} from '../Link' import {ImageLayoutGrid} from '../images/ImageLayoutGrid' @@ -24,11 +25,12 @@ import {usePalette} from 'lib/hooks/usePalette' import {YoutubeEmbed} from './YoutubeEmbed' import {ExternalLinkEmbed} from './ExternalLinkEmbed' import {getYoutubeVideoId} from 'lib/strings/url-helpers' -import QuoteEmbed from './QuoteEmbed' +import {MaybeQuoteEmbed} from './QuoteEmbed' import {AutoSizedImage} from '../images/AutoSizedImage' import {CustomFeedEmbed} from './CustomFeedEmbed' import {ListEmbed} from './ListEmbed' import {isDesktopWeb} from 'platform/detection' +import {isCauseALabelOnUri} from 'lib/moderation' type Embed = | AppBskyEmbedRecord.View @@ -39,9 +41,11 @@ type Embed = export function PostEmbeds({ embed, + moderation, style, }: { embed?: Embed + moderation: ModerationUI style?: StyleProp<ViewStyle> }) { const pal = usePalette('default') @@ -49,51 +53,37 @@ export function PostEmbeds({ // quote post with media // = - if ( - AppBskyEmbedRecordWithMedia.isView(embed) && - AppBskyEmbedRecord.isViewRecord(embed.record.record) && - AppBskyFeedPost.isRecord(embed.record.record.value) && - AppBskyFeedPost.validateRecord(embed.record.record.value).success - ) { + if (AppBskyEmbedRecordWithMedia.isView(embed)) { + const isModOnQuote = + AppBskyEmbedRecord.isViewRecord(embed.record.record) && + isCauseALabelOnUri(moderation.cause, embed.record.record.uri) + const mediaModeration = isModOnQuote ? {} : moderation + const quoteModeration = isModOnQuote ? moderation : {} return ( <View style={[styles.stackContainer, style]}> - <PostEmbeds embed={embed.media} /> - <QuoteEmbed - quote={{ - author: embed.record.record.author, - cid: embed.record.record.cid, - uri: embed.record.record.uri, - indexedAt: embed.record.record.indexedAt, - text: embed.record.record.value.text, - embeds: embed.record.record.embeds, - }} - /> + <PostEmbeds embed={embed.media} moderation={mediaModeration} /> + <MaybeQuoteEmbed embed={embed.record} moderation={quoteModeration} /> </View> ) } - // quote post - // = if (AppBskyEmbedRecord.isView(embed)) { - if ( - AppBskyEmbedRecord.isViewRecord(embed.record) && - AppBskyFeedPost.isRecord(embed.record.value) && - AppBskyFeedPost.validateRecord(embed.record.value).success - ) { - return ( - <QuoteEmbed - quote={{ - author: embed.record.author, - cid: embed.record.cid, - uri: embed.record.uri, - indexedAt: embed.record.indexedAt, - text: embed.record.value.text, - embeds: embed.record.embeds, - }} - style={style} - /> - ) + // custom feed embed (i.e. generator view) + // = + if (AppBskyFeedDefs.isGeneratorView(embed.record)) { + return <CustomFeedEmbed record={embed.record} /> } + + // list embed (e.g. mute lists; i.e. ListView) + if (AppBskyGraphDefs.isListView(embed.record)) { + return <ListEmbed item={embed.record} /> + } + + // quote post + // = + return ( + <MaybeQuoteEmbed embed={embed} style={style} moderation={moderation} /> + ) } // image embed @@ -106,14 +96,9 @@ export function PostEmbeds({ const openLightbox = (index: number) => { store.shell.openLightbox(new ImagesLightbox(items, index)) } - const onPressIn = (index: number) => { - const firstImageToShow = items[index].uri - RNImage.prefetch(firstImageToShow) - items.forEach(item => { - if (firstImageToShow !== item.uri) { - // First image already prefetched above - RNImage.prefetch(item.uri) - } + const onPressIn = (_: number) => { + InteractionManager.runAfterInteractions(() => { + Image.prefetch(items.map(i => i.uri)) }) } @@ -152,23 +137,6 @@ export function PostEmbeds({ } } - // custom feed embed (i.e. generator view) - // = - if ( - AppBskyEmbedRecord.isView(embed) && - AppBskyFeedDefs.isGeneratorView(embed.record) - ) { - return <CustomFeedEmbed record={embed.record} /> - } - - // list embed (e.g. mute lists; i.e. ListView) - if ( - AppBskyEmbedRecord.isView(embed) && - AppBskyGraphDefs.isListView(embed.record) - ) { - return <ListEmbed item={embed.record} /> - } - // external link embed // = if (AppBskyEmbedExternal.isView(embed)) { diff --git a/src/view/index.ts b/src/view/index.ts index 4226e07e7..4294508de 100644 --- a/src/view/index.ts +++ b/src/view/index.ts @@ -72,6 +72,8 @@ import {faShareFromSquare} from '@fortawesome/free-solid-svg-icons/faShareFromSq import {faShield} from '@fortawesome/free-solid-svg-icons/faShield' import {faSignal} from '@fortawesome/free-solid-svg-icons/faSignal' import {faSliders} from '@fortawesome/free-solid-svg-icons/faSliders' +import {faSquare} from '@fortawesome/free-regular-svg-icons/faSquare' +import {faSquareCheck} from '@fortawesome/free-regular-svg-icons/faSquareCheck' import {faSquarePlus} from '@fortawesome/free-regular-svg-icons/faSquarePlus' import {faTicket} from '@fortawesome/free-solid-svg-icons/faTicket' import {faTrashCan} from '@fortawesome/free-regular-svg-icons/faTrashCan' @@ -87,6 +89,7 @@ import {faXmark} from '@fortawesome/free-solid-svg-icons/faXmark' import {faPlay} from '@fortawesome/free-solid-svg-icons/faPlay' import {faPause} from '@fortawesome/free-solid-svg-icons/faPause' import {faThumbtack} from '@fortawesome/free-solid-svg-icons/faThumbtack' +import {faList} from '@fortawesome/free-solid-svg-icons/faList' export function setup() { library.add( @@ -162,6 +165,8 @@ export function setup() { faShield, faSignal, faSliders, + faSquare, + faSquareCheck, faSquarePlus, faUser, faUsers, @@ -177,5 +182,6 @@ export function setup() { faXmark, faPlay, faPause, + faList, ) } diff --git a/src/view/screens/CustomFeed.tsx b/src/view/screens/CustomFeed.tsx index d5ecff042..2da2e2159 100644 --- a/src/view/screens/CustomFeed.tsx +++ b/src/view/screens/CustomFeed.tsx @@ -1,13 +1,14 @@ import React, {useMemo, useRef} from 'react' import {NativeStackScreenProps} from '@react-navigation/native-stack' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {useNavigation} from '@react-navigation/native' import {usePalette} from 'lib/hooks/usePalette' import {HeartIcon, HeartIconSolid} from 'lib/icons' import {CommonNavigatorParams} from 'lib/routes/types' import {makeRecordUri} from 'lib/strings/url-helpers' import {colors, s} from 'lib/styles' import {observer} from 'mobx-react-lite' -import {FlatList, StyleSheet, View} from 'react-native' +import {FlatList, StyleSheet, View, ActivityIndicator} from 'react-native' import {useStores} from 'state/index' import {PostsFeedModel} from 'state/models/feeds/posts' import {useCustomFeed} from 'lib/hooks/useCustomFeed' @@ -34,17 +35,98 @@ import {EmptyState} from 'view/com/util/EmptyState' import {useAnalytics} from 'lib/analytics/analytics' import {NativeDropdown, DropdownItem} from 'view/com/util/forms/NativeDropdown' import {makeProfileLink} from 'lib/routes/links' +import {resolveName} from 'lib/api' +import {CenteredView} from 'view/com/util/Views' +import {NavigationProp} from 'lib/routes/types' type Props = NativeStackScreenProps<CommonNavigatorParams, 'CustomFeed'> + export const CustomFeedScreen = withAuthRequired( - observer(({route}: Props) => { + observer((props: Props) => { + const pal = usePalette('default') + const store = useStores() + const navigation = useNavigation<NavigationProp>() + + const {name: handleOrDid} = props.route.params + + const [feedOwnerDid, setFeedOwnerDid] = React.useState<string | undefined>() + const [error, setError] = React.useState<string | undefined>() + + const onPressBack = React.useCallback(() => { + if (navigation.canGoBack()) { + navigation.goBack() + } else { + navigation.navigate('Home') + } + }, [navigation]) + + React.useEffect(() => { + /* + * We must resolve the DID of the feed owner before we can fetch the feed. + */ + async function fetchDid() { + try { + const did = await resolveName(store, handleOrDid) + setFeedOwnerDid(did) + } catch (e) { + setError( + `We're sorry, but we were unable to resolve this feed. If this persists, please contact the feed creator, @${handleOrDid}.`, + ) + } + } + + fetchDid() + }, [store, handleOrDid, setFeedOwnerDid]) + + if (error) { + return ( + <CenteredView> + <View style={[pal.view, pal.border, styles.notFoundContainer]}> + <Text type="title-lg" style={[pal.text, s.mb10]}> + Could not load feed + </Text> + <Text type="md" style={[pal.text, s.mb20]}> + {error} + </Text> + + <View style={{flexDirection: 'row'}}> + <Button + type="default" + accessibilityLabel="Go Back" + accessibilityHint="Return to previous page" + onPress={onPressBack} + style={{flexShrink: 1}}> + <Text type="button" style={pal.text}> + Go Back + </Text> + </Button> + </View> + </View> + </CenteredView> + ) + } + + return feedOwnerDid ? ( + <CustomFeedScreenInner {...props} feedOwnerDid={feedOwnerDid} /> + ) : ( + <CenteredView> + <View style={s.p20}> + <ActivityIndicator size="large" /> + </View> + </CenteredView> + ) + }), +) + +export const CustomFeedScreenInner = observer( + ({route, feedOwnerDid}: Props & {feedOwnerDid: string}) => { const store = useStores() const pal = usePalette('default') const {track} = useAnalytics() - const {rkey, name} = route.params + const {rkey, name: handleOrDid} = route.params const uri = useMemo( - () => makeRecordUri(name, 'app.bsky.feed.generator', rkey), - [rkey, name], + () => makeRecordUri(feedOwnerDid, 'app.bsky.feed.generator', rkey), + [rkey, feedOwnerDid], ) const scrollElRef = useRef<FlatList>(null) const currentFeed = useCustomFeed(uri) @@ -101,10 +183,19 @@ export const CustomFeedScreen = withAuthRequired( }, [store, currentFeed]) const onPressShare = React.useCallback(() => { - const url = toShareUrl(`/profile/${name}/feed/${rkey}`) + const url = toShareUrl(`/profile/${handleOrDid}/feed/${rkey}`) shareUrl(url) track('CustomFeed:Share') - }, [name, rkey, track]) + }, [handleOrDid, rkey, track]) + + const onPressReport = React.useCallback(() => { + if (!currentFeed) return + store.shell.openModal({ + name: 'report', + uri: currentFeed.uri, + cid: currentFeed.data.cid, + }) + }, [store, currentFeed]) const onScrollToTop = React.useCallback(() => { scrollElRef.current?.scrollToOffset({offset: 0, animated: true}) @@ -118,15 +209,37 @@ export const CustomFeedScreen = withAuthRequired( const dropdownItems: DropdownItem[] = React.useMemo(() => { let items: DropdownItem[] = [ { - testID: 'feedHeaderDropdownRemoveBtn', - label: 'Remove from my feeds', + testID: 'feedHeaderDropdownToggleSavedBtn', + label: currentFeed?.isSaved + ? 'Remove from my feeds' + : 'Add to my feeds', onPress: onToggleSaved, + icon: currentFeed?.isSaved + ? { + ios: { + name: 'trash', + }, + android: 'ic_delete', + web: 'trash', + } + : { + ios: { + name: 'plus', + }, + android: '', + web: 'plus', + }, + }, + { + testID: 'feedHeaderDropdownReportBtn', + label: 'Report feed', + onPress: onPressReport, icon: { ios: { - name: 'trash', + name: 'exclamationmark.triangle', }, - android: 'ic_delete', - web: 'trash', + android: 'ic_menu_report_image', + web: 'circle-exclamation', }, }, { @@ -143,7 +256,7 @@ export const CustomFeedScreen = withAuthRequired( }, ] return items - }, [onToggleSaved, onPressShare]) + }, [currentFeed?.isSaved, onToggleSaved, onPressReport, onPressShare]) const renderHeaderBtns = React.useCallback(() => { return ( @@ -176,12 +289,7 @@ export const CustomFeedScreen = withAuthRequired( /> </Button> ) : undefined} - {currentFeed?.isSaved ? ( - <NativeDropdown - testID="feedHeaderDropdownBtn" - items={dropdownItems} - /> - ) : ( + {!currentFeed?.isSaved ? ( <Button type="default-light" onPress={onToggleSaved} @@ -193,7 +301,21 @@ export const CustomFeedScreen = withAuthRequired( Add to My Feeds </Text> </Button> - )} + ) : null} + <NativeDropdown testID="feedHeaderDropdownBtn" items={dropdownItems}> + <View + style={{ + paddingLeft: currentFeed?.isSaved ? 12 : 6, + paddingRight: 12, + paddingVertical: 8, + }}> + <FontAwesomeIcon + icon="ellipsis" + size={20} + color={pal.colors.textLight} + /> + </View> + </NativeDropdown> </View> ) }, [ @@ -288,6 +410,17 @@ export const CustomFeedScreen = withAuthRequired( color={pal.colors.icon} /> </Button> + <Button + type="default" + accessibilityLabel="Report this feed" + accessibilityHint="" + onPress={onPressReport}> + <FontAwesomeIcon + icon="circle-exclamation" + size={18} + color={pal.colors.icon} + /> + </Button> </View> )} </View> @@ -310,7 +443,7 @@ export const CustomFeedScreen = withAuthRequired( <TextLink type="md-medium" style={pal.textLight} - href={`/profile/${name}/feed/${rkey}/liked-by`} + href={`/profile/${handleOrDid}/feed/${rkey}/liked-by`} text={`Liked by ${currentFeed.data.likeCount} ${pluralize( currentFeed?.data.likeCount || 0, 'user', @@ -336,7 +469,8 @@ export const CustomFeedScreen = withAuthRequired( onToggleSaved, onToggleLiked, onPressShare, - name, + handleOrDid, + onPressReport, rkey, isPinned, onTogglePinned, @@ -375,7 +509,7 @@ export const CustomFeedScreen = withAuthRequired( /> </View> ) - }), + }, ) const styles = StyleSheet.create({ @@ -430,4 +564,10 @@ const styles = StyleSheet.create({ position: 'relative', top: 2, }, + notFoundContainer: { + margin: 10, + paddingHorizontal: 18, + paddingVertical: 14, + borderRadius: 6, + }, }) diff --git a/src/view/screens/DiscoverFeeds.tsx b/src/view/screens/DiscoverFeeds.tsx index e7b685ebc..0f15b8054 100644 --- a/src/view/screens/DiscoverFeeds.tsx +++ b/src/view/screens/DiscoverFeeds.tsx @@ -28,14 +28,14 @@ export const DiscoverFeedsScreen = withAuthRequired( const [isInputFocused, setIsInputFocused] = React.useState<boolean>(false) const [query, setQuery] = React.useState<string>('') const debouncedSearchFeeds = React.useMemo( - () => debounce(() => feeds.search(query), 200), // debouce for 200 ms - [feeds, query], + () => debounce(query => feeds.search(query), 500), // debounce for 500ms + [feeds], ) const onChangeQuery = React.useCallback( (text: string) => { setQuery(text) if (text.length > 1) { - debouncedSearchFeeds() + debouncedSearchFeeds(text) } else { feeds.refresh() } @@ -52,8 +52,9 @@ export const DiscoverFeedsScreen = withAuthRequired( feeds.refresh() }, [feeds]) const onSubmitQuery = React.useCallback(() => { - feeds.search(query) - }, [feeds, query]) + debouncedSearchFeeds(query) + debouncedSearchFeeds.flush() + }, [debouncedSearchFeeds, query]) useFocusEffect( React.useCallback(() => { diff --git a/src/view/screens/ModerationBlockedAccounts.tsx b/src/view/screens/ModerationBlockedAccounts.tsx index 794195e58..959c6d9ca 100644 --- a/src/view/screens/ModerationBlockedAccounts.tsx +++ b/src/view/screens/ModerationBlockedAccounts.tsx @@ -66,7 +66,6 @@ export const ModerationBlockedAccounts = withAuthRequired( testID={`blockedAccount-${index}`} key={item.did} profile={item} - overrideModeration /> ) return ( diff --git a/src/view/screens/ModerationMutedAccounts.tsx b/src/view/screens/ModerationMutedAccounts.tsx index 995223c15..c638a55d7 100644 --- a/src/view/screens/ModerationMutedAccounts.tsx +++ b/src/view/screens/ModerationMutedAccounts.tsx @@ -63,7 +63,6 @@ export const ModerationMutedAccounts = withAuthRequired( testID={`mutedAccount-${index}`} key={item.did} profile={item} - overrideModeration /> ) return ( diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx index f00585336..a51fbcf50 100644 --- a/src/view/screens/Profile.tsx +++ b/src/view/screens/Profile.tsx @@ -232,7 +232,7 @@ export const ProfileScreen = withAuthRequired( ) } else if (item instanceof PostsFeedSliceModel) { return ( - <FeedSlice slice={item} ignoreMuteFor={uiState.profile.did} /> + <FeedSlice slice={item} ignoreFilterFor={uiState.profile.did} /> ) } } @@ -252,7 +252,7 @@ export const ProfileScreen = withAuthRequired( testID="profileView" style={styles.container} screenDescription="profile" - moderation={uiState.profile.moderation.view}> + moderation={uiState.profile.moderation.account}> {uiState.profile.hasError ? ( <ErrorScreen testID="profileErrorScreen" diff --git a/src/view/screens/ProfileList.tsx b/src/view/screens/ProfileList.tsx index 0502e8dc8..651fac21f 100644 --- a/src/view/screens/ProfileList.tsx +++ b/src/view/screens/ProfileList.tsx @@ -86,6 +86,15 @@ export const ProfileListScreen = withAuthRequired( }) }, [store, list, navigation]) + const onPressReportList = React.useCallback(() => { + if (!list.list) return + store.shell.openModal({ + name: 'report', + uri: list.uri, + cid: list.list.cid, + }) + }, [store, list]) + const onPressShareList = React.useCallback(() => { const url = toShareUrl(`/profile/${name}/lists/${rkey}`) shareUrl(url) @@ -104,6 +113,7 @@ export const ProfileListScreen = withAuthRequired( onPressEditList={onPressEditList} onToggleSubscribed={onToggleSubscribed} onPressShareList={onPressShareList} + onPressReportList={onPressReportList} reversed={true} /> ) @@ -114,6 +124,7 @@ export const ProfileListScreen = withAuthRequired( onPressEditList, onPressShareList, onToggleSubscribed, + onPressReportList, ]) return ( @@ -132,6 +143,7 @@ export const ProfileListScreen = withAuthRequired( onToggleSubscribed={onToggleSubscribed} onPressEditList={onPressEditList} onPressDeleteList={onPressDeleteList} + onPressReportList={onPressReportList} onPressShareList={onPressShareList} style={[s.flex1]} /> |