Bluesky app fork with some witchin' additions 💫

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

authored by

Paul Frazee and committed by
GitHub
2fed6c40 a20d034b

+1290 -528
+2
jest/test-pds.ts
··· 79 79 maxSubscriptionBuffer: 200, 80 80 repoBackfillLimitMs: HOUR, 81 81 userInviteInterval: 1, 82 + labelerDid: 'did:example:labeler', 83 + labelerKeywords: {}, 82 84 }) 83 85 84 86 const db =
+2 -2
package.json
··· 21 21 "e2e:run": "detox test --configuration ios.sim.debug --take-screenshots all" 22 22 }, 23 23 "dependencies": { 24 - "@atproto/api": "0.2.5", 24 + "@atproto/api": "0.2.6", 25 25 "@bam.tech/react-native-image-resizer": "^3.0.4", 26 26 "@expo/webpack-config": "^18.0.1", 27 27 "@fortawesome/fontawesome-svg-core": "^6.1.1", ··· 123 123 "zod": "^3.20.2" 124 124 }, 125 125 "devDependencies": { 126 - "@atproto/pds": "^0.1.3", 126 + "@atproto/pds": "^0.1.4", 127 127 "@babel/core": "^7.20.0", 128 128 "@babel/preset-env": "^7.20.0", 129 129 "@babel/runtime": "^7.20.0",
+50
src/lib/labeling/const.ts
··· 1 + import {LabelPreferencesModel} from 'state/models/ui/preferences' 2 + 3 + export interface LabelValGroup { 4 + id: keyof LabelPreferencesModel | 'illegal' | 'unknown' 5 + title: string 6 + values: string[] 7 + } 8 + 9 + export const ILLEGAL_LABEL_GROUP: LabelValGroup = { 10 + id: 'illegal', 11 + title: 'Illegal Content', 12 + values: ['csam', 'dmca-violation', 'nudity-nonconsentual'], 13 + } 14 + 15 + export const UNKNOWN_LABEL_GROUP: LabelValGroup = { 16 + id: 'unknown', 17 + title: 'Unknown Label', 18 + values: [], 19 + } 20 + 21 + export const CONFIGURABLE_LABEL_GROUPS: Record< 22 + keyof LabelPreferencesModel, 23 + LabelValGroup 24 + > = { 25 + nsfw: { 26 + id: 'nsfw', 27 + title: 'Sexual Content', 28 + values: ['porn', 'nudity', 'sexual'], 29 + }, 30 + gore: { 31 + id: 'gore', 32 + title: 'Violent / Bloody', 33 + values: ['gore', 'self-harm', 'torture'], 34 + }, 35 + hate: { 36 + id: 'hate', 37 + title: 'Political Hate-Groups', 38 + values: ['icon-kkk', 'icon-nazi', 'icon-confederate'], 39 + }, 40 + spam: { 41 + id: 'spam', 42 + title: 'Spam', 43 + values: ['spam'], 44 + }, 45 + impersonation: { 46 + id: 'impersonation', 47 + title: 'Impersonation', 48 + values: ['impersonation'], 49 + }, 50 + }
+19
src/lib/labeling/helpers.ts
··· 1 + import { 2 + LabelValGroup, 3 + CONFIGURABLE_LABEL_GROUPS, 4 + ILLEGAL_LABEL_GROUP, 5 + UNKNOWN_LABEL_GROUP, 6 + } from './const' 7 + 8 + export function getLabelValueGroup(labelVal: string): LabelValGroup { 9 + let id: keyof typeof CONFIGURABLE_LABEL_GROUPS 10 + for (id in CONFIGURABLE_LABEL_GROUPS) { 11 + if (ILLEGAL_LABEL_GROUP.values.includes(labelVal)) { 12 + return ILLEGAL_LABEL_GROUP 13 + } 14 + if (CONFIGURABLE_LABEL_GROUPS[id].values.includes(labelVal)) { 15 + return CONFIGURABLE_LABEL_GROUPS[id] 16 + } 17 + } 18 + return UNKNOWN_LABEL_GROUP 19 + }
+3
src/state/models/content/profile.ts
··· 1 1 import {makeAutoObservable, runInAction} from 'mobx' 2 2 import {PickedMedia} from 'lib/media/picker' 3 3 import { 4 + ComAtprotoLabelDefs, 4 5 AppBskyActorGetProfile as GetProfile, 5 6 AppBskyActorProfile, 6 7 RichText, ··· 41 42 followersCount: number = 0 42 43 followsCount: number = 0 43 44 postsCount: number = 0 45 + labels?: ComAtprotoLabelDefs.Label[] = undefined 44 46 viewer = new ProfileViewerModel() 45 47 46 48 // added data ··· 210 212 this.followersCount = res.data.followersCount || 0 211 213 this.followsCount = res.data.followsCount || 0 212 214 this.postsCount = res.data.postsCount || 0 215 + this.labels = res.data.labels 213 216 if (res.data.viewer) { 214 217 Object.assign(this.viewer, res.data.viewer) 215 218 this.rootStore.me.follows.hydrate(this.did, res.data.viewer.following)
+176 -139
src/state/models/feeds/notifications.ts
··· 6 6 AppBskyFeedRepost, 7 7 AppBskyFeedLike, 8 8 AppBskyGraphFollow, 9 + ComAtprotoLabelDefs, 9 10 } from '@atproto/api' 10 11 import AwaitLock from 'await-lock' 11 12 import {bundleAsync} from 'lib/async/bundle' ··· 19 20 const MS_2DAY = MS_1HR * 48 20 21 21 22 let _idCounter = 0 23 + 24 + type CondFn = (notif: ListNotifications.Notification) => boolean 22 25 23 26 export interface GroupedNotification extends ListNotifications.Notification { 24 27 additional?: ListNotifications.Notification[] ··· 47 50 record?: SupportedRecord 48 51 isRead: boolean = false 49 52 indexedAt: string = '' 53 + labels?: ComAtprotoLabelDefs.Label[] 50 54 additional?: NotificationsFeedItemModel[] 51 55 52 56 // additional data ··· 71 75 this.record = this.toSupportedRecord(v.record) 72 76 this.isRead = v.isRead 73 77 this.indexedAt = v.indexedAt 78 + this.labels = v.labels 74 79 if (v.additional?.length) { 75 80 this.additional = [] 76 81 for (const add of v.additional) { ··· 83 88 } 84 89 } 85 90 91 + get numUnreadInGroup(): number { 92 + if (this.additional?.length) { 93 + return ( 94 + this.additional.reduce( 95 + (acc, notif) => acc + notif.numUnreadInGroup, 96 + 0, 97 + ) + (this.isRead ? 0 : 1) 98 + ) 99 + } 100 + return this.isRead ? 0 : 1 101 + } 102 + 103 + markGroupRead() { 104 + if (this.additional?.length) { 105 + for (const notif of this.additional) { 106 + notif.markGroupRead() 107 + } 108 + } 109 + this.isRead = true 110 + } 111 + 86 112 get isLike() { 87 113 return this.reason === 'like' 88 114 } ··· 192 218 hasLoaded = false 193 219 error = '' 194 220 loadMoreError = '' 195 - params: ListNotifications.QueryParams 196 221 hasMore = true 197 222 loadMoreCursor?: string 198 223 ··· 201 226 202 227 // data 203 228 notifications: NotificationsFeedItemModel[] = [] 229 + queuedNotifications: undefined | ListNotifications.Notification[] = undefined 204 230 unreadCount = 0 205 231 206 232 // this is used to help trigger push notifications 207 233 mostRecentNotificationUri: string | undefined 208 234 209 - constructor( 210 - public rootStore: RootStoreModel, 211 - params: ListNotifications.QueryParams, 212 - ) { 235 + constructor(public rootStore: RootStoreModel) { 213 236 makeAutoObservable( 214 237 this, 215 238 { 216 239 rootStore: false, 217 - params: false, 218 240 mostRecentNotificationUri: false, 219 241 }, 220 242 {autoBind: true}, 221 243 ) 222 - this.params = params 223 244 } 224 245 225 246 get hasContent() { ··· 232 253 233 254 get isEmpty() { 234 255 return this.hasLoaded && !this.hasContent 256 + } 257 + 258 + get hasNewLatest() { 259 + return this.queuedNotifications && this.queuedNotifications?.length > 0 235 260 } 236 261 237 262 // public api ··· 258 283 * Load for first render 259 284 */ 260 285 setup = bundleAsync(async (isRefreshing: boolean = false) => { 261 - this.rootStore.log.debug('NotificationsModel:setup', {isRefreshing}) 262 - if (isRefreshing) { 263 - this.isRefreshing = true // set optimistically for UI 264 - } 286 + this.rootStore.log.debug('NotificationsModel:refresh', {isRefreshing}) 265 287 await this.lock.acquireAsync() 266 288 try { 267 289 this._xLoading(isRefreshing) 268 290 try { 269 - const params = Object.assign({}, this.params, { 270 - limit: PAGE_SIZE, 291 + const res = await this._fetchUntil(notif => notif.isRead, { 292 + breakAt: 'page', 271 293 }) 272 - const res = await this.rootStore.agent.listNotifications(params) 273 294 await this._replaceAll(res) 295 + this._setQueued(undefined) 296 + this._countUnread() 274 297 this._xIdle() 275 298 } catch (e: any) { 276 299 this._xIdle(e) ··· 284 307 * Reset and load 285 308 */ 286 309 async refresh() { 310 + this.isRefreshing = true // set optimistically for UI 287 311 return this.setup(true) 288 312 } 289 313 290 314 /** 315 + * Sync the next set of notifications to show 316 + * returns true if the number changed 317 + */ 318 + syncQueue = bundleAsync(async () => { 319 + this.rootStore.log.debug('NotificationsModel:syncQueue') 320 + await this.lock.acquireAsync() 321 + try { 322 + const res = await this._fetchUntil( 323 + notif => 324 + this.notifications.length 325 + ? isEq(notif, this.notifications[0]) 326 + : notif.isRead, 327 + {breakAt: 'record'}, 328 + ) 329 + this._setQueued(res.data.notifications) 330 + this._countUnread() 331 + } catch (e) { 332 + this.rootStore.log.error('NotificationsModel:syncQueue failed', {e}) 333 + } finally { 334 + this.lock.release() 335 + } 336 + }) 337 + 338 + /** 339 + * 340 + */ 341 + processQueue = bundleAsync(async () => { 342 + this.rootStore.log.debug('NotificationsModel:processQueue') 343 + if (!this.queuedNotifications) { 344 + return 345 + } 346 + this.isRefreshing = true 347 + await this.lock.acquireAsync() 348 + try { 349 + runInAction(() => { 350 + this.mostRecentNotificationUri = this.queuedNotifications?.[0].uri 351 + }) 352 + const itemModels = await this._processNotifications( 353 + this.queuedNotifications, 354 + ) 355 + this._setQueued(undefined) 356 + runInAction(() => { 357 + this.notifications = itemModels.concat(this.notifications) 358 + }) 359 + } catch (e) { 360 + this.rootStore.log.error('NotificationsModel:processQueue failed', {e}) 361 + } finally { 362 + runInAction(() => { 363 + this.isRefreshing = false 364 + }) 365 + this.lock.release() 366 + } 367 + }) 368 + 369 + /** 291 370 * Load more posts to the end of the notifications 292 371 */ 293 372 loadMore = bundleAsync(async () => { 294 373 if (!this.hasMore) { 295 374 return 296 375 } 297 - this.lock.acquireAsync() 376 + await this.lock.acquireAsync() 298 377 try { 299 378 this._xLoading() 300 379 try { 301 - const params = Object.assign({}, this.params, { 380 + const res = await this.rootStore.agent.listNotifications({ 302 381 limit: PAGE_SIZE, 303 382 cursor: this.loadMoreCursor, 304 383 }) 305 - const res = await this.rootStore.agent.listNotifications(params) 306 384 await this._appendAll(res) 307 385 this._xIdle() 308 386 } catch (e: any) { ··· 325 403 return this.loadMore() 326 404 } 327 405 328 - /** 329 - * Load more posts at the start of the notifications 330 - */ 331 - loadLatest = bundleAsync(async () => { 332 - if (this.notifications.length === 0 || this.unreadCount > PAGE_SIZE) { 333 - return this.refresh() 334 - } 335 - this.lock.acquireAsync() 336 - try { 337 - this._xLoading() 338 - try { 339 - const res = await this.rootStore.agent.listNotifications({ 340 - limit: PAGE_SIZE, 341 - }) 342 - await this._prependAll(res) 343 - this._xIdle() 344 - } catch (e: any) { 345 - this._xIdle() // don't bubble the error to the user 346 - this.rootStore.log.error('NotificationsView: Failed to load latest', { 347 - params: this.params, 348 - e, 349 - }) 406 + // unread notification in-place 407 + // = 408 + async update() { 409 + const promises = [] 410 + for (const item of this.notifications) { 411 + if (item.additionalPost) { 412 + promises.push(item.additionalPost.update()) 350 413 } 351 - } finally { 352 - this.lock.release() 353 414 } 354 - }) 355 - 356 - /** 357 - * Update content in-place 358 - */ 359 - update = bundleAsync(async () => { 360 - await this.lock.acquireAsync() 361 - try { 362 - if (!this.notifications.length) { 363 - return 364 - } 365 - this._xLoading() 366 - let numToFetch = this.notifications.length 367 - let cursor 368 - try { 369 - do { 370 - const res: ListNotifications.Response = 371 - await this.rootStore.agent.listNotifications({ 372 - cursor, 373 - limit: Math.min(numToFetch, 100), 374 - }) 375 - if (res.data.notifications.length === 0) { 376 - break // sanity check 377 - } 378 - this._updateAll(res) 379 - numToFetch -= res.data.notifications.length 380 - cursor = res.data.cursor 381 - } while (cursor && numToFetch > 0) 382 - this._xIdle() 383 - } catch (e: any) { 384 - this._xIdle() // don't bubble the error to the user 385 - this.rootStore.log.error('NotificationsView: Failed to update', { 386 - params: this.params, 387 - e, 388 - }) 389 - } 390 - } finally { 391 - this.lock.release() 392 - } 393 - }) 394 - 395 - // unread notification apis 396 - // = 397 - 398 - /** 399 - * Get the current number of unread notifications 400 - * returns true if the number changed 401 - */ 402 - loadUnreadCount = bundleAsync(async () => { 403 - const old = this.unreadCount 404 - const res = await this.rootStore.agent.countUnreadNotifications() 405 - runInAction(() => { 406 - this.unreadCount = res.data.count 415 + await Promise.all(promises).catch(e => { 416 + this.rootStore.log.error( 417 + 'Uncaught failure during notifications update()', 418 + e, 419 + ) 407 420 }) 408 - this.rootStore.emitUnreadNotifications(this.unreadCount) 409 - return this.unreadCount !== old 410 - }) 421 + } 411 422 412 423 /** 413 424 * Update read/unread state 414 425 */ 415 - async markAllRead() { 426 + async markAllUnqueuedRead() { 416 427 try { 417 - this.unreadCount = 0 418 - this.rootStore.emitUnreadNotifications(0) 419 428 for (const notif of this.notifications) { 420 - notif.isRead = true 429 + notif.markGroupRead() 421 430 } 422 - await this.rootStore.agent.updateSeenNotifications() 431 + this._countUnread() 432 + if (this.notifications[0]) { 433 + await this.rootStore.agent.updateSeenNotifications( 434 + this.notifications[0].indexedAt, 435 + ) 436 + } 423 437 } catch (e: any) { 424 438 this.rootStore.log.warn('Failed to update notifications read state', e) 425 439 } ··· 472 486 // helper functions 473 487 // = 474 488 489 + async _fetchUntil( 490 + condFn: CondFn, 491 + {breakAt}: {breakAt: 'page' | 'record'}, 492 + ): Promise<ListNotifications.Response> { 493 + const accRes: ListNotifications.Response = { 494 + success: true, 495 + headers: {}, 496 + data: {cursor: undefined, notifications: []}, 497 + } 498 + for (let i = 0; i <= 10; i++) { 499 + const res = await this.rootStore.agent.listNotifications({ 500 + limit: PAGE_SIZE, 501 + cursor: accRes.data.cursor, 502 + }) 503 + accRes.data.cursor = res.data.cursor 504 + 505 + let pageIsDone = false 506 + for (const notif of res.data.notifications) { 507 + if (condFn(notif)) { 508 + if (breakAt === 'record') { 509 + return accRes 510 + } else { 511 + pageIsDone = true 512 + } 513 + } 514 + accRes.data.notifications.push(notif) 515 + } 516 + if (pageIsDone || res.data.notifications.length < PAGE_SIZE) { 517 + return accRes 518 + } 519 + } 520 + return accRes 521 + } 522 + 475 523 async _replaceAll(res: ListNotifications.Response) { 476 524 if (res.data.notifications[0]) { 477 525 this.mostRecentNotificationUri = res.data.notifications[0].uri ··· 482 530 async _appendAll(res: ListNotifications.Response, replace = false) { 483 531 this.loadMoreCursor = res.data.cursor 484 532 this.hasMore = !!this.loadMoreCursor 485 - const promises = [] 486 - const itemModels: NotificationsFeedItemModel[] = [] 487 - for (const item of groupNotifications(res.data.notifications)) { 488 - const itemModel = new NotificationsFeedItemModel( 489 - this.rootStore, 490 - `item-${_idCounter++}`, 491 - item, 492 - ) 493 - if (itemModel.needsAdditionalData) { 494 - promises.push(itemModel.fetchAdditionalData()) 495 - } 496 - itemModels.push(itemModel) 497 - } 498 - await Promise.all(promises).catch(e => { 499 - this.rootStore.log.error( 500 - 'Uncaught failure during notifications-view _appendAll()', 501 - e, 502 - ) 503 - }) 533 + const itemModels = await this._processNotifications(res.data.notifications) 504 534 runInAction(() => { 505 535 if (replace) { 506 536 this.notifications = itemModels ··· 510 540 }) 511 541 } 512 542 513 - async _prependAll(res: ListNotifications.Response) { 543 + async _processNotifications( 544 + items: ListNotifications.Notification[], 545 + ): Promise<NotificationsFeedItemModel[]> { 514 546 const promises = [] 515 547 const itemModels: NotificationsFeedItemModel[] = [] 516 - const dedupedNotifs = res.data.notifications.filter( 517 - n1 => 518 - !this.notifications.find( 519 - n2 => isEq(n1, n2) || n2.additional?.find(n3 => isEq(n1, n3)), 520 - ), 521 - ) 522 - for (const item of groupNotifications(dedupedNotifs)) { 548 + items = items.filter(item => { 549 + return ( 550 + this.rootStore.preferences.getLabelPreference(item.labels).pref !== 551 + 'hide' 552 + ) 553 + }) 554 + for (const item of groupNotifications(items)) { 523 555 const itemModel = new NotificationsFeedItemModel( 524 556 this.rootStore, 525 557 `item-${_idCounter++}`, ··· 532 564 } 533 565 await Promise.all(promises).catch(e => { 534 566 this.rootStore.log.error( 535 - 'Uncaught failure during notifications-view _prependAll()', 567 + 'Uncaught failure during notifications _processNotifications()', 536 568 e, 537 569 ) 538 570 }) 539 - runInAction(() => { 540 - this.notifications = itemModels.concat(this.notifications) 541 - }) 571 + return itemModels 572 + } 573 + 574 + _setQueued(queued: undefined | ListNotifications.Notification[]) { 575 + this.queuedNotifications = queued 542 576 } 543 577 544 - _updateAll(res: ListNotifications.Response) { 545 - for (const item of res.data.notifications) { 546 - const existingItem = this.notifications.find(item2 => isEq(item, item2)) 547 - if (existingItem) { 548 - existingItem.copy(item, true) 549 - } 578 + _countUnread() { 579 + let unread = 0 580 + for (const notif of this.notifications) { 581 + unread += notif.numUnreadInGroup 582 + } 583 + if (this.queuedNotifications) { 584 + unread += this.queuedNotifications.length 550 585 } 586 + this.unreadCount = unread 587 + this.rootStore.emitUnreadNotifications(unread) 551 588 } 552 589 } 553 590
+1 -1
src/state/models/me.ts
··· 119 119 await this.fetchProfile() 120 120 await this.fetchInviteCodes() 121 121 } 122 - await this.notifications.loadUnreadCount() 122 + await this.notifications.syncQueue() 123 123 } 124 124 125 125 async fetchProfile() {
+63
src/state/models/ui/preferences.ts
··· 1 1 import {makeAutoObservable} from 'mobx' 2 2 import {getLocales} from 'expo-localization' 3 3 import {isObj, hasProp} from 'lib/type-guards' 4 + import {ComAtprotoLabelDefs} from '@atproto/api' 5 + import {getLabelValueGroup} from 'lib/labeling/helpers' 6 + import { 7 + LabelValGroup, 8 + UNKNOWN_LABEL_GROUP, 9 + ILLEGAL_LABEL_GROUP, 10 + } from 'lib/labeling/const' 4 11 5 12 const deviceLocales = getLocales() 6 13 14 + export type LabelPreference = 'show' | 'warn' | 'hide' 15 + 16 + export class LabelPreferencesModel { 17 + nsfw: LabelPreference = 'warn' 18 + gore: LabelPreference = 'hide' 19 + hate: LabelPreference = 'hide' 20 + spam: LabelPreference = 'hide' 21 + impersonation: LabelPreference = 'warn' 22 + 23 + constructor() { 24 + makeAutoObservable(this, {}, {autoBind: true}) 25 + } 26 + } 27 + 7 28 export class PreferencesModel { 8 29 _contentLanguages: string[] | undefined 30 + contentLabels = new LabelPreferencesModel() 9 31 10 32 constructor() { 11 33 makeAutoObservable(this, {}, {autoBind: true}) ··· 22 44 serialize() { 23 45 return { 24 46 contentLanguages: this._contentLanguages, 47 + contentLabels: this.contentLabels, 25 48 } 26 49 } 27 50 ··· 34 57 ) { 35 58 this._contentLanguages = v.contentLanguages 36 59 } 60 + if (hasProp(v, 'contentLabels') && typeof v.contentLabels === 'object') { 61 + Object.assign(this.contentLabels, v.contentLabels) 62 + } 37 63 } 64 + } 65 + 66 + setContentLabelPref( 67 + key: keyof LabelPreferencesModel, 68 + value: LabelPreference, 69 + ) { 70 + this.contentLabels[key] = value 71 + } 72 + 73 + getLabelPreference(labels: ComAtprotoLabelDefs.Label[] | undefined): { 74 + pref: LabelPreference 75 + desc: LabelValGroup 76 + } { 77 + let res: {pref: LabelPreference; desc: LabelValGroup} = { 78 + pref: 'show', 79 + desc: UNKNOWN_LABEL_GROUP, 80 + } 81 + if (!labels?.length) { 82 + return res 83 + } 84 + for (const label of labels) { 85 + const group = getLabelValueGroup(label.val) 86 + if (group.id === 'illegal') { 87 + return {pref: 'hide', desc: ILLEGAL_LABEL_GROUP} 88 + } else if (group.id === 'unknown') { 89 + continue 90 + } 91 + let pref = this.contentLabels[group.id] 92 + if (pref === 'hide') { 93 + res.pref = 'hide' 94 + res.desc = group 95 + } else if (pref === 'warn' && res.pref === 'show') { 96 + res.pref = 'warn' 97 + res.desc = group 98 + } 99 + } 100 + return res 38 101 } 39 102 }
+5
src/state/models/ui/shell.ts
··· 65 65 name: 'invite-codes' 66 66 } 67 67 68 + export interface ContentFilteringSettingsModal { 69 + name: 'content-filtering-settings' 70 + } 71 + 68 72 export type Modal = 69 73 | ConfirmModal 70 74 | EditProfileModal ··· 77 81 | ChangeHandleModal 78 82 | WaitlistModal 79 83 | InviteCodesModal 84 + | ContentFilteringSettingsModal 80 85 81 86 interface LightboxModel {} 82 87
+1
src/view/com/discover/SuggestedFollows.tsx
··· 31 31 handle={item.handle} 32 32 displayName={item.displayName} 33 33 avatar={item.avatar} 34 + labels={item.labels} 34 35 noBg 35 36 noBorder 36 37 description={
+185
src/view/com/modals/ContentFilteringSettings.tsx
··· 1 + import React from 'react' 2 + import {StyleSheet, TouchableOpacity, View} from 'react-native' 3 + import LinearGradient from 'react-native-linear-gradient' 4 + import {observer} from 'mobx-react-lite' 5 + import {useStores} from 'state/index' 6 + import {LabelPreference} from 'state/models/ui/preferences' 7 + import {s, colors, gradients} from 'lib/styles' 8 + import {Text} from '../util/text/Text' 9 + import {usePalette} from 'lib/hooks/usePalette' 10 + import {CONFIGURABLE_LABEL_GROUPS} from 'lib/labeling/const' 11 + 12 + export const snapPoints = [500] 13 + 14 + export function Component({}: {}) { 15 + const store = useStores() 16 + const pal = usePalette('default') 17 + const onPressDone = React.useCallback(() => { 18 + store.shell.closeModal() 19 + }, [store]) 20 + 21 + return ( 22 + <View testID="reportPostModal" style={[pal.view, styles.container]}> 23 + <Text style={[pal.text, styles.title]}>Content Filtering</Text> 24 + <ContentLabelPref group="nsfw" /> 25 + <ContentLabelPref group="gore" /> 26 + <ContentLabelPref group="hate" /> 27 + <ContentLabelPref group="spam" /> 28 + <ContentLabelPref group="impersonation" /> 29 + <View style={s.flex1} /> 30 + <TouchableOpacity testID="sendReportBtn" onPress={onPressDone}> 31 + <LinearGradient 32 + colors={[gradients.blueLight.start, gradients.blueLight.end]} 33 + start={{x: 0, y: 0}} 34 + end={{x: 1, y: 1}} 35 + style={[styles.btn]}> 36 + <Text style={[s.white, s.bold, s.f18]}>Done</Text> 37 + </LinearGradient> 38 + </TouchableOpacity> 39 + </View> 40 + ) 41 + } 42 + 43 + const ContentLabelPref = observer( 44 + ({group}: {group: keyof typeof CONFIGURABLE_LABEL_GROUPS}) => { 45 + const store = useStores() 46 + const pal = usePalette('default') 47 + return ( 48 + <View style={[styles.contentLabelPref, pal.border]}> 49 + <Text type="md-medium" style={[pal.text]}> 50 + {CONFIGURABLE_LABEL_GROUPS[group].title} 51 + </Text> 52 + <SelectGroup 53 + current={store.preferences.contentLabels[group]} 54 + onChange={v => store.preferences.setContentLabelPref(group, v)} 55 + /> 56 + </View> 57 + ) 58 + }, 59 + ) 60 + 61 + function SelectGroup({ 62 + current, 63 + onChange, 64 + }: { 65 + current: LabelPreference 66 + onChange: (v: LabelPreference) => void 67 + }) { 68 + return ( 69 + <View style={styles.selectableBtns}> 70 + <SelectableBtn 71 + current={current} 72 + value="hide" 73 + label="Hide" 74 + left 75 + onChange={onChange} 76 + /> 77 + <SelectableBtn 78 + current={current} 79 + value="warn" 80 + label="Warn" 81 + onChange={onChange} 82 + /> 83 + <SelectableBtn 84 + current={current} 85 + value="show" 86 + label="Show" 87 + right 88 + onChange={onChange} 89 + /> 90 + </View> 91 + ) 92 + } 93 + 94 + function SelectableBtn({ 95 + current, 96 + value, 97 + label, 98 + left, 99 + right, 100 + onChange, 101 + }: { 102 + current: string 103 + value: LabelPreference 104 + label: string 105 + left?: boolean 106 + right?: boolean 107 + onChange: (v: LabelPreference) => void 108 + }) { 109 + const pal = usePalette('default') 110 + const palPrimary = usePalette('inverted') 111 + return ( 112 + <TouchableOpacity 113 + style={[ 114 + styles.selectableBtn, 115 + left && styles.selectableBtnLeft, 116 + right && styles.selectableBtnRight, 117 + pal.border, 118 + current === value ? palPrimary.view : pal.view, 119 + ]} 120 + onPress={() => onChange(value)}> 121 + <Text style={current === value ? palPrimary.text : pal.text}> 122 + {label} 123 + </Text> 124 + </TouchableOpacity> 125 + ) 126 + } 127 + 128 + const styles = StyleSheet.create({ 129 + container: { 130 + flex: 1, 131 + paddingHorizontal: 10, 132 + paddingBottom: 40, 133 + }, 134 + title: { 135 + textAlign: 'center', 136 + fontWeight: 'bold', 137 + fontSize: 24, 138 + marginBottom: 12, 139 + }, 140 + description: { 141 + paddingHorizontal: 2, 142 + marginBottom: 10, 143 + }, 144 + 145 + contentLabelPref: { 146 + flexDirection: 'row', 147 + justifyContent: 'space-between', 148 + alignItems: 'center', 149 + paddingTop: 10, 150 + paddingLeft: 4, 151 + marginBottom: 10, 152 + borderTopWidth: 1, 153 + }, 154 + 155 + selectableBtns: { 156 + flexDirection: 'row', 157 + }, 158 + selectableBtn: { 159 + flexDirection: 'row', 160 + justifyContent: 'center', 161 + borderWidth: 1, 162 + borderLeftWidth: 0, 163 + paddingHorizontal: 10, 164 + paddingVertical: 10, 165 + }, 166 + selectableBtnLeft: { 167 + borderTopLeftRadius: 8, 168 + borderBottomLeftRadius: 8, 169 + borderLeftWidth: 1, 170 + }, 171 + selectableBtnRight: { 172 + borderTopRightRadius: 8, 173 + borderBottomRightRadius: 8, 174 + }, 175 + 176 + btn: { 177 + flexDirection: 'row', 178 + alignItems: 'center', 179 + justifyContent: 'center', 180 + width: '100%', 181 + borderRadius: 32, 182 + padding: 14, 183 + backgroundColor: colors.gray1, 184 + }, 185 + })
+6 -3
src/view/com/modals/Modal.tsx
··· 1 1 import React, {useRef, useEffect} from 'react' 2 - import {View} from 'react-native' 2 + import {StyleSheet, View} from 'react-native' 3 3 import {observer} from 'mobx-react-lite' 4 4 import BottomSheet from '@gorhom/bottom-sheet' 5 5 import {useStores} from 'state/index' 6 6 import {createCustomBackdrop} from '../util/BottomSheetCustomBackdrop' 7 + import {usePalette} from 'lib/hooks/usePalette' 7 8 8 9 import * as ConfirmModal from './Confirm' 9 10 import * as EditProfileModal from './EditProfile' ··· 15 16 import * as ChangeHandleModal from './ChangeHandle' 16 17 import * as WaitlistModal from './Waitlist' 17 18 import * as InviteCodesModal from './InviteCodes' 18 - import {usePalette} from 'lib/hooks/usePalette' 19 - import {StyleSheet} from 'react-native' 19 + import * as ContentFilteringSettingsModal from './ContentFilteringSettings' 20 20 21 21 const DEFAULT_SNAPPOINTS = ['90%'] 22 22 ··· 77 77 } else if (activeModal?.name === 'invite-codes') { 78 78 snapPoints = InviteCodesModal.snapPoints 79 79 element = <InviteCodesModal.Component /> 80 + } else if (activeModal?.name === 'content-filtering-settings') { 81 + snapPoints = ContentFilteringSettingsModal.snapPoints 82 + element = <ContentFilteringSettingsModal.Component /> 80 83 } else { 81 84 return <View /> 82 85 }
+3
src/view/com/modals/Modal.web.tsx
··· 17 17 import * as ChangeHandleModal from './ChangeHandle' 18 18 import * as WaitlistModal from './Waitlist' 19 19 import * as InviteCodesModal from './InviteCodes' 20 + import * as ContentFilteringSettingsModal from './ContentFilteringSettings' 20 21 21 22 export const ModalsContainer = observer(function ModalsContainer() { 22 23 const store = useStores() ··· 75 76 element = <WaitlistModal.Component /> 76 77 } else if (modal.name === 'invite-codes') { 77 78 element = <InviteCodesModal.Component /> 79 + } else if (modal.name === 'content-filtering-settings') { 80 + element = <ContentFilteringSettingsModal.Component /> 78 81 } else { 79 82 return null 80 83 }
-1
src/view/com/notifications/Feed.tsx
··· 45 45 const onRefresh = React.useCallback(async () => { 46 46 try { 47 47 await view.refresh() 48 - await view.markAllRead() 49 48 } catch (err) { 50 49 view.rootStore.log.error('Failed to refresh notifications feed', err) 51 50 }
+19 -4
src/view/com/notifications/FeedItem.tsx
··· 8 8 View, 9 9 } from 'react-native' 10 10 import {AppBskyEmbedImages} from '@atproto/api' 11 - import {AtUri} from '@atproto/api' 11 + import {AtUri, ComAtprotoLabelDefs} from '@atproto/api' 12 12 import { 13 13 FontAwesomeIcon, 14 14 FontAwesomeIconStyle, ··· 38 38 handle: string 39 39 displayName?: string 40 40 avatar?: string 41 + labels?: ComAtprotoLabelDefs.Label[] 41 42 } 42 43 43 44 export const FeedItem = observer(function FeedItem({ ··· 129 130 handle: item.author.handle, 130 131 displayName: item.author.displayName, 131 132 avatar: item.author.avatar, 133 + labels: item.author.labels, 132 134 }, 133 135 ] 134 136 if (item.additional?.length) { ··· 138 140 handle: item2.author.handle, 139 141 displayName: item2.author.displayName, 140 142 avatar: item2.author.avatar, 143 + labels: item.author.labels, 141 144 })), 142 145 ) 143 146 } ··· 255 258 href={authors[0].href} 256 259 title={`@${authors[0].handle}`} 257 260 asAnchor> 258 - <UserAvatar size={35} avatar={authors[0].avatar} /> 261 + <UserAvatar 262 + size={35} 263 + avatar={authors[0].avatar} 264 + hasWarning={!!authors[0].labels?.length} 265 + /> 259 266 </Link> 260 267 </View> 261 268 ) ··· 264 271 <View style={styles.avis}> 265 272 {authors.slice(0, MAX_AUTHORS).map(author => ( 266 273 <View key={author.href} style={s.mr5}> 267 - <UserAvatar size={35} avatar={author.avatar} /> 274 + <UserAvatar 275 + size={35} 276 + avatar={author.avatar} 277 + hasWarning={!!author.labels?.length} 278 + /> 268 279 </View> 269 280 ))} 270 281 {authors.length > MAX_AUTHORS ? ( ··· 317 328 style={styles.expandedAuthor} 318 329 asAnchor> 319 330 <View style={styles.expandedAuthorAvi}> 320 - <UserAvatar size={35} avatar={author.avatar} /> 331 + <UserAvatar 332 + size={35} 333 + avatar={author.avatar} 334 + hasWarning={!!author.labels?.length} 335 + /> 321 336 </View> 322 337 <View style={s.flex1}> 323 338 <Text
+1
src/view/com/post-thread/PostLikedBy.tsx
··· 53 53 handle={item.actor.handle} 54 54 displayName={item.actor.displayName} 55 55 avatar={item.actor.avatar} 56 + labels={item.actor.labels} 56 57 isFollowedBy={!!item.actor.viewer?.followedBy} 57 58 /> 58 59 )
+1
src/view/com/post-thread/PostRepostedBy.tsx
··· 64 64 handle={item.handle} 65 65 displayName={item.displayName} 66 66 avatar={item.avatar} 67 + labels={item.labels} 67 68 isFollowedBy={!!item.viewer?.followedBy} 68 69 /> 69 70 )
+56 -32
src/view/com/post-thread/PostThreadItem.tsx
··· 22 22 import {PostMeta} from '../util/PostMeta' 23 23 import {PostEmbeds} from '../util/post-embeds' 24 24 import {PostCtrls} from '../util/PostCtrls' 25 - import {PostMutedWrapper} from '../util/PostMuted' 25 + import {PostHider} from '../util/moderation/PostHider' 26 + import {ContentHider} from '../util/moderation/ContentHider' 26 27 import {ErrorMessage} from '../util/error/ErrorMessage' 27 28 import {usePalette} from 'lib/hooks/usePalette' 28 29 ··· 137 138 <View style={styles.layout}> 138 139 <View style={styles.layoutAvi}> 139 140 <Link href={authorHref} title={authorTitle} asAnchor> 140 - <UserAvatar size={52} avatar={item.post.author.avatar} /> 141 + <UserAvatar 142 + size={52} 143 + avatar={item.post.author.avatar} 144 + hasWarning={!!item.post.author.labels?.length} 145 + /> 141 146 </Link> 142 147 </View> 143 148 <View style={styles.layoutContent}> ··· 193 198 </View> 194 199 </View> 195 200 <View style={[s.pl10, s.pr10, s.pb10]}> 196 - {item.richText?.text ? ( 197 - <View 198 - style={[styles.postTextContainer, styles.postTextLargeContainer]}> 199 - <RichText 200 - type="post-text-lg" 201 - richText={item.richText} 202 - lineHeight={1.3} 203 - /> 204 - </View> 205 - ) : undefined} 206 - <PostEmbeds embed={item.post.embed} style={s.mb10} /> 201 + <ContentHider 202 + isMuted={item.post.author.viewer?.muted === true} 203 + labels={item.post.labels}> 204 + {item.richText?.text ? ( 205 + <View 206 + style={[ 207 + styles.postTextContainer, 208 + styles.postTextLargeContainer, 209 + ]}> 210 + <RichText 211 + type="post-text-lg" 212 + richText={item.richText} 213 + lineHeight={1.3} 214 + /> 215 + </View> 216 + ) : undefined} 217 + <PostEmbeds embed={item.post.embed} style={s.mb10} /> 218 + </ContentHider> 207 219 {item._isHighlightedPost && hasEngagement ? ( 208 220 <View style={[styles.expandedInfo, pal.border]}> 209 221 {item.post.repostCount ? ( ··· 270 282 ) 271 283 } else { 272 284 return ( 273 - <PostMutedWrapper isMuted={item.post.author.viewer?.muted === true}> 274 - <Link 285 + <> 286 + <PostHider 275 287 testID={`postThreadItem-by-${item.post.author.handle}`} 276 - style={[styles.outer, {borderTopColor: pal.colors.border}, pal.view]} 277 288 href={itemHref} 278 - title={itemTitle} 279 - noFeedback> 289 + style={[styles.outer, {borderColor: pal.colors.border}, pal.view]} 290 + isMuted={item.post.author.viewer?.muted === true} 291 + labels={item.post.labels}> 280 292 {item._showParentReplyLine && ( 281 293 <View 282 294 style={[ ··· 296 308 <View style={styles.layout}> 297 309 <View style={styles.layoutAvi}> 298 310 <Link href={authorHref} title={authorTitle} asAnchor> 299 - <UserAvatar size={52} avatar={item.post.author.avatar} /> 311 + <UserAvatar 312 + size={52} 313 + avatar={item.post.author.avatar} 314 + hasWarning={!!item.post.author.labels?.length} 315 + /> 300 316 </Link> 301 317 </View> 302 318 <View style={styles.layoutContent}> 303 319 <PostMeta 304 320 authorHandle={item.post.author.handle} 305 321 authorDisplayName={item.post.author.displayName} 322 + authorHasWarning={!!item.post.author.labels?.length} 306 323 timestamp={item.post.indexedAt} 307 324 postHref={itemHref} 308 325 did={item.post.author.did} 309 326 /> 310 - {item.richText?.text ? ( 311 - <View style={styles.postTextContainer}> 312 - <RichText 313 - type="post-text" 314 - richText={item.richText} 315 - style={pal.text} 316 - lineHeight={1.3} 317 - /> 318 - </View> 319 - ) : undefined} 320 - <PostEmbeds embed={item.post.embed} style={s.mb10} /> 327 + <ContentHider 328 + labels={item.post.labels} 329 + containerStyle={styles.contentHider}> 330 + {item.richText?.text ? ( 331 + <View style={styles.postTextContainer}> 332 + <RichText 333 + type="post-text" 334 + richText={item.richText} 335 + style={pal.text} 336 + lineHeight={1.3} 337 + /> 338 + </View> 339 + ) : undefined} 340 + <PostEmbeds embed={item.post.embed} style={s.mb10} /> 341 + </ContentHider> 321 342 <PostCtrls 322 343 itemUri={itemUri} 323 344 itemCid={itemCid} ··· 345 366 /> 346 367 </View> 347 368 </View> 348 - </Link> 369 + </PostHider> 349 370 {item._hasMore ? ( 350 371 <Link 351 372 style={[ ··· 364 385 /> 365 386 </Link> 366 387 ) : undefined} 367 - </PostMutedWrapper> 388 + </> 368 389 ) 369 390 } 370 391 }) ··· 432 453 postTextLargeContainer: { 433 454 paddingHorizontal: 0, 434 455 paddingBottom: 10, 456 + }, 457 + contentHider: { 458 + marginTop: 4, 435 459 }, 436 460 expandedInfo: { 437 461 flexDirection: 'row',
+135 -82
src/view/com/post/Post.tsx
··· 7 7 View, 8 8 ViewStyle, 9 9 } from 'react-native' 10 + import {AppBskyFeedPost as FeedPost} from '@atproto/api' 10 11 import {observer} from 'mobx-react-lite' 11 12 import Clipboard from '@react-native-clipboard/clipboard' 12 13 import {AtUri} from '@atproto/api' 13 14 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 14 - import {PostThreadModel} from 'state/models/content/post-thread' 15 + import { 16 + PostThreadModel, 17 + PostThreadItemModel, 18 + } from 'state/models/content/post-thread' 15 19 import {Link} from '../util/Link' 16 20 import {UserInfoText} from '../util/UserInfoText' 17 21 import {PostMeta} from '../util/PostMeta' 18 22 import {PostEmbeds} from '../util/post-embeds' 19 23 import {PostCtrls} from '../util/PostCtrls' 20 - import {PostMutedWrapper} from '../util/PostMuted' 24 + import {PostHider} from '../util/moderation/PostHider' 25 + import {ContentHider} from '../util/moderation/ContentHider' 21 26 import {Text} from '../util/text/Text' 22 27 import {RichText} from '../util/text/RichText' 23 28 import * as Toast from '../util/Toast' ··· 61 66 62 67 // loading 63 68 // = 64 - if (!view || view.isLoading || view.params.uri !== uri) { 69 + if ( 70 + !view || 71 + (!view.hasContent && view.isLoading) || 72 + view.params.uri !== uri 73 + ) { 65 74 return ( 66 75 <View style={pal.view}> 67 76 <ActivityIndicator /> ··· 84 93 85 94 // loaded 86 95 // = 87 - const item = view.thread 88 - const record = view.thread.postRecord 96 + 97 + return ( 98 + <PostLoaded 99 + item={view.thread} 100 + record={view.thread.postRecord} 101 + setDeleted={setDeleted} 102 + showReplyLine={showReplyLine} 103 + style={style} 104 + /> 105 + ) 106 + }) 107 + 108 + const PostLoaded = observer( 109 + ({ 110 + item, 111 + record, 112 + setDeleted, 113 + showReplyLine, 114 + style, 115 + }: { 116 + item: PostThreadItemModel 117 + record: FeedPost.Record 118 + setDeleted: (v: boolean) => void 119 + showReplyLine?: boolean 120 + style?: StyleProp<ViewStyle> 121 + }) => { 122 + const pal = usePalette('default') 123 + const store = useStores() 124 + 125 + const itemUri = item.post.uri 126 + const itemCid = item.post.cid 127 + const itemUrip = new AtUri(item.post.uri) 128 + const itemHref = `/profile/${item.post.author.handle}/post/${itemUrip.rkey}` 129 + const itemTitle = `Post by ${item.post.author.handle}` 130 + const authorHref = `/profile/${item.post.author.handle}` 131 + const authorTitle = item.post.author.handle 132 + let replyAuthorDid = '' 133 + if (record.reply) { 134 + const urip = new AtUri(record.reply.parent?.uri || record.reply.root.uri) 135 + replyAuthorDid = urip.hostname 136 + } 137 + const onPressReply = React.useCallback(() => { 138 + store.shell.openComposer({ 139 + replyTo: { 140 + uri: item.post.uri, 141 + cid: item.post.cid, 142 + text: record.text as string, 143 + author: { 144 + handle: item.post.author.handle, 145 + displayName: item.post.author.displayName, 146 + avatar: item.post.author.avatar, 147 + }, 148 + }, 149 + }) 150 + }, [store, item, record]) 151 + 152 + const onPressToggleRepost = React.useCallback(() => { 153 + return item 154 + .toggleRepost() 155 + .catch(e => store.log.error('Failed to toggle repost', e)) 156 + }, [item, store]) 89 157 90 - const itemUri = item.post.uri 91 - const itemCid = item.post.cid 92 - const itemUrip = new AtUri(item.post.uri) 93 - const itemHref = `/profile/${item.post.author.handle}/post/${itemUrip.rkey}` 94 - const itemTitle = `Post by ${item.post.author.handle}` 95 - const authorHref = `/profile/${item.post.author.handle}` 96 - const authorTitle = item.post.author.handle 97 - let replyAuthorDid = '' 98 - if (record.reply) { 99 - const urip = new AtUri(record.reply.parent?.uri || record.reply.root.uri) 100 - replyAuthorDid = urip.hostname 101 - } 102 - const onPressReply = () => { 103 - store.shell.openComposer({ 104 - replyTo: { 105 - uri: item.post.uri, 106 - cid: item.post.cid, 107 - text: record.text as string, 108 - author: { 109 - handle: item.post.author.handle, 110 - displayName: item.post.author.displayName, 111 - avatar: item.post.author.avatar, 158 + const onPressToggleLike = React.useCallback(() => { 159 + return item 160 + .toggleLike() 161 + .catch(e => store.log.error('Failed to toggle like', e)) 162 + }, [item, store]) 163 + 164 + const onCopyPostText = React.useCallback(() => { 165 + Clipboard.setString(record.text) 166 + Toast.show('Copied to clipboard') 167 + }, [record]) 168 + 169 + const onOpenTranslate = React.useCallback(() => { 170 + Linking.openURL( 171 + encodeURI( 172 + `https://translate.google.com/#auto|en|${record?.text || ''}`, 173 + ), 174 + ) 175 + }, [record]) 176 + 177 + const onDeletePost = React.useCallback(() => { 178 + item.delete().then( 179 + () => { 180 + setDeleted(true) 181 + Toast.show('Post deleted') 182 + }, 183 + e => { 184 + store.log.error('Failed to delete post', e) 185 + Toast.show('Failed to delete post, please try again') 112 186 }, 113 - }, 114 - }) 115 - } 116 - const onPressToggleRepost = () => { 117 - return item 118 - .toggleRepost() 119 - .catch(e => store.log.error('Failed to toggle repost', e)) 120 - } 121 - const onPressToggleLike = () => { 122 - return item 123 - .toggleLike() 124 - .catch(e => store.log.error('Failed to toggle like', e)) 125 - } 126 - const onCopyPostText = () => { 127 - Clipboard.setString(record.text) 128 - Toast.show('Copied to clipboard') 129 - } 130 - const onOpenTranslate = () => { 131 - Linking.openURL( 132 - encodeURI(`https://translate.google.com/#auto|en|${record?.text || ''}`), 133 - ) 134 - } 135 - const onDeletePost = () => { 136 - item.delete().then( 137 - () => { 138 - setDeleted(true) 139 - Toast.show('Post deleted') 140 - }, 141 - e => { 142 - store.log.error('Failed to delete post', e) 143 - Toast.show('Failed to delete post, please try again') 144 - }, 145 - ) 146 - } 187 + ) 188 + }, [item, setDeleted, store]) 147 189 148 - return ( 149 - <PostMutedWrapper isMuted={item.post.author.viewer?.muted === true}> 150 - <Link 151 - style={[styles.outer, pal.view, pal.border, style]} 190 + return ( 191 + <PostHider 152 192 href={itemHref} 153 - title={itemTitle} 154 - noFeedback> 193 + style={[styles.outer, pal.view, pal.border, style]} 194 + isMuted={item.post.author.viewer?.muted === true} 195 + labels={item.post.labels}> 155 196 {showReplyLine && <View style={styles.replyLine} />} 156 197 <View style={styles.layout}> 157 198 <View style={styles.layoutAvi}> 158 199 <Link href={authorHref} title={authorTitle} asAnchor> 159 - <UserAvatar size={52} avatar={item.post.author.avatar} /> 200 + <UserAvatar 201 + size={52} 202 + avatar={item.post.author.avatar} 203 + hasWarning={!!item.post.author.labels?.length} 204 + /> 160 205 </Link> 161 206 </View> 162 207 <View style={styles.layoutContent}> 163 208 <PostMeta 164 209 authorHandle={item.post.author.handle} 165 210 authorDisplayName={item.post.author.displayName} 211 + authorHasWarning={!!item.post.author.labels?.length} 166 212 timestamp={item.post.indexedAt} 167 213 postHref={itemHref} 168 214 did={item.post.author.did} ··· 185 231 /> 186 232 </View> 187 233 )} 188 - {item.richText?.text ? ( 189 - <View style={styles.postTextContainer}> 190 - <RichText 191 - type="post-text" 192 - richText={item.richText} 193 - lineHeight={1.3} 194 - /> 195 - </View> 196 - ) : undefined} 197 - <PostEmbeds embed={item.post.embed} style={s.mb10} /> 234 + <ContentHider 235 + labels={item.post.labels} 236 + containerStyle={styles.contentHider}> 237 + {item.richText?.text ? ( 238 + <View style={styles.postTextContainer}> 239 + <RichText 240 + type="post-text" 241 + richText={item.richText} 242 + lineHeight={1.3} 243 + /> 244 + </View> 245 + ) : undefined} 246 + <PostEmbeds embed={item.post.embed} style={s.mb10} /> 247 + </ContentHider> 198 248 <PostCtrls 199 249 itemUri={itemUri} 200 250 itemCid={itemCid} ··· 222 272 /> 223 273 </View> 224 274 </View> 225 - </Link> 226 - </PostMutedWrapper> 227 - ) 228 - }) 275 + </PostHider> 276 + ) 277 + }, 278 + ) 229 279 230 280 const styles = StyleSheet.create({ 231 281 outer: { ··· 256 306 bottom: 0, 257 307 borderLeftWidth: 2, 258 308 borderLeftColor: colors.gray2, 309 + }, 310 + contentHider: { 311 + marginTop: 4, 259 312 }, 260 313 })
+139 -124
src/view/com/posts/FeedItem.tsx
··· 14 14 import {PostMeta} from '../util/PostMeta' 15 15 import {PostCtrls} from '../util/PostCtrls' 16 16 import {PostEmbeds} from '../util/post-embeds' 17 - import {PostMutedWrapper} from '../util/PostMuted' 17 + import {PostHider} from '../util/moderation/PostHider' 18 + import {ContentHider} from '../util/moderation/ContentHider' 18 19 import {RichText} from '../util/text/RichText' 19 20 import * as Toast from '../util/Toast' 20 21 import {UserAvatar} from '../util/UserAvatar' ··· 59 60 return urip.hostname 60 61 }, [record?.reply]) 61 62 62 - const onPressReply = () => { 63 + const onPressReply = React.useCallback(() => { 63 64 track('FeedItem:PostReply') 64 65 store.shell.openComposer({ 65 66 replyTo: { ··· 73 74 }, 74 75 }, 75 76 }) 76 - } 77 - const onPressToggleRepost = () => { 77 + }, [item, track, record, store]) 78 + 79 + const onPressToggleRepost = React.useCallback(() => { 78 80 track('FeedItem:PostRepost') 79 81 return item 80 82 .toggleRepost() 81 83 .catch(e => store.log.error('Failed to toggle repost', e)) 82 - } 83 - const onPressToggleLike = () => { 84 + }, [track, item, store]) 85 + 86 + const onPressToggleLike = React.useCallback(() => { 84 87 track('FeedItem:PostLike') 85 88 return item 86 89 .toggleLike() 87 90 .catch(e => store.log.error('Failed to toggle like', e)) 88 - } 89 - const onCopyPostText = () => { 91 + }, [track, item, store]) 92 + 93 + const onCopyPostText = React.useCallback(() => { 90 94 Clipboard.setString(record?.text || '') 91 95 Toast.show('Copied to clipboard') 92 - } 96 + }, [record]) 97 + 93 98 const onOpenTranslate = React.useCallback(() => { 94 99 Linking.openURL( 95 100 encodeURI(`https://translate.google.com/#auto|en|${record?.text || ''}`), 96 101 ) 97 102 }, [record]) 98 - const onDeletePost = () => { 103 + 104 + const onDeletePost = React.useCallback(() => { 99 105 track('FeedItem:PostDelete') 100 106 item.delete().then( 101 107 () => { ··· 107 113 Toast.show('Failed to delete post, please try again') 108 114 }, 109 115 ) 110 - } 116 + }, [track, item, setDeleted, store]) 111 117 112 118 if (!record || deleted) { 113 119 return <View /> ··· 127 133 ] 128 134 129 135 return ( 130 - <PostMutedWrapper isMuted={isMuted}> 131 - <Link 132 - testID={`feedItem-by-${item.post.author.handle}`} 133 - style={outerStyles} 134 - href={itemHref} 135 - title={itemTitle} 136 - noFeedback> 137 - {isThreadChild && ( 138 - <View 139 - style={[styles.topReplyLine, {borderColor: pal.colors.replyLine}]} 140 - /> 141 - )} 142 - {isThreadParent && ( 143 - <View 136 + <PostHider 137 + testID={`feedItem-by-${item.post.author.handle}`} 138 + style={outerStyles} 139 + href={itemHref} 140 + isMuted={isMuted} 141 + labels={item.post.labels}> 142 + {isThreadChild && ( 143 + <View 144 + style={[styles.topReplyLine, {borderColor: pal.colors.replyLine}]} 145 + /> 146 + )} 147 + {isThreadParent && ( 148 + <View 149 + style={[ 150 + styles.bottomReplyLine, 151 + {borderColor: pal.colors.replyLine}, 152 + isNoTop ? styles.bottomReplyLineNoTop : undefined, 153 + ]} 154 + /> 155 + )} 156 + {item.reasonRepost && ( 157 + <Link 158 + style={styles.includeReason} 159 + href={`/profile/${item.reasonRepost.by.handle}`} 160 + title={sanitizeDisplayName( 161 + item.reasonRepost.by.displayName || item.reasonRepost.by.handle, 162 + )}> 163 + <FontAwesomeIcon 164 + icon="retweet" 144 165 style={[ 145 - styles.bottomReplyLine, 146 - {borderColor: pal.colors.replyLine}, 147 - isNoTop ? styles.bottomReplyLineNoTop : undefined, 166 + styles.includeReasonIcon, 167 + {color: pal.colors.textLight} as FontAwesomeIconStyle, 148 168 ]} 149 169 /> 150 - )} 151 - {item.reasonRepost && ( 152 - <Link 153 - style={styles.includeReason} 154 - href={`/profile/${item.reasonRepost.by.handle}`} 155 - title={sanitizeDisplayName( 156 - item.reasonRepost.by.displayName || item.reasonRepost.by.handle, 157 - )}> 158 - <FontAwesomeIcon 159 - icon="retweet" 160 - style={[ 161 - styles.includeReasonIcon, 162 - {color: pal.colors.textLight} as FontAwesomeIconStyle, 163 - ]} 164 - /> 165 - <Text 170 + <Text 171 + type="sm-bold" 172 + style={pal.textLight} 173 + lineHeight={1.2} 174 + numberOfLines={1}> 175 + Reposted by{' '} 176 + <DesktopWebTextLink 166 177 type="sm-bold" 167 178 style={pal.textLight} 168 179 lineHeight={1.2} 169 - numberOfLines={1}> 170 - Reposted by{' '} 171 - <DesktopWebTextLink 172 - type="sm-bold" 173 - style={pal.textLight} 174 - lineHeight={1.2} 175 - numberOfLines={1} 176 - text={sanitizeDisplayName( 177 - item.reasonRepost.by.displayName || 178 - item.reasonRepost.by.handle, 179 - )} 180 - href={`/profile/${item.reasonRepost.by.handle}`} 180 + numberOfLines={1} 181 + text={sanitizeDisplayName( 182 + item.reasonRepost.by.displayName || item.reasonRepost.by.handle, 183 + )} 184 + href={`/profile/${item.reasonRepost.by.handle}`} 185 + /> 186 + </Text> 187 + </Link> 188 + )} 189 + <View style={styles.layout}> 190 + <View style={styles.layoutAvi}> 191 + <Link href={authorHref} title={item.post.author.handle} asAnchor> 192 + <UserAvatar 193 + size={52} 194 + avatar={item.post.author.avatar} 195 + hasWarning={!!item.post.author.labels?.length} 196 + /> 197 + </Link> 198 + </View> 199 + <View style={styles.layoutContent}> 200 + <PostMeta 201 + authorHandle={item.post.author.handle} 202 + authorDisplayName={item.post.author.displayName} 203 + authorHasWarning={!!item.post.author.labels?.length} 204 + timestamp={item.post.indexedAt} 205 + postHref={itemHref} 206 + did={item.post.author.did} 207 + showFollowBtn={showFollowBtn} 208 + /> 209 + {!isThreadChild && replyAuthorDid !== '' && ( 210 + <View style={[s.flexRow, s.mb2, s.alignCenter]}> 211 + <FontAwesomeIcon 212 + icon="reply" 213 + size={9} 214 + style={[ 215 + {color: pal.colors.textLight} as FontAwesomeIconStyle, 216 + s.mr5, 217 + ]} 181 218 /> 182 - </Text> 183 - </Link> 184 - )} 185 - <View style={styles.layout}> 186 - <View style={styles.layoutAvi}> 187 - <Link href={authorHref} title={item.post.author.handle} asAnchor> 188 - <UserAvatar size={52} avatar={item.post.author.avatar} /> 189 - </Link> 190 - </View> 191 - <View style={styles.layoutContent}> 192 - <PostMeta 193 - authorHandle={item.post.author.handle} 194 - authorDisplayName={item.post.author.displayName} 195 - timestamp={item.post.indexedAt} 196 - postHref={itemHref} 197 - did={item.post.author.did} 198 - showFollowBtn={showFollowBtn} 199 - /> 200 - {!isThreadChild && replyAuthorDid !== '' && ( 201 - <View style={[s.flexRow, s.mb2, s.alignCenter]}> 202 - <FontAwesomeIcon 203 - icon="reply" 204 - size={9} 205 - style={[ 206 - {color: pal.colors.textLight} as FontAwesomeIconStyle, 207 - s.mr5, 208 - ]} 209 - /> 210 - <Text type="md" style={[pal.textLight, s.mr2]} lineHeight={1.2}> 211 - Reply to 212 - </Text> 213 - <UserInfoText 214 - type="md" 215 - did={replyAuthorDid} 216 - attr="displayName" 217 - style={[pal.textLight, s.ml2]} 218 - /> 219 - </View> 220 - )} 219 + <Text type="md" style={[pal.textLight, s.mr2]} lineHeight={1.2}> 220 + Reply to 221 + </Text> 222 + <UserInfoText 223 + type="md" 224 + did={replyAuthorDid} 225 + attr="displayName" 226 + style={[pal.textLight, s.ml2]} 227 + /> 228 + </View> 229 + )} 230 + <ContentHider 231 + labels={item.post.labels} 232 + containerStyle={styles.contentHider}> 221 233 {item.richText?.text ? ( 222 234 <View style={styles.postTextContainer}> 223 235 <RichText ··· 228 240 </View> 229 241 ) : undefined} 230 242 <PostEmbeds embed={item.post.embed} style={styles.embed} /> 231 - <PostCtrls 232 - style={styles.ctrls} 233 - itemUri={itemUri} 234 - itemCid={itemCid} 235 - itemHref={itemHref} 236 - itemTitle={itemTitle} 237 - author={{ 238 - avatar: item.post.author.avatar!, 239 - handle: item.post.author.handle, 240 - displayName: item.post.author.displayName!, 241 - }} 242 - text={item.richText?.text || record.text} 243 - indexedAt={item.post.indexedAt} 244 - isAuthor={item.post.author.did === store.me.did} 245 - replyCount={item.post.replyCount} 246 - repostCount={item.post.repostCount} 247 - likeCount={item.post.likeCount} 248 - isReposted={!!item.post.viewer?.repost} 249 - isLiked={!!item.post.viewer?.like} 250 - onPressReply={onPressReply} 251 - onPressToggleRepost={onPressToggleRepost} 252 - onPressToggleLike={onPressToggleLike} 253 - onCopyPostText={onCopyPostText} 254 - onOpenTranslate={onOpenTranslate} 255 - onDeletePost={onDeletePost} 256 - /> 257 - </View> 243 + </ContentHider> 244 + <PostCtrls 245 + style={styles.ctrls} 246 + itemUri={itemUri} 247 + itemCid={itemCid} 248 + itemHref={itemHref} 249 + itemTitle={itemTitle} 250 + author={{ 251 + avatar: item.post.author.avatar!, 252 + handle: item.post.author.handle, 253 + displayName: item.post.author.displayName!, 254 + }} 255 + text={item.richText?.text || record.text} 256 + indexedAt={item.post.indexedAt} 257 + isAuthor={item.post.author.did === store.me.did} 258 + replyCount={item.post.replyCount} 259 + repostCount={item.post.repostCount} 260 + likeCount={item.post.likeCount} 261 + isReposted={!!item.post.viewer?.repost} 262 + isLiked={!!item.post.viewer?.like} 263 + onPressReply={onPressReply} 264 + onPressToggleRepost={onPressToggleRepost} 265 + onPressToggleLike={onPressToggleLike} 266 + onCopyPostText={onCopyPostText} 267 + onOpenTranslate={onOpenTranslate} 268 + onDeletePost={onDeletePost} 269 + /> 258 270 </View> 259 - </Link> 260 - </PostMutedWrapper> 271 + </View> 272 + </PostHider> 261 273 ) 262 274 }) 263 275 ··· 319 331 alignItems: 'center', 320 332 flexWrap: 'wrap', 321 333 paddingBottom: 4, 334 + }, 335 + contentHider: { 336 + marginTop: 4, 322 337 }, 323 338 embed: { 324 339 marginBottom: 6,
+7 -2
src/view/com/profile/ProfileCard.tsx
··· 1 1 import React from 'react' 2 2 import {StyleSheet, View} from 'react-native' 3 3 import {observer} from 'mobx-react-lite' 4 - import {AppBskyActorDefs} from '@atproto/api' 4 + import {AppBskyActorDefs, ComAtprotoLabelDefs} from '@atproto/api' 5 5 import {Link} from '../util/Link' 6 6 import {Text} from '../util/text/Text' 7 7 import {UserAvatar} from '../util/UserAvatar' ··· 17 17 displayName, 18 18 avatar, 19 19 description, 20 + labels, 20 21 isFollowedBy, 21 22 noBg, 22 23 noBorder, ··· 28 29 displayName?: string 29 30 avatar?: string 30 31 description?: string 32 + labels: ComAtprotoLabelDefs.Label[] | undefined 31 33 isFollowedBy?: boolean 32 34 noBg?: boolean 33 35 noBorder?: boolean ··· 50 52 asAnchor> 51 53 <View style={styles.layout}> 52 54 <View style={styles.layoutAvi}> 53 - <UserAvatar size={40} avatar={avatar} /> 55 + <UserAvatar size={40} avatar={avatar} hasWarning={!!labels?.length} /> 54 56 </View> 55 57 <View style={styles.layoutContent}> 56 58 <Text ··· 114 116 displayName, 115 117 avatar, 116 118 description, 119 + labels, 117 120 isFollowedBy, 118 121 noBg, 119 122 noBorder, ··· 124 127 displayName?: string 125 128 avatar?: string 126 129 description?: string 130 + labels: ComAtprotoLabelDefs.Label[] | undefined 127 131 isFollowedBy?: boolean 128 132 noBg?: boolean 129 133 noBorder?: boolean ··· 138 142 displayName={displayName} 139 143 avatar={avatar} 140 144 description={description} 145 + labels={labels} 141 146 isFollowedBy={isFollowedBy} 142 147 noBg={noBg} 143 148 noBorder={noBorder}
+1
src/view/com/profile/ProfileFollowers.tsx
··· 67 67 handle={item.handle} 68 68 displayName={item.displayName} 69 69 avatar={item.avatar} 70 + labels={item.labels} 70 71 isFollowedBy={!!item.viewer?.followedBy} 71 72 /> 72 73 )
+1
src/view/com/profile/ProfileFollows.tsx
··· 64 64 handle={item.handle} 65 65 displayName={item.displayName} 66 66 avatar={item.avatar} 67 + labels={item.labels} 67 68 isFollowedBy={!!item.viewer?.followedBy} 68 69 /> 69 70 )
+7 -1
src/view/com/profile/ProfileHeader.tsx
··· 27 27 import {RichText} from '../util/text/RichText' 28 28 import {UserAvatar} from '../util/UserAvatar' 29 29 import {UserBanner} from '../util/UserBanner' 30 + import {ProfileHeaderLabels} from '../util/moderation/ProfileHeaderLabels' 30 31 import {usePalette} from 'lib/hooks/usePalette' 31 32 import {useAnalytics} from 'lib/analytics' 32 33 import {NavigationProp} from 'lib/routes/types' ··· 320 321 richText={view.descriptionRichText} 321 322 /> 322 323 ) : undefined} 324 + <ProfileHeaderLabels labels={view.labels} /> 323 325 {view.viewer.muted ? ( 324 326 <View 325 327 testID="profileHeaderMutedNotice" ··· 348 350 onPress={onPressAvi}> 349 351 <View 350 352 style={[pal.view, {borderColor: pal.colors.background}, styles.avi]}> 351 - <UserAvatar size={80} avatar={view.avatar} /> 353 + <UserAvatar 354 + size={80} 355 + avatar={view.avatar} 356 + hasWarning={!!view.labels?.length} 357 + /> 352 358 </View> 353 359 </TouchableWithoutFeedback> 354 360 </View>
+1
src/view/com/search/SearchResults.tsx
··· 101 101 displayName={item.displayName} 102 102 avatar={item.avatar} 103 103 description={item.description} 104 + labels={item.labels} 104 105 /> 105 106 ))} 106 107 <View style={s.footerSpacer} />
+27 -25
src/view/com/util/LoadLatestBtn.tsx
··· 10 10 11 11 const HITSLOP = {left: 20, top: 20, right: 20, bottom: 20} 12 12 13 - export const LoadLatestBtn = observer(({onPress}: {onPress: () => void}) => { 14 - const store = useStores() 15 - const safeAreaInsets = useSafeAreaInsets() 16 - return ( 17 - <TouchableOpacity 18 - style={[ 19 - styles.loadLatest, 20 - !store.shell.minimalShellMode && { 21 - bottom: 60 + clamp(safeAreaInsets.bottom, 15, 30), 22 - }, 23 - ]} 24 - onPress={onPress} 25 - hitSlop={HITSLOP}> 26 - <LinearGradient 27 - colors={[gradients.blueLight.start, gradients.blueLight.end]} 28 - start={{x: 0, y: 0}} 29 - end={{x: 1, y: 1}} 30 - style={styles.loadLatestInner}> 31 - <Text type="md-bold" style={styles.loadLatestText}> 32 - Load new posts 33 - </Text> 34 - </LinearGradient> 35 - </TouchableOpacity> 36 - ) 37 - }) 13 + export const LoadLatestBtn = observer( 14 + ({onPress, label}: {onPress: () => void; label: string}) => { 15 + const store = useStores() 16 + const safeAreaInsets = useSafeAreaInsets() 17 + return ( 18 + <TouchableOpacity 19 + style={[ 20 + styles.loadLatest, 21 + !store.shell.minimalShellMode && { 22 + bottom: 60 + clamp(safeAreaInsets.bottom, 15, 30), 23 + }, 24 + ]} 25 + onPress={onPress} 26 + hitSlop={HITSLOP}> 27 + <LinearGradient 28 + colors={[gradients.blueLight.start, gradients.blueLight.end]} 29 + start={{x: 0, y: 0}} 30 + end={{x: 1, y: 1}} 31 + style={styles.loadLatestInner}> 32 + <Text type="md-bold" style={styles.loadLatestText}> 33 + Load new {label} 34 + </Text> 35 + </LinearGradient> 36 + </TouchableOpacity> 37 + ) 38 + }, 39 + ) 38 40 39 41 const styles = StyleSheet.create({ 40 42 loadLatest: {
+11 -3
src/view/com/util/LoadLatestBtn.web.tsx
··· 6 6 7 7 const HITSLOP = {left: 20, top: 20, right: 20, bottom: 20} 8 8 9 - export const LoadLatestBtn = ({onPress}: {onPress: () => void}) => { 9 + export const LoadLatestBtn = ({ 10 + onPress, 11 + label, 12 + }: { 13 + onPress: () => void 14 + label: string 15 + }) => { 10 16 const pal = usePalette('default') 11 17 return ( 12 18 <TouchableOpacity ··· 15 21 hitSlop={HITSLOP}> 16 22 <Text type="md-bold" style={pal.text}> 17 23 <UpIcon size={16} strokeWidth={1} style={[pal.text, styles.icon]} /> 18 - Load new posts 24 + Load new {label} 19 25 </Text> 20 26 </TouchableOpacity> 21 27 ) ··· 25 31 loadLatest: { 26 32 flexDirection: 'row', 27 33 position: 'absolute', 28 - left: 'calc(50vw - 80px)', 34 + left: '50vw', 35 + // @ts-ignore web only -prf 36 + transform: 'translateX(-50%)', 29 37 top: 30, 30 38 shadowColor: '#000', 31 39 shadowOpacity: 0.2,
+6 -1
src/view/com/util/PostMeta.tsx
··· 15 15 authorAvatar?: string 16 16 authorHandle: string 17 17 authorDisplayName: string | undefined 18 + authorHasWarning: boolean 18 19 postHref: string 19 20 timestamp: string 20 21 did?: string ··· 93 94 <View style={styles.meta}> 94 95 {typeof opts.authorAvatar !== 'undefined' && ( 95 96 <View style={[styles.metaItem, styles.avatar]}> 96 - <UserAvatar avatar={opts.authorAvatar} size={16} /> 97 + <UserAvatar 98 + avatar={opts.authorAvatar} 99 + size={16} 100 + hasWarning={opts.authorHasWarning} 101 + /> 97 102 </View> 98 103 )} 99 104 <View style={[styles.metaItem, styles.maxWidth]}>
-50
src/view/com/util/PostMuted.tsx
··· 1 - import React from 'react' 2 - import {StyleSheet, TouchableOpacity, View} from 'react-native' 3 - import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 4 - import {usePalette} from 'lib/hooks/usePalette' 5 - import {Text} from './text/Text' 6 - 7 - export function PostMutedWrapper({ 8 - isMuted, 9 - children, 10 - }: React.PropsWithChildren<{isMuted?: boolean}>) { 11 - const pal = usePalette('default') 12 - const [override, setOverride] = React.useState(false) 13 - if (!isMuted || override) { 14 - return <>{children}</> 15 - } 16 - return ( 17 - <View style={[styles.container, pal.view, pal.border]}> 18 - <FontAwesomeIcon 19 - icon={['far', 'eye-slash']} 20 - style={[styles.icon, pal.text]} 21 - /> 22 - <Text type="md" style={pal.textLight}> 23 - Post from an account you muted. 24 - </Text> 25 - <TouchableOpacity 26 - style={styles.showBtn} 27 - onPress={() => setOverride(true)}> 28 - <Text type="md" style={pal.link}> 29 - Show post 30 - </Text> 31 - </TouchableOpacity> 32 - </View> 33 - ) 34 - } 35 - 36 - const styles = StyleSheet.create({ 37 - container: { 38 - flexDirection: 'row', 39 - alignItems: 'center', 40 - paddingVertical: 14, 41 - paddingHorizontal: 18, 42 - borderTopWidth: 1, 43 - }, 44 - icon: { 45 - marginRight: 10, 46 - }, 47 - showBtn: { 48 - marginLeft: 'auto', 49 - }, 50 - })
+40 -7
src/view/com/util/UserAvatar.tsx
··· 44 44 export function UserAvatar({ 45 45 size, 46 46 avatar, 47 + hasWarning, 47 48 onSelectNewAvatar, 48 49 }: { 49 50 size: number 50 51 avatar?: string | null 52 + hasWarning?: boolean 51 53 onSelectNewAvatar?: (img: PickedMedia | null) => void 52 54 }) { 53 55 const store = useStores() ··· 105 107 }, 106 108 }, 107 109 ] 110 + 111 + const warning = React.useMemo(() => { 112 + if (!hasWarning) { 113 + return <></> 114 + } 115 + return ( 116 + <View style={[styles.warningIconContainer, pal.view]}> 117 + <FontAwesomeIcon 118 + icon="exclamation-circle" 119 + style={styles.warningIcon} 120 + size={Math.floor(size / 3)} 121 + /> 122 + </View> 123 + ) 124 + }, [hasWarning, size, pal]) 125 + 108 126 // onSelectNewAvatar is only passed as prop on the EditProfile component 109 127 return onSelectNewAvatar ? ( 110 128 <DropdownButton ··· 137 155 </View> 138 156 </DropdownButton> 139 157 ) : avatar ? ( 140 - <HighPriorityImage 141 - testID="userAvatarImage" 142 - style={{width: size, height: size, borderRadius: Math.floor(size / 2)}} 143 - resizeMode="stretch" 144 - source={{uri: avatar}} 145 - /> 158 + <View style={{width: size, height: size}}> 159 + <HighPriorityImage 160 + testID="userAvatarImage" 161 + style={{width: size, height: size, borderRadius: Math.floor(size / 2)}} 162 + resizeMode="stretch" 163 + source={{uri: avatar}} 164 + /> 165 + {warning} 166 + </View> 146 167 ) : ( 147 - <DefaultAvatar size={size} /> 168 + <View style={{width: size, height: size}}> 169 + <DefaultAvatar size={size} /> 170 + {warning} 171 + </View> 148 172 ) 149 173 } 150 174 ··· 164 188 width: 80, 165 189 height: 80, 166 190 borderRadius: 40, 191 + }, 192 + warningIconContainer: { 193 + position: 'absolute', 194 + right: 0, 195 + bottom: 0, 196 + borderRadius: 100, 197 + }, 198 + warningIcon: { 199 + color: colors.red3, 167 200 }, 168 201 })
+109
src/view/com/util/moderation/ContentHider.tsx
··· 1 + import React from 'react' 2 + import { 3 + StyleProp, 4 + StyleSheet, 5 + TouchableOpacity, 6 + View, 7 + ViewStyle, 8 + } from 'react-native' 9 + import {ComAtprotoLabelDefs} from '@atproto/api' 10 + import {usePalette} from 'lib/hooks/usePalette' 11 + import {useStores} from 'state/index' 12 + import {Text} from '../text/Text' 13 + import {addStyle} from 'lib/styles' 14 + 15 + export function ContentHider({ 16 + testID, 17 + isMuted, 18 + labels, 19 + style, 20 + containerStyle, 21 + children, 22 + }: React.PropsWithChildren<{ 23 + testID?: string 24 + isMuted?: boolean 25 + labels: ComAtprotoLabelDefs.Label[] | undefined 26 + style?: StyleProp<ViewStyle> 27 + containerStyle?: StyleProp<ViewStyle> 28 + }>) { 29 + const pal = usePalette('default') 30 + const [override, setOverride] = React.useState(false) 31 + const store = useStores() 32 + const labelPref = store.preferences.getLabelPreference(labels) 33 + 34 + if (!isMuted && labelPref.pref === 'show') { 35 + return ( 36 + <View testID={testID} style={style}> 37 + {children} 38 + </View> 39 + ) 40 + } 41 + 42 + if (labelPref.pref === 'hide') { 43 + return <></> 44 + } 45 + 46 + return ( 47 + <View style={[styles.container, pal.view, pal.border, containerStyle]}> 48 + <View 49 + style={[ 50 + styles.description, 51 + pal.viewLight, 52 + override && styles.descriptionOpen, 53 + ]}> 54 + <Text type="md" style={pal.textLight}> 55 + {isMuted ? ( 56 + <>Post from an account you muted.</> 57 + ) : ( 58 + <>Warning: {labelPref.desc.title}</> 59 + )} 60 + </Text> 61 + <TouchableOpacity 62 + style={styles.showBtn} 63 + onPress={() => setOverride(v => !v)}> 64 + <Text type="md" style={pal.link}> 65 + {override ? 'Hide' : 'Show'} 66 + </Text> 67 + </TouchableOpacity> 68 + </View> 69 + {override && ( 70 + <View style={[styles.childrenContainer, pal.border]}> 71 + <View testID={testID} style={addStyle(style, styles.child)}> 72 + {children} 73 + </View> 74 + </View> 75 + )} 76 + </View> 77 + ) 78 + } 79 + 80 + const styles = StyleSheet.create({ 81 + container: { 82 + marginBottom: 10, 83 + borderWidth: 1, 84 + borderRadius: 12, 85 + }, 86 + description: { 87 + flexDirection: 'row', 88 + alignItems: 'center', 89 + paddingVertical: 14, 90 + paddingLeft: 14, 91 + paddingRight: 18, 92 + borderRadius: 12, 93 + }, 94 + descriptionOpen: { 95 + borderBottomLeftRadius: 0, 96 + borderBottomRightRadius: 0, 97 + }, 98 + icon: { 99 + marginRight: 10, 100 + }, 101 + showBtn: { 102 + marginLeft: 'auto', 103 + }, 104 + childrenContainer: { 105 + paddingHorizontal: 12, 106 + paddingTop: 8, 107 + }, 108 + child: {}, 109 + })
+105
src/view/com/util/moderation/PostHider.tsx
··· 1 + import React from 'react' 2 + import { 3 + StyleProp, 4 + StyleSheet, 5 + TouchableOpacity, 6 + View, 7 + ViewStyle, 8 + } from 'react-native' 9 + import {ComAtprotoLabelDefs} from '@atproto/api' 10 + import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 11 + import {usePalette} from 'lib/hooks/usePalette' 12 + import {Link} from '../Link' 13 + import {Text} from '../text/Text' 14 + import {addStyle} from 'lib/styles' 15 + import {useStores} from 'state/index' 16 + 17 + export function PostHider({ 18 + testID, 19 + href, 20 + isMuted, 21 + labels, 22 + style, 23 + children, 24 + }: React.PropsWithChildren<{ 25 + testID?: string 26 + href: string 27 + isMuted: boolean | undefined 28 + labels: ComAtprotoLabelDefs.Label[] | undefined 29 + style: StyleProp<ViewStyle> 30 + }>) { 31 + const store = useStores() 32 + const pal = usePalette('default') 33 + const [override, setOverride] = React.useState(false) 34 + const bg = override ? pal.viewLight : pal.view 35 + 36 + const labelPref = store.preferences.getLabelPreference(labels) 37 + if (labelPref.pref === 'hide') { 38 + return <></> 39 + } 40 + 41 + if (!isMuted) { 42 + // NOTE: any further label enforcement should occur in ContentContainer 43 + return ( 44 + <Link testID={testID} style={style} href={href} noFeedback> 45 + {children} 46 + </Link> 47 + ) 48 + } 49 + 50 + return ( 51 + <> 52 + <View style={[styles.description, bg, pal.border]}> 53 + <FontAwesomeIcon 54 + icon={['far', 'eye-slash']} 55 + style={[styles.icon, pal.text]} 56 + /> 57 + <Text type="md" style={pal.textLight}> 58 + Post from an account you muted. 59 + </Text> 60 + <TouchableOpacity 61 + style={styles.showBtn} 62 + onPress={() => setOverride(v => !v)}> 63 + <Text type="md" style={pal.link}> 64 + {override ? 'Hide' : 'Show'} post 65 + </Text> 66 + </TouchableOpacity> 67 + </View> 68 + {override && ( 69 + <View style={[styles.childrenContainer, pal.border, bg]}> 70 + <Link 71 + testID={testID} 72 + style={addStyle(style, styles.child)} 73 + href={href} 74 + noFeedback> 75 + {children} 76 + </Link> 77 + </View> 78 + )} 79 + </> 80 + ) 81 + } 82 + 83 + const styles = StyleSheet.create({ 84 + description: { 85 + flexDirection: 'row', 86 + alignItems: 'center', 87 + paddingVertical: 14, 88 + paddingHorizontal: 18, 89 + borderTopWidth: 1, 90 + }, 91 + icon: { 92 + marginRight: 10, 93 + }, 94 + showBtn: { 95 + marginLeft: 'auto', 96 + }, 97 + childrenContainer: { 98 + paddingHorizontal: 6, 99 + paddingBottom: 6, 100 + }, 101 + child: { 102 + borderWidth: 1, 103 + borderRadius: 12, 104 + }, 105 + })
+55
src/view/com/util/moderation/ProfileHeaderLabels.tsx
··· 1 + import React from 'react' 2 + import {StyleSheet, View} from 'react-native' 3 + import {ComAtprotoLabelDefs} from '@atproto/api' 4 + import { 5 + FontAwesomeIcon, 6 + FontAwesomeIconStyle, 7 + } from '@fortawesome/react-native-fontawesome' 8 + import {Text} from '../text/Text' 9 + import {usePalette} from 'lib/hooks/usePalette' 10 + import {getLabelValueGroup} from 'lib/labeling/helpers' 11 + 12 + export function ProfileHeaderLabels({ 13 + labels, 14 + }: { 15 + labels: ComAtprotoLabelDefs.Label[] | undefined 16 + }) { 17 + const palErr = usePalette('error') 18 + if (!labels?.length) { 19 + return null 20 + } 21 + return ( 22 + <> 23 + {labels.map((label, i) => { 24 + const labelGroup = getLabelValueGroup(label?.val || '') 25 + return ( 26 + <View 27 + key={`${label.val}-${i}`} 28 + style={[styles.container, palErr.border, palErr.view]}> 29 + <FontAwesomeIcon 30 + icon="circle-exclamation" 31 + style={palErr.text as FontAwesomeIconStyle} 32 + size={20} 33 + /> 34 + <Text style={palErr.text}> 35 + This account has been flagged for{' '} 36 + {labelGroup.title.toLocaleLowerCase()}. 37 + </Text> 38 + </View> 39 + ) 40 + })} 41 + </> 42 + ) 43 + } 44 + 45 + const styles = StyleSheet.create({ 46 + container: { 47 + flexDirection: 'row', 48 + alignItems: 'center', 49 + gap: 10, 50 + borderWidth: 1, 51 + borderRadius: 6, 52 + paddingHorizontal: 10, 53 + paddingVertical: 8, 54 + }, 55 + })
+1
src/view/com/util/post-embeds/QuoteEmbed.tsx
··· 42 42 authorAvatar={quote.author.avatar} 43 43 authorHandle={quote.author.handle} 44 44 authorDisplayName={quote.author.displayName} 45 + authorHasWarning={false} 45 46 postHref={itemHref} 46 47 timestamp={quote.indexedAt} 47 48 />
+3 -1
src/view/index.ts
··· 34 34 import {faEllipsis} from '@fortawesome/free-solid-svg-icons/faEllipsis' 35 35 import {faEnvelope} from '@fortawesome/free-solid-svg-icons/faEnvelope' 36 36 import {faExclamation} from '@fortawesome/free-solid-svg-icons/faExclamation' 37 + import {faEye} from '@fortawesome/free-solid-svg-icons/faEye' 37 38 import {faEyeSlash as farEyeSlash} from '@fortawesome/free-regular-svg-icons/faEyeSlash' 38 39 import {faGear} from '@fortawesome/free-solid-svg-icons/faGear' 39 40 import {faGlobe} from '@fortawesome/free-solid-svg-icons/faGlobe' ··· 106 107 faCompass, 107 108 faEllipsis, 108 109 faEnvelope, 110 + faEye, 109 111 faExclamation, 110 - faQuoteLeft, 111 112 farEyeSlash, 112 113 faGear, 113 114 faGlobe, ··· 128 129 faPenNib, 129 130 faPenToSquare, 130 131 faPlus, 132 + faQuoteLeft, 131 133 faReply, 132 134 faRetweet, 133 135 faRss,
+1 -1
src/view/screens/Home.tsx
··· 194 194 headerOffset={HEADER_OFFSET} 195 195 /> 196 196 {feed.hasNewLatest && !feed.isRefreshing && ( 197 - <LoadLatestBtn onPress={onPressLoadLatest} /> 197 + <LoadLatestBtn onPress={onPressLoadLatest} label="posts" /> 198 198 )} 199 199 <FAB 200 200 testID="composeFAB"
+18 -41
src/view/screens/Notifications.tsx
··· 1 - import React, {useEffect} from 'react' 1 + import React from 'react' 2 2 import {FlatList, View} from 'react-native' 3 3 import {useFocusEffect} from '@react-navigation/native' 4 4 import {observer} from 'mobx-react-lite' 5 - import useAppState from 'react-native-appstate-hook' 6 5 import { 7 6 NativeStackScreenProps, 8 7 NotificationsTabNavigatorParams, ··· 11 10 import {ViewHeader} from '../com/util/ViewHeader' 12 11 import {Feed} from '../com/notifications/Feed' 13 12 import {InvitedUsers} from '../com/notifications/InvitedUsers' 13 + import {LoadLatestBtn} from 'view/com/util/LoadLatestBtn' 14 14 import {useStores} from 'state/index' 15 15 import {useOnMainScroll} from 'lib/hooks/useOnMainScroll' 16 16 import {s} from 'lib/styles' 17 17 import {useAnalytics} from 'lib/analytics' 18 18 19 - const NOTIFICATIONS_POLL_INTERVAL = 15e3 20 - 21 19 type Props = NativeStackScreenProps< 22 20 NotificationsTabNavigatorParams, 23 21 'Notifications' ··· 28 26 const onMainScroll = useOnMainScroll(store) 29 27 const scrollElRef = React.useRef<FlatList>(null) 30 28 const {screen} = useAnalytics() 31 - const {appState} = useAppState({ 32 - onForeground: () => doPoll(true), 33 - }) 34 29 35 30 // event handlers 36 31 // = 37 - const onPressTryAgain = () => { 32 + const onPressTryAgain = React.useCallback(() => { 38 33 store.me.notifications.refresh() 39 - } 34 + }, [store]) 35 + 40 36 const scrollToTop = React.useCallback(() => { 41 37 scrollElRef.current?.scrollToOffset({offset: 0}) 42 38 }, [scrollElRef]) 43 39 44 - // periodic polling 45 - // = 46 - const doPoll = React.useCallback( 47 - async (isForegrounding = false) => { 48 - if (isForegrounding) { 49 - // app is foregrounding, refresh optimistically 50 - store.log.debug('NotificationsScreen: Refreshing on app foreground') 51 - await Promise.all([ 52 - store.me.notifications.loadUnreadCount(), 53 - store.me.notifications.refresh(), 54 - ]) 55 - } else if (appState === 'active') { 56 - // periodic poll, refresh if there are new notifs 57 - store.log.debug('NotificationsScreen: Polling for new notifications') 58 - const didChange = await store.me.notifications.loadUnreadCount() 59 - if (didChange) { 60 - store.log.debug('NotificationsScreen: Loading new notifications') 61 - await store.me.notifications.loadLatest() 62 - } 63 - } 64 - }, 65 - [appState, store], 66 - ) 67 - useEffect(() => { 68 - const pollInterval = setInterval(doPoll, NOTIFICATIONS_POLL_INTERVAL) 69 - return () => clearInterval(pollInterval) 70 - }, [doPoll]) 40 + const onPressLoadLatest = React.useCallback(() => { 41 + store.me.notifications.processQueue() 42 + scrollToTop() 43 + }, [store, scrollToTop]) 71 44 72 45 // on-visible setup 73 46 // = ··· 75 48 React.useCallback(() => { 76 49 store.shell.setMinimalShellMode(false) 77 50 store.log.debug('NotificationsScreen: Updating feed') 78 - const softResetSub = store.onScreenSoftReset(scrollToTop) 79 - store.me.notifications.loadUnreadCount() 80 - store.me.notifications.loadLatest() 51 + const softResetSub = store.onScreenSoftReset(onPressLoadLatest) 52 + store.me.notifications.syncQueue() 53 + store.me.notifications.update() 81 54 screen('Notifications') 82 55 83 56 return () => { 84 57 softResetSub.remove() 85 - store.me.notifications.markAllRead() 58 + store.me.notifications.markAllUnqueuedRead() 86 59 } 87 - }, [store, screen, scrollToTop]), 60 + }, [store, screen, onPressLoadLatest]), 88 61 ) 89 62 90 63 return ( ··· 97 70 onScroll={onMainScroll} 98 71 scrollElRef={scrollElRef} 99 72 /> 73 + {store.me.notifications.hasNewLatest && 74 + !store.me.notifications.isRefreshing && ( 75 + <LoadLatestBtn onPress={onPressLoadLatest} label="notifications" /> 76 + )} 100 77 </View> 101 78 ) 102 79 }),
+1
src/view/screens/Search.tsx
··· 155 155 testID={`searchAutoCompleteResult-${item.handle}`} 156 156 handle={item.handle} 157 157 displayName={item.displayName} 158 + labels={item.labels} 158 159 avatar={item.avatar} 159 160 /> 160 161 ))}
+19
src/view/screens/Settings.tsx
··· 124 124 store.shell.openModal({name: 'invite-codes'}) 125 125 }, [track, store]) 126 126 127 + const onPressContentFiltering = React.useCallback(() => { 128 + track('Settings:ContentfilteringButtonClicked') 129 + store.shell.openModal({name: 'content-filtering-settings'}) 130 + }, [track, store]) 131 + 127 132 const onPressSignout = React.useCallback(() => { 128 133 track('Settings:SignOutButtonClicked') 129 134 store.session.logout() ··· 248 253 <Text type="xl-bold" style={[pal.text, styles.heading]}> 249 254 Advanced 250 255 </Text> 256 + <TouchableOpacity 257 + testID="contentFilteringBtn" 258 + style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]} 259 + onPress={isSwitching ? undefined : onPressContentFiltering}> 260 + <View style={[styles.iconContainer, pal.btn]}> 261 + <FontAwesomeIcon 262 + icon="eye" 263 + style={pal.text as FontAwesomeIconStyle} 264 + /> 265 + </View> 266 + <Text type="lg" style={pal.text}> 267 + Content moderation 268 + </Text> 269 + </TouchableOpacity> 251 270 <TouchableOpacity 252 271 testID="changeHandleBtn" 253 272 style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]}
+1
src/view/shell/desktop/Search.tsx
··· 90 90 handle={item.handle} 91 91 displayName={item.displayName} 92 92 avatar={item.avatar} 93 + labels={item.labels} 93 94 noBorder={i === 0} 94 95 /> 95 96 ))}
+9 -8
yarn.lock
··· 30 30 tlds "^1.234.0" 31 31 typed-emitter "^2.1.0" 32 32 33 - "@atproto/api@0.2.5": 34 - version "0.2.5" 35 - resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.2.5.tgz#24375497351469a522497c7f92016d0b4233a172" 36 - integrity sha512-RJGhiwj6kOjrlVy7ES/SfJt3JyFwXdFZeBP4iw2ne/Ie0ZlanKhY0y9QHx5tI4rvEUP/wf0iKtaq2neczHi3bg== 33 + "@atproto/api@0.2.6": 34 + version "0.2.6" 35 + resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.2.6.tgz#030d9d3385bb109cc028451ab26a5e24ee126b06" 36 + integrity sha512-vw5D0o0ByuWd89ob8vG8RPd0tDhPi4NTyqn0lCJQDhxcYXx4I96sZ3iCUf61m7g3VNumBAoC2ZRo9kdn/6tb5w== 37 37 dependencies: 38 38 "@atproto/common-web" "*" 39 39 "@atproto/uri" "*" ··· 122 122 resolved "https://registry.yarnpkg.com/@atproto/nsid/-/nsid-0.0.1.tgz#0cdc00cefe8f0b1385f352b9f57b3ad37fff09a4" 123 123 integrity sha512-t5M6/CzWBVYoBbIvfKDpqPj/+ZmyoK9ydZSStcTXosJ27XXwOPhz0VDUGKK2SM9G5Y7TPes8S5KTAU0UdVYFCw== 124 124 125 - "@atproto/pds@^0.1.3": 126 - version "0.1.3" 127 - resolved "https://registry.yarnpkg.com/@atproto/pds/-/pds-0.1.3.tgz#601c556cd1e10306c9b741d9361bc54d70bb2869" 128 - integrity sha512-cVvmgXkzu7w1tDGGDK904sDzxF2AUqu0ij/1EU2rYmnZZAK+FTjKs8cqrJzRur9vm07A23JvBTuINtYzxHwSzA== 125 + "@atproto/pds@^0.1.4": 126 + version "0.1.4" 127 + resolved "https://registry.yarnpkg.com/@atproto/pds/-/pds-0.1.4.tgz#43379912e127d6d4f79a514e785dab9b54fd7810" 128 + integrity sha512-vrFYL+2nNm/0fJyUIgFK9h9FRuEf4rHjU/LJV7/nBO+HA3hP3U/mTgvVxuuHHvcRsRL5AVpAJR0xWFUoYsFmmg== 129 129 dependencies: 130 130 "@atproto/api" "*" 131 131 "@atproto/common" "*" ··· 144 144 express "^4.17.2" 145 145 express-async-errors "^3.1.1" 146 146 file-type "^16.5.4" 147 + form-data "^4.0.0" 147 148 handlebars "^4.7.7" 148 149 http-errors "^2.0.0" 149 150 http-terminator "^3.2.0"