Compare commits

...

44 Commits

Author SHA1 Message Date
Eric Bailey ea77ec4681 Fix lint 2025-06-13 11:53:50 -05:00
Eric Bailey 685a8a6348 Merge remote-tracking branch 'origin/main' into next/embeds
* origin/main:
  new arch (#8295)
  Nightly source-language update
  Loosen post source constraints (#8478)
  hide keyboard when backgrounding (#8450)
2025-06-12 13:10:23 -05:00
hailey 477e5f4ecf new arch (#8295)
Co-authored-by: Samuel Newman <mozzius@protonmail.com>
Co-authored-by: Charlotte Som <charlotte@som.codes>
Co-authored-by: Hailey <me@haileyok.com>
2025-06-12 10:46:22 -07:00
Eric Bailey b2aef1f803 Add back silenced error handling 2025-06-12 11:08:44 -05:00
Eric Bailey dbebb48043 Handle nested quotes same as prod 2025-06-12 10:11:32 -05:00
pfrazee a26b20b56c Nightly source-language update 2025-06-12 02:40:53 +00:00
Samuel Newman 18b258d060 Loosen post source constraints (#8478)
* Loosen post source constraints

* logger warn if failed to find source

* Tweak assertion logic

---------

Co-authored-by: Eric Bailey <git@esb.lol>
2025-06-11 15:22:21 -07:00
Samuel Newman 093454f5b2 hide keyboard when backgrounding (#8450) 2025-06-11 13:19:26 -07:00
Eric Bailey 297b0e93e5 Merge remote-tracking branch 'origin/main' into next/embeds
* origin/main:
  Video - remove `MediaInsetBorder` when fullscreen (#8476)
  disable avatar preview in searchable people list (#8453)
2025-06-11 15:00:54 -05:00
Eric Bailey 678c345526 Fix imports 2025-06-11 15:00:35 -05:00
Eric Bailey 1a386c0d3b Prettier 2025-06-11 15:00:35 -05:00
Eric Bailey bbc08bb7c6 Remove remaining items 2025-06-11 15:00:35 -05:00
Eric Bailey f20de35aad Clean up unused things 2025-06-11 15:00:35 -05:00
Eric Bailey b82e9facfa Add LazyQuoteEmbed for composer use 2025-06-11 15:00:35 -05:00
Samuel Newman 9e186dd2b5 Video - remove MediaInsetBorder when fullscreen (#8476)
* lift up useFullscreen, hide mediainsetborder when fullscreen

* Revert "lift up useFullscreen, hide mediainsetborder when fullscreen"

This reverts commit 66b1765719.

* just move border outside of div that gets fullscreened
2025-06-11 12:38:46 -07:00
Samuel Newman 32dfcc3a54 disable avatar preview in searchable people list (#8453) 2025-06-11 12:33:43 -07:00
Eric Bailey c20f0319a7 Merge remote-tracking branch 'origin/main' into next/embeds
* origin/main:
  [Threads V2] Preliminary integration of unspecced V2 APIs (#8443)
  Post source handling updates (#8472)
  Fix using screen names in `Link` (#8473)
  Instant Feed Update on Mute or Moderation Action (#8463)
  use ref instead of source (#8471)
2025-06-11 14:33:41 -05:00
Eric Bailey 61004b887b [Threads V2] Preliminary integration of unspecced V2 APIs (#8443)
* WIP

* Sorting working

* Rough handling of hidden/muted

* Better muted/hidden sorting and handling

* Clarify some naming

* Fix parents

* Handle first reply under highlighted/composer

* WIP RaW

* WIP optimistic

* Optimistic WIP

* Little cleanup, inserting dupes

* Re-org

* Add in new optimistic insert logic

* Update types

* Sorta working linear view optimistic state

* Simple working version, no pref for OP

* Working optimistic reply insertions, preference for OP

* Ensure deletes are coming through

* WIP scroll handling

* WIP scroll tweaks

* Clean up scrolling

* Clean up onPostSuccess

* Add annotations

* Fix highlighted post calc

* WIP kill me

* Update APIs

* Nvm don't kill me

* Fix optimistic insert

* Handle read more cases in tree view

* Basically working read more

* Handle linear view

* Reorg

* More reorg

* Split up thread post components

* New reply tree layout

* Fix up traversal metadata

* Tighten some spacing

* Use indent ya idiot

* Some linear mode cleanup

* Fix lines on read more items

* Vibe coding to success

* Almost there with read mores

* Update APIs

* Bump sdk

* Update import

* Checkpoint new traversal

* Checkpoint cleanup

* Checkpoint, need to fix blocked posts

* Checkpoint: think we're good, needs more cleanup

* Clean it up

* Two passes only

* Set to default params, update comment

* Fix render bug on native

* Checkpoint parent rendering, can opt for slower handling here

* Clean up parent handling, reply handling

* Fix read more extra space

* Fix read more in linear view

* Fix hidden reply handling, seen count, before/after calc

* Update naming

* Rename Slice to ThreadItem

* Add basic post and anchor skeletons

* Refactor client-side hidden

* WIP hidden fetching

* Update types

* Clean up query a bit

* Scrolling still broken

* Ok maybe fix scrolling

* Checkpoint move state into meta query

* Don't load remote hidden items unless needed

* skeleton view

* Reset hidden items when params change

* Split up traversal and avoid multiple passes

* Clean up

* Checkpoint: handling exhausted replies

* Clean up traversal functions further

* Clean up pagination

* Limit optimistic reply depth

* Handle optimistic insert in hidden replies

* Share root query key for easier cache extraction

* Make blurred posts not look like ass

* Fix double deleted item

* Make optimistic deleted state not look like crap in tree view

* Fix parents traversal 4 real

* Rename tree post

* Make optimistic deletions of linear posts not look bad

* Rename linear post components

* Handle tombstone views

* Rename read more component

* Add moreParents handling

* Align interaction states of read more

* Fix read more on FF

* Tree view skeleton

* Reply composer skele

* Remove hack for showing more replies

* Checkpoint: sort change scrolling fixed

* Checkpoint: learned new things, reset to base

* Feature gate

* Rename

* Replace show more

* Update settings screen

* Update pkg and endpoint

* Remove console

* Eureka

* Cleanup last commit

* No tests atm

* Remove scroll provider

* Clean up callbacks, better error state

* Remove todo

* Remove todo

* Remove todos

* Format

* Ok I think scrolling is solid

* Add back mobile compose input

* Ok need to compute headerHeight every time

* Update comments

* Ok button up web too

* Threads v2 tweaks (#8467)

* fix error screen collapsing

* use personx icon for blocked posts

* Remove height/width

* Revert unused Header change

* Clarify code

* Relate consts to theme values

* Remove debug code

* Typo

* Fix debounce of threads prefs

* Update metadata comments, dev mode

* Missed a spot

* Clean up todo

* Fix up no-unauthenticated posts

* Truncate parents if no-unauth

* Update getBranch docs

* Remove debug code

* Expand fetching in some cases

* Clear scroll need for root post to fix jump bug

* Fix reply composer skeleton state

* Remove uneeded initialized value

* Add profile shadow cache

* Some metrics

* prettier tweak

* eslint ignore

* Fix optimistic insertion

* Typo

* Rename, comment

* Remove wait

* Counter naming

* Replies seen counter for moderated sub-trees

* Remove borders on skeleton

* Align tombstone with optimistic deletion state

* Fix optimistic deletion for thread

* Add tree view icon

* Rename

* Cleanup

* Update settings copy

* Header menu open metric

* Bump package

* Better reply prompt (#8474)

* restyle reply prompt

* hide bottom bar border for cleaner look

* use new border hiding hook in DMs

* create `transparentifyColor` function

* adjust padding

* fix padding in immersive lpayer

* Apply suggestions from code review

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Integrate post-source

(cherry picked from commit fe053e9b38395a4fcb30a4367bc800f64ea84fe9)

---------

Co-authored-by: Samuel Newman <mozzius@protonmail.com>
Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>
2025-06-11 14:32:14 -05:00
Eric Bailey 143d5f3b81 Post source handling updates (#8472)
* Add debugs

* Key post-source using URI with handle

* Enhance

* EnHANCE

* ENHANCE

* ENHANCEEEECEE

* ᵉⁿʰᵃⁿᶜᵉ

* enhance
2025-06-11 13:22:02 -05:00
Samuel Newman 7341294df6 Fix using screen names in Link (#8473)
* use our router in favour of useLinkBuilder

* test feature using Home header feeds button

* handle non-string params properly
2025-06-11 10:12:05 -07:00
Eric Bailey 1cee8a48ce Remove log 2025-06-11 10:22:42 -05:00
Francisco Nascimento 269105371b Instant Feed Update on Mute or Moderation Action (#8463)
* Implemented #2406: Instant Feed Update on Mute or Moderation Action
Posts from muted or blocked users are now removed immediately from
the feed. This is achieved by extending the usePostShadow hook to
check if the post author is muted or blocked and return
POST_TOMBSTONE accordingly.
A unit test was also added to validate the new logic.

Co-authored-by: Pedro Macedo <pedrosantosmacedo@tecnico.ulisboa.pt>

* remove useless tests

---------

Co-authored-by: Pedro Macedo <pedrosantosmacedo@tecnico.ulisboa.pt>
Co-authored-by: Samuel Newman <mozzius@protonmail.com>
2025-06-11 06:21:31 -07:00
Samuel Newman 3049797665 move bandwidth-estimate.ts 2025-06-10 23:46:53 +03:00
Samuel Newman 475f4f9eb6 delete old video utils 2025-06-10 23:46:41 +03:00
Samuel Newman 1380c5ac67 use ref instead of source (#8471) 2025-06-10 15:29:39 -05:00
Eric Bailey 48e22c2adb Prettier 2025-06-10 10:45:22 -05:00
Eric Bailey 094a877403 Merge remote-tracking branch 'origin/main' into next/embeds
* origin/main: (241 commits)
  Nightly source-language update
  Update react-navigation (#5967)
  Update SetNewPasswordForm.tsx (#8349)
  Nightly source-language update
  bskyweb: bump indigo (several codegen updates) (#8442)
  Replace "Note about sharing" prompt with an inline hint (#8452)
  Bump version to v1.104 (#8447)
  show misclass button by the same logic as feedContext (#8445)
  change fontSize: 15 to a.text_sm (#7896)
  Nightly source-language update
  use method that's actually available on android (#8448)
  Release 1.103.0 prep (#8434)
  Add language: European Portuguese (`pt-PT`) (#8375)
  Small test fix
  tweak ordering of explore components (#8432)
  add metrics (#8426)
  invert flag for sending to statsig (#8431)
  Fix link to like-via-repost  (#8428)
  Nightly source-language update
  Make via-repost notifs groupable (#8429)
  ...
2025-06-10 10:37:52 -05:00
Eric Bailey bfc30da46c Format 2025-03-14 13:38:25 -05:00
Eric Bailey aa01620880 Add back in correct moderation on List and Feed embeds 2025-03-14 13:38:25 -05:00
Eric Bailey 950856fb09 Fix up native 2025-03-14 13:38:25 -05:00
Eric Bailey 01b7f9036e Clean up 2025-03-14 13:38:25 -05:00
Eric Bailey 383ecbf8fd Swap in new file location 2025-03-14 13:38:25 -05:00
Eric Bailey df935b99e3 Copy pasta GifEmbed 2025-03-14 13:38:25 -05:00
Eric Bailey 3c5e91385b Copy pasta VideoEmbed 2025-03-14 13:38:25 -05:00
Eric Bailey b0e77f0e13 Migrate view contexts 2025-03-14 13:38:25 -05:00
Eric Bailey 55a0a5126d Replace other usages of old post-embeds 2025-03-14 13:38:25 -05:00
Eric Bailey 2b6924b9d3 Migrate ComposerReplyTo 2025-03-14 13:38:25 -05:00
Eric Bailey 92520166e5 Port over Player and Gif embeds 2025-03-14 13:38:25 -05:00
Eric Bailey 34a2abfdd8 Port over ExternalLinkEmbed 2025-03-14 13:38:25 -05:00
Eric Bailey 43f55a0e37 DRY up a bit 2025-03-14 13:38:25 -05:00
Eric Bailey afcd9beef4 Split out ImageEmbed 2025-03-14 13:38:25 -05:00
Eric Bailey 191b0261cb Split out ListEmbed and FeedEmbed 2025-03-14 13:38:25 -05:00
Eric Bailey 6384cd9f7e Re-org 2025-03-14 13:38:25 -05:00
Eric Bailey f82ec3380a Direct port of embeds to new arch
(cherry picked from commit cc3fa1f6cea396dd9222486c633a508bfee1ecd6)
2025-03-14 13:38:25 -05:00
163 changed files with 7654 additions and 2038 deletions
+14 -37
View File
@@ -1,10 +1,9 @@
import {deleteAsync} from 'expo-file-system'
import {createDownloadResumable, deleteAsync} from 'expo-file-system'
import {manipulateAsync, SaveFormat} from 'expo-image-manipulator'
import RNFetchBlob from 'rn-fetch-blob'
import {
downloadAndResize,
DownloadAndResizeOpts,
type DownloadAndResizeOpts,
getResizedDimensions,
} from '../../src/lib/media/manip'
@@ -32,11 +31,12 @@ describe('downloadAndResize', () => {
})
it('should return resized image for valid URI and options', async () => {
const mockedFetch = RNFetchBlob.fetch as jest.Mock
mockedFetch.mockResolvedValueOnce({
path: jest.fn().mockReturnValue('file://downloaded-image.jpg'),
info: jest.fn().mockReturnValue({status: 200}),
flush: jest.fn(),
const mockedFetch = createDownloadResumable as jest.Mock
mockedFetch.mockReturnValue({
cancelAsync: jest.fn(),
downloadAsync: jest
.fn()
.mockResolvedValue({uri: 'file://resized-image.jpg'}),
})
const opts: DownloadAndResizeOpts = {
@@ -50,13 +50,12 @@ describe('downloadAndResize', () => {
const result = await downloadAndResize(opts)
expect(result).toEqual(mockResizedImage)
expect(RNFetchBlob.config).toHaveBeenCalledWith({
fileCache: true,
appendExt: 'jpeg',
})
expect(RNFetchBlob.fetch).toHaveBeenCalledWith(
'GET',
'https://example.com/image.jpg',
expect(createDownloadResumable).toHaveBeenCalledWith(
opts.uri,
expect.anything(),
{
cache: true,
},
)
// First time it gets called is to get dimensions
@@ -86,28 +85,6 @@ describe('downloadAndResize', () => {
expect(result).toBeUndefined()
})
it('should return undefined for non-200 response', async () => {
const mockedFetch = RNFetchBlob.fetch as jest.Mock
mockedFetch.mockResolvedValueOnce({
path: jest.fn().mockReturnValue('file://downloaded-image'),
info: jest.fn().mockReturnValue({status: 400}),
flush: jest.fn(),
})
const opts: DownloadAndResizeOpts = {
uri: 'https://example.com/image',
width: 100,
height: 100,
maxSize: 500000,
mode: 'cover',
timeout: 10000,
}
const result = await downloadAndResize(opts)
expect(errorSpy).not.toHaveBeenCalled()
expect(result).toBeUndefined()
})
it('should not downsize whenever dimensions are below the max dimensions', () => {
const initialDimensionsOne = {
width: 1200,
+1 -1
View File
@@ -219,7 +219,7 @@ module.exports = function (_config) {
compileSdkVersion: 35,
targetSdkVersion: 35,
buildToolsVersion: '35.0.0',
newArchEnabled: false,
newArchEnabled: true,
},
},
],
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M12 2c5.523 0 10 4.477 10 10s-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2Zm0 2a8 8 0 1 0 0 16 8 8 0 0 0 0-16Zm-.63 3.225a1 1 0 0 1 1.337.068l3 3 .068.076a1 1 0 0 1-1.406 1.406l-.076-.068L13 10.414V16a1 1 0 1 1-2 0v-5.586l-1.293 1.293a1 1 0 1 1-1.414-1.414l3-3 .076-.068Z"/></svg>

After

Width:  |  Height:  |  Size: 371 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M12 2c5.523 0 10 4.477 10 10s-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2Zm0 2a8 8 0 1 0 0 16 8 8 0 0 0 0-16Zm0 3a1 1 0 0 1 1 1v3h3l.102.005a1 1 0 0 1 0 1.99L16 13h-3v3a1 1 0 1 1-2 0v-3H8a1 1 0 0 1 0-2h3V8a1 1 0 0 1 1-1Z"/></svg>

After

Width:  |  Height:  |  Size: 321 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M6 2a2.998 2.998 0 0 1 1 5.825V8a2 2 0 0 0 2 2h1.174c.412-1.165 1.52-2 2.826-2h5a3 3 0 1 1 0 6h-5a3 3 0 0 1-2.826-2H9a4 4 0 0 1-2-.537V16a2 2 0 0 0 2 2h1.174c.412-1.165 1.52-2 2.826-2h5a3 3 0 1 1 0 6h-5a3 3 0 0 1-2.826-2H9a4 4 0 0 1-4-4V7.825A2.998 2.998 0 0 1 6 2Zm7 16a1 1 0 1 0 0 2h5a1 1 0 1 0 0-2h-5Zm0-8a1 1 0 1 0 0 2h5a1 1 0 1 0 0-2h-5ZM6 4a1 1 0 1 0 0 2 1 1 0 0 0 0-2Z"/></svg>

After

Width:  |  Height:  |  Size: 478 B

+2 -2
View File
@@ -1,6 +1,6 @@
import {Express} from 'express'
import {type Express} from 'express'
import {AppContext} from '../context.js'
import {type AppContext} from '../context.js'
import {default as createShortLink} from './createShortLink.js'
import {default as health} from './health.js'
import {default as redirect} from './redirect.js'
+2 -2
View File
@@ -2,9 +2,9 @@ import assert from 'node:assert'
import {DAY, SECOND} from '@atproto/common'
import escapeHTML from 'escape-html'
import {Express} from 'express'
import {type Express} from 'express'
import {AppContext} from '../context.js'
import {type AppContext} from '../context.js'
import {handler} from './util.js'
const INTERNAL_IP_REGEX = new RegExp(
+2 -2
View File
@@ -1,6 +1,6 @@
import {Express} from 'express'
import {type Express} from 'express'
import {AppContext} from '../context.js'
import {type AppContext} from '../context.js'
import {handler} from './util.js'
export default function (ctx: AppContext, app: Express) {
+2 -7
View File
@@ -33,15 +33,10 @@ jest.mock('react-native-safe-area-context', () => {
}
})
jest.mock('rn-fetch-blob', () => ({
config: jest.fn().mockReturnThis(),
cancel: jest.fn(),
fetch: jest.fn(),
}))
jest.mock('expo-file-system', () => ({
getInfoAsync: jest.fn().mockResolvedValue({exists: true, size: 100}),
deleteAsync: jest.fn(),
createDownloadResumable: jest.fn(),
}))
jest.mock('expo-image-manipulator', () => ({
@@ -101,7 +96,7 @@ jest.mock('expo-modules-core', () => ({
}
}
}),
requireNativeViewManager: jest.fn().mockImplementation(moduleName => {
requireNativeViewManager: jest.fn().mockImplementation(_ => {
return () => null
}),
}))
+41 -39
View File
@@ -69,12 +69,12 @@
"icons:optimize": "svgo -f ./assets/icons"
},
"dependencies": {
"@atproto/api": "^0.15.9",
"@atproto/api": "^0.15.14",
"@bitdrift/react-native": "^0.6.8",
"@braintree/sanitize-url": "^6.0.2",
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
"@emoji-mart/react": "^1.1.1",
"@expo/html-elements": "^0.12.4",
"@expo/html-elements": "^0.12.5",
"@expo/webpack-config": "^19.0.1",
"@floating-ui/dom": "^1.6.3",
"@floating-ui/react-dom": "^2.0.8",
@@ -85,12 +85,12 @@
"@fortawesome/free-regular-svg-icons": "^6.1.1",
"@fortawesome/free-solid-svg-icons": "^6.1.1",
"@fortawesome/react-native-fontawesome": "^0.3.2",
"@haileyok/bluesky-video": "0.2.6",
"@haileyok/bluesky-video": "0.3.1",
"@ipld/dag-cbor": "^9.2.0",
"@lingui/react": "^4.14.1",
"@mattermost/react-native-paste-input": "^0.7.1",
"@miblanchard/react-native-slider": "^2.3.1",
"@mozzius/expo-dynamic-app-icon": "^1.5.0",
"@mattermost/react-native-paste-input": "mattermost/react-native-paste-input",
"@miblanchard/react-native-slider": "^2.6.0",
"@mozzius/expo-dynamic-app-icon": "1.5.0",
"@react-native-async-storage/async-storage": "2.1.2",
"@react-native-menu/menu": "^1.2.3",
"@react-native-picker/picker": "2.11.0",
@@ -98,7 +98,7 @@
"@react-navigation/drawer": "^7.3.12",
"@react-navigation/native": "^7.1.9",
"@react-navigation/native-stack": "^7.3.13",
"@sentry/react-native": "~6.10.0",
"@sentry/react-native": "~6.14.0",
"@tanstack/query-async-storage-persister": "^5.25.0",
"@tanstack/react-query": "^5.8.1",
"@tanstack/react-query-persist-client": "^5.25.0",
@@ -130,33 +130,33 @@
"emoji-mart": "^5.5.2",
"emoji-regex": "^10.4.0",
"eventemitter3": "^5.0.1",
"expo": "^53.0.5",
"expo": "53.0.11",
"expo-application": "~6.1.4",
"expo-blur": "~14.1.4",
"expo-blur": "~14.1.5",
"expo-build-properties": "~0.14.6",
"expo-camera": "~16.1.6",
"expo-camera": "~16.1.8",
"expo-clipboard": "~7.1.4",
"expo-dev-client": "~5.1.7",
"expo-dev-client": "~5.2.0",
"expo-device": "~7.1.4",
"expo-file-system": "~18.1.8",
"expo-font": "~13.3.0",
"expo-file-system": "~18.1.10",
"expo-font": "~13.3.1",
"expo-haptics": "~14.1.4",
"expo-image": "~2.1.6",
"expo-image": "~2.2.1",
"expo-image-crop-tool": "^0.1.8",
"expo-image-manipulator": "~13.1.5",
"expo-image-manipulator": "~13.1.7",
"expo-image-picker": "~16.1.4",
"expo-linear-gradient": "~14.1.4",
"expo-linking": "~7.1.4",
"expo-linear-gradient": "~14.1.5",
"expo-linking": "~7.1.5",
"expo-localization": "~16.1.5",
"expo-media-library": "~17.1.6",
"expo-notifications": "~0.31.1",
"expo-screen-orientation": "~8.1.5",
"expo-media-library": "~17.1.7",
"expo-notifications": "~0.31.3",
"expo-screen-orientation": "~8.1.7",
"expo-sharing": "~13.1.5",
"expo-splash-screen": "~0.30.8",
"expo-system-ui": "~5.0.7",
"expo-splash-screen": "~0.30.9",
"expo-system-ui": "~5.0.8",
"expo-task-manager": "~13.1.5",
"expo-updates": "~0.28.12",
"expo-video": "~2.1.8",
"expo-updates": "~0.28.14",
"expo-video": "~2.2.1",
"expo-web-browser": "~14.1.6",
"fast-text-encoding": "^1.0.6",
"history": "^5.3.0",
@@ -182,35 +182,34 @@
"react-image-crop": "^11.0.7",
"react-is": "19",
"react-keyed-flatten-children": "^5.0.0",
"react-native": "0.79.2",
"react-native-compressor": "1.11.0",
"react-native": "^0.79.3",
"react-native-compressor": "^1.11.0",
"react-native-date-picker": "^5.0.12",
"react-native-drawer-layout": "^4.1.6",
"react-native-drawer-layout": "^4.1.8",
"react-native-edge-to-edge": "^1.6.0",
"react-native-gesture-handler": "2.25.0",
"react-native-get-random-values": "~1.11.0",
"react-native-ios-context-menu": "^1.15.3",
"react-native-keyboard-controller": "^1.17.1",
"react-native-mmkv": "^2.12.2",
"react-native-pager-view": "6.7.1",
"react-native-pager-view": "^6.7.1",
"react-native-progress": "bluesky-social/react-native-progress",
"react-native-qrcode-styled": "^0.3.3",
"react-native-reanimated": "~3.17.5",
"react-native-root-siblings": "^4.1.1",
"react-native-root-siblings": "^5.0.1",
"react-native-safe-area-context": "5.4.0",
"react-native-screens": "^4.11.1",
"react-native-svg": "15.11.2",
"react-native-svg": "15.12.0",
"react-native-uitextview": "^1.4.0",
"react-native-url-polyfill": "^1.3.0",
"react-native-uuid": "^2.0.3",
"react-native-view-shot": "^4.0.3",
"react-native-web": "~0.20.0",
"react-native-web-webview": "^1.0.2",
"react-native-webview": "13.13.5",
"react-native-webview": "^13.13.5",
"react-remove-scroll-bar": "^2.3.8",
"react-responsive": "^9.0.2",
"react-textarea-autosize": "^8.5.3",
"rn-fetch-blob": "^0.12.0",
"statsig-react-native-expo": "^4.6.1",
"tippy.js": "^6.3.7",
"tlds": "^1.234.0",
@@ -227,8 +226,9 @@
"@lingui/cli": "^4.14.1",
"@lingui/macro": "^4.14.1",
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.15",
"@react-native/eslint-config": "^0.79.2",
"@react-native/typescript-config": "^0.79.2",
"@react-native/babel-preset": "0.79.3",
"@react-native/eslint-config": "^0.79.3",
"@react-native/typescript-config": "^0.79.3",
"@sentry/webpack-plugin": "^3.2.2",
"@testing-library/jest-native": "^5.4.3",
"@testing-library/react-native": "^13.2.0",
@@ -247,6 +247,7 @@
"babel-plugin-module-resolver": "^5.0.2",
"babel-plugin-react-compiler": "^19.1.0-rc.1",
"babel-preset-expo": "~13.1.11",
"browserslist": "^4.25.0",
"eslint": "^8.19.0",
"eslint-plugin-bsky-internal": "link:./eslint",
"eslint-plugin-ft-flow": "^2.0.3",
@@ -260,7 +261,7 @@
"husky": "^8.0.3",
"is-ci": "^3.0.1",
"jest": "^29.7.0",
"jest-expo": "~53.0.3",
"jest-expo": "~53.0.7",
"jest-junit": "^16.0.0",
"lint-staged": "^13.2.3",
"lockfile-lint": "^4.14.0",
@@ -275,11 +276,11 @@
},
"resolutions": {
"@expo/image-utils": "0.6.3",
"@react-native/babel-preset": "0.79.2",
"@react-native/normalize-colors": "0.79.2",
"@react-native/babel-preset": "0.79.3",
"@react-native/normalize-colors": "0.79.3",
"@types/react": "^18",
"**/expo-constants": "17.0.3",
"**/expo-device": "7.0.1",
"**/expo-device": "7.1.4",
"**/zod": "3.23.8",
"**/multiformats": "9.9.0"
},
@@ -359,7 +360,8 @@
],
"allowedUrls": [
"https://codeload.github.com/bluesky-social/react-native-bottom-sheet/tar.gz/28a87d1bb55e10fc355fa1455545a30734995908",
"https://codeload.github.com/bluesky-social/react-native-progress/tar.gz/5a372f4f2ce5feb26f4f47b6a4d187ab9b923ab4"
"https://codeload.github.com/bluesky-social/react-native-progress/tar.gz/5a372f4f2ce5feb26f4f47b6a4d187ab9b923ab4",
"https://codeload.github.com/mattermost/react-native-paste-input/tar.gz/f260447edc645a817ab1ba7b46d8341d84dba8e9"
],
"emptyHostname": false,
"validatePackageNames": true,
@@ -114,7 +114,7 @@ index e916023..5049c33 100644
}
#pragma mark - UIScrollViewDelegate
@@ -62,7 +92,6 @@
@@ -62,7 +92,6 @@ - (void)setSmartPunctuation:(NSString *)smartPunctuation {
- (void)scrollViewDidScroll:(UIScrollView *)scrollView
{
RCTDirectEventBlock onScroll = self.onScroll;
@@ -122,7 +122,7 @@ index e916023..5049c33 100644
if (onScroll) {
CGPoint contentOffset = scrollView.contentOffset;
CGSize contentSize = scrollView.contentSize;
@@ -71,22 +100,22 @@
@@ -71,22 +100,22 @@ - (void)scrollViewDidScroll:(UIScrollView *)scrollView
onScroll(@{
@"contentOffset": @{
@@ -155,3 +155,110 @@ index e916023..5049c33 100644
},
@"zoomScale": @(scrollView.zoomScale ?: 1),
});
diff --git a/node_modules/@mattermost/react-native-paste-input/ios/PasteTextInput.mm b/node_modules/@mattermost/react-native-paste-input/ios/PasteTextInput.mm
index dd50053..2ed7017 100644
--- a/node_modules/@mattermost/react-native-paste-input/ios/PasteTextInput.mm
+++ b/node_modules/@mattermost/react-native-paste-input/ios/PasteTextInput.mm
@@ -122,8 +122,8 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared &
const auto &newTextInputProps = static_cast<const PasteTextInputProps &>(*props);
// Traits:
- if (newTextInputProps.traits.multiline != oldTextInputProps.traits.multiline) {
- [self _setMultiline:newTextInputProps.traits.multiline];
+ if (newTextInputProps.multiline != oldTextInputProps.multiline) {
+ [self _setMultiline:newTextInputProps.multiline];
}
if (newTextInputProps.traits.autocapitalizationType != oldTextInputProps.traits.autocapitalizationType) {
@@ -421,7 +421,7 @@ - (void)textInputDidChangeSelection
return;
}
const auto &props = static_cast<const PasteTextInputProps &>(*_props);
- if (props.traits.multiline && ![_lastStringStateWasUpdatedWith isEqual:_backedTextInputView.attributedText]) {
+ if (props.multiline && ![_lastStringStateWasUpdatedWith isEqual:_backedTextInputView.attributedText]) {
[self textInputDidChange];
_ignoreNextTextInputCall = YES;
}
@@ -708,11 +708,11 @@ - (BOOL)_textOf:(NSAttributedString *)newText equals:(NSAttributedString *)oldTe
- (SubmitBehavior)getSubmitBehavior
{
const auto &props = static_cast<const PasteTextInputProps &>(*_props);
- const SubmitBehavior submitBehaviorDefaultable = props.traits.submitBehavior;
+ const SubmitBehavior submitBehaviorDefaultable = props.submitBehavior;
// We should always have a non-default `submitBehavior`, but in case we don't, set it based on multiline.
if (submitBehaviorDefaultable == SubmitBehavior::Default) {
- return props.traits.multiline ? SubmitBehavior::Newline : SubmitBehavior::BlurAndSubmit;
+ return props.multiline ? SubmitBehavior::Newline : SubmitBehavior::BlurAndSubmit;
}
return submitBehaviorDefaultable;
diff --git a/node_modules/@mattermost/react-native-paste-input/ios/PasteTextInputSpecs/Props.cpp b/node_modules/@mattermost/react-native-paste-input/ios/PasteTextInputSpecs/Props.cpp
index 29e094f..7ef519a 100644
--- a/node_modules/@mattermost/react-native-paste-input/ios/PasteTextInputSpecs/Props.cpp
+++ b/node_modules/@mattermost/react-native-paste-input/ios/PasteTextInputSpecs/Props.cpp
@@ -22,8 +22,7 @@ PasteTextInputProps::PasteTextInputProps(
const PropsParserContext &context,
const PasteTextInputProps &sourceProps,
const RawProps& rawProps)
- : ViewProps(context, sourceProps, rawProps),
- BaseTextProps(context, sourceProps, rawProps),
+ : BaseTextInputProps(context, sourceProps, rawProps),
traits(convertRawProp(context, rawProps, sourceProps.traits, {})),
smartPunctuation(convertRawProp(context, rawProps, "smartPunctuation", sourceProps.smartPunctuation, {})),
disableCopyPaste(convertRawProp(context, rawProps, "disableCopyPaste", sourceProps.disableCopyPaste, {false})),
@@ -133,7 +132,7 @@ TextAttributes PasteTextInputProps::getEffectiveTextAttributes(Float fontSizeMul
ParagraphAttributes PasteTextInputProps::getEffectiveParagraphAttributes() const {
auto result = paragraphAttributes;
- if (!traits.multiline) {
+ if (!multiline) {
result.maximumNumberOfLines = 1;
}
diff --git a/node_modules/@mattermost/react-native-paste-input/ios/PasteTextInputSpecs/Props.h b/node_modules/@mattermost/react-native-paste-input/ios/PasteTextInputSpecs/Props.h
index 723d00c..31cfe66 100644
--- a/node_modules/@mattermost/react-native-paste-input/ios/PasteTextInputSpecs/Props.h
+++ b/node_modules/@mattermost/react-native-paste-input/ios/PasteTextInputSpecs/Props.h
@@ -15,6 +15,7 @@
#include <react/renderer/components/iostextinput/conversions.h>
#include <react/renderer/components/iostextinput/primitives.h>
#include <react/renderer/components/text/BaseTextProps.h>
+#include <react/renderer/components/textinput/BaseTextInputProps.h>
#include <react/renderer/components/view/ViewProps.h>
#include <react/renderer/core/Props.h>
#include <react/renderer/core/PropsParserContext.h>
@@ -25,7 +26,7 @@
namespace facebook::react {
-class PasteTextInputProps final : public ViewProps, public BaseTextProps {
+class PasteTextInputProps final : public BaseTextInputProps {
public:
PasteTextInputProps() = default;
PasteTextInputProps(const PropsParserContext& context, const PasteTextInputProps& sourceProps, const RawProps& rawProps);
diff --git a/node_modules/@mattermost/react-native-paste-input/ios/PasteTextInputSpecs/ShadowNodes.cpp b/node_modules/@mattermost/react-native-paste-input/ios/PasteTextInputSpecs/ShadowNodes.cpp
index 31e07e3..7f0ebfb 100644
--- a/node_modules/@mattermost/react-native-paste-input/ios/PasteTextInputSpecs/ShadowNodes.cpp
+++ b/node_modules/@mattermost/react-native-paste-input/ios/PasteTextInputSpecs/ShadowNodes.cpp
@@ -91,20 +91,11 @@ void PasteTextInputShadowNode::updateStateIfNeeded(
const auto& state = getStateData();
react_native_assert(textLayoutManager_);
- react_native_assert(
- (!state.layoutManager || state.layoutManager == textLayoutManager_) &&
- "`StateData` refers to a different `TextLayoutManager`");
-
- if (state.reactTreeAttributedString == reactTreeAttributedString &&
- state.layoutManager == textLayoutManager_) {
- return;
- }
auto newState = TextInputState{};
newState.attributedStringBox = AttributedStringBox{reactTreeAttributedString};
newState.paragraphAttributes = getConcreteProps().paragraphAttributes;
newState.reactTreeAttributedString = reactTreeAttributedString;
- newState.layoutManager = textLayoutManager_;
newState.mostRecentEventCount = getConcreteProps().mostRecentEventCount;
setStateData(std::move(newState));
}
@@ -1,3 +1,32 @@
diff --git a/node_modules/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTPullToRefreshViewComponentView.h b/node_modules/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTPullToRefreshViewComponentView.h
index 914a249..0deac55 100644
--- a/node_modules/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTPullToRefreshViewComponentView.h
+++ b/node_modules/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTPullToRefreshViewComponentView.h
@@ -19,6 +19,8 @@ NS_ASSUME_NONNULL_BEGIN
*/
@interface RCTPullToRefreshViewComponentView : RCTViewComponentView <RCTCustomPullToRefreshViewProtocol>
+- (void)beginRefreshingProgrammatically;
+
@end
NS_ASSUME_NONNULL_END
diff --git a/node_modules/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm b/node_modules/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm
index d029337..0f63ea3 100644
--- a/node_modules/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm
+++ b/node_modules/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm
@@ -1003,6 +1003,11 @@ - (void)_adjustForMaintainVisibleContentPosition
}
}
++ (BOOL)shouldBeRecycled
+{
+ return NO;
+}
+
@end
Class<RCTComponentViewProtocol> RCTScrollViewCls(void)
diff --git a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.h b/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.h
index e9b330f..ec5f58c 100644
--- a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.h
@@ -15,7 +44,7 @@ diff --git a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshCont
index 53bfd04..ff1b1ed 100644
--- a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.m
+++ b/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.m
@@ -23,6 +23,7 @@
@@ -23,6 +23,7 @@ @implementation RCTRefreshControl {
UIColor *_titleColor;
CGFloat _progressViewOffset;
BOOL _hasMovedToWindow;
@@ -23,7 +52,7 @@ index 53bfd04..ff1b1ed 100644
}
- (instancetype)init
@@ -58,6 +59,12 @@ RCT_NOT_IMPLEMENTED(-(instancetype)initWithCoder : (NSCoder *)aDecoder)
@@ -58,6 +59,12 @@ - (void)layoutSubviews
_isInitialRender = false;
}
@@ -36,7 +65,7 @@ index 53bfd04..ff1b1ed 100644
- (void)didMoveToWindow
{
[super didMoveToWindow];
@@ -221,4 +228,50 @@ RCT_NOT_IMPLEMENTED(-(instancetype)initWithCoder : (NSCoder *)aDecoder)
@@ -221,4 +228,50 @@ - (void)refreshControlValueChanged
}
}
@@ -91,7 +120,7 @@ diff --git a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshCont
index 40aaf9c..1c60164 100644
--- a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControlManager.m
+++ b/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControlManager.m
@@ -22,11 +22,12 @@ RCT_EXPORT_MODULE()
@@ -22,11 +22,12 @@ - (UIView *)view
RCT_EXPORT_VIEW_PROPERTY(onRefresh, RCTDirectEventBlock)
RCT_EXPORT_VIEW_PROPERTY(refreshing, BOOL)
@@ -105,15 +134,3 @@ index 40aaf9c..1c60164 100644
RCT_EXPORT_METHOD(setNativeRefreshing : (nonnull NSNumber *)viewTag toRefreshing : (BOOL)refreshing)
{
[self.bridge.uiManager addUIBlock:^(RCTUIManager *uiManager, NSDictionary<NSNumber *, UIView *> *viewRegistry) {
diff --git a/node_modules/react-native/React/Views/ScrollView/RCTScrollViewManager.m b/node_modules/react-native/React/Views/ScrollView/RCTScrollViewManager.m
index cd1e7eb..c1d0172 100644
--- a/node_modules/react-native/React/Views/ScrollView/RCTScrollViewManager.m
+++ b/node_modules/react-native/React/Views/ScrollView/RCTScrollViewManager.m
@@ -83,6 +83,7 @@ RCT_EXPORT_VIEW_PROPERTY(showsVerticalScrollIndicator, BOOL)
RCT_EXPORT_VIEW_PROPERTY(scrollEventThrottle, NSTimeInterval)
RCT_EXPORT_VIEW_PROPERTY(zoomScale, CGFloat)
RCT_EXPORT_VIEW_PROPERTY(contentInset, UIEdgeInsets)
+RCT_EXPORT_VIEW_PROPERTY(scrollIndicatorInsets, UIEdgeInsets)
RCT_EXPORT_VIEW_PROPERTY(verticalScrollIndicatorInsets, UIEdgeInsets)
RCT_EXPORT_VIEW_PROPERTY(scrollToOverflowEnabled, BOOL)
RCT_EXPORT_VIEW_PROPERTY(snapToInterval, int)
+4 -4
View File
@@ -58,9 +58,7 @@ import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide'
import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
import {Provider as HiddenRepliesProvider} from '#/state/threadgate-hidden-replies'
import {Provider as UnstablePostSourceProvider} from '#/state/unstable-post-source'
import {TestCtrls} from '#/view/com/testing/TestCtrls'
import {Provider as VideoVolumeProvider} from '#/view/com/util/post-embeds/VideoVolumeContext'
import * as Toast from '#/view/com/util/Toast'
import {Shell} from '#/view/shell'
import {ThemeProvider as Alf} from '#/alf'
@@ -70,9 +68,11 @@ import {NuxDialogs} from '#/components/dialogs/nuxs'
import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialogs'
import {Provider as PortalProvider} from '#/components/Portal'
import {Provider as VideoVolumeProvider} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext'
import {Splash} from '#/Splash'
import {BottomSheetProvider} from '../modules/bottom-sheet'
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
import {Provider as HideBottomBarBorderProvider} from './lib/hooks/useHideBottomBarBorder'
SplashScreen.preventAutoHideAsync()
if (isIOS) {
@@ -151,7 +151,7 @@ function InnerApp() {
<MutedThreadsProvider>
<ProgressGuideProvider>
<ServiceAccountManager>
<UnstablePostSourceProvider>
<HideBottomBarBorderProvider>
<GestureHandlerRootView
style={s.h100pct}>
<IntentDialogProvider>
@@ -160,7 +160,7 @@ function InnerApp() {
<NuxDialogs />
</IntentDialogProvider>
</GestureHandlerRootView>
</UnstablePostSourceProvider>
</HideBottomBarBorderProvider>
</ServiceAccountManager>
</ProgressGuideProvider>
</MutedThreadsProvider>
+5 -5
View File
@@ -48,9 +48,6 @@ import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide'
import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
import {Provider as HiddenRepliesProvider} from '#/state/threadgate-hidden-replies'
import {Provider as UnstablePostSourceProvider} from '#/state/unstable-post-source'
import {Provider as ActiveVideoProvider} from '#/view/com/util/post-embeds/ActiveVideoWebContext'
import {Provider as VideoVolumeProvider} from '#/view/com/util/post-embeds/VideoVolumeContext'
import * as Toast from '#/view/com/util/Toast'
import {ToastContainer} from '#/view/com/util/Toast.web'
import {Shell} from '#/view/shell/index'
@@ -61,7 +58,10 @@ import {NuxDialogs} from '#/components/dialogs/nuxs'
import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialogs'
import {Provider as PortalProvider} from '#/components/Portal'
import {Provider as ActiveVideoProvider} from '#/components/Post/Embed/VideoEmbed/ActiveVideoWebContext'
import {Provider as VideoVolumeProvider} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext'
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
import {Provider as HideBottomBarBorderProvider} from './lib/hooks/useHideBottomBarBorder'
/**
* Begin geolocation ASAP
@@ -132,12 +132,12 @@ function InnerApp() {
<SafeAreaProvider>
<ProgressGuideProvider>
<ServiceConfigProvider>
<UnstablePostSourceProvider>
<HideBottomBarBorderProvider>
<IntentDialogProvider>
<Shell />
<NuxDialogs />
</IntentDialogProvider>
</UnstablePostSourceProvider>
</HideBottomBarBorderProvider>
</ServiceConfigProvider>
</ProgressGuideProvider>
</SafeAreaProvider>
+4
View File
@@ -1051,4 +1051,8 @@ export const atoms = {
transform: [],
},
}) as {transform: Exclude<ViewStyle['transform'], string | undefined>},
pointer: web({
cursor: 'pointer',
}),
} as const
+48
View File
@@ -0,0 +1,48 @@
import {jest} from '@jest/globals'
import {logger} from '#/logger'
import {transparentifyColor} from '../colorGeneration'
jest.mock('#/logger', () => ({
logger: {warn: jest.fn()},
}))
describe('transparentifyColor', () => {
beforeEach(() => {
jest.clearAllMocks()
})
it('converts hsl() to hsla()', () => {
const result = transparentifyColor('hsl(120 100% 50%)', 0.5)
expect(result).toBe('hsla(120 100% 50%, 0.5)')
})
it('converts hsl() to hsla() - fully transparent', () => {
const result = transparentifyColor('hsl(120 100% 50%)', 0)
expect(result).toBe('hsla(120 100% 50%, 0)')
})
it('converts rgb() to rgba()', () => {
const result = transparentifyColor('rgb(255 0 0)', 0.75)
expect(result).toBe('rgba(255 0 0, 0.75)')
})
it('expands 3-digit hex and appends alpha channel', () => {
const result = transparentifyColor('#abc', 0.4)
expect(result).toBe('#aabbcc66')
})
it('appends alpha to 6-digit hex', () => {
const result = transparentifyColor('#aabbcc', 0.4)
expect(result).toBe('#aabbcc66')
})
it('returns the original string and warns for unsupported formats', () => {
const unsupported = 'blue'
const result = transparentifyColor(unsupported, 0.5)
expect(result).toBe(unsupported)
expect(logger.warn).toHaveBeenCalledWith(
`Could not make '${unsupported}' transparent`,
)
})
})
+28
View File
@@ -1,3 +1,5 @@
import {logger} from '#/logger'
export const BLUE_HUE = 211
export const RED_HUE = 346
export const GREEN_HUE = 152
@@ -19,3 +21,29 @@ export function generateScale(start: number, end: number) {
export const defaultScale = generateScale(6, 100)
// dim shifted 6% lighter
export const dimScale = generateScale(12, 100)
export function transparentifyColor(color: string, alpha: number) {
if (color.startsWith('hsl(')) {
return 'hsla(' + color.slice('hsl('.length, -1) + `, ${alpha})`
} else if (color.startsWith('rgb(')) {
return 'rgba(' + color.slice('rgb('.length, -1) + `, ${alpha})`
} else if (color.startsWith('#')) {
if (color.length === 7) {
const alphaHex = Math.round(alpha * 255).toString(16)
// Per MDN: If there is only one number, it is duplicated: e means ee
// https://developer.mozilla.org/en-US/docs/Web/CSS/hex-color
return color.slice(0, 7) + alphaHex.padStart(2, alphaHex)
} else if (color.length === 4) {
// convert to 6-digit hex before adding alpha
const [r, g, b] = color.slice(1).split('')
const alphaHex = Math.round(alpha * 255).toString(16)
return `#${r.repeat(2)}${g.repeat(2)}${b.repeat(2)}${alphaHex.padStart(
2,
alphaHex,
)}`
}
} else {
logger.warn(`Could not make '${color}' transparent`)
}
return color
}
+1 -1
View File
@@ -1,7 +1,7 @@
import * as SystemUI from 'expo-system-ui'
import {isAndroid} from '#/platform/detection'
import {Theme} from '../types'
import {type Theme} from '../types'
export function setSystemUITheme(themeType: 'theme' | 'lightbox', t: Theme) {
if (isAndroid) {
+1 -1
View File
@@ -2,7 +2,7 @@ import {Pressable} from 'react-native'
import Animated, {
Extrapolation,
interpolate,
SharedValue,
type SharedValue,
useAnimatedStyle,
} from 'react-native-reanimated'
import {msg} from '@lingui/macro'
+12 -6
View File
@@ -1,24 +1,30 @@
import React from 'react'
import {View} from 'react-native'
import {ScrollView} from 'react-native-gesture-handler'
import {AppBskyFeedDefs, AtUri} from '@atproto/api'
import {type AppBskyFeedDefs, AtUri} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
import {NavigationProp} from '#/lib/routes/types'
import {type NavigationProp} from '#/lib/routes/types'
import {logEvent} from '#/lib/statsig/statsig'
import {logger} from '#/logger'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useGetPopularFeedsQuery} from '#/state/queries/feed'
import {FeedDescriptor} from '#/state/queries/post-feed'
import {type FeedDescriptor} from '#/state/queries/post-feed'
import {useProfilesQuery} from '#/state/queries/profile'
import {useSuggestedFollowsByActorQuery} from '#/state/queries/suggested-follows'
import {useSession} from '#/state/session'
import * as userActionHistory from '#/state/userActionHistory'
import {SeenPost} from '#/state/userActionHistory'
import {type SeenPost} from '#/state/userActionHistory'
import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture'
import {atoms as a, useBreakpoints, useTheme, ViewStyleProp, web} from '#/alf'
import {
atoms as a,
useBreakpoints,
useTheme,
type ViewStyleProp,
web,
} from '#/alf'
import {Button} from '#/components/Button'
import * as FeedCard from '#/components/FeedCard'
import {ArrowRight_Stroke2_Corner0_Rounded as Arrow} from '#/components/icons/Arrow'
@@ -27,7 +33,7 @@ import {PersonPlus_Stroke2_Corner0_Rounded as Person} from '#/components/icons/P
import {InlineLinkText} from '#/components/Link'
import * as ProfileCard from '#/components/ProfileCard'
import {Text} from '#/components/Typography'
import * as bsky from '#/types/bsky'
import type * as bsky from '#/types/bsky'
import {ProgressGuideList} from './ProgressGuide/List'
const MOBILE_CARD_WIDTH = 300
+4 -4
View File
@@ -1,24 +1,24 @@
import {createContext, useCallback, useContext} from 'react'
import {GestureResponderEvent, Keyboard, View} from 'react-native'
import {type GestureResponderEvent, Keyboard, View} from 'react-native'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
import {HITSLOP_30} from '#/lib/constants'
import {NavigationProp} from '#/lib/routes/types'
import {type NavigationProp} from '#/lib/routes/types'
import {isIOS} from '#/platform/detection'
import {useSetDrawerOpen} from '#/state/shell'
import {
atoms as a,
platform,
TextStyleProp,
type TextStyleProp,
useBreakpoints,
useGutters,
useLayoutBreakpoints,
useTheme,
web,
} from '#/alf'
import {Button, ButtonIcon, ButtonProps} from '#/components/Button'
import {Button, ButtonIcon, type ButtonProps} from '#/components/Button'
import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeft} from '#/components/icons/Arrow'
import {Menu_Stroke2_Corner0_Rounded as Menu} from '#/components/icons/Menu'
import {
+3 -5
View File
@@ -4,7 +4,6 @@ import {sanitizeUrl} from '@braintree/sanitize-url'
import {
type LinkProps as RNLinkProps,
StackActions,
useLinkBuilder,
} from '@react-navigation/native'
import {BSKY_DOWNLOAD_URL} from '#/lib/constants'
@@ -95,20 +94,19 @@ export function useLink({
shouldProxy?: boolean
}) {
const navigation = useNavigationDeduped()
const {buildHref} = useLinkBuilder()
const href = useMemo(() => {
return typeof to === 'string'
? convertBskyAppUrlIfNeeded(sanitizeUrl(to))
: to.screen
? buildHref(to.screen, to.params)
? router.matchName(to.screen)?.build(to.params)
: to.href
? convertBskyAppUrlIfNeeded(sanitizeUrl(to.href))
: undefined
}, [to, buildHref])
}, [to])
if (!href) {
throw new Error(
'Link `to` prop must be a string or an object with `screen` and `params` properties',
'Could not resolve screen. Link `to` prop must be a string or an object with `screen` and `params` properties',
)
}
+1 -1
View File
@@ -1,6 +1,6 @@
import React from 'react'
import type {ContextType, ItemContextType} from '#/components/Menu/types'
import {type ContextType, type ItemContextType} from '#/components/Menu/types'
export const Context = React.createContext<ContextType | null>(null)
@@ -14,7 +14,7 @@ import {EmbedConsentDialog} from '#/components/dialogs/EmbedConsent'
import {Fill} from '#/components/Fill'
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
export function ExternalGifEmbed({
export function ExternalGif({
link,
params,
}: {
@@ -25,12 +25,12 @@ import {NavigationProp} from '#/lib/routes/types'
import {EmbedPlayerParams, getPlayerAspect} from '#/lib/strings/embed-player'
import {isNative} from '#/platform/detection'
import {useExternalEmbedsPrefs} from '#/state/preferences'
import {EventStopper} from '#/view/com/util/EventStopper'
import {atoms as a, useTheme} from '#/alf'
import {useDialogControl} from '#/components/Dialog'
import {EmbedConsentDialog} from '#/components/dialogs/EmbedConsent'
import {Fill} from '#/components/Fill'
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
import {EventStopper} from '../EventStopper'
interface ShouldStartLoadRequest {
url: string
@@ -12,16 +12,16 @@ import {parseEmbedPlayerFromUrl} from '#/lib/strings/embed-player'
import {toNiceDomain} from '#/lib/strings/url-helpers'
import {isNative} from '#/platform/detection'
import {useExternalEmbedsPrefs} from '#/state/preferences'
import {ExternalGifEmbed} from '#/view/com/util/post-embeds/ExternalGifEmbed'
import {ExternalPlayer} from '#/view/com/util/post-embeds/ExternalPlayerEmbed'
import {GifEmbed} from '#/view/com/util/post-embeds/GifEmbed'
import {atoms as a, useTheme} from '#/alf'
import {Divider} from '#/components/Divider'
import {Earth_Stroke2_Corner0_Rounded as Globe} from '#/components/icons/Globe'
import {Link} from '#/components/Link'
import {Text} from '#/components/Typography'
import {ExternalGif} from './ExternalGif'
import {ExternalPlayer} from './ExternalPlayer'
import {GifEmbed} from './Gif'
export const ExternalLinkEmbed = ({
export const ExternalEmbed = ({
link,
onOpen,
style,
@@ -106,7 +106,7 @@ export const ExternalLinkEmbed = ({
) : undefined}
{embedPlayerParams?.isGif ? (
<ExternalGifEmbed link={link} params={embedPlayerParams} />
<ExternalGif link={link} params={embedPlayerParams} />
) : embedPlayerParams ? (
<ExternalPlayer link={link} params={embedPlayerParams} />
) : undefined}
+52
View File
@@ -0,0 +1,52 @@
import React from 'react'
import {StyleSheet} from 'react-native'
import {moderateFeedGenerator} from '@atproto/api'
import {usePalette} from '#/lib/hooks/usePalette'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {FeedSourceCard} from '#/view/com/feeds/FeedSourceCard'
import {ContentHider} from '#/components/moderation/ContentHider'
import {type EmbedType} from '#/types/bsky/post'
import {type CommonProps} from './types'
export function FeedEmbed({
embed,
}: CommonProps & {
embed: EmbedType<'feed'>
}) {
const pal = usePalette('default')
return (
<FeedSourceCard
feedUri={embed.view.uri}
style={[pal.view, pal.border, styles.customFeedOuter]}
showLikes
/>
)
}
export function ModeratedFeedEmbed({
embed,
}: CommonProps & {
embed: EmbedType<'feed'>
}) {
const moderationOpts = useModerationOpts()
const moderation = React.useMemo(() => {
return moderationOpts
? moderateFeedGenerator(embed.view, moderationOpts)
: undefined
}, [embed.view, moderationOpts])
return (
<ContentHider modui={moderation?.ui('contentList')}>
<FeedEmbed embed={embed} />
</ContentHider>
)
}
const styles = StyleSheet.create({
customFeedOuter: {
borderWidth: StyleSheet.hairlineWidth,
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 12,
},
})
+106
View File
@@ -0,0 +1,106 @@
import {InteractionManager, View} from 'react-native'
import {
type AnimatedRef,
measure,
type MeasuredDimensions,
runOnJS,
runOnUI,
} from 'react-native-reanimated'
import {Image} from 'expo-image'
import {useLightboxControls} from '#/state/lightbox'
import {type Dimensions} from '#/view/com/lightbox/ImageViewing/@types'
import {AutoSizedImage} from '#/view/com/util/images/AutoSizedImage'
import {ImageLayoutGrid} from '#/view/com/util/images/ImageLayoutGrid'
import {atoms as a} from '#/alf'
import {PostEmbedViewContext} from '#/components/Post/Embed/types'
import {type EmbedType} from '#/types/bsky/post'
import {type CommonProps} from './types'
export function ImageEmbed({
embed,
...rest
}: CommonProps & {
embed: EmbedType<'images'>
}) {
const {openLightbox} = useLightboxControls()
const {images} = embed.view
if (images.length > 0) {
const items = images.map(img => ({
uri: img.fullsize,
thumbUri: img.thumb,
alt: img.alt,
dimensions: img.aspectRatio ?? null,
}))
const _openLightbox = (
index: number,
thumbRects: (MeasuredDimensions | null)[],
fetchedDims: (Dimensions | null)[],
) => {
openLightbox({
images: items.map((item, i) => ({
...item,
thumbRect: thumbRects[i] ?? null,
thumbDimensions: fetchedDims[i] ?? null,
type: 'image',
})),
index,
})
}
const onPress = (
index: number,
refs: AnimatedRef<any>[],
fetchedDims: (Dimensions | null)[],
) => {
runOnUI(() => {
'worklet'
const rects: (MeasuredDimensions | null)[] = []
for (const r of refs) {
rects.push(measure(r))
}
runOnJS(_openLightbox)(index, rects, fetchedDims)
})()
}
const onPressIn = (_: number) => {
InteractionManager.runAfterInteractions(() => {
Image.prefetch(items.map(i => i.uri))
})
}
if (images.length === 1) {
const image = images[0]
return (
<View style={[a.mt_sm, rest.style]}>
<AutoSizedImage
crop={
rest.viewContext === PostEmbedViewContext.ThreadHighlighted
? 'none'
: rest.viewContext ===
PostEmbedViewContext.FeedEmbedRecordWithMedia
? 'square'
: 'constrained'
}
image={image}
onPress={(containerRef, dims) => onPress(0, [containerRef], [dims])}
onPressIn={() => onPressIn(0)}
hideBadge={
rest.viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
}
/>
</View>
)
}
return (
<View style={[a.mt_sm, rest.style]}>
<ImageLayoutGrid
images={images}
onPress={onPress}
onPressIn={onPressIn}
viewContext={rest.viewContext}
/>
</View>
)
}
}
@@ -0,0 +1,37 @@
import {useMemo} from 'react'
import {View} from 'react-native'
import {createEmbedViewRecordFromPost} from '#/state/queries/postgate/util'
import {useResolveLinkQuery} from '#/state/queries/resolve-link'
import {atoms as a, useTheme} from '#/alf'
import {QuoteEmbed} from '#/components/Post/Embed'
export function LazyQuoteEmbed({uri}: {uri: string}) {
const t = useTheme()
const {data} = useResolveLinkQuery(uri)
const view = useMemo(() => {
if (!data || data.type !== 'record' || data.kind !== 'post') return
return createEmbedViewRecordFromPost(data.view)
}, [data])
return view ? (
<QuoteEmbed
embed={{
type: 'post',
view,
}}
/>
) : (
<View
style={[
a.w_full,
a.rounded_md,
t.atoms.bg_contrast_25,
{
height: 68,
},
]}
/>
)
}
+42
View File
@@ -0,0 +1,42 @@
import React from 'react'
import {View} from 'react-native'
import {moderateUserList} from '@atproto/api'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {atoms as a, useTheme} from '#/alf'
import * as ListCard from '#/components/ListCard'
import {ContentHider} from '#/components/moderation/ContentHider'
import {EmbedType} from '#/types/bsky/post'
import {CommonProps} from './types'
export function ListEmbed({
embed,
}: CommonProps & {
embed: EmbedType<'list'>
}) {
const t = useTheme()
return (
<View
style={[a.border, t.atoms.border_contrast_medium, a.p_md, a.rounded_sm]}>
<ListCard.Default view={embed.view} />
</View>
)
}
export function ModeratedListEmbed({
embed,
}: CommonProps & {
embed: EmbedType<'list'>
}) {
const moderationOpts = useModerationOpts()
const moderation = React.useMemo(() => {
return moderationOpts
? moderateUserList(embed.view, moderationOpts)
: undefined
}, [embed.view, moderationOpts])
return (
<ContentHider modui={moderation?.ui('contentList')}>
<ListEmbed embed={embed} />
</ContentHider>
)
}
@@ -0,0 +1,33 @@
import {StyleSheet, View} from 'react-native'
import {usePalette} from '#/lib/hooks/usePalette'
import {InfoCircleIcon} from '#/lib/icons'
import {Text} from '#/view/com/util/text/Text'
import {atoms as a, useTheme} from '#/alf'
export function PostPlaceholder({children}: {children: React.ReactNode}) {
const t = useTheme()
const pal = usePalette('default')
return (
<View
style={[styles.errorContainer, a.border, t.atoms.border_contrast_low]}>
<InfoCircleIcon size={18} style={pal.text} />
<Text type="lg" style={pal.text}>
{children}
</Text>
</View>
)
}
const styles = StyleSheet.create({
errorContainer: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
borderRadius: 8,
marginTop: 8,
paddingVertical: 14,
paddingHorizontal: 14,
borderWidth: StyleSheet.hairlineWidth,
},
})
@@ -7,7 +7,6 @@ import {useLingui} from '@lingui/react'
import {HITSLOP_30} from '#/lib/constants'
import {useAutoplayDisabled} from '#/state/preferences'
import {useVideoMuteState} from '#/view/com/util/post-embeds/VideoVolumeContext'
import {atoms as a, useTheme} from '#/alf'
import {useIsWithinMessage} from '#/components/dms/MessageContext'
import {Mute_Stroke2_Corner0_Rounded as MuteIcon} from '#/components/icons/Mute'
@@ -15,6 +14,7 @@ import {Pause_Filled_Corner0_Rounded as PauseIcon} from '#/components/icons/Paus
import {Play_Filled_Corner0_Rounded as PlayIcon} from '#/components/icons/Play'
import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as UnmuteIcon} from '#/components/icons/Speaker'
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
import {useVideoMuteState} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext'
import {TimeIndicator} from './TimeIndicator'
export const VideoEmbedInnerNative = React.forwardRef(
@@ -1,4 +1,4 @@
import React, {useEffect, useId, useRef, useState} from 'react'
import {useEffect, useId, useRef, useState} from 'react'
import {View} from 'react-native'
import {type AppBskyEmbedVideo} from '@atproto/api'
import {msg} from '@lingui/macro'
@@ -28,7 +28,7 @@ export function VideoEmbedInnerWeb({
const videoRef = useRef<HTMLVideoElement>(null)
const [focused, setFocused] = useState(false)
const [hasSubtitleTrack, setHasSubtitleTrack] = useState(false)
const [hlsLoading, setHlsLoading] = React.useState(false)
const [hlsLoading, setHlsLoading] = useState(false)
const figId = useId()
const {_} = useLingui()
@@ -101,8 +101,8 @@ export function VideoEmbedInnerWeb({
fullscreenRef={containerRef}
hasSubtitleTrack={hasSubtitleTrack}
/>
<MediaInsetBorder />
</div>
<MediaInsetBorder />
</View>
)
}
@@ -1,8 +1,8 @@
import React from 'react'
import {SvgProps} from 'react-native-svg'
import {PressableWithHover} from '#/view/com/util/PressableWithHover'
import {atoms as a, useTheme, web} from '#/alf'
import {PressableWithHover} from '../../../PressableWithHover'
export function ControlButton({
active,
@@ -1,4 +1,4 @@
import React, {useCallback, useEffect, useRef, useState} from 'react'
import {useCallback, useEffect, useRef, useState} from 'react'
import {Pressable, View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
@@ -8,7 +8,7 @@ import {isSafari, isTouchDevice} from '#/lib/browser'
import {atoms as a} from '#/alf'
import {Mute_Stroke2_Corner0_Rounded as MuteIcon} from '#/components/icons/Mute'
import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as UnmuteIcon} from '#/components/icons/Speaker'
import {useVideoVolumeState} from '../../VideoVolumeContext'
import {useVideoVolumeState} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext'
import {ControlButton} from './ControlButton'
export function VolumeControl({
@@ -1,9 +1,9 @@
import React, {useCallback, useEffect, useRef, useState} from 'react'
import {type RefObject, useCallback, useEffect, useRef, useState} from 'react'
import {isSafari} from '#/lib/browser'
import {useVideoVolumeState} from '../../VideoVolumeContext'
import {useVideoVolumeState} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext'
export function useVideoElement(ref: React.RefObject<HTMLVideoElement>) {
export function useVideoElement(ref: RefObject<HTMLVideoElement>) {
const [playing, setPlaying] = useState(false)
const [muted, setMuted] = useState(true)
const [currentTime, setCurrentTime] = useState(0)
@@ -5,13 +5,13 @@ import {AppBskyEmbedVideo} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
import {ConstrainedImage} from '#/view/com/util/images/AutoSizedImage'
import {VideoEmbedInnerNative} from '#/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative'
import {atoms as a, useTheme} from '#/alf'
import {Button} from '#/components/Button'
import {useThrottledValue} from '#/components/hooks/useThrottledValue'
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
import {ErrorBoundary} from '../ErrorBoundary'
import {VideoEmbedInnerNative} from './VideoEmbedInner/VideoEmbedInnerNative'
import * as VideoFallback from './VideoEmbedInner/VideoFallback'
interface Props {
@@ -5,16 +5,16 @@ import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {isFirefox} from '#/lib/browser'
import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
import {ConstrainedImage} from '#/view/com/util/images/AutoSizedImage'
import {atoms as a} from '#/alf'
import {useIsWithinMessage} from '#/components/dms/MessageContext'
import {useFullscreen} from '#/components/hooks/useFullscreen'
import {
HLSUnsupportedError,
VideoEmbedInnerWeb,
VideoNotFoundError,
} from '#/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb'
import {atoms as a} from '#/alf'
import {useIsWithinMessage} from '#/components/dms/MessageContext'
import {useFullscreen} from '#/components/hooks/useFullscreen'
import {ErrorBoundary} from '../ErrorBoundary'
} from '#/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb'
import {useActiveVideoWeb} from './ActiveVideoWebContext'
import * as VideoFallback from './VideoEmbedInner/VideoFallback'
+332
View File
@@ -0,0 +1,332 @@
import React from 'react'
import {View} from 'react-native'
import {
type $Typed,
type AppBskyFeedDefs,
AppBskyFeedPost,
AtUri,
moderatePost,
RichText as RichTextAPI,
} from '@atproto/api'
import {Trans} from '@lingui/macro'
import {useQueryClient} from '@tanstack/react-query'
import {usePalette} from '#/lib/hooks/usePalette'
import {makeProfileLink} from '#/lib/routes/links'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {unstableCacheProfileView} from '#/state/queries/profile'
import {useSession} from '#/state/session'
import {Link} from '#/view/com/util/Link'
import {PostMeta} from '#/view/com/util/PostMeta'
import {atoms as a, useTheme} from '#/alf'
import {ContentHider} from '#/components/moderation/ContentHider'
import {PostAlerts} from '#/components/moderation/PostAlerts'
import {RichText} from '#/components/RichText'
import {Embed as StarterPackCard} from '#/components/StarterPack/StarterPackCard'
import {SubtleWebHover} from '#/components/SubtleWebHover'
import * as bsky from '#/types/bsky'
import {
type Embed as TEmbed,
type EmbedType,
parseEmbed,
} from '#/types/bsky/post'
import {ExternalEmbed} from './ExternalEmbed'
import {ModeratedFeedEmbed} from './FeedEmbed'
import {ImageEmbed} from './ImageEmbed'
import {ModeratedListEmbed} from './ListEmbed'
import {PostPlaceholder as PostPlaceholderText} from './PostPlaceholder'
import {
type CommonProps,
type EmbedProps,
PostEmbedViewContext,
QuoteEmbedViewContext,
} from './types'
import {VideoEmbed} from './VideoEmbed'
export {PostEmbedViewContext, QuoteEmbedViewContext} from './types'
export function Embed({embed: rawEmbed, ...rest}: EmbedProps) {
const embed = parseEmbed(rawEmbed)
switch (embed.type) {
case 'images':
case 'link':
case 'video': {
return <MediaEmbed embed={embed} {...rest} />
}
case 'feed':
case 'list':
case 'starter_pack':
case 'labeler':
case 'post':
case 'post_not_found':
case 'post_blocked':
case 'post_detached': {
return <RecordEmbed embed={embed} {...rest} />
}
case 'post_with_media': {
return (
<View style={rest.style}>
<MediaEmbed embed={embed.media} {...rest} />
<RecordEmbed embed={embed.view} {...rest} />
</View>
)
}
default: {
return null
}
}
}
function MediaEmbed({
embed,
...rest
}: CommonProps & {
embed: TEmbed
}) {
switch (embed.type) {
case 'images': {
return (
<ContentHider modui={rest.moderation?.ui('contentMedia')}>
<ImageEmbed embed={embed} {...rest} />
</ContentHider>
)
}
case 'link': {
return (
<ContentHider modui={rest.moderation?.ui('contentMedia')}>
<ExternalEmbed
link={embed.view.external}
onOpen={rest.onOpen}
style={[a.mt_sm, rest.style]}
/>
</ContentHider>
)
}
case 'video': {
return (
<ContentHider modui={rest.moderation?.ui('contentMedia')}>
<VideoEmbed embed={embed.view} />
</ContentHider>
)
}
default: {
return null
}
}
}
function RecordEmbed({
embed,
...rest
}: CommonProps & {
embed: TEmbed
}) {
switch (embed.type) {
case 'feed': {
return (
<View style={a.mt_sm}>
<ModeratedFeedEmbed embed={embed} {...rest} />
</View>
)
}
case 'list': {
return (
<View style={a.mt_sm}>
<ModeratedListEmbed embed={embed} />
</View>
)
}
case 'starter_pack': {
return (
<View style={a.mt_sm}>
<StarterPackCard starterPack={embed.view} />
</View>
)
}
case 'labeler': {
// not implemented
return null
}
case 'post': {
if (rest.isWithinQuote && !rest.allowNestedQuotes) {
return null
}
return (
<QuoteEmbed
{...rest}
embed={embed}
viewContext={
rest.viewContext === PostEmbedViewContext.Feed
? QuoteEmbedViewContext.FeedEmbedRecordWithMedia
: undefined
}
isWithinQuote={rest.isWithinQuote}
allowNestedQuotes={rest.allowNestedQuotes}
/>
)
}
case 'post_not_found': {
return (
<PostPlaceholderText>
<Trans>Deleted</Trans>
</PostPlaceholderText>
)
}
case 'post_blocked': {
return (
<PostPlaceholderText>
<Trans>Blocked</Trans>
</PostPlaceholderText>
)
}
case 'post_detached': {
return <PostDetachedEmbed embed={embed} />
}
default: {
return null
}
}
}
export function PostDetachedEmbed({
embed,
}: {
embed: EmbedType<'post_detached'>
}) {
const {currentAccount} = useSession()
const isViewerOwner = currentAccount?.did
? embed.view.uri.includes(currentAccount.did)
: false
return (
<PostPlaceholderText>
{isViewerOwner ? (
<Trans>Removed by you</Trans>
) : (
<Trans>Removed by author</Trans>
)}
</PostPlaceholderText>
)
}
/*
* Nests parent `Embed` component and therefore must live in this file to avoid
* circular imports.
*/
export function QuoteEmbed({
embed,
onOpen,
style,
isWithinQuote: parentIsWithinQuote,
allowNestedQuotes: parentAllowNestedQuotes,
}: Omit<CommonProps, 'viewContext'> & {
embed: EmbedType<'post'>
viewContext?: QuoteEmbedViewContext
}) {
const moderationOpts = useModerationOpts()
const quote = React.useMemo<$Typed<AppBskyFeedDefs.PostView>>(
() => ({
...embed.view,
$type: 'app.bsky.feed.defs#postView',
record: embed.view.value,
embed: embed.view.embeds?.[0],
}),
[embed],
)
const moderation = React.useMemo(() => {
return moderationOpts ? moderatePost(quote, moderationOpts) : undefined
}, [quote, moderationOpts])
const t = useTheme()
const queryClient = useQueryClient()
const pal = usePalette('default')
const itemUrip = new AtUri(quote.uri)
const itemHref = makeProfileLink(quote.author, 'post', itemUrip.rkey)
const itemTitle = `Post by ${quote.author.handle}`
const richText = React.useMemo(() => {
if (
!bsky.dangerousIsType<AppBskyFeedPost.Record>(
quote.record,
AppBskyFeedPost.isRecord,
)
)
return undefined
const {text, facets} = quote.record
return text.trim()
? new RichTextAPI({text: text, facets: facets})
: undefined
}, [quote.record])
const onBeforePress = React.useCallback(() => {
unstableCacheProfileView(queryClient, quote.author)
onOpen?.()
}, [queryClient, quote.author, onOpen])
const [hover, setHover] = React.useState(false)
return (
<View
onPointerEnter={() => {
setHover(true)
}}
onPointerLeave={() => {
setHover(false)
}}>
<ContentHider
modui={moderation?.ui('contentList')}
style={[
a.rounded_md,
a.p_md,
a.mt_sm,
a.border,
t.atoms.border_contrast_low,
style,
]}
childContainerStyle={[a.pt_sm]}>
<SubtleWebHover hover={hover} />
<Link
hoverStyle={{borderColor: pal.colors.borderLinkHover}}
href={itemHref}
title={itemTitle}
onBeforePress={onBeforePress}>
<View pointerEvents="none">
<PostMeta
author={quote.author}
moderation={moderation}
showAvatar
postHref={itemHref}
timestamp={quote.indexedAt}
/>
</View>
{moderation ? (
<PostAlerts
modui={moderation.ui('contentView')}
style={[a.py_xs]}
/>
) : null}
{richText ? (
<RichText
value={richText}
style={a.text_md}
numberOfLines={20}
disableLinks
/>
) : null}
{quote.embed && (
<Embed
embed={quote.embed}
moderation={moderation}
isWithinQuote={parentIsWithinQuote ?? true}
// already within quote? override nested
allowNestedQuotes={
parentIsWithinQuote ? false : parentAllowNestedQuotes
}
/>
)}
</Link>
</ContentHider>
</View>
)
}
+25
View File
@@ -0,0 +1,25 @@
import {type StyleProp, type ViewStyle} from 'react-native'
import {type AppBskyFeedDefs, type ModerationDecision} from '@atproto/api'
export enum PostEmbedViewContext {
ThreadHighlighted = 'ThreadHighlighted',
Feed = 'Feed',
FeedEmbedRecordWithMedia = 'FeedEmbedRecordWithMedia',
}
export enum QuoteEmbedViewContext {
FeedEmbedRecordWithMedia = PostEmbedViewContext.FeedEmbedRecordWithMedia,
}
export type CommonProps = {
moderation?: ModerationDecision
onOpen?: () => void
style?: StyleProp<ViewStyle>
viewContext?: PostEmbedViewContext
isWithinQuote?: boolean
allowNestedQuotes?: boolean
}
export type EmbedProps = CommonProps & {
embed?: AppBskyFeedDefs.PostView['embed']
}
+107
View File
@@ -0,0 +1,107 @@
import {type ReactNode} from 'react'
import {View} from 'react-native'
import {
atoms as a,
flatten,
type TextStyleProp,
useAlf,
useTheme,
type ViewStyleProp,
} from '#/alf'
import {normalizeTextStyles} from '#/alf/typography'
type SkeletonProps = {
blend?: boolean
}
export function Text({blend, style}: TextStyleProp & SkeletonProps) {
const {fonts, flags, theme: t} = useAlf()
const {width, ...flattened} = flatten(style)
const {lineHeight = 14, ...rest} = normalizeTextStyles(
[a.text_sm, a.leading_snug, flattened],
{
fontScale: fonts.scaleMultiplier,
fontFamily: fonts.family,
flags,
},
)
return (
<View
style={[a.flex_1, {maxWidth: width, paddingVertical: lineHeight * 0.15}]}>
<View
style={[
a.rounded_md,
t.atoms.bg_contrast_25,
{
height: lineHeight * 0.7,
opacity: blend ? 0.6 : 1,
},
rest,
]}
/>
</View>
)
}
export function Circle({
children,
size,
blend,
style,
}: ViewStyleProp & {children?: ReactNode; size: number} & SkeletonProps) {
const t = useTheme()
return (
<View
style={[
a.justify_center,
a.align_center,
a.rounded_full,
t.atoms.bg_contrast_25,
{
width: size,
height: size,
opacity: blend ? 0.6 : 1,
},
style,
]}>
{children}
</View>
)
}
export function Pill({
size,
blend,
style,
}: ViewStyleProp & {size: number} & SkeletonProps) {
const t = useTheme()
return (
<View
style={[
a.rounded_full,
t.atoms.bg_contrast_25,
{
width: size * 1.618,
height: size,
opacity: blend ? 0.6 : 1,
},
style,
]}
/>
)
}
export function Col({
children,
style,
}: ViewStyleProp & {children?: React.ReactNode}) {
return <View style={[a.flex_1, style]}>{children}</View>
}
export function Row({
children,
style,
}: ViewStyleProp & {children?: React.ReactNode}) {
return <View style={[a.flex_row, style]}>{children}</View>
}
@@ -397,6 +397,7 @@ function DefaultProfileCard({
<ProfileCard.Avatar
profile={profile}
moderationOpts={moderationOpts}
disabledPreview
/>
<View style={[a.flex_1]}>
<ProfileCard.Name
+1 -1
View File
@@ -1,5 +1,5 @@
import {View} from 'react-native'
import {ChatBskyConvoDefs} from '@atproto/api'
import {type ChatBskyConvoDefs} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
+5 -4
View File
@@ -1,15 +1,16 @@
import React from 'react'
import {useWindowDimensions, View} from 'react-native'
import {AppBskyEmbedRecord} from '@atproto/api'
import {type $Typed, type AppBskyEmbedRecord} from '@atproto/api'
import {PostEmbeds, PostEmbedViewContext} from '#/view/com/util/post-embeds'
import {atoms as a, native, tokens, useTheme, web} from '#/alf'
import {PostEmbedViewContext} from '#/components/Post/Embed'
import {Embed} from '#/components/Post/Embed'
import {MessageContextProvider} from './MessageContext'
let MessageItemEmbed = ({
embed,
}: {
embed: AppBskyEmbedRecord.View
embed: $Typed<AppBskyEmbedRecord.View>
}): React.ReactNode => {
const t = useTheme()
const screen = useWindowDimensions()
@@ -32,7 +33,7 @@ let MessageItemEmbed = ({
}),
]}>
<View style={{marginTop: tokens.space.sm * -1}}>
<PostEmbeds
<Embed
embed={embed}
allowNestedQuotes
viewContext={PostEmbedViewContext.Feed}
+7 -7
View File
@@ -1,12 +1,12 @@
import React from 'react'
import {
AccessibilityProps,
type AccessibilityProps,
StyleSheet,
TextInput,
TextInputProps,
TextStyle,
type TextInputProps,
type TextStyle,
View,
ViewStyle,
type ViewStyle,
} from 'react-native'
import {HITSLOP_20} from '#/lib/constants'
@@ -16,13 +16,13 @@ import {
applyFonts,
atoms as a,
ios,
TextStyleProp,
type TextStyleProp,
useAlf,
useTheme,
web,
} from '#/alf'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import {Props as SVGIconProps} from '#/components/icons/common'
import {type Props as SVGIconProps} from '#/components/icons/common'
import {Text} from '#/components/Typography'
const Context = React.createContext<{
@@ -196,7 +196,7 @@ export function createInput(Component: typeof TextInput) {
minWidth: 0,
},
ios({paddingTop: 12, paddingBottom: 13}),
android(a.py_sm),
android(a.py_md),
// fix for autofill styles covering border
web({
paddingTop: 10,
+2 -2
View File
@@ -8,7 +8,7 @@
*/
import React from 'react'
import {formatDistance, Locale} from 'date-fns'
import {formatDistance, type Locale} from 'date-fns'
import {
ca,
cy,
@@ -47,7 +47,7 @@ import {
zhTW,
} from 'date-fns/locale'
import {AppLanguage} from '#/locale/languages'
import {type AppLanguage} from '#/locale/languages'
import {useLanguagePrefs} from '#/state/preferences'
/**
+5
View File
@@ -0,0 +1,5 @@
import {createSinglePathSVG} from './TEMPLATE'
export const ArrowTopCircle_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M12 2c5.523 0 10 4.477 10 10s-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2Zm0 2a8 8 0 1 0 0 16 8 8 0 0 0 0-16Zm-.63 3.225a1 1 0 0 1 1.337.068l3 3 .068.076a1 1 0 0 1-1.406 1.406l-.076-.068L13 10.414V16a1 1 0 1 1-2 0v-5.586l-1.293 1.293a1 1 0 1 1-1.414-1.414l3-3 .076-.068Z',
})
+5
View File
@@ -0,0 +1,5 @@
import {createSinglePathSVG} from './TEMPLATE'
export const CirclePlus_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M12 2c5.523 0 10 4.477 10 10s-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2Zm0 2a8 8 0 1 0 0 16 8 8 0 0 0 0-16Zm0 3a1 1 0 0 1 1 1v3h3l.102.005a1 1 0 0 1 0 1.99L16 13h-3v3a1 1 0 1 1-2 0v-3H8a1 1 0 0 1 0-2h3V8a1 1 0 0 1 1-1Z',
})
+5
View File
@@ -0,0 +1,5 @@
import {createSinglePathSVG} from './TEMPLATE'
export const Tree_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M6 2a2.998 2.998 0 0 1 1 5.825V8a2 2 0 0 0 2 2h1.174c.412-1.165 1.52-2 2.826-2h5a3 3 0 1 1 0 6h-5a2.998 2.998 0 0 1-2.826-2H9a3.98 3.98 0 0 1-2-.537V16a2 2 0 0 0 2 2h1.174c.412-1.165 1.52-2 2.826-2h5a3 3 0 1 1 0 6h-5a2.998 2.998 0 0 1-2.826-2H9a4 4 0 0 1-4-4V7.825A2.998 2.998 0 0 1 6 2Zm7 16a1 1 0 1 0 0 2h5a1 1 0 1 0 0-2h-5Zm0-8a1 1 0 1 0 0 2h5a1 1 0 1 0 0-2h-5ZM6 4a1 1 0 1 0 0 2 1 1 0 0 0 0-2Z',
})
+13 -3
View File
@@ -1,6 +1,16 @@
import React, {ComponentProps} from 'react'
import {Pressable, StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
import {AppBskyActorDefs, ModerationCause, ModerationUI} from '@atproto/api'
import React, {type ComponentProps} from 'react'
import {
Pressable,
type StyleProp,
StyleSheet,
View,
type ViewStyle,
} from 'react-native'
import {
type AppBskyActorDefs,
type ModerationCause,
type ModerationUI,
} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useQueryClient} from '@tanstack/react-query'
+9 -4
View File
@@ -1,17 +1,22 @@
import {timeout} from '#/lib/async/timeout'
import {isNetworkError} from '#/lib/strings/errors'
export async function retry<P>(
retries: number,
cond: (err: any) => boolean,
fn: () => Promise<P>,
shouldRetry: (err: any) => boolean,
action: () => Promise<P>,
delay?: number,
): Promise<P> {
let lastErr
while (retries > 0) {
try {
return await fn()
return await action()
} catch (e: any) {
lastErr = e
if (cond(e)) {
if (shouldRetry(e)) {
if (delay) {
await timeout(delay)
}
retries--
continue
}
+20
View File
@@ -0,0 +1,20 @@
import {useCallback} from 'react'
export enum OnceKey {
PreferencesThread = 'preferences:thread',
}
const called: Record<OnceKey, boolean> = {
[OnceKey.PreferencesThread]: false,
}
export function useCallOnce(key: OnceKey) {
return useCallback(
(cb: () => void) => {
if (called[key] === true) return
called[key] = true
cb()
},
[key],
)
}
-39
View File
@@ -1,39 +0,0 @@
import {useState} from 'react'
import {AnimatedRef, measure, MeasuredDimensions} from 'react-native-reanimated'
export type HandleRef = {
(node: any): void
current: null | number
}
// This is a lighterweight alternative to `useAnimatedRef()` for imperative UI thread actions.
// Render it like <View ref={ref} />, then pass `ref.current` to `measureHandle()` and such.
export function useHandleRef(): HandleRef {
return useState(() => {
const ref = (node: any) => {
if (node) {
ref.current =
node._nativeTag ??
node.__nativeTag ??
node.canonical?.nativeTag ??
null
} else {
ref.current = null
}
}
ref.current = null
return ref
})[0] as HandleRef
}
// When using this version, you need to read ref.current on the JS thread, and pass it to UI.
export function measureHandle(
current: number | null,
): MeasuredDimensions | null {
'worklet'
if (current !== null) {
return measure((() => current) as AnimatedRef<any>)
} else {
return null
}
}
+50
View File
@@ -0,0 +1,50 @@
import {createContext, useCallback, useContext, useState} from 'react'
import {useFocusEffect} from '@react-navigation/native'
type HideBottomBarBorderSetter = () => () => void
const HideBottomBarBorderContext = createContext<boolean>(false)
const HideBottomBarBorderSetterContext =
createContext<HideBottomBarBorderSetter | null>(null)
export function useHideBottomBarBorderSetter() {
const hideBottomBarBorder = useContext(HideBottomBarBorderSetterContext)
if (!hideBottomBarBorder) {
throw new Error(
'useHideBottomBarBorderSetter must be used within a HideBottomBarBorderProvider',
)
}
return hideBottomBarBorder
}
export function useHideBottomBarBorderForScreen() {
const hideBorder = useHideBottomBarBorderSetter()
useFocusEffect(
useCallback(() => {
const cleanup = hideBorder()
return () => cleanup()
}, [hideBorder]),
)
}
export function useHideBottomBarBorder() {
return useContext(HideBottomBarBorderContext)
}
export function Provider({children}: {children: React.ReactNode}) {
const [refCount, setRefCount] = useState(0)
const setter = useCallback(() => {
setRefCount(prev => prev + 1)
return () => setRefCount(prev => prev - 1)
}, [])
return (
<HideBottomBarBorderSetterContext.Provider value={setter}>
<HideBottomBarBorderContext.Provider value={refCount > 0}>
{children}
</HideBottomBarBorderContext.Provider>
</HideBottomBarBorderSetterContext.Provider>
)
}
+37 -50
View File
@@ -1,8 +1,9 @@
import {Image as RNImage, Share as RNShare} from 'react-native'
import {Image as RNImage} from 'react-native'
import uuid from 'react-native-uuid'
import {
cacheDirectory,
copyAsync,
createDownloadResumable,
deleteAsync,
EncodingType,
getInfoAsync,
@@ -14,7 +15,6 @@ import {manipulateAsync, SaveFormat} from 'expo-image-manipulator'
import * as MediaLibrary from 'expo-media-library'
import * as Sharing from 'expo-sharing'
import {Buffer} from 'buffer'
import RNFetchBlob from 'rn-fetch-blob'
import {POST_IMG_MAX} from '#/lib/constants'
import {logger} from '#/logger'
@@ -68,28 +68,13 @@ export async function downloadAndResize(opts: DownloadAndResizeOpts) {
return
}
let downloadRes
const path = createPath(appendExt)
try {
const downloadResPromise = RNFetchBlob.config({
fileCache: true,
appendExt,
}).fetch('GET', opts.uri)
const to1 = setTimeout(() => downloadResPromise.cancel(), opts.timeout)
downloadRes = await downloadResPromise
clearTimeout(to1)
const status = downloadRes.info().status
if (status !== 200) {
return
}
const localUri = normalizePath(downloadRes.path(), true)
return await doResize(localUri, opts)
await downloadImage(opts.uri, path, opts.timeout)
return await doResize(path, opts)
} finally {
// TODO Whenever we remove `rn-fetch-blob`, we will need to replace this `flush()` with a `deleteAsync()` -hailey
if (downloadRes) {
downloadRes.flush()
}
safeDeleteAsync(path)
}
}
@@ -98,32 +83,16 @@ export async function shareImageModal({uri}: {uri: string}) {
// TODO might need to give an error to the user in this case -prf
return
}
const downloadResponse = await RNFetchBlob.config({
fileCache: true,
}).fetch('GET', uri)
// NOTE
// assuming PNG
// we're currently relying on the fact our CDN only serves pngs
// -prf
let imagePath = downloadResponse.path()
imagePath = normalizePath(await moveToPermanentPath(imagePath, '.png'), true)
// NOTE
// for some reason expo-sharing refuses to work on iOS
// ...and visa versa
// -prf
if (isIOS) {
await RNShare.share({url: imagePath})
} else {
await Sharing.shareAsync(imagePath, {
mimeType: 'image/png',
UTI: 'image/png',
})
}
safeDeleteAsync(imagePath)
const imageUri = await downloadImage(uri, createPath('png'), 5e3)
const imagePath = await moveToPermanentPath(imageUri, '.png')
safeDeleteAsync(imageUri)
await Sharing.shareAsync(imagePath, {
mimeType: 'image/png',
UTI: 'image/png',
})
}
const ALBUM_NAME = 'Bluesky'
@@ -134,11 +103,8 @@ export async function saveImageToMediaLibrary({uri}: {uri: string}) {
// assuming PNG
// we're currently relying on the fact our CDN only serves pngs
// -prf
const downloadResponse = await RNFetchBlob.config({
fileCache: true,
}).fetch('GET', uri)
let imagePath = downloadResponse.path()
imagePath = normalizePath(await moveToPermanentPath(imagePath, '.png'), true)
const imageUri = await downloadImage(uri, createPath('png'), 5e3)
const imagePath = await moveToPermanentPath(imageUri, '.png')
// save
try {
@@ -403,3 +369,24 @@ export function getResizedDimensions(originalDims: {
height: Math.round(originalDims.height * ratio),
}
}
function createPath(ext: string) {
// cacheDirectory will never be null on native, so the null check here is not necessary except for typescript.
// we use a web-only function for downloadAndResize on web
return `${cacheDirectory ?? ''}/${uuid.v4()}.${ext}`
}
async function downloadImage(uri: string, path: string, timeout: number) {
const dlResumable = createDownloadResumable(uri, path, {cache: true})
const to1 = setTimeout(() => dlResumable.cancelAsync(), timeout)
const dlRes = await dlResumable.downloadAsync()
clearTimeout(to1)
if (!dlRes?.uri) {
throw new Error('Failed to download image - dlRes is undefined')
}
return normalizePath(dlRes.uri)
}
+4 -4
View File
@@ -1,4 +1,4 @@
import {Route, RouteParams} from './types'
import {type Route, type RouteParams} from './types'
export class Router {
routes: [string, Route][] = []
@@ -45,7 +45,7 @@ function createRoute(pattern: string): Route {
})
const matcherRe = new RegExp(`^${matcherReInternal}([?]|$)`, 'i')
return {
match(path: string) {
match(path) {
const {pathname, searchParams} = new URL(path, 'http://throwaway.com')
const addedParams = Object.fromEntries(searchParams.entries())
@@ -55,10 +55,10 @@ function createRoute(pattern: string): Route {
}
return undefined
},
build(params: Record<string, string>) {
build(params = {}) {
const str = pattern.replace(
/:([\w]+)/g,
(_m, name) => params[name] || 'undefined',
(_m, name) => params[encodeURIComponent(name)] || 'undefined',
)
let hasQp = false
+1 -1
View File
@@ -143,5 +143,5 @@ export type RouteParams = Record<string, string>
export type MatchResult = {params: RouteParams}
export type Route = {
match: (path: string) => MatchResult | undefined
build: (params: RouteParams) => string
build: (params?: Record<string, any>) => string
}
+1
View File
@@ -6,6 +6,7 @@ export type Gate =
| 'explore_show_suggested_feeds'
| 'old_postonboarding'
| 'onboarding_add_video_feed'
| 'post_threads_v2_unspecced'
| 'remove_show_latest_button'
| 'test_gate_1'
| 'test_gate_2'
+1 -1
View File
@@ -1,4 +1,4 @@
import {AppBskyFeedDefs, AppBskyFeedPost} from '@atproto/api'
import {type AppBskyFeedDefs, AppBskyFeedPost} from '@atproto/api'
import * as bcp47Match from 'bcp-47-match'
import lande from 'lande'
File diff suppressed because it is too large Load Diff
+9
View File
@@ -434,4 +434,13 @@ export type MetricEvents = {
'share:press:dmSelected': {}
'share:press:recentDm': {}
'share:press:embed': {}
'thread:click:showOtherReplies': {}
'thread:preferences:load': {
[key: string]: any
}
'thread:preferences:update': {
[key: string]: any
}
'thread:click:headerMenuOpen': {}
}
+2
View File
@@ -10,6 +10,8 @@ export enum LogContext {
ConversationAgent = 'conversation-agent',
DMsAgent = 'dms-agent',
ReportDialog = 'report-dialog',
FeedFeedback = 'feed-feedback',
PostSource = 'post-source',
/**
* METRIC IS FOR INTERNAL USE ONLY, don't create any other loggers using this
+2 -2
View File
@@ -3,12 +3,12 @@ import {
ActivityIndicator,
Keyboard,
LayoutAnimation,
TextInput,
type TextInput,
View,
} from 'react-native'
import {
ComAtprotoServerCreateSession,
ComAtprotoServerDescribeServer,
type ComAtprotoServerDescribeServer,
} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
@@ -16,6 +16,7 @@ import {
RichText,
} from '@atproto/api'
import {useHideBottomBarBorderForScreen} from '#/lib/hooks/useHideBottomBarBorder'
import {ScrollProvider} from '#/lib/ScrollContext'
import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip'
import {
@@ -106,6 +107,8 @@ export function MessagesList({
const getPost = useGetPost()
const {embedUri, setEmbed} = useMessageEmbed()
useHideBottomBarBorderForScreen()
const flatListRef = useAnimatedRef<ListMethods>()
const [newMessagesPill, setNewMessagesPill] = useState({
+3 -3
View File
@@ -2,7 +2,7 @@ import React from 'react'
import {View} from 'react-native'
import {Image as ExpoImage} from 'expo-image'
import {
ImagePickerOptions,
type ImagePickerOptions,
launchImageLibraryAsync,
MediaTypeOptions,
} from 'expo-image-picker'
@@ -27,7 +27,7 @@ import {AvatarCreatorCircle} from '#/screens/Onboarding/StepProfile/AvatarCreato
import {AvatarCreatorItems} from '#/screens/Onboarding/StepProfile/AvatarCreatorItems'
import {
PlaceholderCanvas,
PlaceholderCanvasRef,
type PlaceholderCanvasRef,
} from '#/screens/Onboarding/StepProfile/PlaceholderCanvas'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
@@ -38,7 +38,7 @@ import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components
import {CircleInfo_Stroke2_Corner0_Rounded} from '#/components/icons/CircleInfo'
import {StreamingLive_Stroke2_Corner0_Rounded as StreamingLive} from '#/components/icons/StreamingLive'
import {Text} from '#/components/Typography'
import {AvatarColor, avatarColors, Emoji, emojiItems} from './types'
import {type AvatarColor, avatarColors, type Emoji, emojiItems} from './types'
export interface Avatar {
image?: {
@@ -0,0 +1,106 @@
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {HITSLOP_10} from '#/lib/constants'
import {logger} from '#/logger'
import {type ThreadPreferences} from '#/state/queries/preferences/useThreadPreferences'
import {Button, ButtonIcon} from '#/components/Button'
import {SettingsSliderVertical_Stroke2_Corner0_Rounded as SettingsSlider} from '#/components/icons/SettingsSlider'
import * as Menu from '#/components/Menu'
export function HeaderDropdown({
sort,
view,
setSort,
setView,
}: Pick<
ThreadPreferences,
'sort' | 'setSort' | 'view' | 'setView'
>): React.ReactNode {
const {_} = useLingui()
return (
<Menu.Root>
<Menu.Trigger label={_(msg`Thread options`)}>
{({props: {onPress, ...props}}) => (
<Button
label={_(msg`Thread options`)}
size="small"
variant="ghost"
color="secondary"
shape="round"
hitSlop={HITSLOP_10}
onPress={() => {
logger.metric('thread:click:headerMenuOpen', {})
onPress()
}}
{...props}>
<ButtonIcon icon={SettingsSlider} size="md" />
</Button>
)}
</Menu.Trigger>
<Menu.Outer>
<Menu.LabelText>
<Trans>Show replies as</Trans>
</Menu.LabelText>
<Menu.Group>
<Menu.Item
label={_(msg`Linear`)}
onPress={() => {
setView('linear')
}}>
<Menu.ItemText>
<Trans>Linear</Trans>
</Menu.ItemText>
<Menu.ItemRadio selected={view === 'linear'} />
</Menu.Item>
<Menu.Item
label={_(msg`Threaded`)}
onPress={() => {
setView('tree')
}}>
<Menu.ItemText>
<Trans>Threaded</Trans>
</Menu.ItemText>
<Menu.ItemRadio selected={view === 'tree'} />
</Menu.Item>
</Menu.Group>
<Menu.Divider />
<Menu.LabelText>
<Trans>Reply sorting</Trans>
</Menu.LabelText>
<Menu.Group>
<Menu.Item
label={_(msg`Top replies first`)}
onPress={() => {
setSort('top')
}}>
<Menu.ItemText>
<Trans>Top replies first</Trans>
</Menu.ItemText>
<Menu.ItemRadio selected={sort === 'top'} />
</Menu.Item>
<Menu.Item
label={_(msg`Oldest replies first`)}
onPress={() => {
setSort('oldest')
}}>
<Menu.ItemText>
<Trans>Oldest replies first</Trans>
</Menu.ItemText>
<Menu.ItemRadio selected={sort === 'oldest'} />
</Menu.Item>
<Menu.Item
label={_(msg`Newest replies first`)}
onPress={() => {
setSort('newest')
}}>
<Menu.ItemText>
<Trans>Newest replies first</Trans>
</Menu.ItemText>
<Menu.ItemRadio selected={sort === 'newest'} />
</Menu.Item>
</Menu.Group>
</Menu.Outer>
</Menu.Root>
)
}
@@ -0,0 +1,89 @@
import {useMemo} from 'react'
import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useCleanError} from '#/lib/hooks/useCleanError'
import {OUTER_SPACE} from '#/screens/PostThread/const'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as RetryIcon} from '#/components/icons/ArrowRotateCounterClockwise'
import * as Layout from '#/components/Layout'
import {Text} from '#/components/Typography'
export function ThreadError({
error,
onRetry,
}: {
error: Error
onRetry: () => void
}) {
const t = useTheme()
const {_} = useLingui()
const cleanError = useCleanError()
const {title, message} = useMemo(() => {
let title = _(msg`Error loading post`)
let message = _(msg`Something went wrong. Please try again in a moment.`)
const {raw, clean} = cleanError(error)
if (error.message.startsWith('Post not found')) {
title = _(msg`Post not found`)
message = clean || raw || message
}
return {title, message}
}, [_, error, cleanError])
return (
<Layout.Center>
<View
style={[
a.w_full,
a.align_center,
{
padding: OUTER_SPACE,
paddingTop: OUTER_SPACE * 2,
},
]}>
<View
style={[
a.w_full,
a.align_center,
a.gap_xl,
{
maxWidth: 260,
},
]}>
<View style={[a.gap_xs]}>
<Text
style={[a.text_center, a.text_lg, a.font_bold, a.leading_snug]}>
{title}
</Text>
<Text
style={[
a.text_center,
a.text_sm,
a.leading_snug,
t.atoms.text_contrast_medium,
]}>
{message}
</Text>
</View>
<Button
label={_(msg`Retry`)}
size="small"
variant="solid"
color="secondary_inverted"
onPress={onRetry}>
<ButtonText>
<Trans>Retry</Trans>
</ButtonText>
<ButtonIcon icon={RetryIcon} position="right" />
</Button>
</View>
</View>
</Layout.Center>
)
}
@@ -0,0 +1,706 @@
import {memo, useCallback, useMemo} from 'react'
import {type GestureResponderEvent, Text as RNText, View} from 'react-native'
import {
AppBskyFeedDefs,
AppBskyFeedPost,
type AppBskyFeedThreadgate,
AtUri,
RichText as RichTextAPI,
} from '@atproto/api'
import {msg, Plural, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useActorStatus} from '#/lib/actor-status'
import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
import {useOpenLink} from '#/lib/hooks/useOpenLink'
import {makeProfileLink} from '#/lib/routes/links'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
import {niceDate} from '#/lib/strings/time'
import {s} from '#/lib/styles'
import {getTranslatorLink, isPostInLanguage} from '#/locale/helpers'
import {logger} from '#/logger'
import {
POST_TOMBSTONE,
type Shadow,
usePostShadow,
} from '#/state/cache/post-shadow'
import {useProfileShadow} from '#/state/cache/profile-shadow'
import {FeedFeedbackProvider, useFeedFeedback} from '#/state/feed-feedback'
import {useLanguagePrefs} from '#/state/preferences'
import {type ThreadItem} from '#/state/queries/usePostThread/types'
import {useSession} from '#/state/session'
import {type OnPostSuccessData} from '#/state/shell/composer'
import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies'
import {type PostSource} from '#/state/unstable-post-source'
import {PostThreadFollowBtn} from '#/view/com/post-thread/PostThreadFollowBtn'
import {Link} from '#/view/com/util/Link'
import {formatCount} from '#/view/com/util/numeric/format'
import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
import {
LINEAR_AVI_WIDTH,
OUTER_SPACE,
REPLY_LINE_WIDTH,
} from '#/screens/PostThread/const'
import {atoms as a, useTheme} from '#/alf'
import {colors} from '#/components/Admonition'
import {Button} from '#/components/Button'
import {CalendarClock_Stroke2_Corner0_Rounded as CalendarClockIcon} from '#/components/icons/CalendarClock'
import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash'
import {InlineLinkText} from '#/components/Link'
import {ContentHider} from '#/components/moderation/ContentHider'
import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe'
import {PostAlerts} from '#/components/moderation/PostAlerts'
import {type AppModerationCause} from '#/components/Pills'
import {Embed, PostEmbedViewContext} from '#/components/Post/Embed'
import {PostControls} from '#/components/PostControls'
import * as Prompt from '#/components/Prompt'
import {RichText} from '#/components/RichText'
import * as Skele from '#/components/Skeleton'
import {Text} from '#/components/Typography'
import {VerificationCheckButton} from '#/components/verification/VerificationCheckButton'
import {WhoCanReply} from '#/components/WhoCanReply'
import * as bsky from '#/types/bsky'
export function ThreadItemAnchor({
item,
onPostSuccess,
threadgateRecord,
postSource,
}: {
item: Extract<ThreadItem, {type: 'threadPost'}>
onPostSuccess?: (data: OnPostSuccessData) => void
threadgateRecord?: AppBskyFeedThreadgate.Record
postSource?: PostSource
}) {
const postShadow = usePostShadow(item.value.post)
const threadRootUri = item.value.post.record.reply?.root?.uri || item.uri
const isRoot = threadRootUri === item.uri
if (postShadow === POST_TOMBSTONE) {
return <ThreadItemAnchorDeleted isRoot={isRoot} />
}
return (
<ThreadItemAnchorInner
// Safeguard from clobbering per-post state below:
key={postShadow.uri}
item={item}
isRoot={isRoot}
postShadow={postShadow}
onPostSuccess={onPostSuccess}
threadgateRecord={threadgateRecord}
postSource={postSource}
/>
)
}
function ThreadItemAnchorDeleted({isRoot}: {isRoot: boolean}) {
const t = useTheme()
return (
<>
<ThreadItemAnchorParentReplyLine isRoot={isRoot} />
<View
style={[
{
paddingHorizontal: OUTER_SPACE,
paddingBottom: OUTER_SPACE,
},
isRoot && [a.pt_lg],
]}>
<View
style={[
a.flex_row,
a.align_center,
a.py_md,
a.rounded_sm,
t.atoms.bg_contrast_25,
]}>
<View
style={[
a.flex_row,
a.align_center,
a.justify_center,
{
width: LINEAR_AVI_WIDTH,
},
]}>
<TrashIcon style={[t.atoms.text_contrast_medium]} />
</View>
<Text style={[a.text_md, a.font_bold, t.atoms.text_contrast_medium]}>
<Trans>Post has been deleted</Trans>
</Text>
</View>
</View>
</>
)
}
function ThreadItemAnchorParentReplyLine({isRoot}: {isRoot: boolean}) {
const t = useTheme()
return !isRoot ? (
<View style={[a.pl_lg, a.flex_row, a.pb_xs, {height: a.pt_lg.paddingTop}]}>
<View style={{width: 42}}>
<View
style={[
{
width: REPLY_LINE_WIDTH,
marginLeft: 'auto',
marginRight: 'auto',
flexGrow: 1,
backgroundColor: t.atoms.border_contrast_low.borderColor,
},
]}
/>
</View>
</View>
) : null
}
const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
item,
isRoot,
postShadow,
onPostSuccess,
threadgateRecord,
postSource,
}: {
item: Extract<ThreadItem, {type: 'threadPost'}>
isRoot: boolean
postShadow: Shadow<AppBskyFeedDefs.PostView>
onPostSuccess?: (data: OnPostSuccessData) => void
threadgateRecord?: AppBskyFeedThreadgate.Record
postSource?: PostSource
}) {
const t = useTheme()
const {_, i18n} = useLingui()
const {openComposer} = useOpenComposer()
const {currentAccount, hasSession} = useSession()
const feedFeedback = useFeedFeedback(postSource?.feed, hasSession)
const post = item.value.post
const record = item.value.post.record
const moderation = item.moderation
const authorShadow = useProfileShadow(post.author)
const {isActive: live} = useActorStatus(post.author)
const richText = useMemo(
() =>
new RichTextAPI({
text: record.text,
facets: record.facets,
}),
[record],
)
const threadRootUri = record.reply?.root?.uri || post.uri
const authorHref = makeProfileLink(post.author)
const authorTitle = post.author.handle
const isThreadAuthor = getThreadAuthor(post, record) === currentAccount?.did
const likesHref = useMemo(() => {
const urip = new AtUri(post.uri)
return makeProfileLink(post.author, 'post', urip.rkey, 'liked-by')
}, [post.uri, post.author])
const repostsHref = useMemo(() => {
const urip = new AtUri(post.uri)
return makeProfileLink(post.author, 'post', urip.rkey, 'reposted-by')
}, [post.uri, post.author])
const quotesHref = useMemo(() => {
const urip = new AtUri(post.uri)
return makeProfileLink(post.author, 'post', urip.rkey, 'quotes')
}, [post.uri, post.author])
const threadgateHiddenReplies = useMergedThreadgateHiddenReplies({
threadgateRecord,
})
const additionalPostAlerts: AppModerationCause[] = useMemo(() => {
const isPostHiddenByThreadgate = threadgateHiddenReplies.has(post.uri)
const isControlledByViewer =
new AtUri(threadRootUri).host === currentAccount?.did
return isControlledByViewer && isPostHiddenByThreadgate
? [
{
type: 'reply-hidden',
source: {type: 'user', did: currentAccount?.did},
priority: 6,
},
]
: []
}, [post, currentAccount?.did, threadgateHiddenReplies, threadRootUri])
const onlyFollowersCanReply = !!threadgateRecord?.allow?.find(
rule => rule.$type === 'app.bsky.feed.threadgate#followerRule',
)
const showFollowButton =
currentAccount?.did !== post.author.did && !onlyFollowersCanReply
const viaRepost = useMemo(() => {
const reason = postSource?.post.reason
if (AppBskyFeedDefs.isReasonRepost(reason) && reason.uri && reason.cid) {
return {
uri: reason.uri,
cid: reason.cid,
}
}
}, [postSource])
const onPressReply = useCallback(() => {
openComposer({
replyTo: {
uri: post.uri,
cid: post.cid,
text: record.text,
author: post.author,
embed: post.embed,
moderation,
},
onPostSuccess: onPostSuccess,
})
if (postSource) {
feedFeedback.sendInteraction({
item: post.uri,
event: 'app.bsky.feed.defs#interactionReply',
feedContext: postSource.post.feedContext,
reqId: postSource.post.reqId,
})
}
}, [
openComposer,
post,
record,
onPostSuccess,
moderation,
postSource,
feedFeedback,
])
const onOpenAuthor = () => {
if (postSource) {
feedFeedback.sendInteraction({
item: post.uri,
event: 'app.bsky.feed.defs#clickthroughAuthor',
feedContext: postSource.post.feedContext,
reqId: postSource.post.reqId,
})
}
}
const onOpenEmbed = () => {
if (postSource) {
feedFeedback.sendInteraction({
item: post.uri,
event: 'app.bsky.feed.defs#clickthroughEmbed',
feedContext: postSource.post.feedContext,
reqId: postSource.post.reqId,
})
}
}
return (
<>
<ThreadItemAnchorParentReplyLine isRoot={isRoot} />
<View
testID={`postThreadItem-by-${post.author.handle}`}
style={[
{
paddingHorizontal: OUTER_SPACE,
},
isRoot && [a.pt_lg],
]}>
<View style={[a.flex_row, a.gap_md, a.pb_md]}>
<PreviewableUserAvatar
size={42}
profile={post.author}
moderation={moderation.ui('avatar')}
type={post.author.associated?.labeler ? 'labeler' : 'user'}
live={live}
onBeforePress={onOpenAuthor}
/>
<View style={[a.flex_1]}>
<View style={[a.flex_row, a.align_center]}>
<Link
style={[a.flex_shrink]}
href={authorHref}
title={authorTitle}
onBeforePress={onOpenAuthor}>
<Text
emoji
style={[a.text_lg, a.font_bold, a.leading_snug, a.self_start]}
numberOfLines={1}>
{sanitizeDisplayName(
post.author.displayName ||
sanitizeHandle(post.author.handle),
moderation.ui('displayName'),
)}
</Text>
</Link>
<View style={[{paddingLeft: 3, top: -1}]}>
<VerificationCheckButton profile={authorShadow} size="md" />
</View>
</View>
<Link style={s.flex1} href={authorHref} title={authorTitle}>
<Text
emoji
style={[
a.text_md,
a.leading_snug,
t.atoms.text_contrast_medium,
]}
numberOfLines={1}>
{sanitizeHandle(post.author.handle, '@')}
</Text>
</Link>
</View>
{showFollowButton && (
<View>
<PostThreadFollowBtn did={post.author.did} />
</View>
)}
</View>
<View style={[a.pb_sm]}>
<LabelsOnMyPost post={post} style={[a.pb_sm]} />
<ContentHider
modui={moderation.ui('contentView')}
ignoreMute
childContainerStyle={[a.pt_sm]}>
<PostAlerts
modui={moderation.ui('contentView')}
size="lg"
includeMute
style={[a.pb_sm]}
additionalCauses={additionalPostAlerts}
/>
{richText?.text ? (
<RichText
enableTags
selectable
value={richText}
style={[a.flex_1, a.text_xl]}
authorHandle={post.author.handle}
shouldProxyLinks={true}
/>
) : undefined}
{post.embed && (
<View style={[a.py_xs]}>
<Embed
embed={post.embed}
moderation={moderation}
viewContext={PostEmbedViewContext.ThreadHighlighted}
onOpen={onOpenEmbed}
/>
</View>
)}
</ContentHider>
<ExpandedPostDetails
post={item.value.post}
isThreadAuthor={isThreadAuthor}
/>
{post.repostCount !== 0 ||
post.likeCount !== 0 ||
post.quoteCount !== 0 ? (
// Show this section unless we're *sure* it has no engagement.
<View
style={[
a.flex_row,
a.align_center,
a.gap_lg,
a.border_t,
a.border_b,
a.mt_md,
a.py_md,
t.atoms.border_contrast_low,
]}>
{post.repostCount != null && post.repostCount !== 0 ? (
<Link href={repostsHref} title={_(msg`Reposts of this post`)}>
<Text
testID="repostCount-expanded"
style={[a.text_md, t.atoms.text_contrast_medium]}>
<Text style={[a.text_md, a.font_bold, t.atoms.text]}>
{formatCount(i18n, post.repostCount)}
</Text>{' '}
<Plural
value={post.repostCount}
one="repost"
other="reposts"
/>
</Text>
</Link>
) : null}
{post.quoteCount != null &&
post.quoteCount !== 0 &&
!post.viewer?.embeddingDisabled ? (
<Link href={quotesHref} title={_(msg`Quotes of this post`)}>
<Text
testID="quoteCount-expanded"
style={[a.text_md, t.atoms.text_contrast_medium]}>
<Text style={[a.text_md, a.font_bold, t.atoms.text]}>
{formatCount(i18n, post.quoteCount)}
</Text>{' '}
<Plural
value={post.quoteCount}
one="quote"
other="quotes"
/>
</Text>
</Link>
) : null}
{post.likeCount != null && post.likeCount !== 0 ? (
<Link href={likesHref} title={_(msg`Likes on this post`)}>
<Text
testID="likeCount-expanded"
style={[a.text_md, t.atoms.text_contrast_medium]}>
<Text style={[a.text_md, a.font_bold, t.atoms.text]}>
{formatCount(i18n, post.likeCount)}
</Text>{' '}
<Plural value={post.likeCount} one="like" other="likes" />
</Text>
</Link>
) : null}
</View>
) : null}
<View
style={[
a.pt_sm,
a.pb_2xs,
{
marginLeft: -5,
},
]}>
<FeedFeedbackProvider value={feedFeedback}>
<PostControls
big
post={postShadow}
record={record}
richText={richText}
onPressReply={onPressReply}
logContext="PostThreadItem"
threadgateRecord={threadgateRecord}
feedContext={postSource?.post?.feedContext}
reqId={postSource?.post?.reqId}
viaRepost={viaRepost}
/>
</FeedFeedbackProvider>
</View>
</View>
</View>
</>
)
})
function ExpandedPostDetails({
post,
isThreadAuthor,
}: {
post: Extract<ThreadItem, {type: 'threadPost'}>['value']['post']
isThreadAuthor: boolean
}) {
const t = useTheme()
const {_, i18n} = useLingui()
const openLink = useOpenLink()
const langPrefs = useLanguagePrefs()
const translatorUrl = getTranslatorLink(
post.record?.text || '',
langPrefs.primaryLanguage,
)
const needsTranslation = useMemo(
() =>
Boolean(
langPrefs.primaryLanguage &&
!isPostInLanguage(post, [langPrefs.primaryLanguage]),
),
[post, langPrefs.primaryLanguage],
)
const onTranslatePress = useCallback(
(e: GestureResponderEvent) => {
e.preventDefault()
openLink(translatorUrl, true)
if (
bsky.dangerousIsType<AppBskyFeedPost.Record>(
post.record,
AppBskyFeedPost.isRecord,
)
) {
logger.metric('translate', {
sourceLanguages: post.record.langs ?? [],
targetLanguage: langPrefs.primaryLanguage,
textLength: post.record.text.length,
})
}
return false
},
[openLink, translatorUrl, langPrefs, post],
)
return (
<View style={[a.gap_md, a.pt_md, a.align_start]}>
<BackdatedPostIndicator post={post} />
<View style={[a.flex_row, a.align_center, a.flex_wrap, a.gap_sm]}>
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
{niceDate(i18n, post.indexedAt)}
</Text>
<WhoCanReply post={post} isThreadAuthor={isThreadAuthor} />
{needsTranslation && (
<>
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
&middot;
</Text>
<InlineLinkText
to={translatorUrl}
label={_(msg`Translate`)}
style={[a.text_sm]}
onPress={onTranslatePress}>
<Trans>Translate</Trans>
</InlineLinkText>
</>
)}
</View>
</View>
)
}
function BackdatedPostIndicator({post}: {post: AppBskyFeedDefs.PostView}) {
const t = useTheme()
const {_, i18n} = useLingui()
const control = Prompt.usePromptControl()
const indexedAt = new Date(post.indexedAt)
const createdAt = bsky.dangerousIsType<AppBskyFeedPost.Record>(
post.record,
AppBskyFeedPost.isRecord,
)
? new Date(post.record.createdAt)
: new Date(post.indexedAt)
// backdated if createdAt is 24 hours or more before indexedAt
const isBackdated =
indexedAt.getTime() - createdAt.getTime() > 24 * 60 * 60 * 1000
if (!isBackdated) return null
const orange = t.name === 'light' ? colors.warning.dark : colors.warning.light
return (
<>
<Button
label={_(msg`Archived post`)}
accessibilityHint={_(
msg`Shows information about when this post was created`,
)}
onPress={e => {
e.preventDefault()
e.stopPropagation()
control.open()
}}>
{({hovered, pressed}) => (
<View
style={[
a.flex_row,
a.align_center,
a.rounded_full,
t.atoms.bg_contrast_25,
(hovered || pressed) && t.atoms.bg_contrast_50,
{
gap: 3,
paddingHorizontal: 6,
paddingVertical: 3,
},
]}>
<CalendarClockIcon fill={orange} size="sm" aria-hidden />
<Text
style={[
a.text_xs,
a.font_bold,
a.leading_tight,
t.atoms.text_contrast_medium,
]}>
<Trans>Archived from {niceDate(i18n, createdAt)}</Trans>
</Text>
</View>
)}
</Button>
<Prompt.Outer control={control}>
<Prompt.TitleText>
<Trans>Archived post</Trans>
</Prompt.TitleText>
<Prompt.DescriptionText>
<Trans>
This post claims to have been created on{' '}
<RNText style={[a.font_bold]}>{niceDate(i18n, createdAt)}</RNText>,
but was first seen by Bluesky on{' '}
<RNText style={[a.font_bold]}>{niceDate(i18n, indexedAt)}</RNText>.
</Trans>
</Prompt.DescriptionText>
<Text
style={[
a.text_md,
a.leading_snug,
t.atoms.text_contrast_high,
a.pb_xl,
]}>
<Trans>
Bluesky cannot confirm the authenticity of the claimed date.
</Trans>
</Text>
<Prompt.Actions>
<Prompt.Action cta={_(msg`Okay`)} onPress={() => {}} />
</Prompt.Actions>
</Prompt.Outer>
</>
)
}
function getThreadAuthor(
post: AppBskyFeedDefs.PostView,
record: AppBskyFeedPost.Record,
): string {
if (!record.reply) {
return post.author.did
}
try {
return new AtUri(record.reply.root.uri).host
} catch {
return ''
}
}
export function ThreadItemAnchorSkeleton() {
return (
<View style={[a.p_lg, a.gap_md]}>
<Skele.Row style={[a.align_center, a.gap_md]}>
<Skele.Circle size={42} />
<Skele.Col>
<Skele.Text style={[a.text_lg, {width: '20%'}]} />
<Skele.Text blend style={[a.text_md, {width: '40%'}]} />
</Skele.Col>
</Skele.Row>
<View>
<Skele.Text style={[a.text_xl, {width: '100%'}]} />
<Skele.Text style={[a.text_xl, {width: '60%'}]} />
</View>
<Skele.Text style={[a.text_sm, {width: '50%'}]} />
<Skele.Row style={[a.justify_between]}>
<Skele.Pill blend size={24} />
<Skele.Pill blend size={24} />
<Skele.Pill blend size={24} />
<Skele.Circle blend size={24} />
<Skele.Circle blend size={24} />
</Skele.Row>
</View>
)
}
@@ -0,0 +1,32 @@
import {View} from 'react-native'
import {Trans} from '@lingui/macro'
import {atoms as a, useTheme} from '#/alf'
import {Lock_Stroke2_Corner0_Rounded as LockIcon} from '#/components/icons/Lock'
import * as Skele from '#/components/Skeleton'
import {Text} from '#/components/Typography'
export function ThreadItemAnchorNoUnauthenticated() {
const t = useTheme()
return (
<View style={[a.p_lg, a.gap_md]}>
<Skele.Row style={[a.align_center, a.gap_md]}>
<Skele.Circle size={42}>
<LockIcon size="md" fill={t.atoms.text_contrast_medium.color} />
</Skele.Circle>
<Skele.Col>
<Skele.Text style={[a.text_lg, {width: '20%'}]} />
<Skele.Text blend style={[a.text_md, {width: '40%'}]} />
</Skele.Col>
</Skele.Row>
<View style={[a.py_sm]}>
<Text style={[a.text_xl, a.italic, t.atoms.text_contrast_medium]}>
<Trans>You must sign in to view this post.</Trans>
</Text>
</View>
</View>
)
}
@@ -0,0 +1,405 @@
import {memo, type ReactNode, useCallback, useMemo, useState} from 'react'
import {View} from 'react-native'
import {
type AppBskyFeedDefs,
type AppBskyFeedThreadgate,
AtUri,
RichText as RichTextAPI,
} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useActorStatus} from '#/lib/actor-status'
import {MAX_POST_LINES} from '#/lib/constants'
import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
import {usePalette} from '#/lib/hooks/usePalette'
import {makeProfileLink} from '#/lib/routes/links'
import {countLines} from '#/lib/strings/helpers'
import {
POST_TOMBSTONE,
type Shadow,
usePostShadow,
} from '#/state/cache/post-shadow'
import {type ThreadItem} from '#/state/queries/usePostThread/types'
import {useSession} from '#/state/session'
import {type OnPostSuccessData} from '#/state/shell/composer'
import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies'
import {TextLink} from '#/view/com/util/Link'
import {PostMeta} from '#/view/com/util/PostMeta'
import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
import {
LINEAR_AVI_WIDTH,
OUTER_SPACE,
REPLY_LINE_WIDTH,
} from '#/screens/PostThread/const'
import {atoms as a, useTheme} from '#/alf'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash'
import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe'
import {PostAlerts} from '#/components/moderation/PostAlerts'
import {PostHider} from '#/components/moderation/PostHider'
import {type AppModerationCause} from '#/components/Pills'
import {Embed, PostEmbedViewContext} from '#/components/Post/Embed'
import {PostControls} from '#/components/PostControls'
import {RichText} from '#/components/RichText'
import * as Skele from '#/components/Skeleton'
import {SubtleWebHover} from '#/components/SubtleWebHover'
import {Text} from '#/components/Typography'
export type ThreadItemPostProps = {
item: Extract<ThreadItem, {type: 'threadPost'}>
overrides?: {
moderation?: boolean
topBorder?: boolean
}
onPostSuccess?: (data: OnPostSuccessData) => void
threadgateRecord?: AppBskyFeedThreadgate.Record
}
export function ThreadItemPost({
item,
overrides,
onPostSuccess,
threadgateRecord,
}: ThreadItemPostProps) {
const postShadow = usePostShadow(item.value.post)
if (postShadow === POST_TOMBSTONE) {
return <ThreadItemPostDeleted item={item} overrides={overrides} />
}
return (
<ThreadItemPostInner
item={item}
postShadow={postShadow}
threadgateRecord={threadgateRecord}
overrides={overrides}
onPostSuccess={onPostSuccess}
/>
)
}
function ThreadItemPostDeleted({
item,
overrides,
}: Pick<ThreadItemPostProps, 'item' | 'overrides'>) {
const t = useTheme()
return (
<ThreadItemPostOuterWrapper item={item} overrides={overrides}>
<ThreadItemPostParentReplyLine item={item} />
<View
style={[
a.flex_row,
a.align_center,
a.py_md,
a.rounded_sm,
t.atoms.bg_contrast_25,
]}>
<View
style={[
a.flex_row,
a.align_center,
a.justify_center,
{
width: LINEAR_AVI_WIDTH,
},
]}>
<TrashIcon style={[t.atoms.text_contrast_medium]} />
</View>
<Text style={[a.text_md, a.font_bold, t.atoms.text_contrast_medium]}>
<Trans>Post has been deleted</Trans>
</Text>
</View>
<View style={[{height: 4}]} />
</ThreadItemPostOuterWrapper>
)
}
const ThreadItemPostOuterWrapper = memo(function ThreadItemPostOuterWrapper({
item,
overrides,
children,
}: Pick<ThreadItemPostProps, 'item' | 'overrides'> & {
children: ReactNode
}) {
const t = useTheme()
const showTopBorder =
!item.ui.showParentReplyLine && overrides?.topBorder !== true
return (
<View
style={[
showTopBorder && [a.border_t, t.atoms.border_contrast_low],
{
paddingHorizontal: OUTER_SPACE,
},
// If there's no next child, add a little padding to bottom
!item.ui.showChildReplyLine &&
!item.ui.precedesChildReadMore && {
paddingBottom: OUTER_SPACE / 2,
},
]}>
{children}
</View>
)
})
/**
* Provides some space between posts as well as contains the reply line
*/
const ThreadItemPostParentReplyLine = memo(
function ThreadItemPostParentReplyLine({
item,
}: Pick<ThreadItemPostProps, 'item'>) {
const t = useTheme()
return (
<View style={[a.flex_row, {height: 12}]}>
<View style={{width: LINEAR_AVI_WIDTH}}>
{item.ui.showParentReplyLine && (
<View
style={[
a.mx_auto,
a.flex_1,
a.mb_xs,
{
width: REPLY_LINE_WIDTH,
backgroundColor: t.atoms.border_contrast_low.borderColor,
},
]}
/>
)}
</View>
</View>
)
},
)
const ThreadItemPostInner = memo(function ThreadItemPostInner({
item,
postShadow,
overrides,
onPostSuccess,
threadgateRecord,
}: ThreadItemPostProps & {
postShadow: Shadow<AppBskyFeedDefs.PostView>
}) {
const t = useTheme()
const pal = usePalette('default')
const {_} = useLingui()
const {openComposer} = useOpenComposer()
const {currentAccount} = useSession()
const post = item.value.post
const record = item.value.post.record
const moderation = item.moderation
const richText = useMemo(
() =>
new RichTextAPI({
text: record.text,
facets: record.facets,
}),
[record],
)
const [limitLines, setLimitLines] = useState(
() => countLines(richText?.text) >= MAX_POST_LINES,
)
const threadRootUri = record.reply?.root?.uri || post.uri
const postHref = useMemo(() => {
const urip = new AtUri(post.uri)
return makeProfileLink(post.author, 'post', urip.rkey)
}, [post.uri, post.author])
const threadgateHiddenReplies = useMergedThreadgateHiddenReplies({
threadgateRecord,
})
const additionalPostAlerts: AppModerationCause[] = useMemo(() => {
const isPostHiddenByThreadgate = threadgateHiddenReplies.has(post.uri)
const isControlledByViewer =
new AtUri(threadRootUri).host === currentAccount?.did
return isControlledByViewer && isPostHiddenByThreadgate
? [
{
type: 'reply-hidden',
source: {type: 'user', did: currentAccount?.did},
priority: 6,
},
]
: []
}, [post, currentAccount?.did, threadgateHiddenReplies, threadRootUri])
const onPressReply = useCallback(() => {
openComposer({
replyTo: {
uri: post.uri,
cid: post.cid,
text: record.text,
author: post.author,
embed: post.embed,
moderation,
},
onPostSuccess: onPostSuccess,
})
}, [openComposer, post, record, onPostSuccess, moderation])
const onPressShowMore = useCallback(() => {
setLimitLines(false)
}, [setLimitLines])
const {isActive: live} = useActorStatus(post.author)
return (
<SubtleHover>
<ThreadItemPostOuterWrapper item={item} overrides={overrides}>
<PostHider
testID={`postThreadItem-by-${post.author.handle}`}
href={postHref}
disabled={overrides?.moderation === true}
modui={moderation.ui('contentList')}
iconSize={LINEAR_AVI_WIDTH}
iconStyles={{marginLeft: 2, marginRight: 2}}
profile={post.author}
interpretFilterAsBlur>
<ThreadItemPostParentReplyLine item={item} />
<View style={[a.flex_row, a.gap_md]}>
<View>
<PreviewableUserAvatar
size={LINEAR_AVI_WIDTH}
profile={post.author}
moderation={moderation.ui('avatar')}
type={post.author.associated?.labeler ? 'labeler' : 'user'}
live={live}
/>
{(item.ui.showChildReplyLine ||
item.ui.precedesChildReadMore) && (
<View
style={[
a.mx_auto,
a.mt_xs,
a.flex_1,
{
width: REPLY_LINE_WIDTH,
backgroundColor: t.atoms.border_contrast_low.borderColor,
},
]}
/>
)}
</View>
<View style={[a.flex_1]}>
<PostMeta
author={post.author}
moderation={moderation}
timestamp={post.indexedAt}
postHref={postHref}
style={[a.pb_xs]}
/>
<LabelsOnMyPost post={post} style={[a.pb_xs]} />
<PostAlerts
modui={moderation.ui('contentList')}
style={[a.pb_2xs]}
additionalCauses={additionalPostAlerts}
/>
{richText?.text ? (
<RichText
enableTags
value={richText}
style={[a.flex_1, a.text_md]}
numberOfLines={limitLines ? MAX_POST_LINES : undefined}
authorHandle={post.author.handle}
shouldProxyLinks={true}
/>
) : undefined}
{limitLines ? (
<TextLink
text={_(msg`Show More`)}
style={pal.link}
onPress={onPressShowMore}
href="#"
/>
) : undefined}
{post.embed && (
<View style={[a.pb_xs]}>
<Embed
embed={post.embed}
moderation={moderation}
viewContext={PostEmbedViewContext.Feed}
/>
</View>
)}
<PostControls
post={postShadow}
record={record}
richText={richText}
onPressReply={onPressReply}
logContext="PostThreadItem"
threadgateRecord={threadgateRecord}
/>
</View>
</View>
</PostHider>
</ThreadItemPostOuterWrapper>
</SubtleHover>
)
})
function SubtleHover({children}: {children: ReactNode}) {
const {
state: hover,
onIn: onHoverIn,
onOut: onHoverOut,
} = useInteractionState()
return (
<View
onPointerEnter={onHoverIn}
onPointerLeave={onHoverOut}
style={a.pointer}>
<SubtleWebHover hover={hover} />
{children}
</View>
)
}
export function ThreadItemPostSkeleton({index}: {index: number}) {
const even = index % 2 === 0
return (
<View
style={[
{paddingHorizontal: OUTER_SPACE, paddingVertical: OUTER_SPACE / 1.5},
a.gap_md,
]}>
<Skele.Row style={[a.align_start, a.gap_md]}>
<Skele.Circle size={LINEAR_AVI_WIDTH} />
<Skele.Col style={[a.gap_xs]}>
<Skele.Row style={[a.gap_sm]}>
<Skele.Text style={[a.text_md, {width: '20%'}]} />
<Skele.Text blend style={[a.text_md, {width: '30%'}]} />
</Skele.Row>
<Skele.Col>
{even ? (
<>
<Skele.Text blend style={[a.text_md, {width: '100%'}]} />
<Skele.Text blend style={[a.text_md, {width: '60%'}]} />
</>
) : (
<Skele.Text blend style={[a.text_md, {width: '60%'}]} />
)}
</Skele.Col>
<Skele.Row style={[a.justify_between, a.pt_xs]}>
<Skele.Pill blend size={16} />
<Skele.Pill blend size={16} />
<Skele.Pill blend size={16} />
<Skele.Circle blend size={16} />
<View />
</Skele.Row>
</Skele.Col>
</Skele.Row>
</View>
)
}
@@ -0,0 +1,74 @@
import {View} from 'react-native'
import {Trans} from '@lingui/macro'
import {type ThreadItem} from '#/state/queries/usePostThread/types'
import {
LINEAR_AVI_WIDTH,
OUTER_SPACE,
REPLY_LINE_WIDTH,
} from '#/screens/PostThread/const'
import {atoms as a, useTheme} from '#/alf'
import {Lock_Stroke2_Corner0_Rounded as LockIcon} from '#/components/icons/Lock'
import * as Skele from '#/components/Skeleton'
import {Text} from '#/components/Typography'
export function ThreadItemPostNoUnauthenticated({
item,
}: {
item: Extract<ThreadItem, {type: 'threadPostNoUnauthenticated'}>
}) {
const t = useTheme()
return (
<View style={[{paddingHorizontal: OUTER_SPACE}]}>
<View style={[a.flex_row, {height: 12}]}>
<View style={{width: LINEAR_AVI_WIDTH}}>
{item.ui.showParentReplyLine && (
<View
style={[
a.mx_auto,
a.flex_1,
a.mb_xs,
{
width: REPLY_LINE_WIDTH,
backgroundColor: t.atoms.border_contrast_low.borderColor,
},
]}
/>
)}
</View>
</View>
<Skele.Row style={[a.align_center, a.gap_md]}>
<Skele.Circle size={LINEAR_AVI_WIDTH}>
<LockIcon size="md" fill={t.atoms.text_contrast_medium.color} />
</Skele.Circle>
<Text style={[a.text_md, a.italic, t.atoms.text_contrast_medium]}>
<Trans>You must sign in to view this post.</Trans>
</Text>
</Skele.Row>
<View
style={[
a.flex_row,
a.justify_center,
{
height: OUTER_SPACE / 1.5,
width: LINEAR_AVI_WIDTH,
},
]}>
{item.ui.showChildReplyLine && (
<View
style={[
a.mt_xs,
a.h_full,
{
width: REPLY_LINE_WIDTH,
backgroundColor: t.atoms.border_contrast_low.borderColor,
},
]}
/>
)}
</View>
</View>
)
}
@@ -0,0 +1,55 @@
import {useMemo} from 'react'
import {View} from 'react-native'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {LINEAR_AVI_WIDTH, OUTER_SPACE} from '#/screens/PostThread/const'
import {atoms as a, useTheme} from '#/alf'
import {PersonX_Stroke2_Corner0_Rounded as PersonXIcon} from '#/components/icons/Person'
import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash'
import {Text} from '#/components/Typography'
export type ThreadItemPostTombstoneProps = {
type: 'not-found' | 'blocked'
}
export function ThreadItemPostTombstone({type}: ThreadItemPostTombstoneProps) {
const t = useTheme()
const {_} = useLingui()
const {copy, Icon} = useMemo(() => {
switch (type) {
case 'blocked':
return {copy: _(msg`Post blocked`), Icon: PersonXIcon}
case 'not-found':
default:
return {copy: _(msg`Post not found`), Icon: TrashIcon}
}
}, [_, type])
return (
<View
style={[
a.mb_xs,
{
paddingHorizontal: OUTER_SPACE,
paddingTop: OUTER_SPACE / 1.2,
},
]}>
<View
style={[
a.flex_row,
a.align_center,
a.rounded_sm,
t.atoms.bg_contrast_25,
{paddingVertical: OUTER_SPACE / 1.2},
]}>
<View style={[a.flex_row, a.justify_center, {width: LINEAR_AVI_WIDTH}]}>
<Icon style={[t.atoms.text_contrast_medium]} />
</View>
<Text style={[a.text_md, a.font_bold, t.atoms.text_contrast_medium]}>
{copy}
</Text>
</View>
</View>
)
}
@@ -0,0 +1,107 @@
import {memo} from 'react'
import {View} from 'react-native'
import {msg, Plural, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {
type PostThreadParams,
type ThreadItem,
} from '#/state/queries/usePostThread'
import {
LINEAR_AVI_WIDTH,
REPLY_LINE_WIDTH,
TREE_AVI_WIDTH,
TREE_INDENT,
} from '#/screens/PostThread/const'
import {atoms as a, useTheme} from '#/alf'
import {CirclePlus_Stroke2_Corner0_Rounded as CirclePlus} from '#/components/icons/CirclePlus'
import {Link} from '#/components/Link'
import {Text} from '#/components/Typography'
export const ThreadItemReadMore = memo(function ThreadItemReadMore({
item,
view,
}: {
item: Extract<ThreadItem, {type: 'readMore'}>
view: PostThreadParams['view']
}) {
const t = useTheme()
const {_} = useLingui()
const isTreeView = view === 'tree'
const indent = Math.max(0, item.depth - 1)
const spacers = isTreeView
? Array.from(Array(indent)).map((_, n: number) => {
const isSkipped = item.skippedIndentIndices.has(n)
return (
<View
key={`${item.key}-padding-${n}`}
style={[
t.atoms.border_contrast_low,
{
borderRightWidth: isSkipped ? 0 : REPLY_LINE_WIDTH,
width: TREE_INDENT + TREE_AVI_WIDTH / 2,
left: 1,
},
]}
/>
)
})
: null
return (
<View style={[a.flex_row]}>
{spacers}
<View
style={[
t.atoms.border_contrast_low,
{
marginLeft: isTreeView
? TREE_INDENT + TREE_AVI_WIDTH / 2 - 1
: (LINEAR_AVI_WIDTH - REPLY_LINE_WIDTH) / 2 + 16,
borderLeftWidth: 2,
borderBottomWidth: 2,
borderBottomLeftRadius: a.rounded_sm.borderRadius,
height: 18, // magic, Link below is 38px tall
width: isTreeView ? TREE_INDENT : LINEAR_AVI_WIDTH / 2 + 10,
},
]}
/>
<Link
label={_(msg`Read more replies`)}
to={item.href}
style={[a.pt_sm, a.pb_md, a.gap_xs]}>
{({hovered, pressed}) => {
const interacted = hovered || pressed
return (
<>
<CirclePlus
fill={
interacted
? t.atoms.text_contrast_high.color
: t.atoms.text_contrast_low.color
}
width={18}
/>
<Text
style={[
a.text_sm,
t.atoms.text_contrast_medium,
interacted && a.underline,
]}>
<Trans>
Read {item.moreReplies} more{' '}
<Plural
one="reply"
other="replies"
value={item.moreReplies}
/>
</Trans>
</Text>
</>
)
}}
</Link>
</View>
)
})
@@ -0,0 +1,89 @@
import {memo} from 'react'
import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {type ThreadItem} from '#/state/queries/usePostThread'
import {
LINEAR_AVI_WIDTH,
OUTER_SPACE,
REPLY_LINE_WIDTH,
} from '#/screens/PostThread/const'
import {atoms as a, useTheme} from '#/alf'
import {ArrowTopCircle_Stroke2_Corner0_Rounded as UpIcon} from '#/components/icons/ArrowTopCircle'
import {Link} from '#/components/Link'
import {Text} from '#/components/Typography'
export const ThreadItemReadMoreUp = memo(function ThreadItemReadMoreUp({
item,
}: {
item: Extract<ThreadItem, {type: 'readMoreUp'}>
}) {
const t = useTheme()
const {_} = useLingui()
return (
<Link
label={_(msg`Continue thread`)}
to={item.href}
style={[
a.gap_xs,
{
paddingTop: OUTER_SPACE,
paddingHorizontal: OUTER_SPACE,
},
]}>
{({hovered, pressed}) => {
const interacted = hovered || pressed
return (
<View>
<View style={[a.flex_row, a.align_center, a.gap_md]}>
<View
style={[
a.align_center,
{
width: LINEAR_AVI_WIDTH,
},
]}>
<UpIcon
fill={
interacted
? t.atoms.text_contrast_high.color
: t.atoms.text_contrast_low.color
}
width={24}
/>
</View>
<Text
style={[
a.text_sm,
t.atoms.text_contrast_medium,
interacted && [a.underline],
]}>
<Trans>Continue thread...</Trans>
</Text>
</View>
<View
style={[
a.align_center,
{
width: LINEAR_AVI_WIDTH,
},
]}>
<View
style={[
a.mt_xs,
{
height: OUTER_SPACE / 2,
width: REPLY_LINE_WIDTH,
backgroundColor: t.atoms.border_contrast_low.borderColor,
},
]}
/>
</View>
</View>
)
}}
</Link>
)
})
@@ -0,0 +1,31 @@
import {View} from 'react-native'
import {OUTER_SPACE} from '#/screens/PostThread/const'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import * as Skele from '#/components/Skeleton'
/*
* Wacky padding here is just replicating what we have in the actual
* `PostThreadComposePrompt` component
*/
export function ThreadItemReplyComposerSkeleton() {
const t = useTheme()
const {gtMobile} = useBreakpoints()
return (
<View
style={[
a.border_t,
t.atoms.border_contrast_low,
gtMobile ? a.py_xs : {paddingTop: 8, paddingBottom: 11},
{
paddingHorizontal: OUTER_SPACE,
},
]}>
<View style={[a.flex_row, a.align_center, a.gap_xs, a.py_sm]}>
<Skele.Circle size={gtMobile ? 24 : 22} />
<Skele.Text style={[a.text_md]} />
</View>
</View>
)
}
@@ -0,0 +1,59 @@
import {View} from 'react-native'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {logger} from '#/logger'
import {atoms as a, useTheme} from '#/alf'
import {Button} from '#/components/Button'
import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/EyeSlash'
import {Text} from '#/components/Typography'
export function ThreadItemShowOtherReplies({onPress}: {onPress: () => void}) {
const {_} = useLingui()
const t = useTheme()
const label = _(msg`Show more replies`)
return (
<Button
onPress={() => {
onPress()
logger.metric('thread:click:showOtherReplies', {})
}}
label={label}>
{({hovered, pressed}) => (
<View
style={[
a.flex_1,
a.flex_row,
a.align_center,
a.gap_sm,
a.py_lg,
a.px_xl,
a.border_t,
t.atoms.border_contrast_low,
hovered || pressed ? t.atoms.bg_contrast_25 : t.atoms.bg,
]}>
<View
style={[
t.atoms.bg_contrast_25,
a.align_center,
a.justify_center,
{
width: 26,
height: 26,
borderRadius: 13,
marginRight: 4,
},
]}>
<EyeSlash size="sm" fill={t.atoms.text_contrast_medium.color} />
</View>
<Text
style={[t.atoms.text_contrast_medium, a.flex_1, a.leading_snug]}
numberOfLines={1}>
{label}
</Text>
</View>
)}
</Button>
)
}
@@ -0,0 +1,456 @@
import React, {memo, useMemo} from 'react'
import {View} from 'react-native'
import {
type AppBskyFeedDefs,
type AppBskyFeedThreadgate,
AtUri,
RichText as RichTextAPI,
} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {MAX_POST_LINES} from '#/lib/constants'
import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
import {usePalette} from '#/lib/hooks/usePalette'
import {makeProfileLink} from '#/lib/routes/links'
import {countLines} from '#/lib/strings/helpers'
import {
POST_TOMBSTONE,
type Shadow,
usePostShadow,
} from '#/state/cache/post-shadow'
import {type ThreadItem} from '#/state/queries/usePostThread/types'
import {useSession} from '#/state/session'
import {type OnPostSuccessData} from '#/state/shell/composer'
import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies'
import {TextLink} from '#/view/com/util/Link'
import {PostMeta} from '#/view/com/util/PostMeta'
import {
OUTER_SPACE,
REPLY_LINE_WIDTH,
TREE_AVI_WIDTH,
TREE_INDENT,
} from '#/screens/PostThread/const'
import {atoms as a, useTheme} from '#/alf'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash'
import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe'
import {PostAlerts} from '#/components/moderation/PostAlerts'
import {PostHider} from '#/components/moderation/PostHider'
import {type AppModerationCause} from '#/components/Pills'
import {Embed, PostEmbedViewContext} from '#/components/Post/Embed'
import {PostControls} from '#/components/PostControls'
import {RichText} from '#/components/RichText'
import * as Skele from '#/components/Skeleton'
import {SubtleWebHover} from '#/components/SubtleWebHover'
import {Text} from '#/components/Typography'
/**
* Mimic the space in PostMeta
*/
const TREE_AVI_PLUS_SPACE = TREE_AVI_WIDTH + a.gap_xs.gap
export function ThreadItemTreePost({
item,
overrides,
onPostSuccess,
threadgateRecord,
}: {
item: Extract<ThreadItem, {type: 'threadPost'}>
overrides?: {
moderation?: boolean
topBorder?: boolean
}
onPostSuccess?: (data: OnPostSuccessData) => void
threadgateRecord?: AppBskyFeedThreadgate.Record
}) {
const postShadow = usePostShadow(item.value.post)
if (postShadow === POST_TOMBSTONE) {
return <ThreadItemTreePostDeleted item={item} />
}
return (
<ThreadItemTreePostInner
// Safeguard from clobbering per-post state below:
key={postShadow.uri}
item={item}
postShadow={postShadow}
threadgateRecord={threadgateRecord}
overrides={overrides}
onPostSuccess={onPostSuccess}
/>
)
}
function ThreadItemTreePostDeleted({
item,
}: {
item: Extract<ThreadItem, {type: 'threadPost'}>
}) {
const t = useTheme()
return (
<ThreadItemTreePostOuterWrapper item={item}>
<ThreadItemTreePostInnerWrapper item={item}>
<View
style={[
a.flex_row,
a.align_center,
a.rounded_sm,
t.atoms.bg_contrast_25,
{
gap: 6,
paddingHorizontal: OUTER_SPACE / 2,
height: TREE_AVI_WIDTH,
},
]}>
<TrashIcon style={[t.atoms.text]} width={14} />
<Text style={[t.atoms.text_contrast_medium, a.mt_2xs]}>
<Trans>Post has been deleted</Trans>
</Text>
</View>
{item.ui.isLastChild && !item.ui.precedesChildReadMore && (
<View style={{height: OUTER_SPACE / 2}} />
)}
</ThreadItemTreePostInnerWrapper>
</ThreadItemTreePostOuterWrapper>
)
}
const ThreadItemTreePostOuterWrapper = memo(
function ThreadItemTreePostOuterWrapper({
item,
children,
}: {
item: Extract<ThreadItem, {type: 'threadPost'}>
children: React.ReactNode
}) {
const t = useTheme()
const indents = Math.max(0, item.ui.indent - 1)
return (
<View
style={[
a.flex_row,
item.ui.indent === 1 &&
!item.ui.showParentReplyLine && [
a.border_t,
t.atoms.border_contrast_low,
],
]}>
{Array.from(Array(indents)).map((_, n: number) => {
const isSkipped = item.ui.skippedIndentIndices.has(n)
return (
<View
key={`${item.value.post.uri}-padding-${n}`}
style={[
t.atoms.border_contrast_low,
{
borderRightWidth: isSkipped ? 0 : REPLY_LINE_WIDTH,
width: TREE_INDENT + TREE_AVI_WIDTH / 2,
left: 1,
},
]}
/>
)
})}
{children}
</View>
)
},
)
const ThreadItemTreePostInnerWrapper = memo(
function ThreadItemTreePostInnerWrapper({
item,
children,
}: {
item: Extract<ThreadItem, {type: 'threadPost'}>
children: React.ReactNode
}) {
const t = useTheme()
return (
<View
style={[
a.flex_1, // TODO check on ios
{
paddingHorizontal: OUTER_SPACE,
paddingTop: OUTER_SPACE / 2,
},
item.ui.indent === 1 && [
!item.ui.showParentReplyLine && a.pt_lg,
!item.ui.showChildReplyLine && a.pb_sm,
],
item.ui.isLastChild &&
!item.ui.precedesChildReadMore && [
{
paddingBottom: OUTER_SPACE / 2,
},
],
]}>
{item.ui.indent > 1 && (
<View
style={[
a.absolute,
t.atoms.border_contrast_low,
{
left: -1,
top: 0,
height:
TREE_AVI_WIDTH / 2 + REPLY_LINE_WIDTH / 2 + OUTER_SPACE / 2,
width: OUTER_SPACE,
borderLeftWidth: REPLY_LINE_WIDTH,
borderBottomWidth: REPLY_LINE_WIDTH,
borderBottomLeftRadius: a.rounded_sm.borderRadius,
},
]}
/>
)}
{children}
</View>
)
},
)
const ThreadItemTreeReplyChildReplyLine = memo(
function ThreadItemTreeReplyChildReplyLine({
item,
}: {
item: Extract<ThreadItem, {type: 'threadPost'}>
}) {
const t = useTheme()
return (
<View style={[a.relative, {width: TREE_AVI_PLUS_SPACE}]}>
{item.ui.showChildReplyLine && (
<View
style={[
a.flex_1,
t.atoms.border_contrast_low,
{
borderRightWidth: 2,
width: '50%',
left: -1,
},
]}
/>
)}
</View>
)
},
)
const ThreadItemTreePostInner = memo(function ThreadItemTreePostInner({
item,
postShadow,
overrides,
onPostSuccess,
threadgateRecord,
}: {
item: Extract<ThreadItem, {type: 'threadPost'}>
postShadow: Shadow<AppBskyFeedDefs.PostView>
overrides?: {
moderation?: boolean
topBorder?: boolean
}
onPostSuccess?: (data: OnPostSuccessData) => void
threadgateRecord?: AppBskyFeedThreadgate.Record
}): React.ReactNode {
const pal = usePalette('default')
const {_} = useLingui()
const {openComposer} = useOpenComposer()
const {currentAccount} = useSession()
const post = item.value.post
const record = item.value.post.record
const moderation = item.moderation
const richText = useMemo(
() =>
new RichTextAPI({
text: record.text,
facets: record.facets,
}),
[record],
)
const [limitLines, setLimitLines] = React.useState(
() => countLines(richText?.text) >= MAX_POST_LINES,
)
const threadRootUri = record.reply?.root?.uri || post.uri
const postHref = React.useMemo(() => {
const urip = new AtUri(post.uri)
return makeProfileLink(post.author, 'post', urip.rkey)
}, [post.uri, post.author])
const threadgateHiddenReplies = useMergedThreadgateHiddenReplies({
threadgateRecord,
})
const additionalPostAlerts: AppModerationCause[] = React.useMemo(() => {
const isPostHiddenByThreadgate = threadgateHiddenReplies.has(post.uri)
const isControlledByViewer =
new AtUri(threadRootUri).host === currentAccount?.did
return isControlledByViewer && isPostHiddenByThreadgate
? [
{
type: 'reply-hidden',
source: {type: 'user', did: currentAccount?.did},
priority: 6,
},
]
: []
}, [post, currentAccount?.did, threadgateHiddenReplies, threadRootUri])
const onPressReply = React.useCallback(() => {
openComposer({
replyTo: {
uri: post.uri,
cid: post.cid,
text: record.text,
author: post.author,
embed: post.embed,
moderation,
},
onPostSuccess: onPostSuccess,
})
}, [openComposer, post, record, onPostSuccess, moderation])
const onPressShowMore = React.useCallback(() => {
setLimitLines(false)
}, [setLimitLines])
return (
<ThreadItemTreePostOuterWrapper item={item}>
<SubtleHover>
<PostHider
testID={`postThreadItem-by-${post.author.handle}`}
href={postHref}
disabled={overrides?.moderation === true}
modui={moderation.ui('contentList')}
iconSize={42}
iconStyles={{marginLeft: 2, marginRight: 2}}
profile={post.author}
interpretFilterAsBlur>
<ThreadItemTreePostInnerWrapper item={item}>
<View style={[a.flex_1]}>
<PostMeta
author={post.author}
moderation={moderation}
timestamp={post.indexedAt}
postHref={postHref}
avatarSize={TREE_AVI_WIDTH}
style={[a.pb_2xs]}
showAvatar
/>
<View style={[a.flex_row]}>
<ThreadItemTreeReplyChildReplyLine item={item} />
<View style={[a.flex_1]}>
<LabelsOnMyPost post={post} style={[a.pb_2xs]} />
<PostAlerts
modui={moderation.ui('contentList')}
style={[a.pb_2xs]}
additionalCauses={additionalPostAlerts}
/>
{richText?.text ? (
<View>
<RichText
enableTags
value={richText}
style={[a.flex_1, a.text_md]}
numberOfLines={limitLines ? MAX_POST_LINES : undefined}
authorHandle={post.author.handle}
shouldProxyLinks={true}
/>
</View>
) : undefined}
{limitLines ? (
<TextLink
text={_(msg`Show More`)}
style={pal.link}
onPress={onPressShowMore}
href="#"
/>
) : undefined}
{post.embed && (
<View style={[a.pb_xs]}>
<Embed
embed={post.embed}
moderation={moderation}
viewContext={PostEmbedViewContext.Feed}
/>
</View>
)}
<PostControls
post={postShadow}
record={record}
richText={richText}
onPressReply={onPressReply}
logContext="PostThreadItem"
threadgateRecord={threadgateRecord}
/>
</View>
</View>
</View>
</ThreadItemTreePostInnerWrapper>
</PostHider>
</SubtleHover>
</ThreadItemTreePostOuterWrapper>
)
})
function SubtleHover({children}: {children: React.ReactNode}) {
const {
state: hover,
onIn: onHoverIn,
onOut: onHoverOut,
} = useInteractionState()
return (
<View
onPointerEnter={onHoverIn}
onPointerLeave={onHoverOut}
style={[a.flex_1, a.pointer]}>
<SubtleWebHover hover={hover} />
{children}
</View>
)
}
export function ThreadItemTreePostSkeleton({index}: {index: number}) {
const t = useTheme()
const even = index % 2 === 0
return (
<View
style={[
{paddingHorizontal: OUTER_SPACE, paddingVertical: OUTER_SPACE / 1.5},
a.gap_md,
a.border_t,
t.atoms.border_contrast_low,
]}>
<Skele.Row style={[a.align_start, a.gap_md]}>
<Skele.Circle size={TREE_AVI_WIDTH} />
<Skele.Col style={[a.gap_xs]}>
<Skele.Row style={[a.gap_sm]}>
<Skele.Text style={[a.text_md, {width: '20%'}]} />
<Skele.Text blend style={[a.text_md, {width: '30%'}]} />
</Skele.Row>
<Skele.Col>
{even ? (
<>
<Skele.Text blend style={[a.text_md, {width: '100%'}]} />
<Skele.Text blend style={[a.text_md, {width: '60%'}]} />
</>
) : (
<Skele.Text blend style={[a.text_md, {width: '60%'}]} />
)}
</Skele.Col>
<Skele.Row style={[a.justify_between, a.pt_xs]}>
<Skele.Pill blend size={16} />
<Skele.Pill blend size={16} />
<Skele.Pill blend size={16} />
<Skele.Circle blend size={16} />
<View />
</Skele.Row>
</Skele.Col>
</Skele.Row>
</View>
)
}
+7
View File
@@ -0,0 +1,7 @@
import {tokens} from '#/alf'
export const TREE_INDENT = tokens.space.lg
export const TREE_AVI_WIDTH = 24
export const LINEAR_AVI_WIDTH = 42
export const REPLY_LINE_WIDTH = 2
export const OUTER_SPACE = tokens.space.lg
+577
View File
@@ -0,0 +1,577 @@
import {useCallback, useMemo, useRef, useState} from 'react'
import {useWindowDimensions, View} from 'react-native'
import Animated, {useAnimatedStyle} from 'react-native-reanimated'
import {Trans} from '@lingui/macro'
import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
import {useFeedFeedback} from '#/state/feed-feedback'
import {type ThreadViewOption} from '#/state/queries/preferences/useThreadPreferences'
import {type ThreadItem, usePostThread} from '#/state/queries/usePostThread'
import {useSession} from '#/state/session'
import {type OnPostSuccessData} from '#/state/shell/composer'
import {useShellLayout} from '#/state/shell/shell-layout'
import {useUnstablePostSource} from '#/state/unstable-post-source'
import {PostThreadComposePrompt} from '#/view/com/post-thread/PostThreadComposePrompt'
import {List, type ListMethods} from '#/view/com/util/List'
import {HeaderDropdown} from '#/screens/PostThread/components/HeaderDropdown'
import {ThreadError} from '#/screens/PostThread/components/ThreadError'
import {
ThreadItemAnchor,
ThreadItemAnchorSkeleton,
} from '#/screens/PostThread/components/ThreadItemAnchor'
import {ThreadItemAnchorNoUnauthenticated} from '#/screens/PostThread/components/ThreadItemAnchorNoUnauthenticated'
import {
ThreadItemPost,
ThreadItemPostSkeleton,
} from '#/screens/PostThread/components/ThreadItemPost'
import {ThreadItemPostNoUnauthenticated} from '#/screens/PostThread/components/ThreadItemPostNoUnauthenticated'
import {ThreadItemPostTombstone} from '#/screens/PostThread/components/ThreadItemPostTombstone'
import {ThreadItemReadMore} from '#/screens/PostThread/components/ThreadItemReadMore'
import {ThreadItemReadMoreUp} from '#/screens/PostThread/components/ThreadItemReadMoreUp'
import {ThreadItemReplyComposerSkeleton} from '#/screens/PostThread/components/ThreadItemReplyComposer'
import {ThreadItemShowOtherReplies} from '#/screens/PostThread/components/ThreadItemShowOtherReplies'
import {
ThreadItemTreePost,
ThreadItemTreePostSkeleton,
} from '#/screens/PostThread/components/ThreadItemTreePost'
import {atoms as a, native, platform, useBreakpoints, web} from '#/alf'
import * as Layout from '#/components/Layout'
import {ListFooter} from '#/components/Lists'
const PARENT_CHUNK_SIZE = 5
const CHILDREN_CHUNK_SIZE = 50
export function PostThread({uri}: {uri: string}) {
const {gtMobile} = useBreakpoints()
const {hasSession} = useSession()
const initialNumToRender = useInitialNumToRender() // TODO
const {height: windowHeight} = useWindowDimensions()
const anchorPostSource = useUnstablePostSource(uri)
const feedFeedback = useFeedFeedback(anchorPostSource?.feed, hasSession)
/*
* One query to rule them all
*/
const thread = usePostThread({anchor: uri})
const anchor = useMemo(() => {
for (const item of thread.data.items) {
if (item.type === 'threadPost' && item.depth === 0) {
return item
}
}
return
}, [thread.data.items])
const {openComposer} = useOpenComposer()
const optimisticOnPostReply = useCallback(
(payload: OnPostSuccessData) => {
if (payload) {
const {replyToUri, posts} = payload
if (replyToUri && posts.length) {
thread.actions.insertReplies(replyToUri, posts)
}
}
},
[thread],
)
const onReplyToAnchor = useCallback(() => {
if (anchor?.type !== 'threadPost') {
return
}
const post = anchor.value.post
openComposer({
replyTo: {
uri: anchor.uri,
cid: post.cid,
text: post.record.text,
author: post.author,
embed: post.embed,
moderation: anchor.moderation,
},
onPostSuccess: optimisticOnPostReply,
})
if (anchorPostSource) {
feedFeedback.sendInteraction({
item: post.uri,
event: 'app.bsky.feed.defs#interactionReply',
feedContext: anchorPostSource.post.feedContext,
reqId: anchorPostSource.post.reqId,
})
}
}, [
anchor,
openComposer,
optimisticOnPostReply,
anchorPostSource,
feedFeedback,
])
const isRoot = !!anchor && anchor.value.post.record.reply === undefined
const canReply = !anchor?.value.post?.viewer?.replyDisabled
const [maxParentCount, setMaxParentCount] = useState(PARENT_CHUNK_SIZE)
const [maxChildrenCount, setMaxChildrenCount] = useState(CHILDREN_CHUNK_SIZE)
const totalParentCount = useRef(0) // recomputed below
const totalChildrenCount = useRef(thread.data.items.length) // recomputed below
const listRef = useRef<ListMethods>(null)
const anchorRef = useRef<View | null>(null)
const headerRef = useRef<View | null>(null)
/*
* On a cold load, parents are not prepended until the anchor post has
* rendered as the first item in the list. This gives us a consistent
* reference point for which to pin the anchor post to the top of the screen.
*
* We simulate a cold load any time the user changes the view or sort params
* so that this handling is consistent.
*
* On native, `maintainVisibleContentPosition={{minIndexForVisible: 0}}` gives
* us this for free, since the anchor post is the first item in the list.
*
* On web, `onContentSizeChange` is used to get ahead of next paint and handle
* this scrolling.
*/
const [deferParents, setDeferParents] = useState(true)
/**
* Used to flag whether we should scroll to the anchor post. On a cold load,
* this is always true. And when a user changes thread parameters, we also
* manually set this to true.
*/
const shouldHandleScroll = useRef(true)
/**
* Called any time the content size of the list changes, _just_ before paint.
*
* We want this to fire every time we change params (which will reset
* `deferParents` via `onLayout` on the anchor post, due to the key change),
* or click into a new post (which will result in a fresh `deferParents`
* hook).
*
* The result being: any intentional change in view by the user will result
* in the anchor being pinned as the first item.
*/
const onContentSizeChangeWebOnly = web(() => {
const list = listRef.current
const anchor = anchorRef.current as any as Element
const header = headerRef.current as any as Element
if (list && anchor && header && shouldHandleScroll.current) {
const anchorOffsetTop = anchor.getBoundingClientRect().top
const headerHeight = header.getBoundingClientRect().height
/*
* `deferParents` is `true` on a cold load, and always reset to
* `true` when params change via `prepareForParamsUpdate`.
*
* On a cold load or a push to a new post, on the first pass of this
* logic, the anchor post is the first item in the list. Therefore
* `anchorOffsetTop - headerHeight` will be 0.
*
* When a user changes thread params, on the first pass of this logic,
* the anchor post may not move (if there are no parents above it), or it
* may have gone off the screen above, because of the sudden lack of
* parents due to `deferParents === true`. This negative value (minus
* `headerHeight`) will result in a _negative_ `offset` value, which will
* scroll the anchor post _down_ to the top of the screen.
*
* However, `prepareForParamsUpdate` also resets scroll to `0`, so when a user
* changes params, the anchor post's offset will actually be equivalent
* to the `headerHeight` because of how the DOM is stacked on web.
* Therefore, `anchorOffsetTop - headerHeight` will once again be 0,
* which means the first pass in this case will result in no scroll.
*
* Then, once parents are prepended, this will fire again. Now, the
* `anchorOffsetTop` will be positive, which minus the header height,
* will give us a _positive_ offset, which will scroll the anchor post
* back _up_ to the top of the screen.
*/
list.scrollToOffset({
offset: anchorOffsetTop - headerHeight,
})
/*
* After the second pass, `deferParents` will be `false`, and we need
* to ensure this doesn't run again until scroll handling is requested
* again via `shouldHandleScroll.current === true` and a params
* change via `prepareForParamsUpdate`.
*
* The `isRoot` here is needed because if we're looking at the anchor
* post, this handler will not fire after `deferParents` is set to
* `false`, since there are no parents to render above it. In this case,
* we want to make sure `shouldHandleScroll` is set to `false` so that
* subsequent size changes unrelated to a params change (like pagination)
* do not affect scroll.
*/
if (!deferParents || isRoot) shouldHandleScroll.current = false
}
})
/**
* Ditto the above, but for native.
*/
const onContentSizeChangeNativeOnly = native(() => {
const list = listRef.current
const anchor = anchorRef.current
if (list && anchor && shouldHandleScroll.current) {
/*
* `prepareForParamsUpdate` is called any time the user changes thread params like
* `view` or `sort`, which sets `deferParents(true)` and resets the
* scroll to the top of the list. However, there is a split second
* where the top of the list is wherever the parents _just were_. So if
* there were parents, the anchor is not at the top of the list just
* prior to this handler being called.
*
* Once this handler is called, the anchor post is the first item in
* the list (because of `deferParents` being `true`), and so we can
* synchronously scroll the list back to the top of the list (which is
* 0 on native, no need to handle `headerHeight`).
*/
list.scrollToOffset({
animated: false,
offset: 0,
})
/*
* After this first pass, `deferParents` will be `false`, and those
* will render in. However, the anchor post will retain its position
* because of `maintainVisibleContentPosition` handling on native. So we
* don't need to let this handler run again, like we do on web.
*/
shouldHandleScroll.current = false
}
})
/**
* Called any time the user changes thread params, such as `view` or `sort`.
* Prepares the UI for repositioning of the scroll so that the anchor post is
* always at the top after a params change.
*
* No need to handle max parents here, deferParents will handle that and we
* want it to re-render with the same items above the anchor.
*/
const prepareForParamsUpdate = useCallback(() => {
/**
* Truncate list so that anchor post is the first item in the list. Manual
* scroll handling on web is predicated on this, and on native, this allows
* `maintainVisibleContentPosition` to do its thing.
*/
setDeferParents(true)
// reset this to a lower value for faster re-render
setMaxChildrenCount(CHILDREN_CHUNK_SIZE)
// set flag
shouldHandleScroll.current = true
}, [setDeferParents, setMaxChildrenCount])
const setSortWrapped = useCallback(
(sort: string) => {
prepareForParamsUpdate()
thread.actions.setSort(sort)
},
[thread, prepareForParamsUpdate],
)
const setViewWrapped = useCallback(
(view: ThreadViewOption) => {
prepareForParamsUpdate()
thread.actions.setView(view)
},
[thread, prepareForParamsUpdate],
)
const onStartReached = () => {
if (thread.state.isFetching) return
// can be true after `prepareForParamsUpdate` is called
if (deferParents) return
// prevent any state mutations if we know we're done
if (maxParentCount >= totalParentCount.current) return
setMaxParentCount(n => n + PARENT_CHUNK_SIZE)
}
const onEndReached = () => {
if (thread.state.isFetching) return
// can be true after `prepareForParamsUpdate` is called
if (deferParents) return
// prevent any state mutations if we know we're done
if (maxChildrenCount >= totalChildrenCount.current) return
setMaxChildrenCount(prev => prev + CHILDREN_CHUNK_SIZE)
}
const slices = useMemo(() => {
const results: ThreadItem[] = []
if (!thread.data.items.length) return results
/*
* Pagination hack, tracks the # of items below the anchor post.
*/
let childrenCount = 0
for (let i = 0; i < thread.data.items.length; i++) {
const item = thread.data.items[i]
/*
* Need to check `depth`, since not found or blocked posts are not
* `threadPost`s, but still have `depth`.
*/
const hasDepth = 'depth' in item
/*
* Handle anchor post.
*/
if (hasDepth && item.depth === 0) {
results.push(item)
// Recalculate total parents current index.
totalParentCount.current = i
// Recalculate total children using (length - 1) - current index.
totalChildrenCount.current = thread.data.items.length - 1 - i
/*
* Walk up the parents, limiting by `maxParentCount`
*/
if (!deferParents) {
const start = i - 1
if (start >= 0) {
const limit = Math.max(0, start - maxParentCount)
for (let pi = start; pi >= limit; pi--) {
results.unshift(thread.data.items[pi])
}
}
}
} else {
// ignore any parent items
if (item.type === 'readMoreUp' || (hasDepth && item.depth < 0)) continue
// can exit early if we've reached the max children count
if (childrenCount > maxChildrenCount) break
results.push(item)
childrenCount++
}
}
return results
}, [thread, deferParents, maxParentCount, maxChildrenCount])
const isTombstoneView = useMemo(() => {
if (slices.length > 1) return false
return slices.every(
s => s.type === 'threadPostBlocked' || s.type === 'threadPostNotFound',
)
}, [slices])
const renderItem = useCallback(
({item, index}: {item: ThreadItem; index: number}) => {
if (item.type === 'threadPost') {
if (item.depth < 0) {
return (
<ThreadItemPost
item={item}
threadgateRecord={thread.data.threadgate?.record ?? undefined}
overrides={{
topBorder: index === 0,
}}
onPostSuccess={optimisticOnPostReply}
/>
)
} else if (item.depth === 0) {
return (
/*
* Keep this view wrapped so that the anchor post is always index 0
* in the list and `maintainVisibleContentPosition` can do its
* thing.
*/
<View collapsable={false}>
<View
/*
* IMPORTANT: this is a load-bearing key on all platforms. We
* want to force `onLayout` to fire any time the thread params
* change so that `deferParents` is always reset to `false` once
* the anchor post is rendered.
*
* If we ever add additional thread params to this screen, they
* will need to be added here.
*/
key={item.uri + thread.state.view + thread.state.sort}
ref={anchorRef}
onLayout={() => setDeferParents(false)}
/>
<ThreadItemAnchor
item={item}
threadgateRecord={thread.data.threadgate?.record ?? undefined}
onPostSuccess={optimisticOnPostReply}
postSource={anchorPostSource}
/>
</View>
)
} else {
if (thread.state.view === 'tree') {
return (
<ThreadItemTreePost
item={item}
threadgateRecord={thread.data.threadgate?.record ?? undefined}
overrides={{
moderation: thread.state.otherItemsVisible && item.depth > 0,
}}
onPostSuccess={optimisticOnPostReply}
/>
)
} else {
return (
<ThreadItemPost
item={item}
threadgateRecord={thread.data.threadgate?.record ?? undefined}
overrides={{
moderation: thread.state.otherItemsVisible && item.depth > 0,
}}
onPostSuccess={optimisticOnPostReply}
/>
)
}
}
} else if (item.type === 'threadPostNoUnauthenticated') {
if (item.depth < 0) {
return <ThreadItemPostNoUnauthenticated item={item} />
} else if (item.depth === 0) {
return <ThreadItemAnchorNoUnauthenticated />
}
} else if (item.type === 'readMore') {
return (
<ThreadItemReadMore
item={item}
view={thread.state.view === 'tree' ? 'tree' : 'linear'}
/>
)
} else if (item.type === 'readMoreUp') {
return <ThreadItemReadMoreUp item={item} />
} else if (item.type === 'threadPostBlocked') {
return <ThreadItemPostTombstone type="blocked" />
} else if (item.type === 'threadPostNotFound') {
return <ThreadItemPostTombstone type="not-found" />
} else if (item.type === 'replyComposer') {
return (
<View>
{gtMobile && (
<PostThreadComposePrompt onPressCompose={onReplyToAnchor} />
)}
</View>
)
} else if (item.type === 'showOtherReplies') {
return <ThreadItemShowOtherReplies onPress={item.onPress} />
} else if (item.type === 'skeleton') {
if (item.item === 'anchor') {
return <ThreadItemAnchorSkeleton />
} else if (item.item === 'reply') {
if (thread.state.view === 'linear') {
return <ThreadItemPostSkeleton index={index} />
} else {
return <ThreadItemTreePostSkeleton index={index} />
}
} else if (item.item === 'replyComposer') {
return <ThreadItemReplyComposerSkeleton />
}
}
return null
},
[
thread,
optimisticOnPostReply,
onReplyToAnchor,
gtMobile,
anchorPostSource,
],
)
return (
<>
<Layout.Header.Outer headerRef={headerRef}>
<Layout.Header.BackButton />
<Layout.Header.Content>
<Layout.Header.TitleText>
<Trans context="description">Post</Trans>
</Layout.Header.TitleText>
</Layout.Header.Content>
<Layout.Header.Slot>
<HeaderDropdown
sort={thread.state.sort}
setSort={setSortWrapped}
view={thread.state.view}
setView={setViewWrapped}
/>
</Layout.Header.Slot>
</Layout.Header.Outer>
{thread.state.error ? (
<ThreadError
error={thread.state.error}
onRetry={thread.actions.refetch}
/>
) : (
<List
ref={listRef}
data={slices}
renderItem={renderItem}
keyExtractor={keyExtractor}
onContentSizeChange={platform({
web: onContentSizeChangeWebOnly,
default: onContentSizeChangeNativeOnly,
})}
onStartReached={onStartReached}
onEndReached={onEndReached}
onEndReachedThreshold={2}
onStartReachedThreshold={1}
/**
* NATIVE ONLY
* {@link https://reactnative.dev/docs/scrollview#maintainvisiblecontentposition}
*/
maintainVisibleContentPosition={{minIndexForVisible: 0}}
desktopFixedHeight
ListFooterComponent={
<ListFooter
/*
* On native, if `deferParents` is true, we need some extra buffer to
* account for the `on*ReachedThreshold` values.
*
* Otherwise, and on web, this value needs to be the height of
* the viewport _minus_ a sensible min-post height e.g. 200, so
* that there's enough scroll remaining to get the anchor post
* back to the top of the screen when handling scroll.
*/
height={platform({
web: windowHeight - 200,
default: deferParents ? windowHeight * 2 : windowHeight - 200,
})}
style={isTombstoneView ? {borderTopWidth: 0} : undefined}
/>
}
initialNumToRender={initialNumToRender}
windowSize={11}
sideBorders={false}
/>
)}
{!gtMobile && canReply && hasSession && (
<MobileComposePrompt onPressReply={onReplyToAnchor} />
)}
</>
)
}
function MobileComposePrompt({onPressReply}: {onPressReply: () => unknown}) {
const {footerHeight} = useShellLayout()
const animatedStyle = useAnimatedStyle(() => {
return {
bottom: footerHeight.get(),
}
})
return (
<Animated.View style={[a.fixed, a.left_0, a.right_0, animatedStyle]}>
<PostThreadComposePrompt onPressCompose={onPressReply} />
</Animated.View>
)
}
const keyExtractor = (item: ThreadItem) => {
return item.key
}

Some files were not shown because too many files have changed in this diff Show More