From 2fed6c402159c6084dd481ab87c5e8b034e910ac Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Wed, 12 Apr 2023 18:26:38 -0700 Subject: [PATCH] Add first round of labeling tools (#467) * Rework notifications to sync locally in full and give users better control * Fix positioning of load more btn on web * Improve behavior of load more notifications btn * Fix to post rendering * Fix notification fetch abort condition * Add start of post-hiding by labels * Create a standard postcontainer and improve show/hide UI on posts * Add content hiding to expanded post form * Improve label rendering to give more context to users when appropriate * Fix rendering bug * Add user/profile labeling * Implement content filtering preferences * Filter notifications by content prefs * Update test-pds config * Bump deps --- jest/test-pds.ts | 2 + package.json | 4 +- src/lib/labeling/const.ts | 50 +++ src/lib/labeling/helpers.ts | 19 ++ src/state/models/content/profile.ts | 3 + src/state/models/feeds/notifications.ts | 317 ++++++++++-------- src/state/models/me.ts | 2 +- src/state/models/ui/preferences.ts | 63 ++++ src/state/models/ui/shell.ts | 5 + src/view/com/discover/SuggestedFollows.tsx | 1 + .../com/modals/ContentFilteringSettings.tsx | 185 ++++++++++ src/view/com/modals/Modal.tsx | 9 +- src/view/com/modals/Modal.web.tsx | 3 + src/view/com/notifications/Feed.tsx | 1 - src/view/com/notifications/FeedItem.tsx | 23 +- src/view/com/post-thread/PostLikedBy.tsx | 1 + src/view/com/post-thread/PostRepostedBy.tsx | 1 + src/view/com/post-thread/PostThreadItem.tsx | 88 +++-- src/view/com/post/Post.tsx | 219 +++++++----- src/view/com/posts/FeedItem.tsx | 263 ++++++++------- src/view/com/profile/ProfileCard.tsx | 9 +- src/view/com/profile/ProfileFollowers.tsx | 1 + src/view/com/profile/ProfileFollows.tsx | 1 + src/view/com/profile/ProfileHeader.tsx | 8 +- src/view/com/search/SearchResults.tsx | 1 + src/view/com/util/LoadLatestBtn.tsx | 52 +-- src/view/com/util/LoadLatestBtn.web.tsx | 14 +- src/view/com/util/PostMeta.tsx | 7 +- src/view/com/util/PostMuted.tsx | 50 --- src/view/com/util/UserAvatar.tsx | 47 ++- src/view/com/util/moderation/ContentHider.tsx | 109 ++++++ src/view/com/util/moderation/PostHider.tsx | 105 ++++++ .../util/moderation/ProfileHeaderLabels.tsx | 55 +++ src/view/com/util/post-embeds/QuoteEmbed.tsx | 1 + src/view/index.ts | 4 +- src/view/screens/Home.tsx | 2 +- src/view/screens/Notifications.tsx | 59 +--- src/view/screens/Search.tsx | 1 + src/view/screens/Settings.tsx | 19 ++ src/view/shell/desktop/Search.tsx | 1 + yarn.lock | 17 +- 41 files changed, 1292 insertions(+), 530 deletions(-) create mode 100644 src/lib/labeling/const.ts create mode 100644 src/lib/labeling/helpers.ts create mode 100644 src/view/com/modals/ContentFilteringSettings.tsx delete mode 100644 src/view/com/util/PostMuted.tsx create mode 100644 src/view/com/util/moderation/ContentHider.tsx create mode 100644 src/view/com/util/moderation/PostHider.tsx create mode 100644 src/view/com/util/moderation/ProfileHeaderLabels.tsx diff --git a/jest/test-pds.ts b/jest/test-pds.ts index 85177558..64963898 100644 --- a/jest/test-pds.ts +++ b/jest/test-pds.ts @@ -79,6 +79,8 @@ export async function createServer( maxSubscriptionBuffer: 200, repoBackfillLimitMs: HOUR, userInviteInterval: 1, + labelerDid: 'did:example:labeler', + labelerKeywords: {}, }) const db = diff --git a/package.json b/package.json index 089805d5..b6dd9bde 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "e2e:run": "detox test --configuration ios.sim.debug --take-screenshots all" }, "dependencies": { - "@atproto/api": "0.2.5", + "@atproto/api": "0.2.6", "@bam.tech/react-native-image-resizer": "^3.0.4", "@expo/webpack-config": "^18.0.1", "@fortawesome/fontawesome-svg-core": "^6.1.1", @@ -123,7 +123,7 @@ "zod": "^3.20.2" }, "devDependencies": { - "@atproto/pds": "^0.1.3", + "@atproto/pds": "^0.1.4", "@babel/core": "^7.20.0", "@babel/preset-env": "^7.20.0", "@babel/runtime": "^7.20.0", diff --git a/src/lib/labeling/const.ts b/src/lib/labeling/const.ts new file mode 100644 index 00000000..8403fdf1 --- /dev/null +++ b/src/lib/labeling/const.ts @@ -0,0 +1,50 @@ +import {LabelPreferencesModel} from 'state/models/ui/preferences' + +export interface LabelValGroup { + id: keyof LabelPreferencesModel | 'illegal' | 'unknown' + title: string + values: string[] +} + +export const ILLEGAL_LABEL_GROUP: LabelValGroup = { + id: 'illegal', + title: 'Illegal Content', + values: ['csam', 'dmca-violation', 'nudity-nonconsentual'], +} + +export const UNKNOWN_LABEL_GROUP: LabelValGroup = { + id: 'unknown', + title: 'Unknown Label', + values: [], +} + +export const CONFIGURABLE_LABEL_GROUPS: Record< + keyof LabelPreferencesModel, + LabelValGroup +> = { + nsfw: { + id: 'nsfw', + title: 'Sexual Content', + values: ['porn', 'nudity', 'sexual'], + }, + gore: { + id: 'gore', + title: 'Violent / Bloody', + values: ['gore', 'self-harm', 'torture'], + }, + hate: { + id: 'hate', + title: 'Political Hate-Groups', + values: ['icon-kkk', 'icon-nazi', 'icon-confederate'], + }, + spam: { + id: 'spam', + title: 'Spam', + values: ['spam'], + }, + impersonation: { + id: 'impersonation', + title: 'Impersonation', + values: ['impersonation'], + }, +} diff --git a/src/lib/labeling/helpers.ts b/src/lib/labeling/helpers.ts new file mode 100644 index 00000000..b2057ff1 --- /dev/null +++ b/src/lib/labeling/helpers.ts @@ -0,0 +1,19 @@ +import { + LabelValGroup, + CONFIGURABLE_LABEL_GROUPS, + ILLEGAL_LABEL_GROUP, + UNKNOWN_LABEL_GROUP, +} from './const' + +export function getLabelValueGroup(labelVal: string): LabelValGroup { + let id: keyof typeof CONFIGURABLE_LABEL_GROUPS + for (id in CONFIGURABLE_LABEL_GROUPS) { + if (ILLEGAL_LABEL_GROUP.values.includes(labelVal)) { + return ILLEGAL_LABEL_GROUP + } + if (CONFIGURABLE_LABEL_GROUPS[id].values.includes(labelVal)) { + return CONFIGURABLE_LABEL_GROUPS[id] + } + } + return UNKNOWN_LABEL_GROUP +} diff --git a/src/state/models/content/profile.ts b/src/state/models/content/profile.ts index 8d9c71b3..45d928c9 100644 --- a/src/state/models/content/profile.ts +++ b/src/state/models/content/profile.ts @@ -1,6 +1,7 @@ import {makeAutoObservable, runInAction} from 'mobx' import {PickedMedia} from 'lib/media/picker' import { + ComAtprotoLabelDefs, AppBskyActorGetProfile as GetProfile, AppBskyActorProfile, RichText, @@ -41,6 +42,7 @@ export class ProfileModel { followersCount: number = 0 followsCount: number = 0 postsCount: number = 0 + labels?: ComAtprotoLabelDefs.Label[] = undefined viewer = new ProfileViewerModel() // added data @@ -210,6 +212,7 @@ export class ProfileModel { this.followersCount = res.data.followersCount || 0 this.followsCount = res.data.followsCount || 0 this.postsCount = res.data.postsCount || 0 + this.labels = res.data.labels if (res.data.viewer) { Object.assign(this.viewer, res.data.viewer) this.rootStore.me.follows.hydrate(this.did, res.data.viewer.following) diff --git a/src/state/models/feeds/notifications.ts b/src/state/models/feeds/notifications.ts index 4daa3ca8..12db9510 100644 --- a/src/state/models/feeds/notifications.ts +++ b/src/state/models/feeds/notifications.ts @@ -6,6 +6,7 @@ import { AppBskyFeedRepost, AppBskyFeedLike, AppBskyGraphFollow, + ComAtprotoLabelDefs, } from '@atproto/api' import AwaitLock from 'await-lock' import {bundleAsync} from 'lib/async/bundle' @@ -20,6 +21,8 @@ const MS_2DAY = MS_1HR * 48 let _idCounter = 0 +type CondFn = (notif: ListNotifications.Notification) => boolean + export interface GroupedNotification extends ListNotifications.Notification { additional?: ListNotifications.Notification[] } @@ -47,6 +50,7 @@ export class NotificationsFeedItemModel { record?: SupportedRecord isRead: boolean = false indexedAt: string = '' + labels?: ComAtprotoLabelDefs.Label[] additional?: NotificationsFeedItemModel[] // additional data @@ -71,6 +75,7 @@ export class NotificationsFeedItemModel { this.record = this.toSupportedRecord(v.record) this.isRead = v.isRead this.indexedAt = v.indexedAt + this.labels = v.labels if (v.additional?.length) { this.additional = [] for (const add of v.additional) { @@ -83,6 +88,27 @@ export class NotificationsFeedItemModel { } } + get numUnreadInGroup(): number { + if (this.additional?.length) { + return ( + this.additional.reduce( + (acc, notif) => acc + notif.numUnreadInGroup, + 0, + ) + (this.isRead ? 0 : 1) + ) + } + return this.isRead ? 0 : 1 + } + + markGroupRead() { + if (this.additional?.length) { + for (const notif of this.additional) { + notif.markGroupRead() + } + } + this.isRead = true + } + get isLike() { return this.reason === 'like' } @@ -192,7 +218,6 @@ export class NotificationsFeedModel { hasLoaded = false error = '' loadMoreError = '' - params: ListNotifications.QueryParams hasMore = true loadMoreCursor?: string @@ -201,25 +226,21 @@ export class NotificationsFeedModel { // data notifications: NotificationsFeedItemModel[] = [] + queuedNotifications: undefined | ListNotifications.Notification[] = undefined unreadCount = 0 // this is used to help trigger push notifications mostRecentNotificationUri: string | undefined - constructor( - public rootStore: RootStoreModel, - params: ListNotifications.QueryParams, - ) { + constructor(public rootStore: RootStoreModel) { makeAutoObservable( this, { rootStore: false, - params: false, mostRecentNotificationUri: false, }, {autoBind: true}, ) - this.params = params } get hasContent() { @@ -234,6 +255,10 @@ export class NotificationsFeedModel { return this.hasLoaded && !this.hasContent } + get hasNewLatest() { + return this.queuedNotifications && this.queuedNotifications?.length > 0 + } + // public api // = @@ -258,19 +283,17 @@ export class NotificationsFeedModel { * Load for first render */ setup = bundleAsync(async (isRefreshing: boolean = false) => { - this.rootStore.log.debug('NotificationsModel:setup', {isRefreshing}) - if (isRefreshing) { - this.isRefreshing = true // set optimistically for UI - } + this.rootStore.log.debug('NotificationsModel:refresh', {isRefreshing}) await this.lock.acquireAsync() try { this._xLoading(isRefreshing) try { - const params = Object.assign({}, this.params, { - limit: PAGE_SIZE, + const res = await this._fetchUntil(notif => notif.isRead, { + breakAt: 'page', }) - const res = await this.rootStore.agent.listNotifications(params) await this._replaceAll(res) + this._setQueued(undefined) + this._countUnread() this._xIdle() } catch (e: any) { this._xIdle(e) @@ -284,9 +307,65 @@ export class NotificationsFeedModel { * Reset and load */ async refresh() { + this.isRefreshing = true // set optimistically for UI return this.setup(true) } + /** + * Sync the next set of notifications to show + * returns true if the number changed + */ + syncQueue = bundleAsync(async () => { + this.rootStore.log.debug('NotificationsModel:syncQueue') + await this.lock.acquireAsync() + try { + const res = await this._fetchUntil( + notif => + this.notifications.length + ? isEq(notif, this.notifications[0]) + : notif.isRead, + {breakAt: 'record'}, + ) + this._setQueued(res.data.notifications) + this._countUnread() + } catch (e) { + this.rootStore.log.error('NotificationsModel:syncQueue failed', {e}) + } finally { + this.lock.release() + } + }) + + /** + * + */ + processQueue = bundleAsync(async () => { + this.rootStore.log.debug('NotificationsModel:processQueue') + if (!this.queuedNotifications) { + return + } + this.isRefreshing = true + await this.lock.acquireAsync() + try { + runInAction(() => { + this.mostRecentNotificationUri = this.queuedNotifications?.[0].uri + }) + const itemModels = await this._processNotifications( + this.queuedNotifications, + ) + this._setQueued(undefined) + runInAction(() => { + this.notifications = itemModels.concat(this.notifications) + }) + } catch (e) { + this.rootStore.log.error('NotificationsModel:processQueue failed', {e}) + } finally { + runInAction(() => { + this.isRefreshing = false + }) + this.lock.release() + } + }) + /** * Load more posts to the end of the notifications */ @@ -294,15 +373,14 @@ export class NotificationsFeedModel { if (!this.hasMore) { return } - this.lock.acquireAsync() + await this.lock.acquireAsync() try { this._xLoading() try { - const params = Object.assign({}, this.params, { + const res = await this.rootStore.agent.listNotifications({ limit: PAGE_SIZE, cursor: this.loadMoreCursor, }) - const res = await this.rootStore.agent.listNotifications(params) await this._appendAll(res) this._xIdle() } catch (e: any) { @@ -325,101 +403,37 @@ export class NotificationsFeedModel { return this.loadMore() } - /** - * Load more posts at the start of the notifications - */ - loadLatest = bundleAsync(async () => { - if (this.notifications.length === 0 || this.unreadCount > PAGE_SIZE) { - return this.refresh() - } - this.lock.acquireAsync() - try { - this._xLoading() - try { - const res = await this.rootStore.agent.listNotifications({ - limit: PAGE_SIZE, - }) - await this._prependAll(res) - this._xIdle() - } catch (e: any) { - this._xIdle() // don't bubble the error to the user - this.rootStore.log.error('NotificationsView: Failed to load latest', { - params: this.params, - e, - }) - } - } finally { - this.lock.release() - } - }) - - /** - * Update content in-place - */ - update = bundleAsync(async () => { - await this.lock.acquireAsync() - try { - if (!this.notifications.length) { - return - } - this._xLoading() - let numToFetch = this.notifications.length - let cursor - try { - do { - const res: ListNotifications.Response = - await this.rootStore.agent.listNotifications({ - cursor, - limit: Math.min(numToFetch, 100), - }) - if (res.data.notifications.length === 0) { - break // sanity check - } - this._updateAll(res) - numToFetch -= res.data.notifications.length - cursor = res.data.cursor - } while (cursor && numToFetch > 0) - this._xIdle() - } catch (e: any) { - this._xIdle() // don't bubble the error to the user - this.rootStore.log.error('NotificationsView: Failed to update', { - params: this.params, - e, - }) - } - } finally { - this.lock.release() - } - }) - - // unread notification apis + // unread notification in-place // = - - /** - * Get the current number of unread notifications - * returns true if the number changed - */ - loadUnreadCount = bundleAsync(async () => { - const old = this.unreadCount - const res = await this.rootStore.agent.countUnreadNotifications() - runInAction(() => { - this.unreadCount = res.data.count + async update() { + const promises = [] + for (const item of this.notifications) { + if (item.additionalPost) { + promises.push(item.additionalPost.update()) + } + } + await Promise.all(promises).catch(e => { + this.rootStore.log.error( + 'Uncaught failure during notifications update()', + e, + ) }) - this.rootStore.emitUnreadNotifications(this.unreadCount) - return this.unreadCount !== old - }) + } /** * Update read/unread state */ - async markAllRead() { + async markAllUnqueuedRead() { try { - this.unreadCount = 0 - this.rootStore.emitUnreadNotifications(0) for (const notif of this.notifications) { - notif.isRead = true + notif.markGroupRead() + } + this._countUnread() + if (this.notifications[0]) { + await this.rootStore.agent.updateSeenNotifications( + this.notifications[0].indexedAt, + ) } - await this.rootStore.agent.updateSeenNotifications() } catch (e: any) { this.rootStore.log.warn('Failed to update notifications read state', e) } @@ -472,6 +486,40 @@ export class NotificationsFeedModel { // helper functions // = + async _fetchUntil( + condFn: CondFn, + {breakAt}: {breakAt: 'page' | 'record'}, + ): Promise { + const accRes: ListNotifications.Response = { + success: true, + headers: {}, + data: {cursor: undefined, notifications: []}, + } + for (let i = 0; i <= 10; i++) { + const res = await this.rootStore.agent.listNotifications({ + limit: PAGE_SIZE, + cursor: accRes.data.cursor, + }) + accRes.data.cursor = res.data.cursor + + let pageIsDone = false + for (const notif of res.data.notifications) { + if (condFn(notif)) { + if (breakAt === 'record') { + return accRes + } else { + pageIsDone = true + } + } + accRes.data.notifications.push(notif) + } + if (pageIsDone || res.data.notifications.length < PAGE_SIZE) { + return accRes + } + } + return accRes + } + async _replaceAll(res: ListNotifications.Response) { if (res.data.notifications[0]) { this.mostRecentNotificationUri = res.data.notifications[0].uri @@ -482,25 +530,7 @@ export class NotificationsFeedModel { async _appendAll(res: ListNotifications.Response, replace = false) { this.loadMoreCursor = res.data.cursor this.hasMore = !!this.loadMoreCursor - const promises = [] - const itemModels: NotificationsFeedItemModel[] = [] - for (const item of groupNotifications(res.data.notifications)) { - const itemModel = new NotificationsFeedItemModel( - this.rootStore, - `item-${_idCounter++}`, - item, - ) - if (itemModel.needsAdditionalData) { - promises.push(itemModel.fetchAdditionalData()) - } - itemModels.push(itemModel) - } - await Promise.all(promises).catch(e => { - this.rootStore.log.error( - 'Uncaught failure during notifications-view _appendAll()', - e, - ) - }) + const itemModels = await this._processNotifications(res.data.notifications) runInAction(() => { if (replace) { this.notifications = itemModels @@ -510,16 +540,18 @@ export class NotificationsFeedModel { }) } - async _prependAll(res: ListNotifications.Response) { + async _processNotifications( + items: ListNotifications.Notification[], + ): Promise { const promises = [] const itemModels: NotificationsFeedItemModel[] = [] - const dedupedNotifs = res.data.notifications.filter( - n1 => - !this.notifications.find( - n2 => isEq(n1, n2) || n2.additional?.find(n3 => isEq(n1, n3)), - ), - ) - for (const item of groupNotifications(dedupedNotifs)) { + items = items.filter(item => { + return ( + this.rootStore.preferences.getLabelPreference(item.labels).pref !== + 'hide' + ) + }) + for (const item of groupNotifications(items)) { const itemModel = new NotificationsFeedItemModel( this.rootStore, `item-${_idCounter++}`, @@ -532,22 +564,27 @@ export class NotificationsFeedModel { } await Promise.all(promises).catch(e => { this.rootStore.log.error( - 'Uncaught failure during notifications-view _prependAll()', + 'Uncaught failure during notifications _processNotifications()', e, ) }) - runInAction(() => { - this.notifications = itemModels.concat(this.notifications) - }) + return itemModels } - _updateAll(res: ListNotifications.Response) { - for (const item of res.data.notifications) { - const existingItem = this.notifications.find(item2 => isEq(item, item2)) - if (existingItem) { - existingItem.copy(item, true) - } + _setQueued(queued: undefined | ListNotifications.Notification[]) { + this.queuedNotifications = queued + } + + _countUnread() { + let unread = 0 + for (const notif of this.notifications) { + unread += notif.numUnreadInGroup } + if (this.queuedNotifications) { + unread += this.queuedNotifications.length + } + this.unreadCount = unread + this.rootStore.emitUnreadNotifications(unread) } } diff --git a/src/state/models/me.ts b/src/state/models/me.ts index a0591aec..3774e1e5 100644 --- a/src/state/models/me.ts +++ b/src/state/models/me.ts @@ -119,7 +119,7 @@ export class MeModel { await this.fetchProfile() await this.fetchInviteCodes() } - await this.notifications.loadUnreadCount() + await this.notifications.syncQueue() } async fetchProfile() { diff --git a/src/state/models/ui/preferences.ts b/src/state/models/ui/preferences.ts index bffb2d89..5ab5d13f 100644 --- a/src/state/models/ui/preferences.ts +++ b/src/state/models/ui/preferences.ts @@ -1,11 +1,33 @@ import {makeAutoObservable} from 'mobx' import {getLocales} from 'expo-localization' import {isObj, hasProp} from 'lib/type-guards' +import {ComAtprotoLabelDefs} from '@atproto/api' +import {getLabelValueGroup} from 'lib/labeling/helpers' +import { + LabelValGroup, + UNKNOWN_LABEL_GROUP, + ILLEGAL_LABEL_GROUP, +} from 'lib/labeling/const' const deviceLocales = getLocales() +export type LabelPreference = 'show' | 'warn' | 'hide' + +export class LabelPreferencesModel { + nsfw: LabelPreference = 'warn' + gore: LabelPreference = 'hide' + hate: LabelPreference = 'hide' + spam: LabelPreference = 'hide' + impersonation: LabelPreference = 'warn' + + constructor() { + makeAutoObservable(this, {}, {autoBind: true}) + } +} + export class PreferencesModel { _contentLanguages: string[] | undefined + contentLabels = new LabelPreferencesModel() constructor() { makeAutoObservable(this, {}, {autoBind: true}) @@ -22,6 +44,7 @@ export class PreferencesModel { serialize() { return { contentLanguages: this._contentLanguages, + contentLabels: this.contentLabels, } } @@ -34,6 +57,46 @@ export class PreferencesModel { ) { this._contentLanguages = v.contentLanguages } + if (hasProp(v, 'contentLabels') && typeof v.contentLabels === 'object') { + Object.assign(this.contentLabels, v.contentLabels) + } } } + + setContentLabelPref( + key: keyof LabelPreferencesModel, + value: LabelPreference, + ) { + this.contentLabels[key] = value + } + + getLabelPreference(labels: ComAtprotoLabelDefs.Label[] | undefined): { + pref: LabelPreference + desc: LabelValGroup + } { + let res: {pref: LabelPreference; desc: LabelValGroup} = { + pref: 'show', + desc: UNKNOWN_LABEL_GROUP, + } + if (!labels?.length) { + return res + } + for (const label of labels) { + const group = getLabelValueGroup(label.val) + if (group.id === 'illegal') { + return {pref: 'hide', desc: ILLEGAL_LABEL_GROUP} + } else if (group.id === 'unknown') { + continue + } + let pref = this.contentLabels[group.id] + if (pref === 'hide') { + res.pref = 'hide' + res.desc = group + } else if (pref === 'warn' && res.pref === 'show') { + res.pref = 'warn' + res.desc = group + } + } + return res + } } diff --git a/src/state/models/ui/shell.ts b/src/state/models/ui/shell.ts index 917e7a09..dd5c899b 100644 --- a/src/state/models/ui/shell.ts +++ b/src/state/models/ui/shell.ts @@ -65,6 +65,10 @@ export interface InviteCodesModal { name: 'invite-codes' } +export interface ContentFilteringSettingsModal { + name: 'content-filtering-settings' +} + export type Modal = | ConfirmModal | EditProfileModal @@ -77,6 +81,7 @@ export type Modal = | ChangeHandleModal | WaitlistModal | InviteCodesModal + | ContentFilteringSettingsModal interface LightboxModel {} diff --git a/src/view/com/discover/SuggestedFollows.tsx b/src/view/com/discover/SuggestedFollows.tsx index e4ada520..ae5605c5 100644 --- a/src/view/com/discover/SuggestedFollows.tsx +++ b/src/view/com/discover/SuggestedFollows.tsx @@ -31,6 +31,7 @@ export const SuggestedFollows = ({ handle={item.handle} displayName={item.displayName} avatar={item.avatar} + labels={item.labels} noBg noBorder description={ diff --git a/src/view/com/modals/ContentFilteringSettings.tsx b/src/view/com/modals/ContentFilteringSettings.tsx new file mode 100644 index 00000000..2e015e40 --- /dev/null +++ b/src/view/com/modals/ContentFilteringSettings.tsx @@ -0,0 +1,185 @@ +import React from 'react' +import {StyleSheet, TouchableOpacity, View} from 'react-native' +import LinearGradient from 'react-native-linear-gradient' +import {observer} from 'mobx-react-lite' +import {useStores} from 'state/index' +import {LabelPreference} from 'state/models/ui/preferences' +import {s, colors, gradients} from 'lib/styles' +import {Text} from '../util/text/Text' +import {usePalette} from 'lib/hooks/usePalette' +import {CONFIGURABLE_LABEL_GROUPS} from 'lib/labeling/const' + +export const snapPoints = [500] + +export function Component({}: {}) { + const store = useStores() + const pal = usePalette('default') + const onPressDone = React.useCallback(() => { + store.shell.closeModal() + }, [store]) + + return ( + + Content Filtering + + + + + + + + + Done + + + + ) +} + +const ContentLabelPref = observer( + ({group}: {group: keyof typeof CONFIGURABLE_LABEL_GROUPS}) => { + const store = useStores() + const pal = usePalette('default') + return ( + + + {CONFIGURABLE_LABEL_GROUPS[group].title} + + store.preferences.setContentLabelPref(group, v)} + /> + + ) + }, +) + +function SelectGroup({ + current, + onChange, +}: { + current: LabelPreference + onChange: (v: LabelPreference) => void +}) { + return ( + + + + + + ) +} + +function SelectableBtn({ + current, + value, + label, + left, + right, + onChange, +}: { + current: string + value: LabelPreference + label: string + left?: boolean + right?: boolean + onChange: (v: LabelPreference) => void +}) { + const pal = usePalette('default') + const palPrimary = usePalette('inverted') + return ( + onChange(value)}> + + {label} + + + ) +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + paddingHorizontal: 10, + paddingBottom: 40, + }, + title: { + textAlign: 'center', + fontWeight: 'bold', + fontSize: 24, + marginBottom: 12, + }, + description: { + paddingHorizontal: 2, + marginBottom: 10, + }, + + contentLabelPref: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingTop: 10, + paddingLeft: 4, + marginBottom: 10, + borderTopWidth: 1, + }, + + selectableBtns: { + flexDirection: 'row', + }, + selectableBtn: { + flexDirection: 'row', + justifyContent: 'center', + borderWidth: 1, + borderLeftWidth: 0, + paddingHorizontal: 10, + paddingVertical: 10, + }, + selectableBtnLeft: { + borderTopLeftRadius: 8, + borderBottomLeftRadius: 8, + borderLeftWidth: 1, + }, + selectableBtnRight: { + borderTopRightRadius: 8, + borderBottomRightRadius: 8, + }, + + btn: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + width: '100%', + borderRadius: 32, + padding: 14, + backgroundColor: colors.gray1, + }, +}) diff --git a/src/view/com/modals/Modal.tsx b/src/view/com/modals/Modal.tsx index b1c7d473..3f10ec83 100644 --- a/src/view/com/modals/Modal.tsx +++ b/src/view/com/modals/Modal.tsx @@ -1,9 +1,10 @@ import React, {useRef, useEffect} from 'react' -import {View} from 'react-native' +import {StyleSheet, View} from 'react-native' import {observer} from 'mobx-react-lite' import BottomSheet from '@gorhom/bottom-sheet' import {useStores} from 'state/index' import {createCustomBackdrop} from '../util/BottomSheetCustomBackdrop' +import {usePalette} from 'lib/hooks/usePalette' import * as ConfirmModal from './Confirm' import * as EditProfileModal from './EditProfile' @@ -15,8 +16,7 @@ import * as DeleteAccountModal from './DeleteAccount' import * as ChangeHandleModal from './ChangeHandle' import * as WaitlistModal from './Waitlist' import * as InviteCodesModal from './InviteCodes' -import {usePalette} from 'lib/hooks/usePalette' -import {StyleSheet} from 'react-native' +import * as ContentFilteringSettingsModal from './ContentFilteringSettings' const DEFAULT_SNAPPOINTS = ['90%'] @@ -77,6 +77,9 @@ export const ModalsContainer = observer(function ModalsContainer() { } else if (activeModal?.name === 'invite-codes') { snapPoints = InviteCodesModal.snapPoints element = + } else if (activeModal?.name === 'content-filtering-settings') { + snapPoints = ContentFilteringSettingsModal.snapPoints + element = } else { return } diff --git a/src/view/com/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx index e6d54926..6f026e17 100644 --- a/src/view/com/modals/Modal.web.tsx +++ b/src/view/com/modals/Modal.web.tsx @@ -17,6 +17,7 @@ import * as CropImageModal from './crop-image/CropImage.web' import * as ChangeHandleModal from './ChangeHandle' import * as WaitlistModal from './Waitlist' import * as InviteCodesModal from './InviteCodes' +import * as ContentFilteringSettingsModal from './ContentFilteringSettings' export const ModalsContainer = observer(function ModalsContainer() { const store = useStores() @@ -75,6 +76,8 @@ function Modal({modal}: {modal: ModalIface}) { element = } else if (modal.name === 'invite-codes') { element = + } else if (modal.name === 'content-filtering-settings') { + element = } else { return null } diff --git a/src/view/com/notifications/Feed.tsx b/src/view/com/notifications/Feed.tsx index 2196b346..23a3166d 100644 --- a/src/view/com/notifications/Feed.tsx +++ b/src/view/com/notifications/Feed.tsx @@ -45,7 +45,6 @@ export const Feed = observer(function Feed({ const onRefresh = React.useCallback(async () => { try { await view.refresh() - await view.markAllRead() } catch (err) { view.rootStore.log.error('Failed to refresh notifications feed', err) } diff --git a/src/view/com/notifications/FeedItem.tsx b/src/view/com/notifications/FeedItem.tsx index e77eae17..22a354da 100644 --- a/src/view/com/notifications/FeedItem.tsx +++ b/src/view/com/notifications/FeedItem.tsx @@ -8,7 +8,7 @@ import { View, } from 'react-native' import {AppBskyEmbedImages} from '@atproto/api' -import {AtUri} from '@atproto/api' +import {AtUri, ComAtprotoLabelDefs} from '@atproto/api' import { FontAwesomeIcon, FontAwesomeIconStyle, @@ -38,6 +38,7 @@ interface Author { handle: string displayName?: string avatar?: string + labels?: ComAtprotoLabelDefs.Label[] } export const FeedItem = observer(function FeedItem({ @@ -129,6 +130,7 @@ export const FeedItem = observer(function FeedItem({ handle: item.author.handle, displayName: item.author.displayName, avatar: item.author.avatar, + labels: item.author.labels, }, ] if (item.additional?.length) { @@ -138,6 +140,7 @@ export const FeedItem = observer(function FeedItem({ handle: item2.author.handle, displayName: item2.author.displayName, avatar: item2.author.avatar, + labels: item.author.labels, })), ) } @@ -255,7 +258,11 @@ function CondensedAuthorsList({ href={authors[0].href} title={`@${authors[0].handle}`} asAnchor> - + ) @@ -264,7 +271,11 @@ function CondensedAuthorsList({ {authors.slice(0, MAX_AUTHORS).map(author => ( - + ))} {authors.length > MAX_AUTHORS ? ( @@ -317,7 +328,11 @@ function ExpandedAuthorsList({ style={styles.expandedAuthor} asAnchor> - + ) diff --git a/src/view/com/post-thread/PostRepostedBy.tsx b/src/view/com/post-thread/PostRepostedBy.tsx index 30f8fd44..9874460e 100644 --- a/src/view/com/post-thread/PostRepostedBy.tsx +++ b/src/view/com/post-thread/PostRepostedBy.tsx @@ -64,6 +64,7 @@ export const PostRepostedBy = observer(function PostRepostedBy({ handle={item.handle} displayName={item.displayName} avatar={item.avatar} + labels={item.labels} isFollowedBy={!!item.viewer?.followedBy} /> ) diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx index 3d3647f6..6e8758f7 100644 --- a/src/view/com/post-thread/PostThreadItem.tsx +++ b/src/view/com/post-thread/PostThreadItem.tsx @@ -22,7 +22,8 @@ import {useStores} from 'state/index' import {PostMeta} from '../util/PostMeta' import {PostEmbeds} from '../util/post-embeds' import {PostCtrls} from '../util/PostCtrls' -import {PostMutedWrapper} from '../util/PostMuted' +import {PostHider} from '../util/moderation/PostHider' +import {ContentHider} from '../util/moderation/ContentHider' import {ErrorMessage} from '../util/error/ErrorMessage' import {usePalette} from 'lib/hooks/usePalette' @@ -137,7 +138,11 @@ export const PostThreadItem = observer(function PostThreadItem({ - + @@ -193,17 +198,24 @@ export const PostThreadItem = observer(function PostThreadItem({ - {item.richText?.text ? ( - - - - ) : undefined} - + + {item.richText?.text ? ( + + + + ) : undefined} + + {item._isHighlightedPost && hasEngagement ? ( {item.post.repostCount ? ( @@ -270,13 +282,13 @@ export const PostThreadItem = observer(function PostThreadItem({ ) } else { return ( - - + + style={[styles.outer, {borderColor: pal.colors.border}, pal.view]} + isMuted={item.post.author.viewer?.muted === true} + labels={item.post.labels}> {item._showParentReplyLine && ( - + - {item.richText?.text ? ( - - - - ) : undefined} - + + {item.richText?.text ? ( + + + + ) : undefined} + + - + {item._hasMore ? ( ) : undefined} - + ) } }) @@ -433,6 +454,9 @@ const styles = StyleSheet.create({ paddingHorizontal: 0, paddingBottom: 10, }, + contentHider: { + marginTop: 4, + }, expandedInfo: { flexDirection: 'row', padding: 10, diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx index e8e74126..60d46f5c 100644 --- a/src/view/com/post/Post.tsx +++ b/src/view/com/post/Post.tsx @@ -7,17 +7,22 @@ import { View, ViewStyle, } from 'react-native' +import {AppBskyFeedPost as FeedPost} from '@atproto/api' import {observer} from 'mobx-react-lite' import Clipboard from '@react-native-clipboard/clipboard' import {AtUri} from '@atproto/api' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {PostThreadModel} from 'state/models/content/post-thread' +import { + PostThreadModel, + PostThreadItemModel, +} from 'state/models/content/post-thread' import {Link} from '../util/Link' import {UserInfoText} from '../util/UserInfoText' import {PostMeta} from '../util/PostMeta' import {PostEmbeds} from '../util/post-embeds' import {PostCtrls} from '../util/PostCtrls' -import {PostMutedWrapper} from '../util/PostMuted' +import {PostHider} from '../util/moderation/PostHider' +import {ContentHider} from '../util/moderation/ContentHider' import {Text} from '../util/text/Text' import {RichText} from '../util/text/RichText' import * as Toast from '../util/Toast' @@ -61,7 +66,11 @@ export const Post = observer(function Post({ // loading // = - if (!view || view.isLoading || view.params.uri !== uri) { + if ( + !view || + (!view.hasContent && view.isLoading) || + view.params.uri !== uri + ) { return ( @@ -84,85 +93,122 @@ export const Post = observer(function Post({ // loaded // = - const item = view.thread - const record = view.thread.postRecord - - const itemUri = item.post.uri - const itemCid = item.post.cid - const itemUrip = new AtUri(item.post.uri) - const itemHref = `/profile/${item.post.author.handle}/post/${itemUrip.rkey}` - const itemTitle = `Post by ${item.post.author.handle}` - const authorHref = `/profile/${item.post.author.handle}` - const authorTitle = item.post.author.handle - let replyAuthorDid = '' - if (record.reply) { - const urip = new AtUri(record.reply.parent?.uri || record.reply.root.uri) - replyAuthorDid = urip.hostname - } - const onPressReply = () => { - store.shell.openComposer({ - replyTo: { - uri: item.post.uri, - cid: item.post.cid, - text: record.text as string, - author: { - handle: item.post.author.handle, - displayName: item.post.author.displayName, - avatar: item.post.author.avatar, - }, - }, - }) - } - const onPressToggleRepost = () => { - return item - .toggleRepost() - .catch(e => store.log.error('Failed to toggle repost', e)) - } - const onPressToggleLike = () => { - return item - .toggleLike() - .catch(e => store.log.error('Failed to toggle like', e)) - } - const onCopyPostText = () => { - Clipboard.setString(record.text) - Toast.show('Copied to clipboard') - } - const onOpenTranslate = () => { - Linking.openURL( - encodeURI(`https://translate.google.com/#auto|en|${record?.text || ''}`), - ) - } - const onDeletePost = () => { - item.delete().then( - () => { - setDeleted(true) - Toast.show('Post deleted') - }, - e => { - store.log.error('Failed to delete post', e) - Toast.show('Failed to delete post, please try again') - }, - ) - } return ( - - + ) +}) + +const PostLoaded = observer( + ({ + item, + record, + setDeleted, + showReplyLine, + style, + }: { + item: PostThreadItemModel + record: FeedPost.Record + setDeleted: (v: boolean) => void + showReplyLine?: boolean + style?: StyleProp + }) => { + const pal = usePalette('default') + const store = useStores() + + const itemUri = item.post.uri + const itemCid = item.post.cid + const itemUrip = new AtUri(item.post.uri) + const itemHref = `/profile/${item.post.author.handle}/post/${itemUrip.rkey}` + const itemTitle = `Post by ${item.post.author.handle}` + const authorHref = `/profile/${item.post.author.handle}` + const authorTitle = item.post.author.handle + let replyAuthorDid = '' + if (record.reply) { + const urip = new AtUri(record.reply.parent?.uri || record.reply.root.uri) + replyAuthorDid = urip.hostname + } + const onPressReply = React.useCallback(() => { + store.shell.openComposer({ + replyTo: { + uri: item.post.uri, + cid: item.post.cid, + text: record.text as string, + author: { + handle: item.post.author.handle, + displayName: item.post.author.displayName, + avatar: item.post.author.avatar, + }, + }, + }) + }, [store, item, record]) + + const onPressToggleRepost = React.useCallback(() => { + return item + .toggleRepost() + .catch(e => store.log.error('Failed to toggle repost', e)) + }, [item, store]) + + const onPressToggleLike = React.useCallback(() => { + return item + .toggleLike() + .catch(e => store.log.error('Failed to toggle like', e)) + }, [item, store]) + + const onCopyPostText = React.useCallback(() => { + Clipboard.setString(record.text) + Toast.show('Copied to clipboard') + }, [record]) + + const onOpenTranslate = React.useCallback(() => { + Linking.openURL( + encodeURI( + `https://translate.google.com/#auto|en|${record?.text || ''}`, + ), + ) + }, [record]) + + const onDeletePost = React.useCallback(() => { + item.delete().then( + () => { + setDeleted(true) + Toast.show('Post deleted') + }, + e => { + store.log.error('Failed to delete post', e) + Toast.show('Failed to delete post, please try again') + }, + ) + }, [item, setDeleted, store]) + + return ( + + style={[styles.outer, pal.view, pal.border, style]} + isMuted={item.post.author.viewer?.muted === true} + labels={item.post.labels}> {showReplyLine && } - + )} - {item.richText?.text ? ( - - - - ) : undefined} - + + {item.richText?.text ? ( + + + + ) : undefined} + + - - - ) -}) + + ) + }, +) const styles = StyleSheet.create({ outer: { @@ -257,4 +307,7 @@ const styles = StyleSheet.create({ borderLeftWidth: 2, borderLeftColor: colors.gray2, }, + contentHider: { + marginTop: 4, + }, }) diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx index 569d1125..c2baa4d4 100644 --- a/src/view/com/posts/FeedItem.tsx +++ b/src/view/com/posts/FeedItem.tsx @@ -14,7 +14,8 @@ import {UserInfoText} from '../util/UserInfoText' import {PostMeta} from '../util/PostMeta' import {PostCtrls} from '../util/PostCtrls' import {PostEmbeds} from '../util/post-embeds' -import {PostMutedWrapper} from '../util/PostMuted' +import {PostHider} from '../util/moderation/PostHider' +import {ContentHider} from '../util/moderation/ContentHider' import {RichText} from '../util/text/RichText' import * as Toast from '../util/Toast' import {UserAvatar} from '../util/UserAvatar' @@ -59,7 +60,7 @@ export const FeedItem = observer(function ({ return urip.hostname }, [record?.reply]) - const onPressReply = () => { + const onPressReply = React.useCallback(() => { track('FeedItem:PostReply') store.shell.openComposer({ replyTo: { @@ -73,29 +74,34 @@ export const FeedItem = observer(function ({ }, }, }) - } - const onPressToggleRepost = () => { + }, [item, track, record, store]) + + const onPressToggleRepost = React.useCallback(() => { track('FeedItem:PostRepost') return item .toggleRepost() .catch(e => store.log.error('Failed to toggle repost', e)) - } - const onPressToggleLike = () => { + }, [track, item, store]) + + const onPressToggleLike = React.useCallback(() => { track('FeedItem:PostLike') return item .toggleLike() .catch(e => store.log.error('Failed to toggle like', e)) - } - const onCopyPostText = () => { + }, [track, item, store]) + + const onCopyPostText = React.useCallback(() => { Clipboard.setString(record?.text || '') Toast.show('Copied to clipboard') - } + }, [record]) + const onOpenTranslate = React.useCallback(() => { Linking.openURL( encodeURI(`https://translate.google.com/#auto|en|${record?.text || ''}`), ) }, [record]) - const onDeletePost = () => { + + const onDeletePost = React.useCallback(() => { track('FeedItem:PostDelete') item.delete().then( () => { @@ -107,7 +113,7 @@ export const FeedItem = observer(function ({ Toast.show('Failed to delete post, please try again') }, ) - } + }, [track, item, setDeleted, store]) if (!record || deleted) { return @@ -127,97 +133,103 @@ export const FeedItem = observer(function ({ ] return ( - - - {isThreadChild && ( - - )} - {isThreadParent && ( - + {isThreadChild && ( + + )} + {isThreadParent && ( + + )} + {item.reasonRepost && ( + + - )} - {item.reasonRepost && ( - - - + Reposted by{' '} + - Reposted by{' '} - - - - )} - - - - - - - - - {!isThreadChild && replyAuthorDid !== '' && ( - - - - Reply to - - - - )} + + + )} + + + + + + + + + {!isThreadChild && replyAuthorDid !== '' && ( + + + + Reply to + + + + )} + {item.richText?.text ? ( ) : undefined} - - + + - - + + ) }) @@ -320,6 +332,9 @@ const styles = StyleSheet.create({ flexWrap: 'wrap', paddingBottom: 4, }, + contentHider: { + marginTop: 4, + }, embed: { marginBottom: 6, }, diff --git a/src/view/com/profile/ProfileCard.tsx b/src/view/com/profile/ProfileCard.tsx index dfbc2ddb..d14d5e16 100644 --- a/src/view/com/profile/ProfileCard.tsx +++ b/src/view/com/profile/ProfileCard.tsx @@ -1,7 +1,7 @@ import React from 'react' import {StyleSheet, View} from 'react-native' import {observer} from 'mobx-react-lite' -import {AppBskyActorDefs} from '@atproto/api' +import {AppBskyActorDefs, ComAtprotoLabelDefs} from '@atproto/api' import {Link} from '../util/Link' import {Text} from '../util/text/Text' import {UserAvatar} from '../util/UserAvatar' @@ -17,6 +17,7 @@ export function ProfileCard({ displayName, avatar, description, + labels, isFollowedBy, noBg, noBorder, @@ -28,6 +29,7 @@ export function ProfileCard({ displayName?: string avatar?: string description?: string + labels: ComAtprotoLabelDefs.Label[] | undefined isFollowedBy?: boolean noBg?: boolean noBorder?: boolean @@ -50,7 +52,7 @@ export function ProfileCard({ asAnchor> - + ) diff --git a/src/view/com/profile/ProfileFollows.tsx b/src/view/com/profile/ProfileFollows.tsx index 54b5a319..10da79c5 100644 --- a/src/view/com/profile/ProfileFollows.tsx +++ b/src/view/com/profile/ProfileFollows.tsx @@ -64,6 +64,7 @@ export const ProfileFollows = observer(function ProfileFollows({ handle={item.handle} displayName={item.displayName} avatar={item.avatar} + labels={item.labels} isFollowedBy={!!item.viewer?.followedBy} /> ) diff --git a/src/view/com/profile/ProfileHeader.tsx b/src/view/com/profile/ProfileHeader.tsx index 1326d3ec..d520a712 100644 --- a/src/view/com/profile/ProfileHeader.tsx +++ b/src/view/com/profile/ProfileHeader.tsx @@ -27,6 +27,7 @@ import {Text} from '../util/text/Text' import {RichText} from '../util/text/RichText' import {UserAvatar} from '../util/UserAvatar' import {UserBanner} from '../util/UserBanner' +import {ProfileHeaderLabels} from '../util/moderation/ProfileHeaderLabels' import {usePalette} from 'lib/hooks/usePalette' import {useAnalytics} from 'lib/analytics' import {NavigationProp} from 'lib/routes/types' @@ -320,6 +321,7 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoaded({ richText={view.descriptionRichText} /> ) : undefined} + {view.viewer.muted ? ( - + diff --git a/src/view/com/search/SearchResults.tsx b/src/view/com/search/SearchResults.tsx index b53965f4..5d6163d4 100644 --- a/src/view/com/search/SearchResults.tsx +++ b/src/view/com/search/SearchResults.tsx @@ -101,6 +101,7 @@ const Profiles = observer(({model}: {model: SearchUIModel}) => { displayName={item.displayName} avatar={item.avatar} description={item.description} + labels={item.labels} /> ))} diff --git a/src/view/com/util/LoadLatestBtn.tsx b/src/view/com/util/LoadLatestBtn.tsx index fd05ecc9..88b6dffd 100644 --- a/src/view/com/util/LoadLatestBtn.tsx +++ b/src/view/com/util/LoadLatestBtn.tsx @@ -10,31 +10,33 @@ import {useStores} from 'state/index' const HITSLOP = {left: 20, top: 20, right: 20, bottom: 20} -export const LoadLatestBtn = observer(({onPress}: {onPress: () => void}) => { - const store = useStores() - const safeAreaInsets = useSafeAreaInsets() - return ( - - - - Load new posts - - - - ) -}) +export const LoadLatestBtn = observer( + ({onPress, label}: {onPress: () => void; label: string}) => { + const store = useStores() + const safeAreaInsets = useSafeAreaInsets() + return ( + + + + Load new {label} + + + + ) + }, +) const styles = StyleSheet.create({ loadLatest: { diff --git a/src/view/com/util/LoadLatestBtn.web.tsx b/src/view/com/util/LoadLatestBtn.web.tsx index ba33f92a..c85f44f3 100644 --- a/src/view/com/util/LoadLatestBtn.web.tsx +++ b/src/view/com/util/LoadLatestBtn.web.tsx @@ -6,7 +6,13 @@ import {UpIcon} from 'lib/icons' const HITSLOP = {left: 20, top: 20, right: 20, bottom: 20} -export const LoadLatestBtn = ({onPress}: {onPress: () => void}) => { +export const LoadLatestBtn = ({ + onPress, + label, +}: { + onPress: () => void + label: string +}) => { const pal = usePalette('default') return ( void}) => { hitSlop={HITSLOP}> - Load new posts + Load new {label} ) @@ -25,7 +31,9 @@ const styles = StyleSheet.create({ loadLatest: { flexDirection: 'row', position: 'absolute', - left: 'calc(50vw - 80px)', + left: '50vw', + // @ts-ignore web only -prf + transform: 'translateX(-50%)', top: 30, shadowColor: '#000', shadowOpacity: 0.2, diff --git a/src/view/com/util/PostMeta.tsx b/src/view/com/util/PostMeta.tsx index c46c16da..d9dd11e0 100644 --- a/src/view/com/util/PostMeta.tsx +++ b/src/view/com/util/PostMeta.tsx @@ -15,6 +15,7 @@ interface PostMetaOpts { authorAvatar?: string authorHandle: string authorDisplayName: string | undefined + authorHasWarning: boolean postHref: string timestamp: string did?: string @@ -93,7 +94,11 @@ export const PostMeta = observer(function (opts: PostMetaOpts) { {typeof opts.authorAvatar !== 'undefined' && ( - + )} diff --git a/src/view/com/util/PostMuted.tsx b/src/view/com/util/PostMuted.tsx deleted file mode 100644 index 539a71ec..00000000 --- a/src/view/com/util/PostMuted.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import React from 'react' -import {StyleSheet, TouchableOpacity, View} from 'react-native' -import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {usePalette} from 'lib/hooks/usePalette' -import {Text} from './text/Text' - -export function PostMutedWrapper({ - isMuted, - children, -}: React.PropsWithChildren<{isMuted?: boolean}>) { - const pal = usePalette('default') - const [override, setOverride] = React.useState(false) - if (!isMuted || override) { - return <>{children} - } - return ( - - - - Post from an account you muted. - - setOverride(true)}> - - Show post - - - - ) -} - -const styles = StyleSheet.create({ - container: { - flexDirection: 'row', - alignItems: 'center', - paddingVertical: 14, - paddingHorizontal: 18, - borderTopWidth: 1, - }, - icon: { - marginRight: 10, - }, - showBtn: { - marginLeft: 'auto', - }, -}) diff --git a/src/view/com/util/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx index ff741cd3..d18c2d69 100644 --- a/src/view/com/util/UserAvatar.tsx +++ b/src/view/com/util/UserAvatar.tsx @@ -44,10 +44,12 @@ function DefaultAvatar({size}: {size: number}) { export function UserAvatar({ size, avatar, + hasWarning, onSelectNewAvatar, }: { size: number avatar?: string | null + hasWarning?: boolean onSelectNewAvatar?: (img: PickedMedia | null) => void }) { const store = useStores() @@ -105,6 +107,22 @@ export function UserAvatar({ }, }, ] + + const warning = React.useMemo(() => { + if (!hasWarning) { + return <> + } + return ( + + + + ) + }, [hasWarning, size, pal]) + // onSelectNewAvatar is only passed as prop on the EditProfile component return onSelectNewAvatar ? ( ) : avatar ? ( - + + + {warning} + ) : ( - + + + {warning} + ) } @@ -165,4 +189,13 @@ const styles = StyleSheet.create({ height: 80, borderRadius: 40, }, + warningIconContainer: { + position: 'absolute', + right: 0, + bottom: 0, + borderRadius: 100, + }, + warningIcon: { + color: colors.red3, + }, }) diff --git a/src/view/com/util/moderation/ContentHider.tsx b/src/view/com/util/moderation/ContentHider.tsx new file mode 100644 index 00000000..f65635d3 --- /dev/null +++ b/src/view/com/util/moderation/ContentHider.tsx @@ -0,0 +1,109 @@ +import React from 'react' +import { + StyleProp, + StyleSheet, + TouchableOpacity, + View, + ViewStyle, +} from 'react-native' +import {ComAtprotoLabelDefs} from '@atproto/api' +import {usePalette} from 'lib/hooks/usePalette' +import {useStores} from 'state/index' +import {Text} from '../text/Text' +import {addStyle} from 'lib/styles' + +export function ContentHider({ + testID, + isMuted, + labels, + style, + containerStyle, + children, +}: React.PropsWithChildren<{ + testID?: string + isMuted?: boolean + labels: ComAtprotoLabelDefs.Label[] | undefined + style?: StyleProp + containerStyle?: StyleProp +}>) { + const pal = usePalette('default') + const [override, setOverride] = React.useState(false) + const store = useStores() + const labelPref = store.preferences.getLabelPreference(labels) + + if (!isMuted && labelPref.pref === 'show') { + return ( + + {children} + + ) + } + + if (labelPref.pref === 'hide') { + return <> + } + + return ( + + + + {isMuted ? ( + <>Post from an account you muted. + ) : ( + <>Warning: {labelPref.desc.title} + )} + + setOverride(v => !v)}> + + {override ? 'Hide' : 'Show'} + + + + {override && ( + + + {children} + + + )} + + ) +} + +const styles = StyleSheet.create({ + container: { + marginBottom: 10, + borderWidth: 1, + borderRadius: 12, + }, + description: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 14, + paddingLeft: 14, + paddingRight: 18, + borderRadius: 12, + }, + descriptionOpen: { + borderBottomLeftRadius: 0, + borderBottomRightRadius: 0, + }, + icon: { + marginRight: 10, + }, + showBtn: { + marginLeft: 'auto', + }, + childrenContainer: { + paddingHorizontal: 12, + paddingTop: 8, + }, + child: {}, +}) diff --git a/src/view/com/util/moderation/PostHider.tsx b/src/view/com/util/moderation/PostHider.tsx new file mode 100644 index 00000000..bafc7aec --- /dev/null +++ b/src/view/com/util/moderation/PostHider.tsx @@ -0,0 +1,105 @@ +import React from 'react' +import { + StyleProp, + StyleSheet, + TouchableOpacity, + View, + ViewStyle, +} from 'react-native' +import {ComAtprotoLabelDefs} from '@atproto/api' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {usePalette} from 'lib/hooks/usePalette' +import {Link} from '../Link' +import {Text} from '../text/Text' +import {addStyle} from 'lib/styles' +import {useStores} from 'state/index' + +export function PostHider({ + testID, + href, + isMuted, + labels, + style, + children, +}: React.PropsWithChildren<{ + testID?: string + href: string + isMuted: boolean | undefined + labels: ComAtprotoLabelDefs.Label[] | undefined + style: StyleProp +}>) { + const store = useStores() + const pal = usePalette('default') + const [override, setOverride] = React.useState(false) + const bg = override ? pal.viewLight : pal.view + + const labelPref = store.preferences.getLabelPreference(labels) + if (labelPref.pref === 'hide') { + return <> + } + + if (!isMuted) { + // NOTE: any further label enforcement should occur in ContentContainer + return ( + + {children} + + ) + } + + return ( + <> + + + + Post from an account you muted. + + setOverride(v => !v)}> + + {override ? 'Hide' : 'Show'} post + + + + {override && ( + + + {children} + + + )} + + ) +} + +const styles = StyleSheet.create({ + description: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 14, + paddingHorizontal: 18, + borderTopWidth: 1, + }, + icon: { + marginRight: 10, + }, + showBtn: { + marginLeft: 'auto', + }, + childrenContainer: { + paddingHorizontal: 6, + paddingBottom: 6, + }, + child: { + borderWidth: 1, + borderRadius: 12, + }, +}) diff --git a/src/view/com/util/moderation/ProfileHeaderLabels.tsx b/src/view/com/util/moderation/ProfileHeaderLabels.tsx new file mode 100644 index 00000000..e099f09a --- /dev/null +++ b/src/view/com/util/moderation/ProfileHeaderLabels.tsx @@ -0,0 +1,55 @@ +import React from 'react' +import {StyleSheet, View} from 'react-native' +import {ComAtprotoLabelDefs} from '@atproto/api' +import { + FontAwesomeIcon, + FontAwesomeIconStyle, +} from '@fortawesome/react-native-fontawesome' +import {Text} from '../text/Text' +import {usePalette} from 'lib/hooks/usePalette' +import {getLabelValueGroup} from 'lib/labeling/helpers' + +export function ProfileHeaderLabels({ + labels, +}: { + labels: ComAtprotoLabelDefs.Label[] | undefined +}) { + const palErr = usePalette('error') + if (!labels?.length) { + return null + } + return ( + <> + {labels.map((label, i) => { + const labelGroup = getLabelValueGroup(label?.val || '') + return ( + + + + This account has been flagged for{' '} + {labelGroup.title.toLocaleLowerCase()}. + + + ) + })} + + ) +} + +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/post-embeds/QuoteEmbed.tsx b/src/view/com/util/post-embeds/QuoteEmbed.tsx index 5a8be5a1..94e83723 100644 --- a/src/view/com/util/post-embeds/QuoteEmbed.tsx +++ b/src/view/com/util/post-embeds/QuoteEmbed.tsx @@ -42,6 +42,7 @@ export function QuoteEmbed({ authorAvatar={quote.author.avatar} authorHandle={quote.author.handle} authorDisplayName={quote.author.displayName} + authorHasWarning={false} postHref={itemHref} timestamp={quote.indexedAt} /> diff --git a/src/view/index.ts b/src/view/index.ts index 47a5f8ac..e6e34269 100644 --- a/src/view/index.ts +++ b/src/view/index.ts @@ -34,6 +34,7 @@ import {faCompass} from '@fortawesome/free-regular-svg-icons/faCompass' import {faEllipsis} from '@fortawesome/free-solid-svg-icons/faEllipsis' import {faEnvelope} from '@fortawesome/free-solid-svg-icons/faEnvelope' import {faExclamation} from '@fortawesome/free-solid-svg-icons/faExclamation' +import {faEye} from '@fortawesome/free-solid-svg-icons/faEye' import {faEyeSlash as farEyeSlash} from '@fortawesome/free-regular-svg-icons/faEyeSlash' import {faGear} from '@fortawesome/free-solid-svg-icons/faGear' import {faGlobe} from '@fortawesome/free-solid-svg-icons/faGlobe' @@ -106,8 +107,8 @@ export function setup() { faCompass, faEllipsis, faEnvelope, + faEye, faExclamation, - faQuoteLeft, farEyeSlash, faGear, faGlobe, @@ -128,6 +129,7 @@ export function setup() { faPenNib, faPenToSquare, faPlus, + faQuoteLeft, faReply, faRetweet, faRss, diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx index 260df040..fac522c6 100644 --- a/src/view/screens/Home.tsx +++ b/src/view/screens/Home.tsx @@ -194,7 +194,7 @@ const FeedPage = observer( headerOffset={HEADER_OFFSET} /> {feed.hasNewLatest && !feed.isRefreshing && ( - + )} (null) const {screen} = useAnalytics() - const {appState} = useAppState({ - onForeground: () => doPoll(true), - }) // event handlers // = - const onPressTryAgain = () => { + const onPressTryAgain = React.useCallback(() => { store.me.notifications.refresh() - } + }, [store]) + const scrollToTop = React.useCallback(() => { scrollElRef.current?.scrollToOffset({offset: 0}) }, [scrollElRef]) - // periodic polling - // = - const doPoll = React.useCallback( - async (isForegrounding = false) => { - if (isForegrounding) { - // app is foregrounding, refresh optimistically - store.log.debug('NotificationsScreen: Refreshing on app foreground') - await Promise.all([ - store.me.notifications.loadUnreadCount(), - store.me.notifications.refresh(), - ]) - } else if (appState === 'active') { - // periodic poll, refresh if there are new notifs - store.log.debug('NotificationsScreen: Polling for new notifications') - const didChange = await store.me.notifications.loadUnreadCount() - if (didChange) { - store.log.debug('NotificationsScreen: Loading new notifications') - await store.me.notifications.loadLatest() - } - } - }, - [appState, store], - ) - useEffect(() => { - const pollInterval = setInterval(doPoll, NOTIFICATIONS_POLL_INTERVAL) - return () => clearInterval(pollInterval) - }, [doPoll]) + const onPressLoadLatest = React.useCallback(() => { + store.me.notifications.processQueue() + scrollToTop() + }, [store, scrollToTop]) // on-visible setup // = @@ -75,16 +48,16 @@ export const NotificationsScreen = withAuthRequired( React.useCallback(() => { store.shell.setMinimalShellMode(false) store.log.debug('NotificationsScreen: Updating feed') - const softResetSub = store.onScreenSoftReset(scrollToTop) - store.me.notifications.loadUnreadCount() - store.me.notifications.loadLatest() + const softResetSub = store.onScreenSoftReset(onPressLoadLatest) + store.me.notifications.syncQueue() + store.me.notifications.update() screen('Notifications') return () => { softResetSub.remove() - store.me.notifications.markAllRead() + store.me.notifications.markAllUnqueuedRead() } - }, [store, screen, scrollToTop]), + }, [store, screen, onPressLoadLatest]), ) return ( @@ -97,6 +70,10 @@ export const NotificationsScreen = withAuthRequired( onScroll={onMainScroll} scrollElRef={scrollElRef} /> + {store.me.notifications.hasNewLatest && + !store.me.notifications.isRefreshing && ( + + )} ) }), diff --git a/src/view/screens/Search.tsx b/src/view/screens/Search.tsx index e1fb3ec0..ed9effd0 100644 --- a/src/view/screens/Search.tsx +++ b/src/view/screens/Search.tsx @@ -155,6 +155,7 @@ export const SearchScreen = withAuthRequired( testID={`searchAutoCompleteResult-${item.handle}`} handle={item.handle} displayName={item.displayName} + labels={item.labels} avatar={item.avatar} /> ))} diff --git a/src/view/screens/Settings.tsx b/src/view/screens/Settings.tsx index d429db1b..081be8dc 100644 --- a/src/view/screens/Settings.tsx +++ b/src/view/screens/Settings.tsx @@ -124,6 +124,11 @@ export const SettingsScreen = withAuthRequired( store.shell.openModal({name: 'invite-codes'}) }, [track, store]) + const onPressContentFiltering = React.useCallback(() => { + track('Settings:ContentfilteringButtonClicked') + store.shell.openModal({name: 'content-filtering-settings'}) + }, [track, store]) + const onPressSignout = React.useCallback(() => { track('Settings:SignOutButtonClicked') store.session.logout() @@ -248,6 +253,20 @@ export const SettingsScreen = withAuthRequired( Advanced + + + + + + Content moderation + + ))} diff --git a/yarn.lock b/yarn.lock index 09034ada..c5446697 100644 --- a/yarn.lock +++ b/yarn.lock @@ -30,10 +30,10 @@ tlds "^1.234.0" typed-emitter "^2.1.0" -"@atproto/api@0.2.5": - version "0.2.5" - resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.2.5.tgz#24375497351469a522497c7f92016d0b4233a172" - integrity sha512-RJGhiwj6kOjrlVy7ES/SfJt3JyFwXdFZeBP4iw2ne/Ie0ZlanKhY0y9QHx5tI4rvEUP/wf0iKtaq2neczHi3bg== +"@atproto/api@0.2.6": + version "0.2.6" + resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.2.6.tgz#030d9d3385bb109cc028451ab26a5e24ee126b06" + integrity sha512-vw5D0o0ByuWd89ob8vG8RPd0tDhPi4NTyqn0lCJQDhxcYXx4I96sZ3iCUf61m7g3VNumBAoC2ZRo9kdn/6tb5w== dependencies: "@atproto/common-web" "*" "@atproto/uri" "*" @@ -122,10 +122,10 @@ resolved "https://registry.yarnpkg.com/@atproto/nsid/-/nsid-0.0.1.tgz#0cdc00cefe8f0b1385f352b9f57b3ad37fff09a4" integrity sha512-t5M6/CzWBVYoBbIvfKDpqPj/+ZmyoK9ydZSStcTXosJ27XXwOPhz0VDUGKK2SM9G5Y7TPes8S5KTAU0UdVYFCw== -"@atproto/pds@^0.1.3": - version "0.1.3" - resolved "https://registry.yarnpkg.com/@atproto/pds/-/pds-0.1.3.tgz#601c556cd1e10306c9b741d9361bc54d70bb2869" - integrity sha512-cVvmgXkzu7w1tDGGDK904sDzxF2AUqu0ij/1EU2rYmnZZAK+FTjKs8cqrJzRur9vm07A23JvBTuINtYzxHwSzA== +"@atproto/pds@^0.1.4": + version "0.1.4" + resolved "https://registry.yarnpkg.com/@atproto/pds/-/pds-0.1.4.tgz#43379912e127d6d4f79a514e785dab9b54fd7810" + integrity sha512-vrFYL+2nNm/0fJyUIgFK9h9FRuEf4rHjU/LJV7/nBO+HA3hP3U/mTgvVxuuHHvcRsRL5AVpAJR0xWFUoYsFmmg== dependencies: "@atproto/api" "*" "@atproto/common" "*" @@ -144,6 +144,7 @@ express "^4.17.2" express-async-errors "^3.1.1" file-type "^16.5.4" + form-data "^4.0.0" handlebars "^4.7.7" http-errors "^2.0.0" http-terminator "^3.2.0"