Compare commits

...

66 Commits

Author SHA1 Message Date
Eric Bailey 7d840faacc Make it safer 2024-06-04 14:19:34 -05:00
Eric Bailey ded615d9f9 Report persisted schema validation failures 2024-06-04 14:17:00 -05:00
Eric Bailey 551af88f22 [🙅] Remove fallback that's no longer valid (#4353)
* Remove fallback that's no longer valid

* Update test
2024-06-04 19:36:07 +01:00
Eric Bailey a49fe13223 Use recent convos for share via dialog (#4352) 2024-06-04 19:35:07 +01:00
Hailey 9f001526d3 Fix a few border nits (#4349)
* replace w/ hairline width

* no border for placeholder

* few notifications screen fixes tablet

* still show the border on desktop

* Simp

---------

Co-authored-by: Dan Abramov <dan.abramov@gmail.com>
2024-06-04 19:31:54 +01:00
surfdude29 d6b8313932 Mark accessibilityLabel and accessibilityHint for translation (#4351)
* mark `accessibilityLabel` and `accessibilityHint` for translation

* lint

* try again
2024-06-04 19:15:28 +01:00
Hailey e4b4d854d6 use rngh scrollview in search horizontal list (#4350) 2024-06-04 11:06:31 -07:00
dan c352e0f8b4 Fix forwarded ref (#4348) 2024-06-04 18:36:00 +01:00
Ryan Skinner e7968bc8d7 add profiles to search history (#4169)
* add profiles to search history

* increasing horizontal padding slightly

* tightening up styling

* fixing navigation issue

* making corrections

* Make the search history profiles a little smaller

* bug stomping

* Fix issues

* Persist taps

* Rm unnecessary

---------

Co-authored-by: Paul Frazee <pfrazee@gmail.com>
Co-authored-by: Dan Abramov <dan.abramov@gmail.com>
2024-06-04 16:31:24 +01:00
dan 6f1589971c Fix missing top borders (#4346) 2024-06-04 11:07:11 +01:00
Samuel Newman 2ffb98e22a allow nested quotes in DMs (#4345) 2024-06-04 11:03:43 +01:00
Samuel Newman d918f8dc2a Composer - unbork web (#4344)
* reduce side gap + add overflow hidden

also remove the animations since they don't appear in prod, and are kinda broken

* removed fixed height to fix alt text
2024-06-04 10:58:09 +01:00
Samuel Newman d032734278 Composer - make bottom border more consistent when typing (#4343)
* floor values

* fix last line being obscured

* Rm unnecessary runOnUI

---------

Co-authored-by: Dan Abramov <dan.abramov@gmail.com>
2024-06-04 10:55:35 +01:00
Eric Bailey 3e1f076891 [🙅] Disambiguation of the deactivation (#4267)
* Disambiguation of the deactivation

* Snapshot crackle pop

* Change log context

* [🙅] Add status to session state (#4269)

* Add status to session state

* [🙅] Add new deactivated screen (#4270)

* Add new deactivated screen

* Update copy, handle logout

* Remove icons, adjust padding

* [🙅] Add deactivate account dialog (#4290)

* Deactivate dialog

(cherry picked from commit 33940e2dfe0d710c0665a7f68b198b46f54db4a2)

* Factor out dialog, add to delete modal too

(cherry picked from commit 47d70f6b74e7d2ea7330fd172499fe91ba41062d)

* Update copy, icon

(cherry picked from commit e6efabbe78c3f3d9f0f8fb0a06a6a1c4fbfb70a9)

* Update copy

(cherry picked from commit abb0ce26f6747ab0548f6f12df0dee3c64464852)

* Sizing tweaks

(cherry picked from commit fc716d5716873f0fddef56496fc48af0614b2e55)

* Add a11y label
2024-06-04 02:10:43 +01:00
Eric Bailey de93e8de74 [🐴] Post embeds polish (#4339)
* Handle message cleanup

* Handle last message in chat list

* Memoize lastMessage
2024-06-04 02:07:01 +01:00
Samuel Newman da96fb1ef5 Native formSheet for GIF select on iOS (#4328)
* native formsheet for gif select

* trigger confirm discard if have gif

* give modal a background color

* fix web top bar - unrelated but I cba to make a separate PR
2024-06-04 02:05:46 +01:00
Hailey b02445883a add an apk to production build outputs for Obtanium release support (#4317)
* add an apk to production build outputs

* test a build

* Revert "test a build"

This reverts commit f89bfeefb7.
2024-06-03 17:32:58 -07:00
Thomas Dickerson bd4703ca1e Support for Flickr album and group pool embeds (#3936)
* Support for Flickr album and group pool embeds

* Oops, forgot to add flickr to the persisted externalEmbeds schema

* Need a bigint since our id can have more than 52 bits...

* Remove unexpected trailing / from test data to match the expected behavior

* nits

---------

Co-authored-by: Hailey <me@haileyok.com>
2024-06-03 17:29:45 -07:00
Samuel Newman 891b432ead Composer - add animated bottom border (#4325)
* start adding bottom border (wip)

* add content change listener

* add layout listener and move to hook

* remove logs

* use square-er image icon

* visually align bottom bar icons

* reduce keyboard vertical offset slightly

* only add border to top/bottom

* run worklet function on UI thread
2024-06-04 00:49:50 +01:00
dan 3b55f61d5f Avi follow experiment tweaks (#4341)
* Move avi button to visually align content

* Fix wrong prop warning

* Remove avi follow from post thread
2024-06-04 00:38:12 +01:00
Hailey 8c596b61c0 fix top border width for user list updates (#4340)
* fix nits in add/remove users from list screen

invert check

use `ViewHeader`

simplify

replace with hairline width

fix top border width for user list updates

* dont use `ViewHeader`

* update one more hairline
2024-06-03 16:34:37 -07:00
Bartosz Kaszubowski 44670c7ee2 Unify profile tabs and lists screens placeholders (#4315) 2024-06-03 16:05:26 -07:00
Hailey 8d8323421c remove resolution from post thread (#4297)
* remove resolution from post thread

nit

completely remove did cache lookup

move cache check for did to `usePostThreadQuery`

remove resolution from post thread

* helper function

* simplify

* simplify search too

* fix missing check for root or parent quoted post 🤯

* fix thread traversal
2024-06-03 23:58:16 +01:00
Yoshihiro Sugi 21d4d5f600 Fix filtering uris of fetchSubjects (#4324) 2024-06-03 23:41:03 +01:00
Hailey bda10510a4 use the new icon in reposted by (#4307)
* use the new icon in reposted by

* tweak
2024-06-03 23:35:57 +01:00
Hailey 16f295ca85 truncate if extending one line acct switcher (#4310) 2024-06-03 23:33:35 +01:00
Hailey f05aebf78e don't use flexBasis on web for message post embeds (#4303)
* don't use flexBasis on web

* rm unnecessary style
2024-06-03 17:05:37 -05:00
Eric Bailey de257a1186 Revert "[🐴] Embed backwards compat (#4302)" (#4338)
This reverts commit f868821cfc.
2024-06-03 17:05:14 -05:00
Hailey d93acb25f4 hide top border for mentions and replies (#4330) 2024-06-03 17:21:02 +01:00
Hailey 708a80e7a7 fix accessibility label in notifications (#4305)
* fix accessibility label in notifications

* add accessibility options to expand post

* inherit from outside, but always include `activate`

* include option to disable label/hint on previewable avatar

* fix hidden elements still being read on voiceover

* make it work for followers too

* extract variable

* fix hint

* update wording elsewhere
2024-05-31 21:02:18 +01:00
Samuel Newman b51640fbc0 [🐴] add emoji multiplier prop to RichText and bump it up for DMs (#4229)
* add emoji multiplier prop to RichText and bump it up for DMs

* remove background if only emoji

* Handle more emoji

* Adjust emoji regex and length

* Fix bad merge conflict res

* Fix logic

* Revert to emoji specific regex

---------

Co-authored-by: Eric Bailey <git@esb.lol>
2024-05-31 20:57:42 +01:00
Eric Bailey 5cda807d9d Tweak avi follow button styles (#4304)
* Tighten up

* Tweak colors

* Tweak for night mode

* Add missing file

* Contrast plus
2024-05-31 20:45:41 +01:00
Eric Bailey f868821cfc [🐴] Embed backwards compat (#4302) 2024-05-31 12:56:06 -05:00
Eric Bailey 2bb3694819 [🐴] Add labels to chats (#4293)
* Add labels to chat list

* Add to convo header

* Prevent click through on PostAlert buttons

* Fix space

* Fix alignment
2024-05-31 12:14:11 -05:00
Samuel Newman cd3b502b34 [🐴] Option to share via chat in post dropdown (#4231)
* add send via chat button to post dropdown

(cherry picked from commit d8458c0bc344f993266f7bc7e325d47e40619648)

* let usePostQuery take uris with DIDs

(cherry picked from commit 16b577ce749fd07e1d5f8461e8ca71c5b874a936)

* add embed preview in composer

(cherry picked from commit 795ceb98d55b6a3ab5b83187a582f9656d71db69)

* rm log

(cherry picked from commit 374d6b8869459f08d8442a3a47d67149e8d9ddd4)

* remove params properly, or at least as close to

(cherry picked from commit c20e0062c2ca4d9c2b28324eee5e713a1a3ab251)

* show images in preview

(cherry picked from commit 5bb617a3ce00f67bfc79784b2f81ef8dcb5bfc25)

* Register embed immediately

(cherry picked from commit ee120d5438a2c91c8980288665576d6a29b4c7e7)

* Add hover to match embeds

(cherry picked from commit 5297a5b06e499f46a9f6da510124610005db2448)

* Update post dropdown copy

(cherry picked from commit bc7e9f6a4303926a53c5c889f1f1b136faf20491)

* Embed preview style tweaks

(cherry picked from commit 9e3ccb0f25ac2f3ce6af538bb29112a3e96e01b1)

* use hydrated posts from API and just use postembed component

(cherry picked from commit cc0b84db87ca812d76cc69f46170ae84cfdde4ef)

* fix type error

(cherry picked from commit 9c49b940e1248e8a7c3b64190c5cb20750043619)

* undo needless export

(cherry picked from commit 1186701c997c50c0b29a809637cb9bc061b8c0a0)

* fix overflow

(cherry picked from commit 8868d5075062d0199c8ef6946fabde27e46ea378)

---------

Co-authored-by: Eric Bailey <git@esb.lol>
2024-05-31 11:10:00 -05:00
Samuel Newman 22e1eb18c8 [🐴] Record message (#4230)
* send record via link in text

* re-trim text after removing link

* record message

* only show copy text if message + add translate

* reduce padding

* adjust padding

* Tweak spacing

* Stop clickthrough for hidden content

* Update bg to show labels

---------

Co-authored-by: Eric Bailey <git@esb.lol>
2024-05-31 10:43:04 -05:00
Samuel Newman 8eb3cebb36 [🐴] send record via link in text (Record DMs - base PR) (#4227)
* send record via link in text

* re-trim text after removing link
2024-05-31 10:41:06 -05:00
Samuel Newman 455937dd0f Use new icons on notifications screen (#4299)
* use new icons on notif screen

* replace placeholder icon too

* cull unused legacy icons
2024-05-31 13:13:01 +01:00
Samuel Newman 05b55c1966 Composer - fix modals, and other tweaks (#4298)
* fix depreciated import

* add animations to old dropdown

* wrap modals in fullwindowoverlay

* move errors inside header

* add background to bottom bar and stop overlap

* nest dialogs on android

* fix android (wrap in gesturehandlerrootview)

* make borders all the same color

* revert threadgate button back to solid
2024-05-31 12:55:51 +01:00
Eric Bailey d614f6cb71 Shadows (#4265) 2024-05-31 05:56:09 +01:00
Hailey 89c9fd3be1 Change many border widths from 1 to hairlineWidth (#4294)
* feed items

* update some more

* moar

* profile card

* composer and notifications

* settings screen

* remove border from first item in feeds

* remove border from first item in feeds

* more removal of top border

* fix flatlist rendering

* oops

* scroll to top fab

* a.border

* centeredview/list

* placeholder

* web sidebar

* search posts

* feeds list

* user lists

* list header

* account list width 1

* hide top border feedgens

* same for lists

* fix tab bar web desktop

* wait...

* show the border on desktop web

* fix lists

* fix lists

* round
2024-05-31 05:32:54 +01:00
Samuel Newman 8569e2e389 Add follow button to feed item avatar (#3560)
* add follow button to feed item avatar

* remove confirmation

* add confirmation (just system alert)

* Shrink the avi follow indicator a smidge

* gate the follow button

* remove from your own posts

* add to post thread item

* hide the follow button locally to component

* Use native dropdown

* Add follow btn to notifications and search

* UI tweaks

* Hide on PWI

* Add toast for confirmation

* Check gate last

* compiler

* Rm unused

* Use names

---------

Co-authored-by: Paul Frazee <pfrazee@gmail.com>
Co-authored-by: Eric Bailey <git@esb.lol>
Co-authored-by: Dan Abramov <dan.abramov@gmail.com>
2024-05-31 05:05:52 +01:00
dan 9879159438 Disable non-deterministic flaky test (#4295) 2024-05-31 04:09:23 +01:00
Hailey 8e2c21a882 Don't log downsample warning when unnecessary (#4291)
* fix log

use process.env

ensure the log happens regardless of `isDownsampledSession`

fix log

* oops, revert test
2024-05-31 01:52:56 +01:00
dan d6275e98c2 [Statsig] Sample noisy events (#4288)
* Sample state:background and state:foreground

* Sample feed events

* Add DEV protection against forgetting to add events to the list
2024-05-30 16:32:59 +01:00
dan 9431201026 Bump FontAwesome (#4285) 2024-05-30 15:57:56 +01:00
dan 9d4622e713 Fix scrolling for labeler profiles (#4286) 2024-05-30 15:57:45 +01:00
dan 8de028387c Reduce Threadgate button size (#4287) 2024-05-30 15:57:03 +01:00
Samuel Newman 8feb2ab449 put dropdown in fullscreenoverlay on iOS (#4284) 2024-05-30 14:06:59 +01:00
Samuel Newman 16925baf8d play haptics before closing modal (#4283) 2024-05-30 13:46:26 +01:00
Samuel Newman b077cbe399 match loadmore position to fab (#4280) 2024-05-30 12:40:22 +01:00
Samuel Newman 3bdceac2fb Composer - Use sheet presentation on iOS (#4278)
* use sheet presentation + tweak spacing

* line up elements + add hitslop to cancel

* fixing spacing on replies
2024-05-30 12:39:36 +01:00
Hailey 76f860dad2 don't maintain position whenever there are no parents (#4277) 2024-05-30 12:36:40 +01:00
Samuel Newman 13c08f56ba Fix native translations on iOS 17.5.1 (#4282)
* enable translations on iOS 17.5.1

* add comment
2024-05-30 12:35:38 +01:00
Samuel Newman a72f55a11f Composer - fix divider when replying to someone (#4279)
* move replyto border to beneath

* use hairline width for consistency

* fix border colors
2024-05-30 12:34:30 +01:00
Samuel Newman cd497a3974 only show divider when scrolled (#4275) 2024-05-30 10:45:35 +03:00
Samuel Newman d92036f2c5 Post controls update followup (#4276)
* rm legacy repost modal

* make repost button transparent

* reduce gap between post and ctrls

* remove old repost modal on web
2024-05-30 09:44:49 +03:00
Samuel Newman c4abaa1abc Use <Modal> for Composer (#3588)
* use <Modal> to display composer

* trigger `onPressCancel` on modal cancel

* remove android top padding

* use light statusbar on ios

* use KeyboardStickyView from r-n-keyboard-controller

* make extra bottom padding ios-only

* make cancelRef optional

* scope legacy modals

* don't change bg color on ios

* use fullScreen instead of formSheet

* adjust padding on keyboardaccessory to account for new buttons

* Revert "use KeyboardStickyView from r-n-keyboard-controller"

This reverts commit 426c812904.

* fix insets

* tweaks and merge

* revert 89f51c72

* nit

* import keyboard provider

---------

Co-authored-by: Hailey <me@haileyok.com>
Co-authored-by: Dan Abramov <dan.abramov@gmail.com>
2024-05-30 05:44:20 +01:00
Samuel Newman fba4a9ca02 scale down FAB on press (#4259) 2024-05-30 05:36:07 +01:00
Paul Frazee e48f8e15eb Interpret 'hide' setting as ALWAYS hiding from thread replies (#4263) 2024-05-30 05:34:47 +01:00
Paul Frazee 5fe1e171f0 Improve the visual clarity of labels on profiles and posts (#4262)
* Update PostAlerts rendering to show the avi of the labeler rather than the display name; also add size variations

* Update ProfileHeaderAlerts to match PostAlerts behavior
2024-05-30 05:33:18 +01:00
Hailey 4cc55f05c2 Use a margin of -6 instead of -5 for PostCtrls 😵‍💫 (#4272)
* -6 instead of -5 😵‍💫

* same here
2024-05-30 04:37:45 +01:00
Hailey 9edb487949 Always show the header on post threads on native (#4254)
* always show header on native

* ALF ALF ALF

* rm offset for top border

* wrap in a `CenteredView`

* use `CenteredView`'s side borders

* account for loading state on web

* move `isTabletOrMobile`

* hide top border on first post in list

* show border if parents are loading

* don't show top border for deleted or blocked posts

* hide top border for hidden replies

* Rm root post top border

---------

Co-authored-by: Dan Abramov <dan.abramov@gmail.com>
2024-05-30 04:28:32 +01:00
Hailey 9628070e52 add prop to ListImpl for disabling content-visibility style (#4236)
* add prop to `ListImpl` for `content-visibility` style

* change to `disableContentVisibility`

* lint

* tweaks

* Keep the fix more general

* Clarify ambiguity

---------

Co-authored-by: Dan Abramov <dan.abramov@gmail.com>
2024-05-30 04:09:24 +01:00
Hailey eb6f44853d adjust notifications experiment by removing canAskAgain (#4271)
* adjust notifications experiment by removing `canAskAgain`

* move to `StepFinished` for after onboarding
2024-05-30 02:42:12 +01:00
Samuel Newman 165feedb86 Use ALF for post controls (#3400)
* alf the repost dropdown on web + import icons

* alf like icon

* convert other post controls

* add missing padding to share button

* refine buttons and use better icons

* revert buttonicon changes

* remove ButtonIcon and ButtonText from repost dialog

* use 15px font size when not big

* reduce size and use contrast_25

* add hover state to logged out view

* add `userSelect: 'none'` to buttons

* use width rather than height

* fix quote close behaviour

* prettier

* Fix Esc on repost

* Use new icons for placeholder

* Fix placeholder

---------

Co-authored-by: Dan Abramov <dan.abramov@gmail.com>
2024-05-30 01:25:11 +01:00
162 changed files with 4828 additions and 2974 deletions
@@ -120,6 +120,32 @@ jobs:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK
- name: 🏗️ Build Production APK
if: ${{ inputs.profile == 'production' }}
run: yarn use-build-number-with-bump eas build -p android --profile production-apk --local --output build.apk --non-interactive
- name: 🚀 Upload Production APK Artifact
id: upload-artifact-production-apk
if: ${{ inputs.profile == 'production' }}
uses: actions/upload-artifact@v4
with:
retention-days: 30
compression-level: 6
name: build-${{ steps.timestamp.outputs.time }}.apk
path: build.apk
- name: 🔔 Notify Slack of Production APK Build
if: ${{ inputs.profile == 'production' }}
uses: slackapi/slack-github-action@v1.25.0
with:
payload: |
{
"text": "Android production APK build is ready for download. This is a production build, and you should add it to the GitHub release! Download the artifact here: ${{ steps.upload-artifact-production-apk.outputs.artifact-url }}"
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK
- name: ⬇️ Restore Cache
id: get-base-commit
uses: actions/cache@v4
+82 -1
View File
@@ -142,7 +142,8 @@ describe('makeRecordUri', () => {
})
})
describe('ago', () => {
// FIXME: Reenable after fixing non-deterministic test.
describe.skip('ago', () => {
const oneYearDate = new Date(
new Date().setMonth(new Date().getMonth() - 11),
).setDate(new Date().getDate() - 28)
@@ -479,6 +480,26 @@ describe('parseEmbedPlayerFromUrl', () => {
'https://media.tenor.com/someID/someName.gif',
'https://media.tenor.com/someID',
'https://media.tenor.com',
'https://www.flickr.com/photos/username/albums/72177720308493661',
'https://flickr.com/photos/username/albums/72177720308493661',
'https://flickr.com/photos/username/albums/72177720308493661/',
'https://flickr.com/photos/username/albums/72177720308493661//',
'https://flic.kr/s/aHBqjAES3i',
'https://flickr.com/foetoes/username/albums/3903',
'https://flickr.com/albums/3903',
'https://flic.kr/s/OolI',
'https://flic.kr/t/aHBqjAES3i',
'https://www.flickr.com/groups/898944@N23/pool',
'https://flickr.com/groups/898944@N23/pool',
'https://flickr.com/groups/898944@N23/pool/',
'https://flickr.com/groups/898944@N23/pool//',
'https://flic.kr/go/8WJtR',
'https://www.flickr.com/groups/898944@N23/',
'https://www.flickr.com/groups',
]
const outputs = [
@@ -776,6 +797,66 @@ describe('parseEmbedPlayerFromUrl', () => {
undefined,
undefined,
undefined,
{
type: 'flickr_album',
source: 'flickr',
playerUri: 'https://embedr.flickr.com/photosets/72177720308493661',
},
{
type: 'flickr_album',
source: 'flickr',
playerUri: 'https://embedr.flickr.com/photosets/72177720308493661',
},
{
type: 'flickr_album',
source: 'flickr',
playerUri: 'https://embedr.flickr.com/photosets/72177720308493661',
},
{
type: 'flickr_album',
source: 'flickr',
playerUri: 'https://embedr.flickr.com/photosets/72177720308493661',
},
{
type: 'flickr_album',
source: 'flickr',
playerUri: 'https://embedr.flickr.com/photosets/72177720308493661',
},
undefined,
undefined,
undefined,
undefined,
{
type: 'flickr_album',
source: 'flickr',
playerUri: 'https://embedr.flickr.com/groups/898944@N23',
},
{
type: 'flickr_album',
source: 'flickr',
playerUri: 'https://embedr.flickr.com/groups/898944@N23',
},
{
type: 'flickr_album',
source: 'flickr',
playerUri: 'https://embedr.flickr.com/groups/898944@N23',
},
{
type: 'flickr_album',
source: 'flickr',
playerUri: 'https://embedr.flickr.com/groups/898944@N23',
},
{
type: 'flickr_album',
source: 'flickr',
playerUri: 'https://embedr.flickr.com/groups/898944@N23',
},
undefined,
undefined,
]
it('correctly grabs the correct id from uri', () => {
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M3.004 4a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h2v5a1 1 0 0 0 .43.822c.57.395 1.176.031 1.685-.255.428-.24.998-.614 1.569-1.15 1.154-1.082 2.316-2.832 2.316-5.417V5a1 1 0 0 0-1-1h-7ZM14.004 4a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h2v5a1 1 0 0 0 .43.822c.57.395 1.176.031 1.685-.255.428-.24.998-.614 1.569-1.15 1.154-1.082 2.316-2.832 2.316-5.417V5a1 1 0 0 0-1-1h-7Z"/></svg>

After

Width:  |  Height:  |  Size: 450 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M2.004 5a1 1 0 0 1 1-1h7a1 1 0 0 1 1 1v8c0 2.585-1.162 4.335-2.316 5.417-.571.536-1.14.91-1.569 1.15a7.01 7.01 0 0 1-.738.36l-.016.006-.006.002h-.002l-.001.001L6.004 19l.351.936A1 1 0 0 1 5.004 19v-5h-2a1 1 0 0 1-1-1V5Zm5 12.234c.104-.085.21-.177.316-.276.846-.793 1.684-2.043 1.684-3.958V6h-5v6h2a1 1 0 0 1 1 1v4.234Zm6-12.234a1 1 0 0 1 1-1h7a1 1 0 0 1 1 1v8c0 2.585-1.162 4.335-2.316 5.417-.571.536-1.14.91-1.569 1.15a7.018 7.018 0 0 1-.738.36l-.016.006-.006.002h-.002l-.001.001-.352-.936.351.936A1 1 0 0 1 16.004 19v-5h-2a1 1 0 0 1-1-1V5Zm5 12.234V13a1 1 0 0 0-1-1h-2V6h5v7c0 1.915-.838 3.165-1.684 3.958-.106.1-.212.191-.316.276Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 775 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M2.003 5.999a2 2 0 0 1 2-1.999h5c1.104 0 2 .893 2 1.999V13c0 2.585-1.16 4.335-2.315 5.417-.571.536-1.14.91-1.569 1.15a7.01 7.01 0 0 1-.738.36l-.016.006-.006.002h-.002l-.001.001L6.004 19l.351.936a1 1 0 0 1-1.351-.935L5 14H4a2 2 0 0 1-2-2.001l.002-6Zm5 11.236L7 12.999A1 1 0 0 0 6 12H4l.003-6h5v7c0 1.915-.837 3.165-1.683 3.958-.106.1-.213.192-.317.277Zm6-11.235a2 2 0 0 1 2-2h5c1.104 0 2 .893 2 1.999V13c0 2.585-1.16 4.335-2.315 5.417-.571.536-1.14.91-1.569 1.15a7.018 7.018 0 0 1-.738.36l-.016.006-.006.002h-.002l-.001.001-.352-.936.351.936A1 1 0 0 1 16.004 19v-5h-1a2 2 0 0 1-2-2V6Zm7 0h-5v6h2a1 1 0 0 1 1 1v4.234c.105-.085.211-.177.317-.276.846-.793 1.684-2.043 1.684-3.958V6Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 821 B

@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M3 5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5Zm16 0H5v7.213l1.246-.932.044-.03a3 3 0 0 1 3.863.454c1.468 1.58 2.941 2.749 4.847 2.749 1.703 0 2.855-.555 4-1.618V5Zm0 10.357c-1.112.697-2.386 1.097-4 1.097-2.81 0-4.796-1.755-6.313-3.388a1 1 0 0 0-1.269-.164L5 14.712V19h14v-3.643ZM15 8a1 1 0 1 0 0 2 1 1 0 0 0 0-2Zm-3 1a3 3 0 1 1 6 0 3 3 0 0 1-6 0Z" clip-rule="evenodd"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M3 4a1 1 0 0 1 1-1h16a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4Zm2 1v7.213l1.246-.932.044-.03a3 3 0 0 1 3.863.454c1.468 1.58 2.941 2.749 4.847 2.749 1.703 0 2.855-.555 4-1.618V5H5Zm14 10.357c-1.112.697-2.386 1.097-4 1.097-2.81 0-4.796-1.755-6.313-3.388a1 1 0 0 0-1.269-.164L5 14.712V19h14v-3.643ZM15 8a1 1 0 1 0 0 2 1 1 0 0 0 0-2Zm-3 1a3 3 0 1 1 6 0 3 3 0 0 1-6 0Z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 513 B

After

Width:  |  Height:  |  Size: 514 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M8.004 5a1 1 0 0 0-.43-.822c-.57-.395-1.176-.031-1.685.255-.428.24-.998.614-1.569 1.15C3.166 6.665 2.004 8.415 2.004 11v8a1 1 0 0 0 1 1h7a1 1 0 0 0 1-1v-8a1 1 0 0 0-1-1h-2V5ZM19.004 5a1 1 0 0 0-.43-.822c-.57-.395-1.176-.031-1.685.255-.428.24-.998.614-1.569 1.15-1.154 1.082-2.316 2.832-2.316 5.417v8a1 1 0 0 0 1 1h7a1 1 0 0 0 1-1v-8a1 1 0 0 0-1-1h-2V5Z"/></svg>

After

Width:  |  Height:  |  Size: 455 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M7.574 4.178a1 1 0 0 1 .43.822v5h2a1 1 0 0 1 1 1v8a1 1 0 0 1-1 1h-7a1 1 0 0 1-1-1v-8c0-2.585 1.162-4.335 2.316-5.417a8.163 8.163 0 0 1 1.569-1.15 7.029 7.029 0 0 1 .738-.36l.016-.005.005-.003h.003v-.001c.001 0 .002 0 .353.936l-.351-.936a1 1 0 0 1 .92.114Zm-1.57 2.588a5.99 5.99 0 0 0-.316.276C4.842 7.835 4.004 9.085 4.004 11v7h5v-6h-2a1 1 0 0 1-1-1V6.766Zm12.57-2.588a1 1 0 0 1 .43.822v5h2a1 1 0 0 1 1 1v8a1 1 0 0 1-1 1h-7a1 1 0 0 1-1-1v-8c0-2.585 1.162-4.335 2.316-5.417a8.166 8.166 0 0 1 1.569-1.15 7.038 7.038 0 0 1 .738-.36l.016-.005.005-.003h.003v-.001c.001 0 .002 0 .353.936l-.351-.936a1 1 0 0 1 .92.114Zm-1.57 2.588c-.105.085-.21.177-.316.276-.846.793-1.684 2.043-1.684 3.958v7h5v-6h-2a1 1 0 0 1-1-1V6.766Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 857 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M7.5 6.5a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM12 12c-4.758 0-8.083 3.521-8.496 7.906A1 1 0 0 0 4.5 21H15a3 3 0 1 1 0-6c0-.824.332-1.571.87-2.113C14.739 12.32 13.435 12 12 12Zm6 2a1 1 0 0 1 1 1v2h2a1 1 0 1 1 0 2h-2v2a1 1 0 1 1-2 0v-2h-2a1 1 0 1 1 0-2h2v-2a1 1 0 0 1 1-1Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 412 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M12 4a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM7.5 6.5a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM5.678 19c.71-2.909 3.092-5 6.322-5 .621 0 1.206.077 1.748.218a1 1 0 1 0 .504-1.936A8.931 8.931 0 0 0 12 12c-4.758 0-8.083 3.521-8.496 7.906A1 1 0 0 0 4.5 21H11a1 1 0 1 0 0-2H5.678ZM18 14a1 1 0 0 1 1 1v2h2a1 1 0 1 1 0 2h-2v2a1 1 0 1 1-2 0v-2h-2a1 1 0 1 1 0-2h2v-2a1 1 0 0 1 1-1Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 508 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M16.293 2.293a1 1 0 0 1 1.414 0l3 3a1 1 0 0 1 0 1.414l-3 3a1 1 0 0 1-1.414-1.414L17.586 7H5v4a1 1 0 1 1-2 0V6a1 1 0 0 1 1-1h13.586l-1.293-1.293a1 1 0 0 1 0-1.414ZM21 13v5a1 1 0 0 1-1 1H6.414l1.293 1.293a1 1 0 1 1-1.414 1.414l-3-3a1 1 0 0 1 0-1.414l3-3a1 1 0 0 1 1.414 1.414L6.414 17H19v-4a1 1 0 1 1 2 0Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 446 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M16.793 2.293a1 1 0 0 1 1.414 0L20.5 4.586a2 2 0 0 1 0 2.828l-2.293 2.293a1 1 0 0 1-1.414-1.414L18.086 7H7a2 2 0 0 0-2 2v2a1 1 0 1 1-2 0V9a4 4 0 0 1 4-4h11.086l-1.293-1.293a1 1 0 0 1 0-1.414ZM20 12a1 1 0 0 1 1 1v2a4 4 0 0 1-4 4H5.914l1.293 1.293a1 1 0 1 1-1.414 1.414L3.5 19.414a2 2 0 0 1 0-2.828l2.293-2.293a1 1 0 0 1 1.414 1.414L5.914 17H17a2 2 0 0 0 2-2v-2a1 1 0 0 1 1-1Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 517 B

+14
View File
@@ -46,6 +46,20 @@
"EXPO_PUBLIC_ENV": "production"
}
},
"production-apk": {
"extends": "base",
"distribution": "internal",
"ios": {
"autoIncrement": false
},
"android": {
"autoIncrement": false
},
"channel": "production",
"env": {
"EXPO_PUBLIC_ENV": "production"
}
},
"testflight": {
"extends": "base",
"ios": {
@@ -15,7 +15,10 @@ export function NativeTranslationView() {
return <NativeView />
}
export const isAvailable = Number(Platform.Version) >= 17.4
// can be something like "17.5.1", so just take the first two parts
const version = String(Platform.Version).split('.').slice(0, 2).join('.')
export const isAvailable = Number(version) >= 17.4
// https://en.wikipedia.org/wiki/Translate_(Apple)#Languages
const SUPPORTED_LANGUAGES = [
+2 -2
View File
@@ -49,7 +49,7 @@
"open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web"
},
"dependencies": {
"@atproto/api": "^0.12.13",
"@atproto/api": "^0.12.14",
"@bam.tech/react-native-image-resizer": "^3.0.4",
"@braintree/sanitize-url": "^6.0.2",
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
@@ -63,7 +63,7 @@
"@fortawesome/fontawesome-svg-core": "^6.1.1",
"@fortawesome/free-regular-svg-icons": "^6.1.1",
"@fortawesome/free-solid-svg-icons": "^6.1.1",
"@fortawesome/react-native-fontawesome": "^0.3.0",
"@fortawesome/react-native-fontawesome": "^0.3.2",
"@lingui/react": "^4.5.0",
"@mattermost/react-native-paste-input": "^0.7.1",
"@miblanchard/react-native-slider": "^2.3.1",
+24 -7
View File
@@ -1,7 +1,8 @@
import {Platform} from 'react-native'
import {Platform, StyleSheet} from 'react-native'
import * as tokens from '#/alf/tokens'
import {native, web} from '#/alf/util/platform'
import hairlineWidth = StyleSheet.hairlineWidth
export const atoms = {
/*
@@ -55,6 +56,13 @@ export const atoms = {
height: '100vh',
}),
/*
* Theme-independent bg colors
*/
bg_transparent: {
backgroundColor: 'transparent',
},
/*
* Border radius
*/
@@ -270,19 +278,19 @@ export const atoms = {
borderWidth: 0,
},
border: {
borderWidth: 1,
borderWidth: hairlineWidth,
},
border_t: {
borderTopWidth: 1,
borderTopWidth: hairlineWidth,
},
border_b: {
borderBottomWidth: 1,
borderBottomWidth: hairlineWidth,
},
border_l: {
borderLeftWidth: 1,
borderLeftWidth: hairlineWidth,
},
border_r: {
borderRightWidth: 1,
borderRightWidth: hairlineWidth,
},
/*
@@ -841,7 +849,7 @@ export const atoms = {
marginRight: 'auto',
},
/*
* Pointer events
* Pointer events & user select
*/
pointer_events_none: {
pointerEvents: 'none',
@@ -849,6 +857,15 @@ export const atoms = {
pointer_events_auto: {
pointerEvents: 'auto',
},
user_select_none: {
userSelect: 'none',
},
user_select_text: {
userSelect: 'text',
},
user_select_all: {
userSelect: 'all',
},
/*
* Text decoration
*/
+5 -3
View File
@@ -1,12 +1,14 @@
import React from 'react'
import {Dimensions} from 'react-native'
import * as themes from '#/alf/themes'
export * from '#/alf/types'
export * as tokens from '#/alf/tokens'
export {atoms} from '#/alf/atoms'
export * from '#/alf/util/platform'
export * as tokens from '#/alf/tokens'
export * from '#/alf/types'
export * from '#/alf/util/flatten'
export * from '#/alf/util/platform'
export * from '#/alf/util/themeSelector'
type BreakpointName = keyof typeof breakpoints
+5 -5
View File
@@ -1,11 +1,11 @@
import {atoms} from '#/alf/atoms'
import * as tokens from '#/alf/tokens'
import type {Mutable} from '#/alf/types'
import {atoms} from '#/alf/atoms'
import {BLUE_HUE, GREEN_HUE, RED_HUE} from '#/alf/util/colorGeneration'
export type ThemeName = 'light' | 'dim' | 'dark'
export type ReadonlyTheme = typeof light
export type Theme = Mutable<ReadonlyTheme>
export type Theme = Mutable<ReadonlyTheme> & {name: ThemeName}
export type ReadonlyPalette = typeof lightPalette
export type Palette = Mutable<ReadonlyPalette>
@@ -193,7 +193,7 @@ export const dimPalette: Palette = {
} as const
export const light = {
name: 'light',
name: 'light' as ThemeName,
palette: lightPalette,
atoms: {
text: {
@@ -278,7 +278,7 @@ export const light = {
}
export const dark: Theme = {
name: 'dark',
name: 'dark' as ThemeName,
palette: darkPalette,
atoms: {
text: {
@@ -367,7 +367,7 @@ export const dark: Theme = {
export const dim: Theme = {
...dark,
name: 'dim',
name: 'dim' as ThemeName,
palette: dimPalette,
atoms: {
...dark.atoms,
+14
View File
@@ -0,0 +1,14 @@
import {ThemeName} from '#/alf/themes'
export function select<T>(name: ThemeName, options: Record<ThemeName, T>) {
switch (name) {
case 'light':
return options.light
case 'dark':
return options.dark || options.dim
case 'dim':
return options.dim || options.dark
default:
throw new Error(`select(theme, options) received unknown theme ${name}`)
}
}
+17 -15
View File
@@ -1,25 +1,27 @@
import React from 'react'
import {ColorSchemeName, useColorScheme} from 'react-native'
import {useThemePrefs} from 'state/shell'
import {isWeb} from 'platform/detection'
import {ThemeName, light, dark, dim} from '#/alf/themes'
import * as SystemUI from 'expo-system-ui'
import {isWeb} from 'platform/detection'
import {useThemePrefs} from 'state/shell'
import {dark, dim, light, ThemeName} from '#/alf/themes'
export function useColorModeTheme(): ThemeName {
const theme = useThemeName()
React.useLayoutEffect(() => {
updateDocument(theme)
SystemUI.setBackgroundColorAsync(getBackgroundColor(theme))
}, [theme])
return theme
}
export function useThemeName(): ThemeName {
const colorScheme = useColorScheme()
const {colorMode, darkTheme} = useThemePrefs()
React.useLayoutEffect(() => {
const theme = getThemeName(colorScheme, colorMode, darkTheme)
updateDocument(theme)
SystemUI.setBackgroundColorAsync(getBackgroundColor(theme))
}, [colorMode, colorScheme, darkTheme])
return React.useMemo(
() => getThemeName(colorScheme, colorMode, darkTheme),
[colorScheme, colorMode, darkTheme],
)
return getThemeName(colorScheme, colorMode, darkTheme)
}
function getThemeName(
@@ -53,7 +55,7 @@ function updateDocument(theme: ThemeName) {
}
}
function getBackgroundColor(theme: ThemeName): string {
export function getBackgroundColor(theme: ThemeName): string {
switch (theme) {
case 'light':
return light.atoms.bg.backgroundColor
+2 -2
View File
@@ -37,7 +37,7 @@ export function AccountList({
style={[
a.rounded_md,
a.overflow_hidden,
a.border,
{borderWidth: 1},
t.atoms.border_contrast_low,
]}>
{accounts.map(account => (
@@ -48,7 +48,7 @@ export function AccountList({
isCurrentAccount={account.did === currentAccount?.did}
isPendingAccount={account.did === pendingDid}
/>
<View style={[a.border_b, t.atoms.border_contrast_low]} />
<View style={[{borderBottomWidth: 1}, t.atoms.border_contrast_low]} />
</React.Fragment>
))}
<Button
+11 -3
View File
@@ -28,7 +28,7 @@ export type ButtonColor =
| 'gradient_sunset'
| 'gradient_nordic'
| 'gradient_bonfire'
export type ButtonSize = 'tiny' | 'small' | 'medium' | 'large'
export type ButtonSize = 'tiny' | 'xsmall' | 'small' | 'medium' | 'large'
export type ButtonShape = 'round' | 'square' | 'default'
export type VariantProps = {
/**
@@ -64,13 +64,14 @@ type NonTextElements =
export type ButtonProps = Pick<
PressableProps,
'disabled' | 'onPress' | 'testID' | 'onLongPress'
'disabled' | 'onPress' | 'testID' | 'onLongPress' | 'hitSlop'
> &
AccessibilityProps &
VariantProps & {
testID?: string
label: string
style?: StyleProp<ViewStyle>
hoverStyle?: StyleProp<ViewStyle>
children: NonTextElements | ((context: ButtonContext) => NonTextElements)
}
@@ -96,6 +97,7 @@ export function Button({
label,
disabled = false,
style,
hoverStyle: hoverStyleProp,
...rest
}: ButtonProps) {
const t = useTheme()
@@ -281,6 +283,8 @@ export function Button({
baseStyles.push({paddingVertical: 12}, a.px_2xl, a.rounded_sm, a.gap_md)
} else if (size === 'small') {
baseStyles.push({paddingVertical: 9}, a.px_lg, a.rounded_sm, a.gap_sm)
} else if (size === 'xsmall') {
baseStyles.push({paddingVertical: 6}, a.px_sm, a.rounded_sm, a.gap_sm)
} else if (size === 'tiny') {
baseStyles.push({paddingVertical: 4}, a.px_sm, a.rounded_xs, a.gap_xs)
}
@@ -293,6 +297,8 @@ export function Button({
}
} else if (size === 'small') {
baseStyles.push({height: 34, width: 34})
} else if (size === 'xsmall') {
baseStyles.push({height: 28, width: 28})
} else if (size === 'tiny') {
baseStyles.push({height: 20, width: 20})
}
@@ -374,8 +380,10 @@ export function Button({
a.align_center,
a.justify_center,
flattenedBaseStyles,
...(state.hovered || state.pressed ? hoverStyles : []),
flatten(style),
...(state.hovered || state.pressed
? [hoverStyles, flatten(hoverStyleProp)]
: []),
]}
onPressIn={onPressIn}
onPressOut={onPressOut}
+44 -41
View File
@@ -32,6 +32,7 @@ import {
DialogOuterProps,
} from '#/components/Dialog/types'
import {createInput} from '#/components/forms/TextField'
import {FullWindowOverlay} from '#/components/FullWindowOverlay'
import {Portal} from '#/components/Portal'
export {useDialogContext, useDialogControl} from '#/components/Dialog/context'
@@ -170,47 +171,49 @@ export function Outer({
return (
isOpen && (
<Portal>
<View
// iOS
accessibilityViewIsModal
// Android
importantForAccessibility="yes"
style={[a.absolute, a.inset_0]}
testID={testID}
onTouchMove={() => Keyboard.dismiss()}>
<BottomSheet
enableDynamicSizing={!hasSnapPoints}
enablePanDownToClose
keyboardBehavior="interactive"
android_keyboardInputMode="adjustResize"
keyboardBlurBehavior="restore"
topInset={insets.top}
{...sheetOptions}
snapPoints={sheetOptions.snapPoints || ['100%']}
ref={sheet}
index={openIndex}
backgroundStyle={{backgroundColor: 'transparent'}}
backdropComponent={Backdrop}
handleIndicatorStyle={{backgroundColor: t.palette.primary_500}}
handleStyle={{display: 'none'}}
onClose={onCloseAnimationComplete}>
<Context.Provider value={context}>
<View
style={[
a.absolute,
a.inset_0,
t.atoms.bg,
{
borderTopLeftRadius: 40,
borderTopRightRadius: 40,
height: Dimensions.get('window').height * 2,
},
]}
/>
{children}
</Context.Provider>
</BottomSheet>
</View>
<FullWindowOverlay>
<View
// iOS
accessibilityViewIsModal
// Android
importantForAccessibility="yes"
style={[a.absolute, a.inset_0]}
testID={testID}
onTouchMove={() => Keyboard.dismiss()}>
<BottomSheet
enableDynamicSizing={!hasSnapPoints}
enablePanDownToClose
keyboardBehavior="interactive"
android_keyboardInputMode="adjustResize"
keyboardBlurBehavior="restore"
topInset={insets.top}
{...sheetOptions}
snapPoints={sheetOptions.snapPoints || ['100%']}
ref={sheet}
index={openIndex}
backgroundStyle={{backgroundColor: 'transparent'}}
backdropComponent={Backdrop}
handleIndicatorStyle={{backgroundColor: t.palette.primary_500}}
handleStyle={{display: 'none'}}
onClose={onCloseAnimationComplete}>
<Context.Provider value={context}>
<View
style={[
a.absolute,
a.inset_0,
t.atoms.bg,
{
borderTopLeftRadius: 40,
borderTopRightRadius: 40,
height: Dimensions.get('window').height * 2,
},
]}
/>
{children}
</Context.Provider>
</BottomSheet>
</View>
</FullWindowOverlay>
</Portal>
)
)
+1
View File
@@ -0,0 +1 @@
export {FullWindowOverlay} from 'react-native-screens'
+1
View File
@@ -0,0 +1 @@
export {Fragment as FullWindowOverlay} from 'react'
+13 -7
View File
@@ -28,6 +28,7 @@ export function RichText({
authorHandle,
onLinkPress,
interactiveStyle,
emojiMultiplier = 1.85,
}: TextStyleProp &
Pick<TextProps, 'selectable'> & {
value: RichTextAPI | string
@@ -38,6 +39,7 @@ export function RichText({
authorHandle?: string
onLinkPress?: LinkProps['onPress']
interactiveStyle?: TextStyle
emojiMultiplier?: number
}) {
const richText = React.useMemo(
() =>
@@ -57,17 +59,14 @@ export function RichText({
const {text, facets} = richText
if (!facets?.length) {
if (text.length <= 5 && /^\p{Extended_Pictographic}+$/u.test(text)) {
if (isOnlyEmoji(text)) {
const fontSize =
(flattenedStyle.fontSize ?? a.text_sm.fontSize) * emojiMultiplier
return (
<Text
selectable={selectable}
testID={testID}
style={[
{
fontSize: 26,
lineHeight: 30,
},
]}
style={[plainStyles, {fontSize}]}
// @ts-ignore web only -prf
dataSet={WORD_WRAP}>
{text}
@@ -247,3 +246,10 @@ function RichTextTag({
</React.Fragment>
)
}
export function isOnlyEmoji(text: string) {
return (
text.length <= 15 &&
/^[\p{Emoji_Presentation}\p{Extended_Pictographic}]+$/u.test(text)
)
}
+255
View File
@@ -0,0 +1,255 @@
import React, {
useCallback,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react'
import {Modal, ScrollView, TextInput, View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {cleanError} from '#/lib/strings/errors'
import {
Gif,
useFeaturedGifsQuery,
useGifSearchQuery,
} from '#/state/queries/tenor'
import {ErrorScreen} from '#/view/com/util/error/ErrorScreen'
import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
import {FlatList_INTERNAL} from '#/view/com/util/Views'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import * as TextField from '#/components/forms/TextField'
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2'
import {Button, ButtonText} from '../Button'
import {Handle} from '../Dialog'
import {useThrottledValue} from '../hooks/useThrottledValue'
import {ListFooter, ListMaybePlaceholder} from '../Lists'
import {GifPreview} from './GifSelect.shared'
export function GifSelectDialog({
controlRef,
onClose,
onSelectGif: onSelectGifProp,
}: {
controlRef: React.RefObject<{open: () => void}>
onClose: () => void
onSelectGif: (gif: Gif) => void
}) {
const t = useTheme()
const [open, setOpen] = useState(false)
useImperativeHandle(controlRef, () => ({
open: () => setOpen(true),
}))
const close = useCallback(() => {
setOpen(false)
onClose()
}, [onClose])
const onSelectGif = useCallback(
(gif: Gif) => {
onSelectGifProp(gif)
close()
},
[onSelectGifProp, close],
)
const renderErrorBoundary = useCallback(
(error: any) => <ModalError details={String(error)} close={close} />,
[close],
)
return (
<Modal
visible={open}
animationType="slide"
presentationStyle="formSheet"
onRequestClose={close}
aria-modal
accessibilityViewIsModal>
<View style={[a.flex_1, t.atoms.bg]}>
<Handle />
<ErrorBoundary renderError={renderErrorBoundary}>
<GifList onSelectGif={onSelectGif} close={close} />
</ErrorBoundary>
</View>
</Modal>
)
}
function GifList({
onSelectGif,
}: {
close: () => void
onSelectGif: (gif: Gif) => void
}) {
const {_} = useLingui()
const t = useTheme()
const {gtMobile} = useBreakpoints()
const textInputRef = useRef<TextInput>(null)
const listRef = useRef<FlatList_INTERNAL>(null)
const [undeferredSearch, setSearch] = useState('')
const search = useThrottledValue(undeferredSearch, 500)
const isSearching = search.length > 0
const trendingQuery = useFeaturedGifsQuery()
const searchQuery = useGifSearchQuery(search)
const {
data,
fetchNextPage,
isFetchingNextPage,
hasNextPage,
error,
isLoading,
isError,
refetch,
} = isSearching ? searchQuery : trendingQuery
const flattenedData = useMemo(() => {
return data?.pages.flatMap(page => page.results) || []
}, [data])
const renderItem = useCallback(
({item}: {item: Gif}) => {
return <GifPreview gif={item} onSelectGif={onSelectGif} />
},
[onSelectGif],
)
const onEndReached = React.useCallback(() => {
if (isFetchingNextPage || !hasNextPage || error) return
fetchNextPage()
}, [isFetchingNextPage, hasNextPage, error, fetchNextPage])
const hasData = flattenedData.length > 0
const onGoBack = useCallback(() => {
if (isSearching) {
// clear the input and reset the state
textInputRef.current?.clear()
setSearch('')
} else {
close()
}
}, [isSearching])
const listHeader = useMemo(() => {
return (
<View style={[a.relative, a.mb_lg, a.pt_4xl, a.flex_row, a.align_center]}>
{/* cover top corners */}
<View
style={[
a.absolute,
a.inset_0,
{
borderBottomLeftRadius: 8,
borderBottomRightRadius: 8,
},
t.atoms.bg,
]}
/>
<TextField.Root>
<TextField.Icon icon={Search} />
<TextField.Input
label={_(msg`Search GIFs`)}
placeholder={_(msg`Search Tenor`)}
onChangeText={text => {
setSearch(text)
listRef.current?.scrollToOffset({offset: 0, animated: false})
}}
returnKeyType="search"
clearButtonMode="while-editing"
inputRef={textInputRef}
maxLength={50}
/>
</TextField.Root>
</View>
)
}, [t.atoms.bg, _])
return (
<FlatList_INTERNAL
ref={listRef}
key={gtMobile ? '3 cols' : '2 cols'}
data={flattenedData}
renderItem={renderItem}
numColumns={gtMobile ? 3 : 2}
columnWrapperStyle={a.gap_sm}
contentContainerStyle={a.px_lg}
ListHeaderComponent={
<>
{listHeader}
{!hasData && (
<ListMaybePlaceholder
isLoading={isLoading}
isError={isError}
onRetry={refetch}
onGoBack={onGoBack}
emptyType="results"
sideBorders={false}
topBorder={false}
errorTitle={_(msg`Failed to load GIFs`)}
errorMessage={_(msg`There was an issue connecting to Tenor.`)}
emptyMessage={
isSearching
? _(msg`No search results found for "${search}".`)
: _(
msg`No featured GIFs found. There may be an issue with Tenor.`,
)
}
/>
)}
</>
}
stickyHeaderIndices={[0]}
onEndReached={onEndReached}
onEndReachedThreshold={4}
keyExtractor={(item: Gif) => item.id}
keyboardDismissMode="on-drag"
ListFooterComponent={
hasData ? (
<ListFooter
isFetchingNextPage={isFetchingNextPage}
error={cleanError(error)}
onRetry={fetchNextPage}
style={{borderTopWidth: 0}}
/>
) : null
}
/>
)
}
function ModalError({details, close}: {details?: string; close: () => void}) {
const {_} = useLingui()
return (
<ScrollView
style={[a.flex_1, a.gap_md]}
centerContent
contentContainerStyle={a.px_lg}>
<ErrorScreen
title={_(msg`Oh no!`)}
message={_(
msg`There was an unexpected issue in the application. Please let us know if this happened to you!`,
)}
details={details}
/>
<Button
label={_(msg`Close dialog`)}
onPress={close}
color="primary"
size="medium"
variant="solid">
<ButtonText>
<Trans>Close</Trans>
</ButtonText>
</Button>
</ScrollView>
)
}
@@ -0,0 +1,53 @@
import React, {useCallback} from 'react'
import {Image} from 'expo-image'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {logEvent} from '#/lib/statsig/statsig'
import {Gif} from '#/state/queries/tenor'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {Button} from '../Button'
export function GifPreview({
gif,
onSelectGif,
}: {
gif: Gif
onSelectGif: (gif: Gif) => void
}) {
const {gtTablet} = useBreakpoints()
const {_} = useLingui()
const t = useTheme()
const onPress = useCallback(() => {
logEvent('composer:gif:select', {})
onSelectGif(gif)
}, [onSelectGif, gif])
return (
<Button
label={_(msg`Select GIF "${gif.title}"`)}
style={[a.flex_1, gtTablet ? {maxWidth: '33%'} : {maxWidth: '50%'}]}
onPress={onPress}>
{({pressed}) => (
<Image
style={[
a.flex_1,
a.mb_sm,
a.rounded_sm,
{aspectRatio: 1, opacity: pressed ? 0.8 : 1},
t.atoms.bg_contrast_25,
]}
source={{
uri: gif.media_formats.tinygif.url,
}}
contentFit="cover"
accessibilityLabel={gif.title}
accessibilityHint=""
cachePolicy="none"
accessibilityIgnoresInvertColors
/>
)}
</Button>
)
}
+16 -49
View File
@@ -1,11 +1,15 @@
import React, {useCallback, useMemo, useRef, useState} from 'react'
import React, {
useCallback,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react'
import {TextInput, View} from 'react-native'
import {Image} from 'expo-image'
import {BottomSheetFlatListMethods} from '@discord/bottom-sheet'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {logEvent} from '#/lib/statsig/statsig'
import {cleanError} from '#/lib/strings/errors'
import {isWeb} from '#/platform/detection'
import {
@@ -23,16 +27,23 @@ import {ArrowLeft_Stroke2_Corner0_Rounded as Arrow} from '#/components/icons/Arr
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2'
import {Button, ButtonIcon, ButtonText} from '../Button'
import {ListFooter, ListMaybePlaceholder} from '../Lists'
import {GifPreview} from './GifSelect.shared'
export function GifSelectDialog({
control,
controlRef,
onClose,
onSelectGif: onSelectGifProp,
}: {
control: Dialog.DialogControlProps
controlRef: React.RefObject<{open: () => void}>
onClose: () => void
onSelectGif: (gif: Gif) => void
}) {
const control = Dialog.useDialogControl()
useImperativeHandle(controlRef, () => ({
open: () => control.open(),
}))
const onSelectGif = useCallback(
(gif: Gif) => {
control.close(() => onSelectGifProp(gif))
@@ -233,50 +244,6 @@ function GifList({
)
}
function GifPreview({
gif,
onSelectGif,
}: {
gif: Gif
onSelectGif: (gif: Gif) => void
}) {
const {gtTablet} = useBreakpoints()
const {_} = useLingui()
const t = useTheme()
const onPress = useCallback(() => {
logEvent('composer:gif:select', {})
onSelectGif(gif)
}, [onSelectGif, gif])
return (
<Button
label={_(msg`Select GIF "${gif.title}"`)}
style={[a.flex_1, gtTablet ? {maxWidth: '33%'} : {maxWidth: '50%'}]}
onPress={onPress}>
{({pressed}) => (
<Image
style={[
a.flex_1,
a.mb_sm,
a.rounded_sm,
{aspectRatio: 1, opacity: pressed ? 0.8 : 1},
t.atoms.bg_contrast_25,
]}
source={{
uri: gif.media_formats.tinygif.url,
}}
contentFit="cover"
accessibilityLabel={gif.title}
accessibilityHint=""
cachePolicy="none"
accessibilityIgnoresInvertColors
/>
)}
</Button>
)
}
function DialogError({details}: {details?: string}) {
const {_} = useLingui()
const control = Dialog.useDialogContext()
+5 -3
View File
@@ -26,9 +26,11 @@ import {ArrowBoxLeft_Stroke2_Corner0_Rounded as ArrowBoxLeft} from '#/components
import {DotGrid_Stroke2_Corner0_Rounded as DotsHorizontal} from '#/components/icons/DotGrid'
import {Flag_Stroke2_Corner0_Rounded as Flag} from '#/components/icons/Flag'
import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute'
import {Person_Stroke2_Corner0_Rounded as Person} from '#/components/icons/Person'
import {PersonCheck_Stroke2_Corner0_Rounded as PersonCheck} from '#/components/icons/PersonCheck'
import {PersonX_Stroke2_Corner0_Rounded as PersonX} from '#/components/icons/PersonX'
import {
Person_Stroke2_Corner0_Rounded as Person,
PersonCheck_Stroke2_Corner0_Rounded as PersonCheck,
PersonX_Stroke2_Corner0_Rounded as PersonX,
} from '#/components/icons/Person'
import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/icons/Speaker'
import * as Menu from '#/components/Menu'
import * as Prompt from '#/components/Prompt'
+47 -33
View File
@@ -6,7 +6,11 @@ import {
TextStyle,
View,
} from 'react-native'
import {ChatBskyConvoDefs, RichText as RichTextAPI} from '@atproto/api'
import {
AppBskyEmbedRecord,
ChatBskyConvoDefs,
RichText as RichTextAPI,
} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
@@ -17,7 +21,8 @@ import {atoms as a, useTheme} from '#/alf'
import {ActionsWrapper} from '#/components/dms/ActionsWrapper'
import {InlineLinkText} from '#/components/Link'
import {Text} from '#/components/Typography'
import {RichText} from '../RichText'
import {isOnlyEmoji, RichText} from '../RichText'
import {MessageItemEmbed} from './MessageItemEmbed'
let MessageItem = ({
item,
@@ -77,37 +82,46 @@ let MessageItem = ({
return (
<View style={[isFromSelf ? a.mr_md : a.ml_md]}>
<ActionsWrapper isFromSelf={isFromSelf} message={message}>
<View
style={[
a.py_sm,
a.my_2xs,
a.rounded_md,
{
paddingLeft: 14,
paddingRight: 14,
backgroundColor: isFromSelf
? isPending
? pendingColor
: t.palette.primary_500
: t.palette.contrast_50,
borderRadius: 17,
},
isFromSelf
? {borderBottomRightRadius: isLastInGroup ? 2 : 17}
: {borderBottomLeftRadius: isLastInGroup ? 2 : 17},
]}>
<RichText
value={rt}
style={[
a.text_md,
a.leading_snug,
isFromSelf && {color: t.palette.white},
isPending && t.name !== 'light' && {color: t.palette.primary_300},
]}
interactiveStyle={a.underline}
enableTags
/>
</View>
{AppBskyEmbedRecord.isView(message.embed) && (
<MessageItemEmbed embed={message.embed} />
)}
{rt.text.length > 0 && (
<View
style={
!isOnlyEmoji(message.text) && [
a.py_sm,
a.my_2xs,
a.rounded_md,
{
paddingLeft: 14,
paddingRight: 14,
backgroundColor: isFromSelf
? isPending
? pendingColor
: t.palette.primary_500
: t.palette.contrast_50,
borderRadius: 17,
},
isFromSelf ? a.self_end : a.self_start,
isFromSelf
? {borderBottomRightRadius: isLastInGroup ? 2 : 17}
: {borderBottomLeftRadius: isLastInGroup ? 2 : 17},
]
}>
<RichText
value={rt}
style={[
a.text_md,
isFromSelf && {color: t.palette.white},
isPending &&
t.name !== 'light' && {color: t.palette.primary_300},
]}
interactiveStyle={a.underline}
enableTags
emojiMultiplier={3}
/>
</View>
)}
</ActionsWrapper>
{isLastInGroup && (
+22
View File
@@ -0,0 +1,22 @@
import React from 'react'
import {View} from 'react-native'
import {AppBskyEmbedRecord} from '@atproto/api'
import {PostEmbeds} from '#/view/com/util/post-embeds'
import {atoms as a, native, useTheme} from '#/alf'
let MessageItemEmbed = ({
embed,
}: {
embed: AppBskyEmbedRecord.View
}): React.ReactNode => {
const t = useTheme()
return (
<View style={[a.my_xs, t.atoms.bg, native({flexBasis: 0})]}>
<PostEmbeds embed={embed} allowNestedQuotes />
</View>
)
}
MessageItemEmbed = React.memo(MessageItemEmbed)
export {MessageItemEmbed}
+36 -11
View File
@@ -6,12 +6,16 @@ import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {richTextToString} from '#/lib/strings/rich-text-helpers'
import {getTranslatorLink} from '#/locale/helpers'
import {useLanguagePrefs} from '#/state/preferences'
import {useOpenLink} from '#/state/preferences/in-app-browser'
import {isWeb} from 'platform/detection'
import {useConvoActive} from 'state/messages/convo'
import {useSession} from 'state/session'
import * as Toast from '#/view/com/util/Toast'
import {atoms as a, useTheme} from '#/alf'
import {ReportDialog} from '#/components/dms/ReportDialog'
import {BubbleQuestion_Stroke2_Corner0_Rounded as Translate} from '#/components/icons/Bubble'
import {DotGrid_Stroke2_Corner0_Rounded as DotsHorizontal} from '#/components/icons/DotGrid'
import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning'
@@ -35,10 +39,12 @@ export let MessageMenu = ({
const convo = useConvoActive()
const deleteControl = usePromptControl()
const reportControl = usePromptControl()
const langPrefs = useLanguagePrefs()
const openLink = useOpenLink()
const isFromSelf = message.sender?.did === currentAccount?.did
const onCopyPostText = React.useCallback(() => {
const onCopyMessage = React.useCallback(() => {
const str = richTextToString(
new RichText({
text: message.text,
@@ -51,6 +57,14 @@ export let MessageMenu = ({
Toast.show(_(msg`Copied to clipboard`))
}, [_, message.text, message.facets])
const onPressTranslateMessage = React.useCallback(() => {
const translatorUrl = getTranslatorLink(
message.text,
langPrefs.primaryLanguage,
)
openLink(translatorUrl)
}, [langPrefs.primaryLanguage, message.text, openLink])
const onDelete = React.useCallback(() => {
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
convo
@@ -81,16 +95,27 @@ export let MessageMenu = ({
)}
<Menu.Outer>
<Menu.Group>
<Menu.Item
testID="messageDropdownCopyBtn"
label={_(msg`Copy message text`)}
onPress={onCopyPostText}>
<Menu.ItemText>{_(msg`Copy message text`)}</Menu.ItemText>
<Menu.ItemIcon icon={ClipboardIcon} position="right" />
</Menu.Item>
</Menu.Group>
<Menu.Divider />
{message.text.length > 0 && (
<>
<Menu.Group>
<Menu.Item
testID="messageDropdownTranslateBtn"
label={_(msg`Translate`)}
onPress={onPressTranslateMessage}>
<Menu.ItemText>{_(msg`Translate`)}</Menu.ItemText>
<Menu.ItemIcon icon={Translate} position="right" />
</Menu.Item>
<Menu.Item
testID="messageDropdownCopyBtn"
label={_(msg`Copy message text`)}
onPress={onCopyMessage}>
<Menu.ItemText>{_(msg`Copy message text`)}</Menu.ItemText>
<Menu.ItemIcon icon={ClipboardIcon} position="right" />
</Menu.Item>
</Menu.Group>
<Menu.Divider />
</>
)}
<Menu.Group>
<Menu.Item
testID="messageDropdownDeleteBtn"
+64 -43
View File
@@ -22,6 +22,7 @@ import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
import {ConvoMenu} from '#/components/dms/ConvoMenu'
import {Bell2Off_Filled_Corner0_Rounded as BellStroke} from '#/components/icons/Bell2'
import {Link} from '#/components/Link'
import {PostAlerts} from '#/components/moderation/PostAlerts'
import {Text} from '#/components/Typography'
const PFP_SIZE = isWeb ? 40 : 34
@@ -58,7 +59,7 @@ export let MessagesListHeader = ({
t.atoms.border_contrast_low,
a.border_b,
a.flex_row,
a.align_center,
a.align_start,
a.gap_sm,
gtTablet ? a.pl_lg : a.pl_xl,
a.pr_lg,
@@ -69,7 +70,7 @@ export let MessagesListHeader = ({
testID="conversationHeaderBackBtn"
onPress={onPressBack}
hitSlop={BACK_HITSLOP}
style={{width: 30, height: 30}}
style={{width: 30, height: 30, marginTop: isWeb ? 6 : 4}}
accessibilityRole="button"
accessibilityLabel={_(msg`Back`)}
accessibilityHint="">
@@ -152,51 +153,71 @@ function HeaderReady({
)
return (
<>
<Link
style={[a.flex_row, a.align_center, a.gap_md, a.flex_1, a.pr_md]}
to={makeProfileLink(profile)}>
<PreviewableUserAvatar
size={PFP_SIZE}
profile={profile}
moderation={moderation.ui('avatar')}
disableHoverCard={moderation.blocked}
/>
<View style={a.flex_1}>
<Text
style={[a.text_md, a.font_bold, web(a.leading_normal)]}
numberOfLines={1}>
{displayName}
</Text>
{!isDeletedAccount && (
<View style={[a.flex_1]}>
<View style={[a.w_full, a.flex_row, a.align_center, a.justify_between]}>
<Link
style={[a.flex_row, a.align_start, a.gap_md, a.flex_1, a.pr_md]}
to={makeProfileLink(profile)}>
<View style={[a.pt_2xs]}>
<PreviewableUserAvatar
size={PFP_SIZE}
profile={profile}
moderation={moderation.ui('avatar')}
disableHoverCard={moderation.blocked}
/>
</View>
<View style={a.flex_1}>
<Text
style={[
t.atoms.text_contrast_medium,
a.text_sm,
web([a.leading_normal, {marginTop: -2}]),
]}
style={[a.text_md, a.font_bold, web(a.leading_normal)]}
numberOfLines={1}>
@{profile.handle}
{convoState.convo?.muted && (
<>
{' '}
&middot;{' '}
<BellStroke size="xs" style={t.atoms.text_contrast_medium} />
</>
)}
{displayName}
</Text>
)}
</View>
</Link>
{!isDeletedAccount && (
<Text
style={[
t.atoms.text_contrast_medium,
a.text_sm,
web([a.leading_normal, {marginTop: -2}]),
]}
numberOfLines={1}>
@{profile.handle}
{convoState.convo?.muted && (
<>
{' '}
&middot;{' '}
<BellStroke
size="xs"
style={t.atoms.text_contrast_medium}
/>
</>
)}
</Text>
)}
</View>
</Link>
{isConvoActive(convoState) && (
<ConvoMenu
convo={convoState.convo}
profile={profile}
currentScreen="conversation"
blockInfo={blockInfo}
{isConvoActive(convoState) && (
<ConvoMenu
convo={convoState.convo}
profile={profile}
currentScreen="conversation"
blockInfo={blockInfo}
/>
)}
</View>
<View
style={[
{
paddingLeft: PFP_SIZE + a.gap_md.gap,
},
]}>
<PostAlerts
modui={moderation.ui('contentList')}
size="large"
style={[a.pt_xs]}
/>
)}
</>
</View>
</View>
)
}
@@ -0,0 +1,67 @@
import React, {useCallback} from 'react'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members'
import {logEvent} from 'lib/statsig/statsig'
import {FAB} from '#/view/com/util/fab/FAB'
import * as Toast from '#/view/com/util/Toast'
import {useTheme} from '#/alf'
import * as Dialog from '#/components/Dialog'
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
import {SearchablePeopleList} from './SearchablePeopleList'
export function NewChat({
control,
onNewChat,
}: {
control: Dialog.DialogControlProps
onNewChat: (chatId: string) => void
}) {
const t = useTheme()
const {_} = useLingui()
const {mutate: createChat} = useGetConvoForMembers({
onSuccess: data => {
onNewChat(data.convo.id)
if (!data.convo.lastMessage) {
logEvent('chat:create', {logContext: 'NewChatDialog'})
}
logEvent('chat:open', {logContext: 'NewChatDialog'})
},
onError: error => {
Toast.show(error.message)
},
})
const onCreateChat = useCallback(
(did: string) => {
control.close(() => createChat([did]))
},
[control, createChat],
)
return (
<>
<FAB
testID="newChatFAB"
onPress={control.open}
icon={<Plus size="lg" fill={t.palette.white} />}
accessibilityRole="button"
accessibilityLabel={_(msg`New chat`)}
accessibilityHint=""
/>
<Dialog.Outer
control={control}
testID="newChatDialog"
nativeOptions={{sheet: {snapPoints: ['100%']}}}>
<SearchablePeopleList
title={_(msg`Start a new chat`)}
onSelectChat={onCreateChat}
/>
</Dialog.Outer>
</>
)
}
@@ -16,23 +16,19 @@ import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
import {isWeb} from '#/platform/detection'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members'
import {useListConvosQuery} from '#/state/queries/messages/list-converations'
import {useProfileFollowsQuery} from '#/state/queries/profile-follows'
import {useSession} from '#/state/session'
import {logEvent} from 'lib/statsig/statsig'
import {useActorAutocompleteQuery} from 'state/queries/actor-autocomplete'
import {FAB} from '#/view/com/util/fab/FAB'
import * as Toast from '#/view/com/util/Toast'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, native, useTheme, web} from '#/alf'
import {Button} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {TextInput} from '#/components/dms/NewChatDialog/TextInput'
import {TextInput} from '#/components/dms/dialogs/TextInput'
import {canBeMessaged} from '#/components/dms/util'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import {ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeft} from '#/components/icons/Chevron'
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2'
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
import {Text} from '#/components/Typography'
@@ -57,55 +53,291 @@ type Item =
key: string
}
export function NewChat({
control,
onNewChat,
export function SearchablePeopleList({
title,
onSelectChat,
showRecentConvos,
}: {
control: Dialog.DialogControlProps
onNewChat: (chatId: string) => void
title: string
onSelectChat: (did: string) => void
showRecentConvos?: boolean
}) {
const t = useTheme()
const {_} = useLingui()
const moderationOpts = useModerationOpts()
const control = Dialog.useDialogContext()
const listRef = useRef<BottomSheetFlatListMethods>(null)
const {currentAccount} = useSession()
const inputRef = useRef<TextInputType>(null)
const {mutate: createChat} = useGetConvoForMembers({
onSuccess: data => {
onNewChat(data.convo.id)
const [searchText, setSearchText] = useState('')
if (!data.convo.lastMessage) {
logEvent('chat:create', {logContext: 'NewChatDialog'})
const {
data: results,
isError,
isFetching,
} = useActorAutocompleteQuery(searchText, true, 12)
const {data: follows} = useProfileFollowsQuery(currentAccount?.did)
const {data: convos} = useListConvosQuery({enabled: showRecentConvos})
const items = useMemo(() => {
let _items: Item[] = []
if (isError) {
_items.push({
type: 'empty',
key: 'empty',
message: _(msg`We're having network issues, try again`),
})
} else if (searchText.length) {
if (results?.length) {
for (const profile of results) {
if (profile.did === currentAccount?.did) continue
_items.push({
type: 'profile',
key: profile.did,
enabled: canBeMessaged(profile),
profile,
})
}
_items = _items.sort(a => {
// @ts-ignore
return a.enabled ? -1 : 1
})
}
logEvent('chat:open', {logContext: 'NewChatDialog'})
},
onError: error => {
Toast.show(error.message)
},
})
} else {
const placeholders: Item[] = Array(10)
.fill(0)
.map((_, i) => ({
type: 'placeholder',
key: i + '',
}))
const onCreateChat = useCallback(
(did: string) => {
control.close(() => createChat([did]))
if (showRecentConvos) {
if (convos && follows) {
const usedDids = new Set()
for (const page of convos.pages) {
for (const convo of page.convos) {
const profiles = convo.members.filter(
m => m.did !== currentAccount?.did,
)
for (const profile of profiles) {
if (usedDids.has(profile.did)) continue
usedDids.add(profile.did)
_items.push({
type: 'profile',
key: profile.did,
enabled: true,
profile,
})
}
}
}
let followsItems: typeof _items = []
for (const page of follows.pages) {
for (const profile of page.follows) {
if (usedDids.has(profile.did)) continue
followsItems.push({
type: 'profile',
key: profile.did,
enabled: canBeMessaged(profile),
profile,
})
}
}
// only sort follows
followsItems = followsItems.sort(a => {
// @ts-ignore
return a.enabled ? -1 : 1
})
// then append
_items.push(...followsItems)
} else {
_items.push(...placeholders)
}
} else if (follows) {
for (const page of follows.pages) {
for (const profile of page.follows) {
_items.push({
type: 'profile',
key: profile.did,
enabled: canBeMessaged(profile),
profile,
})
}
}
_items = _items.sort(a => {
// @ts-ignore
return a.enabled ? -1 : 1
})
} else {
_items.push(...placeholders)
}
}
return _items
}, [
_,
searchText,
results,
isError,
currentAccount?.did,
follows,
convos,
showRecentConvos,
])
if (searchText && !isFetching && !items.length && !isError) {
items.push({type: 'empty', key: 'empty', message: _(msg`No results`)})
}
const renderItems = useCallback(
({item}: {item: Item}) => {
switch (item.type) {
case 'profile': {
return (
<ProfileCard
key={item.key}
enabled={item.enabled}
profile={item.profile}
moderationOpts={moderationOpts!}
onPress={onSelectChat}
/>
)
}
case 'placeholder': {
return <ProfileCardSkeleton key={item.key} />
}
case 'empty': {
return <Empty key={item.key} message={item.message} />
}
default:
return null
}
},
[control, createChat],
[moderationOpts, onSelectChat],
)
return (
<>
<FAB
testID="newChatFAB"
onPress={control.open}
icon={<Plus size="lg" fill={t.palette.white} />}
accessibilityRole="button"
accessibilityLabel={_(msg`New chat`)}
accessibilityHint=""
/>
useLayoutEffect(() => {
if (isWeb) {
setImmediate(() => {
inputRef?.current?.focus()
})
}
}, [])
<Dialog.Outer
control={control}
testID="newChatDialog"
nativeOptions={{sheet: {snapPoints: ['100%']}}}>
<SearchablePeopleList onCreateChat={onCreateChat} />
</Dialog.Outer>
</>
const listHeader = useMemo(() => {
return (
<View
style={[
a.relative,
a.pt_md,
a.pb_xs,
a.px_lg,
a.border_b,
t.atoms.border_contrast_low,
t.atoms.bg,
native([a.pt_lg]),
]}>
<View
style={[
a.relative,
native(a.align_center),
a.justify_center,
{height: 32},
]}>
<Button
label={_(msg`Close`)}
size="small"
shape="round"
variant="ghost"
color="secondary"
style={[
a.absolute,
a.z_20,
native({
left: -7,
}),
web({
right: -4,
}),
]}
onPress={() => control.close()}>
{isWeb ? (
<X size="md" fill={t.palette.contrast_500} />
) : (
<ChevronLeft size="md" fill={t.palette.contrast_500} />
)}
</Button>
<Text
style={[
a.z_10,
a.text_lg,
a.font_bold,
a.leading_tight,
t.atoms.text_contrast_high,
]}>
{title}
</Text>
</View>
<View style={[native([a.pt_sm]), web([a.pt_xs])]}>
<SearchInput
inputRef={inputRef}
value={searchText}
onChangeText={text => {
setSearchText(text)
listRef.current?.scrollToOffset({offset: 0, animated: false})
}}
onEscape={control.close}
/>
</View>
</View>
)
}, [
t.atoms.border_contrast_low,
t.atoms.bg,
t.atoms.text_contrast_high,
t.palette.contrast_500,
_,
title,
searchText,
control,
])
return (
<Dialog.InnerFlatList
ref={listRef}
data={items}
renderItem={renderItems}
ListHeaderComponent={listHeader}
stickyHeaderIndices={[0]}
keyExtractor={(item: Item) => item.key}
style={[
web([a.py_0, {height: '100vh', maxHeight: 600}, a.px_0]),
native({
height: '100%',
paddingHorizontal: 0,
marginTop: 0,
paddingTop: 0,
borderTopLeftRadius: 40,
borderTopRightRadius: 40,
}),
]}
webInnerStyle={[a.py_0, {maxWidth: 500, minWidth: 200}]}
keyboardDismissMode="on-drag"
/>
)
}
@@ -293,217 +525,3 @@ function SearchInput({
</View>
)
}
function SearchablePeopleList({
onCreateChat,
}: {
onCreateChat: (did: string) => void
}) {
const t = useTheme()
const {_} = useLingui()
const moderationOpts = useModerationOpts()
const control = Dialog.useDialogContext()
const listRef = useRef<BottomSheetFlatListMethods>(null)
const {currentAccount} = useSession()
const inputRef = useRef<TextInputType>(null)
const [searchText, setSearchText] = useState('')
const {
data: results,
isError,
isFetching,
} = useActorAutocompleteQuery(searchText, true, 12)
const {data: follows} = useProfileFollowsQuery(currentAccount?.did)
const items = useMemo(() => {
let _items: Item[] = []
if (isError) {
_items.push({
type: 'empty',
key: 'empty',
message: _(msg`We're having network issues, try again`),
})
} else if (searchText.length) {
if (results?.length) {
for (const profile of results) {
if (profile.did === currentAccount?.did) continue
_items.push({
type: 'profile',
key: profile.did,
enabled: canBeMessaged(profile),
profile,
})
}
_items = _items.sort(a => {
// @ts-ignore
return a.enabled ? -1 : 1
})
}
} else {
if (follows) {
for (const page of follows.pages) {
for (const profile of page.follows) {
_items.push({
type: 'profile',
key: profile.did,
enabled: canBeMessaged(profile),
profile,
})
}
}
_items = _items.sort(a => {
// @ts-ignore
return a.enabled ? -1 : 1
})
} else {
Array(10)
.fill(0)
.forEach((_, i) => {
_items.push({
type: 'placeholder',
key: i + '',
})
})
}
}
return _items
}, [_, searchText, results, isError, currentAccount?.did, follows])
if (searchText && !isFetching && !items.length && !isError) {
items.push({type: 'empty', key: 'empty', message: _(msg`No results`)})
}
const renderItems = useCallback(
({item}: {item: Item}) => {
switch (item.type) {
case 'profile': {
return (
<ProfileCard
key={item.key}
enabled={item.enabled}
profile={item.profile}
moderationOpts={moderationOpts!}
onPress={onCreateChat}
/>
)
}
case 'placeholder': {
return <ProfileCardSkeleton key={item.key} />
}
case 'empty': {
return <Empty key={item.key} message={item.message} />
}
default:
return null
}
},
[moderationOpts, onCreateChat],
)
useLayoutEffect(() => {
if (isWeb) {
setImmediate(() => {
inputRef?.current?.focus()
})
}
}, [])
const listHeader = useMemo(() => {
return (
<View
style={[
a.relative,
a.pt_md,
a.pb_xs,
a.px_lg,
a.border_b,
t.atoms.border_contrast_low,
t.atoms.bg,
native([a.pt_lg]),
]}>
<View
style={[
a.relative,
native(a.align_center),
a.justify_center,
{height: 32},
]}>
<Button
label={_(msg`Close`)}
size="small"
shape="round"
variant="ghost"
color="secondary"
style={[
a.absolute,
a.z_20,
native({
left: -7,
}),
web({
right: -4,
}),
]}
onPress={() => control.close()}>
{isWeb ? (
<X size="md" fill={t.palette.contrast_500} />
) : (
<ChevronLeft size="md" fill={t.palette.contrast_500} />
)}
</Button>
<Text
style={[
a.z_10,
a.text_lg,
a.font_bold,
a.leading_tight,
t.atoms.text_contrast_high,
]}>
<Trans>Start a new chat</Trans>
</Text>
</View>
<View style={[native([a.pt_sm]), web([a.pt_xs])]}>
<SearchInput
inputRef={inputRef}
value={searchText}
onChangeText={text => {
setSearchText(text)
listRef.current?.scrollToOffset({offset: 0, animated: false})
}}
onEscape={control.close}
/>
</View>
</View>
)
}, [t, _, control, searchText])
return (
<Dialog.InnerFlatList
ref={listRef}
data={items}
renderItem={renderItems}
ListHeaderComponent={listHeader}
stickyHeaderIndices={[0]}
keyExtractor={(item: Item) => item.key}
style={[
web([a.py_0, {height: '100vh', maxHeight: 600}, a.px_0]),
native({
height: '100%',
paddingHorizontal: 0,
marginTop: 0,
paddingTop: 0,
borderTopLeftRadius: 40,
borderTopRightRadius: 40,
}),
]}
webInnerStyle={[a.py_0, {maxWidth: 500, minWidth: 200}]}
keyboardDismissMode="on-drag"
/>
)
}
@@ -0,0 +1,53 @@
import React, {useCallback} from 'react'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members'
import {logEvent} from 'lib/statsig/statsig'
import * as Toast from '#/view/com/util/Toast'
import * as Dialog from '#/components/Dialog'
import {SearchablePeopleList} from './SearchablePeopleList'
export function SendViaChatDialog({
control,
onSelectChat,
}: {
control: Dialog.DialogControlProps
onSelectChat: (chatId: string) => void
}) {
const {_} = useLingui()
const {mutate: createChat} = useGetConvoForMembers({
onSuccess: data => {
onSelectChat(data.convo.id)
if (!data.convo.lastMessage) {
logEvent('chat:create', {logContext: 'SendViaChatDialog'})
}
logEvent('chat:open', {logContext: 'SendViaChatDialog'})
},
onError: error => {
Toast.show(error.message)
},
})
const onCreateChat = useCallback(
(did: string) => {
control.close(() => createChat([did]))
},
[control, createChat],
)
return (
<Dialog.Outer
control={control}
testID="sendViaChatChatDialog"
nativeOptions={{sheet: {snapPoints: ['100%']}}}>
<SearchablePeopleList
title={_(msg`Send post to...`)}
onSelectChat={onCreateChat}
showRecentConvos
/>
</Dialog.Outer>
)
}
+1 -1
View File
@@ -1,5 +1,5 @@
import {createSinglePathSVG} from './TEMPLATE'
export const Image_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M3 5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5Zm16 0H5v7.213l1.246-.932.044-.03a3 3 0 0 1 3.863.454c1.468 1.58 2.941 2.749 4.847 2.749 1.703 0 2.855-.555 4-1.618V5Zm0 10.357c-1.112.697-2.386 1.097-4 1.097-2.81 0-4.796-1.755-6.313-3.388a1 1 0 0 0-1.269-.164L5 14.712V19h14v-3.643ZM15 8a1 1 0 1 0 0 2 1 1 0 0 0 0-2Zm-3 1a3 3 0 1 1 6 0 3 3 0 0 1-6 0Z',
path: 'M3 4a1 1 0 0 1 1-1h16a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4Zm2 1v7.213l1.246-.932.044-.03a3 3 0 0 1 3.863.454c1.468 1.58 2.941 2.749 4.847 2.749 1.703 0 2.855-.555 4-1.618V5H5Zm14 10.357c-1.112.697-2.386 1.097-4 1.097-2.81 0-4.796-1.755-6.313-3.388a1 1 0 0 0-1.269-.164L5 14.712V19h14v-3.643ZM15 8a1 1 0 1 0 0 2 1 1 0 0 0 0-2Zm-3 1a3 3 0 1 1 6 0 3 3 0 0 1-6 0Z',
})
+16
View File
@@ -3,3 +3,19 @@ import {createSinglePathSVG} from './TEMPLATE'
export const Person_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M12 4a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM7.5 6.5a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM5.678 19h12.644c-.71-2.909-3.092-5-6.322-5s-5.613 2.091-6.322 5Zm-2.174.906C3.917 15.521 7.242 12 12 12c4.758 0 8.083 3.521 8.496 7.906A1 1 0 0 1 19.5 21h-15a1 1 0 0 1-.996-1.094Z',
})
export const PersonCheck_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M12 4a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM7.5 6.5a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM5.679 19c.709-2.902 3.079-5 6.321-5a6.69 6.69 0 0 1 2.612.51 1 1 0 0 0 .776-1.844A8.687 8.687 0 0 0 12 12c-4.3 0-7.447 2.884-8.304 6.696-.29 1.29.767 2.304 1.902 2.304H11a1 1 0 1 0 0-2H5.679Zm14.835-4.857a1 1 0 0 1 .344 1.371l-3 5a1 1 0 0 1-1.458.286l-2-1.5a1 1 0 0 1 1.2-1.6l1.113.835 2.43-4.05a1 1 0 0 1 1.372-.342Z',
})
export const PersonX_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M12 4a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM7.5 6.5a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM5.679 19c.709-2.902 3.079-5 6.321-5 .302 0 .595.018.878.053a1 1 0 0 0 .243-1.985A9.235 9.235 0 0 0 12 12c-4.3 0-7.447 2.884-8.304 6.696-.29 1.29.767 2.304 1.902 2.304H12a1 1 0 1 0 0-2H5.679Zm9.614-3.707a1 1 0 0 1 1.414 0L18 16.586l1.293-1.293a1 1 0 0 1 1.414 1.414L19.414 18l1.293 1.293a1 1 0 0 1-1.414 1.414L18 19.414l-1.293 1.293a1 1 0 0 1-1.414-1.414L16.586 18l-1.293-1.293a1 1 0 0 1 0-1.414Z',
})
export const PersonPlus_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M12 4a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM7.5 6.5a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM5.678 19c.71-2.909 3.092-5 6.322-5 .621 0 1.206.077 1.748.218a1 1 0 1 0 .504-1.936A8.931 8.931 0 0 0 12 12c-4.758 0-8.083 3.521-8.496 7.906A1 1 0 0 0 4.5 21H11a1 1 0 1 0 0-2H5.678ZM18 14a1 1 0 0 1 1 1v2h2a1 1 0 1 1 0 2h-2v2a1 1 0 1 1-2 0v-2h-2a1 1 0 1 1 0-2h2v-2a1 1 0 0 1 1-1Z',
})
export const PersonPlus_Filled_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M7.5 6.5a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM12 12c-4.758 0-8.083 3.521-8.496 7.906A1 1 0 0 0 4.5 21H15a3 3 0 1 1 0-6c0-.824.332-1.571.87-2.113C14.739 12.32 13.435 12 12 12Zm6 2a1 1 0 0 1 1 1v2h2a1 1 0 1 1 0 2h-2v2a1 1 0 1 1-2 0v-2h-2a1 1 0 1 1 0-2h2v-2a1 1 0 0 1 1-1Z',
})
-5
View File
@@ -1,5 +0,0 @@
import {createSinglePathSVG} from './TEMPLATE'
export const PersonCheck_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M12 4a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM7.5 6.5a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM5.679 19c.709-2.902 3.079-5 6.321-5a6.69 6.69 0 0 1 2.612.51 1 1 0 0 0 .776-1.844A8.687 8.687 0 0 0 12 12c-4.3 0-7.447 2.884-8.304 6.696-.29 1.29.767 2.304 1.902 2.304H11a1 1 0 1 0 0-2H5.679Zm14.835-4.857a1 1 0 0 1 .344 1.371l-3 5a1 1 0 0 1-1.458.286l-2-1.5a1 1 0 0 1 1.2-1.6l1.113.835 2.43-4.05a1 1 0 0 1 1.372-.342Z',
})
-5
View File
@@ -1,5 +0,0 @@
import {createSinglePathSVG} from './TEMPLATE'
export const PersonX_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M12 4a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM7.5 6.5a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM5.679 19c.709-2.902 3.079-5 6.321-5 .302 0 .595.018.878.053a1 1 0 0 0 .243-1.985A9.235 9.235 0 0 0 12 12c-4.3 0-7.447 2.884-8.304 6.696-.29 1.29.767 2.304 1.902 2.304H12a1 1 0 1 0 0-2H5.679Zm9.614-3.707a1 1 0 0 1 1.414 0L18 16.586l1.293-1.293a1 1 0 0 1 1.414 1.414L19.414 18l1.293 1.293a1 1 0 0 1-1.414 1.414L18 19.414l-1.293 1.293a1 1 0 0 1-1.414-1.414L16.586 18l-1.293-1.293a1 1 0 0 1 0-1.414Z',
})
+21
View File
@@ -0,0 +1,21 @@
import {createSinglePathSVG} from './TEMPLATE'
export const OpenQuote_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M7.574 4.178a1 1 0 0 1 .43.822v5h2a1 1 0 0 1 1 1v8a1 1 0 0 1-1 1h-7a1 1 0 0 1-1-1v-8c0-2.585 1.162-4.335 2.316-5.417a8.163 8.163 0 0 1 1.569-1.15 7.029 7.029 0 0 1 .738-.36l.016-.005.005-.003h.003v-.001c.001 0 .002 0 .353.936l-.351-.936a1 1 0 0 1 .92.114Zm-1.57 2.588a5.99 5.99 0 0 0-.316.276C4.842 7.835 4.004 9.085 4.004 11v7h5v-6h-2a1 1 0 0 1-1-1V6.766Zm12.57-2.588a1 1 0 0 1 .43.822v5h2a1 1 0 0 1 1 1v8a1 1 0 0 1-1 1h-7a1 1 0 0 1-1-1v-8c0-2.585 1.162-4.335 2.316-5.417a8.166 8.166 0 0 1 1.569-1.15 7.038 7.038 0 0 1 .738-.36l.016-.005.005-.003h.003v-.001c.001 0 .002 0 .353.936l-.351-.936a1 1 0 0 1 .92.114Zm-1.57 2.588c-.105.085-.21.177-.316.276-.846.793-1.684 2.043-1.684 3.958v7h5v-6h-2a1 1 0 0 1-1-1V6.766Z',
})
export const OpenQuote_Filled_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M8.004 5a1 1 0 0 0-.43-.822c-.57-.395-1.176-.031-1.685.255-.428.24-.998.614-1.569 1.15C3.166 6.665 2.004 8.415 2.004 11v8a1 1 0 0 0 1 1h7a1 1 0 0 0 1-1v-8a1 1 0 0 0-1-1h-2V5ZM19.004 5a1 1 0 0 0-.43-.822c-.57-.395-1.176-.031-1.685.255-.428.24-.998.614-1.569 1.15-1.154 1.082-2.316 2.832-2.316 5.417v8a1 1 0 0 0 1 1h7a1 1 0 0 0 1-1v-8a1 1 0 0 0-1-1h-2V5Z',
})
export const CloseQuote_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M2.004 5a1 1 0 0 1 1-1h7a1 1 0 0 1 1 1v8c0 2.585-1.162 4.335-2.316 5.417-.571.536-1.14.91-1.569 1.15a7.01 7.01 0 0 1-.738.36l-.016.006-.006.002h-.002l-.001.001L6.004 19l.351.936A1 1 0 0 1 5.004 19v-5h-2a1 1 0 0 1-1-1V5Zm5 12.234c.104-.085.21-.177.316-.276.846-.793 1.684-2.043 1.684-3.958V6h-5v6h2a1 1 0 0 1 1 1v4.234Zm6-12.234a1 1 0 0 1 1-1h7a1 1 0 0 1 1 1v8c0 2.585-1.162 4.335-2.316 5.417-.571.536-1.14.91-1.569 1.15a7.018 7.018 0 0 1-.738.36l-.016.006-.006.002h-.002l-.001.001-.352-.936.351.936A1 1 0 0 1 16.004 19v-5h-2a1 1 0 0 1-1-1V5Zm5 12.234V13a1 1 0 0 0-1-1h-2V6h5v7c0 1.915-.838 3.165-1.684 3.958-.106.1-.212.191-.316.276Z',
})
export const CloseQuote_Stroke2_Corner1_Rounded = createSinglePathSVG({
path: 'M2.003 5.999a2 2 0 0 1 2-1.999h5c1.104 0 2 .893 2 1.999V13c0 2.585-1.16 4.335-2.315 5.417-.571.536-1.14.91-1.569 1.15a7.01 7.01 0 0 1-.738.36l-.016.006-.006.002h-.002l-.001.001L6.004 19l.351.936a1 1 0 0 1-1.351-.935L5 14H4a2 2 0 0 1-2-2.001l.002-6Zm5 11.236L7 12.999A1 1 0 0 0 6 12H4l.003-6h5v7c0 1.915-.837 3.165-1.683 3.958-.106.1-.213.192-.317.277Zm6-11.235a2 2 0 0 1 2-2h5c1.104 0 2 .893 2 1.999V13c0 2.585-1.16 4.335-2.315 5.417-.571.536-1.14.91-1.569 1.15a7.018 7.018 0 0 1-.738.36l-.016.006-.006.002h-.002l-.001.001-.352-.936.351.936A1 1 0 0 1 16.004 19v-5h-1a2 2 0 0 1-2-2V6Zm7 0h-5v6h2a1 1 0 0 1 1 1v4.234c.105-.085.211-.177.317-.276.846-.793 1.684-2.043 1.684-3.958V6Z',
})
export const CloseQuote_Filled_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M3.004 4a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h2v5a1 1 0 0 0 .43.822c.57.395 1.176.031 1.685-.255.428-.24.998-.614 1.569-1.15 1.154-1.082 2.316-2.832 2.316-5.417V5a1 1 0 0 0-1-1h-7ZM14.004 4a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h2v5a1 1 0 0 0 .43.822c.57.395 1.176.031 1.685-.255.428-.24.998-.614 1.569-1.15 1.154-1.082 2.316-2.832 2.316-5.417V5a1 1 0 0 0-1-1h-7Z',
})
+13
View File
@@ -0,0 +1,13 @@
import {createSinglePathSVG} from './TEMPLATE'
export const Repost_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M16.293 2.293a1 1 0 0 1 1.414 0l3 3a1 1 0 0 1 0 1.414l-3 3a1 1 0 0 1-1.414-1.414L17.586 7H5v4a1 1 0 1 1-2 0V6a1 1 0 0 1 1-1h13.586l-1.293-1.293a1 1 0 0 1 0-1.414ZM21 13v5a1 1 0 0 1-1 1H6.414l1.293 1.293a1 1 0 1 1-1.414 1.414l-3-3a1 1 0 0 1 0-1.414l3-3a1 1 0 0 1 1.414 1.414L6.414 17H19v-4a1 1 0 1 1 2 0Z',
})
export const Repost_Stroke2_Corner2_Rounded = createSinglePathSVG({
path: 'M17.957 2.293a1 1 0 1 0-1.414 1.414L17.836 5H6a3 3 0 0 0-3 3v3a1 1 0 1 0 2 0V8a1 1 0 0 1 1-1h11.836l-1.293 1.293a1 1 0 0 0 1.414 1.414l2.47-2.47a1.75 1.75 0 0 0 0-2.474l-2.47-2.47ZM20 12a1 1 0 0 1 1 1v3a3 3 0 0 1-3 3H6.164l1.293 1.293a1 1 0 1 1-1.414 1.414l-2.47-2.47a1.75 1.75 0 0 1 0-2.474l2.47-2.47a1 1 0 0 1 1.414 1.414L6.164 17H18a1 1 0 0 0 1-1v-3a1 1 0 0 1 1-1Z',
})
export const Repost_Stroke2_Corner3_Rounded = createSinglePathSVG({
path: 'M16.793 2.293a1 1 0 0 1 1.414 0L20.5 4.586a2 2 0 0 1 0 2.828l-2.293 2.293a1 1 0 0 1-1.414-1.414L18.086 7H7a2 2 0 0 0-2 2v2a1 1 0 1 1-2 0V9a4 4 0 0 1 4-4h11.086l-1.293-1.293a1 1 0 0 1 0-1.414ZM20 12a1 1 0 0 1 1 1v2a4 4 0 0 1-4 4H5.914l1.293 1.293a1 1 0 1 1-1.414 1.414L3.5 19.414a2 2 0 0 1 0-2.828l2.293-2.293a1 1 0 0 1 1.414 1.414L5.914 17H17a2 2 0 0 0 2-2v-2a1 1 0 0 1 1-1Z',
})
+1 -1
View File
@@ -1,5 +1,5 @@
import {StyleSheet, TextProps} from 'react-native'
import type {SvgProps, PathProps} from 'react-native-svg'
import type {PathProps, SvgProps} from 'react-native-svg'
import {tokens} from '#/alf'
+10 -7
View File
@@ -1,20 +1,19 @@
import React from 'react'
import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
import {ModerationUI} from '@atproto/api'
import {useLingui} from '@lingui/react'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription'
import {isJustAMute} from '#/lib/moderation'
import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {atoms as a, useTheme, useBreakpoints, web} from '#/alf'
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
import {Button} from '#/components/Button'
import {Text} from '#/components/Typography'
import {
ModerationDetailsDialog,
useModerationDetailsDialogControl,
} from '#/components/moderation/ModerationDetailsDialog'
import {Text} from '#/components/Typography'
export function ContentHider({
testID,
@@ -52,7 +51,9 @@ export function ContentHider({
<ModerationDetailsDialog control={control} modcause={blur} />
<Button
onPress={() => {
onPress={e => {
e.preventDefault()
e.stopPropagation()
if (!modui.noOverride) {
setOverride(v => !v)
} else {
@@ -121,7 +122,9 @@ export function ContentHider({
{desc.source && blur.type === 'label' && !override && (
<Button
onPress={() => {
onPress={e => {
e.preventDefault()
e.stopPropagation()
control.open()
}}
label={_(
+44 -13
View File
@@ -1,9 +1,10 @@
import React from 'react'
import {StyleProp, View, ViewStyle} from 'react-native'
import {ModerationCause, ModerationUI} from '@atproto/api'
import {BSKY_LABELER_DID, ModerationCause, ModerationUI} from '@atproto/api'
import {getModerationCauseKey} from '#/lib/moderation'
import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useTheme} from '#/alf'
import {Button} from '#/components/Button'
import {
@@ -14,9 +15,11 @@ import {Text} from '#/components/Typography'
export function PostAlerts({
modui,
size,
style,
}: {
modui: ModerationUI
size?: 'medium' | 'large'
includeMute?: boolean
style?: StyleProp<ViewStyle>
}) {
@@ -28,17 +31,31 @@ export function PostAlerts({
<View style={[a.flex_col, a.gap_xs, style]}>
<View style={[a.flex_row, a.flex_wrap, a.gap_xs]}>
{modui.alerts.map(cause => (
<PostLabel key={getModerationCauseKey(cause)} cause={cause} />
<PostLabel
key={getModerationCauseKey(cause)}
cause={cause}
size={size}
/>
))}
{modui.informs.map(cause => (
<PostLabel key={getModerationCauseKey(cause)} cause={cause} />
<PostLabel
key={getModerationCauseKey(cause)}
cause={cause}
size={size}
/>
))}
</View>
</View>
)
}
function PostLabel({cause}: {cause: ModerationCause}) {
function PostLabel({
cause,
size,
}: {
cause: ModerationCause
size?: 'medium' | 'large'
}) {
const control = useModerationDetailsDialogControl()
const desc = useModerationCauseDescription(cause)
const t = useTheme()
@@ -47,7 +64,9 @@ function PostLabel({cause}: {cause: ModerationCause}) {
<>
<Button
label={desc.name}
onPress={() => {
onPress={e => {
e.preventDefault()
e.stopPropagation()
control.open()
}}>
{({hovered, pressed}) => (
@@ -55,24 +74,36 @@ function PostLabel({cause}: {cause: ModerationCause}) {
style={[
a.flex_row,
a.align_center,
{paddingLeft: 4, paddingRight: 6, paddingVertical: 1},
a.gap_xs,
a.rounded_sm,
hovered || pressed
? t.atoms.bg_contrast_50
: t.atoms.bg_contrast_25,
? size === 'large'
? t.atoms.bg_contrast_50
: t.atoms.bg_contrast_25
: size === 'large'
? t.atoms.bg_contrast_25
: undefined,
size === 'large'
? {paddingLeft: 4, paddingRight: 6, paddingVertical: 2}
: {paddingRight: 4, paddingVertical: 1},
]}>
<desc.icon size="xs" fill={t.atoms.text_contrast_medium.color} />
{desc.sourceType === 'labeler' &&
desc.sourceDid !== BSKY_LABELER_DID ? (
<UserAvatar
avatar={desc.sourceAvi}
size={size === 'large' ? 16 : 12}
/>
) : (
<desc.icon size="sm" fill={t.atoms.text_contrast_medium.color} />
)}
<Text
style={[
a.text_left,
a.leading_snug,
a.text_xs,
t.atoms.text_contrast_medium,
a.font_semibold,
size === 'large' ? {fontSize: 13} : a.text_xs,
size === 'large' ? t.atoms.text : t.atoms.text_contrast_high,
]}>
{desc.name}
{desc.source ? ` ${desc.source}` : ''}
</Text>
</View>
)}
+4 -1
View File
@@ -23,6 +23,7 @@ interface Props extends ComponentProps<typeof Link> {
iconStyles: StyleProp<ViewStyle>
modui: ModerationUI
profile: AppBskyActorDefs.ProfileViewBasic
interpretFilterAsBlur?: boolean
}
export function PostHider({
@@ -35,6 +36,7 @@ export function PostHider({
iconSize,
iconStyles,
profile,
interpretFilterAsBlur,
...props
}: Props) {
const queryClient = useQueryClient()
@@ -42,7 +44,8 @@ export function PostHider({
const {_} = useLingui()
const [override, setOverride] = React.useState(false)
const control = useModerationDetailsDialogControl()
const blur = modui.blurs[0]
const blur =
modui.blurs[0] || (interpretFilterAsBlur ? modui.filters[0] : undefined)
const desc = useModerationCauseDescription(blur)
const onBeforePress = React.useCallback(() => {
@@ -1,9 +1,14 @@
import React from 'react'
import {StyleProp, View, ViewStyle} from 'react-native'
import {ModerationCause, ModerationDecision} from '@atproto/api'
import {
BSKY_LABELER_DID,
ModerationCause,
ModerationDecision,
} from '@atproto/api'
import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription'
import {getModerationCauseKey} from 'lib/moderation'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useTheme} from '#/alf'
import {Button} from '#/components/Button'
import {
@@ -62,7 +67,12 @@ function ProfileLabel({cause}: {cause: ModerationCause}) {
? t.atoms.bg_contrast_50
: t.atoms.bg_contrast_25,
]}>
<desc.icon size="sm" fill={t.atoms.text_contrast_medium.color} />
{desc.sourceType === 'labeler' &&
desc.sourceDid !== BSKY_LABELER_DID ? (
<UserAvatar avatar={desc.sourceAvi} size={16} />
) : (
<desc.icon size="sm" fill={t.atoms.text_contrast_medium.color} />
)}
<Text
style={[
a.text_left,
@@ -72,7 +82,6 @@ function ProfileLabel({cause}: {cause: ModerationCause}) {
a.font_semibold,
]}>
{desc.name}
{desc.source ? ` ${desc.source}` : ''}
</Text>
</View>
)}
+2 -13
View File
@@ -4,7 +4,6 @@ import {
AppBskyEmbedRecord,
AppBskyEmbedRecordWithMedia,
AppBskyFeedThreadgate,
AppBskyRichtextFacet,
BskyAgent,
ComAtprotoLabelDefs,
ComAtprotoRepoUploadBlob,
@@ -15,7 +14,7 @@ import {AtUri} from '@atproto/api'
import {logger} from '#/logger'
import {ThreadgateSetting} from '#/state/queries/threadgate'
import {isNetworkError} from 'lib/strings/errors'
import {shortenLinks} from 'lib/strings/rich-text-manip'
import {shortenLinks, stripInvalidMentions} from 'lib/strings/rich-text-manip'
import {isNative, isWeb} from 'platform/detection'
import {ImageModel} from 'state/models/media/image'
import {LinkMeta} from '../link-meta/link-meta'
@@ -81,17 +80,7 @@ export async function post(agent: BskyAgent, opts: PostOpts) {
opts.onStateChange?.('Processing...')
await rt.detectFacets(agent)
rt = shortenLinks(rt)
// filter out any mention facets that didn't map to a user
rt.facets = rt.facets?.filter(facet => {
const mention = facet.features.find(feature =>
AppBskyRichtextFacet.isMention(feature),
)
if (mention && !mention.did) {
return false
}
return true
})
rt = stripInvalidMentions(rt)
// add quote embed if present
if (opts.quote) {
-634
View File
@@ -2,98 +2,6 @@ import React from 'react'
import {StyleProp, TextStyle, ViewStyle} from 'react-native'
import Svg, {Ellipse, Line, Path, Rect} from 'react-native-svg'
export function GridIcon({
style,
solid,
}: {
style?: StyleProp<ViewStyle>
solid?: boolean
}) {
const DIM = 4
const ARC = 2
return (
<Svg width="24" height="24" style={style}>
<Path
d={`M4,1 h${DIM} a${ARC},${ARC} 0 0 1 ${ARC},${ARC} v${DIM} a${ARC},${ARC} 0 0 1 -${ARC},${ARC} h-${DIM} a${ARC},${ARC} 0 0 1 -${ARC},-${ARC} v-${DIM} a${ARC},${ARC} 0 0 1 ${ARC},-${ARC} z`}
strokeWidth={2}
stroke="#000"
fill={solid ? '#000' : undefined}
/>
<Path
d={`M16,1 h${DIM} a${ARC},${ARC} 0 0 1 ${ARC},${ARC} v${DIM} a${ARC},${ARC} 0 0 1 -${ARC},${ARC} h-${DIM} a${ARC},${ARC} 0 0 1 -${ARC},-${ARC} v-${DIM} a${ARC},${ARC} 0 0 1 ${ARC},-${ARC} z`}
strokeWidth={2}
stroke="#000"
fill={solid ? '#000' : undefined}
/>
<Path
d={`M4,13 h${DIM} a${ARC},${ARC} 0 0 1 ${ARC},${ARC} v${DIM} a${ARC},${ARC} 0 0 1 -${ARC},${ARC} h-${DIM} a${ARC},${ARC} 0 0 1 -${ARC},-${ARC} v-${DIM} a${ARC},${ARC} 0 0 1 ${ARC},-${ARC} z`}
strokeWidth={2}
stroke="#000"
fill={solid ? '#000' : undefined}
/>
<Path
d={`M16,13 h${DIM} a${ARC},${ARC} 0 0 1 ${ARC},${ARC} v${DIM} a${ARC},${ARC} 0 0 1 -${ARC},${ARC} h-${DIM} a${ARC},${ARC} 0 0 1 -${ARC},-${ARC} v-${DIM} a${ARC},${ARC} 0 0 1 ${ARC},-${ARC} z`}
strokeWidth={2}
stroke="#000"
fill={solid ? '#000' : undefined}
/>
</Svg>
)
}
export function GridIconSolid({style}: {style?: StyleProp<ViewStyle>}) {
return <GridIcon style={style} solid />
}
export function HomeIcon({
style,
size,
strokeWidth = 4,
}: {
style?: StyleProp<ViewStyle>
size?: string | number
strokeWidth?: number
}) {
return (
<Svg
viewBox="0 0 48 48"
width={size || 24}
height={size || 24}
stroke="currentColor"
fill="none"
style={style}>
<Path
strokeWidth={strokeWidth}
d="M 23.951 2 C 23.631 2.011 23.323 2.124 23.072 2.322 L 8.859 13.52 C 7.055 14.941 6 17.114 6 19.41 L 6 38.5 C 6 39.864 7.136 41 8.5 41 L 18.5 41 C 19.864 41 21 39.864 21 38.5 L 21 28.5 C 21 28.205 21.205 28 21.5 28 L 26.5 28 C 26.795 28 27 28.205 27 28.5 L 27 38.5 C 27 39.864 28.136 41 29.5 41 L 39.5 41 C 40.864 41 42 39.864 42 38.5 L 42 19.41 C 42 17.114 40.945 14.941 39.141 13.52 L 24.928 2.322 C 24.65 2.103 24.304 1.989 23.951 2 Z"
/>
</Svg>
)
}
export function HomeIconSolid({
style,
size,
strokeWidth = 4,
}: {
style?: StyleProp<ViewStyle>
size?: string | number
strokeWidth?: number
}) {
return (
<Svg
viewBox="0 0 48 48"
width={size || 24}
height={size || 24}
stroke="currentColor"
style={style}>
<Path
fill="currentColor"
strokeWidth={strokeWidth}
d="m 23.951,2 c -0.32,0.011 -0.628,0.124 -0.879,0.322 L 8.859,13.52 C 7.055,14.941 6,17.114 6,19.41 V 38.5 C 6,39.864 7.136,41 8.5,41 h 8 c 1.364,0 2.5,-1.136 2.5,-2.5 v -12 C 19,26.205 19.205,26 19.5,26 h 9 c 0.295,0 0.5,0.205 0.5,0.5 v 12 c 0,1.364 1.136,2.5 2.5,2.5 h 8 C 40.864,41 42,39.864 42,38.5 V 19.41 c 0,-2.296 -1.055,-4.469 -2.859,-5.89 L 24.928,2.322 C 24.65,2.103 24.304,1.989 23.951,2 Z"
/>
</Svg>
)
}
// Copyright (c) 2020 Refactoring UI Inc.
// https://github.com/tailwindlabs/heroicons/blob/master/LICENSE
export function MagnifyingGlassIcon({
@@ -147,90 +55,6 @@ export function MagnifyingGlassIcon2({
)
}
export function MagnifyingGlassIcon2Solid({
style,
size,
strokeWidth = 2,
}: {
style?: StyleProp<ViewStyle>
size?: string | number
strokeWidth?: number
}) {
return (
<Svg
fill="none"
viewBox="0 0 24 24"
strokeWidth={strokeWidth}
stroke="currentColor"
width={size || 24}
height={size || 24}
style={style}>
<Ellipse
cx="12"
cy="10.5"
rx="6.5"
ry="6.5"
stroke="none"
fill="currentColor"
/>
<Ellipse cx="12" cy="10.5" rx="9" ry="9" />
<Line x1="18.5" y1="17" x2="22" y2="20.5" strokeLinecap="round" />
</Svg>
)
}
// https://github.com/Remix-Design/RemixIcon/blob/master/License
export function BellIcon({
style,
size,
strokeWidth = 1.5,
}: {
style?: StyleProp<ViewStyle>
size?: string | number
strokeWidth?: number
}) {
return (
<Svg
fill="none"
viewBox="0 0 24 24"
width={size || 24}
height={size || 24}
strokeWidth={strokeWidth}
stroke="currentColor"
style={style}>
<Path d="M 11.642 2 H 12.442 A 8.6 8.55 0 0 1 21.042 10.55 V 18.1 A 1 1 0 0 1 20.042 19.1 H 4.042 A 1 1 0 0 1 3.042 18.1 V 10.55 A 8.6 8.55 0 0 1 11.642 2 Z" />
<Line x1="9" y1="22" x2="15" y2="22" />
</Svg>
)
}
// https://github.com/Remix-Design/RemixIcon/blob/master/License
export function BellIconSolid({
style,
size,
strokeWidth = 1.5,
}: {
style?: StyleProp<ViewStyle>
size?: string | number
strokeWidth?: number
}) {
return (
<Svg
viewBox="0 0 24 24"
width={size || 24}
height={size || 24}
strokeWidth={strokeWidth}
stroke="currentColor"
style={style}>
<Path
d="M 11.642 2 H 12.442 A 8.6 8.55 0 0 1 21.042 10.55 V 18.1 A 1 1 0 0 1 20.042 19.1 H 4.042 A 1 1 0 0 1 3.042 18.1 V 10.55 A 8.6 8.55 0 0 1 11.642 2 Z"
fill="currentColor"
/>
<Line x1="9" y1="22" x2="15" y2="22" />
</Svg>
)
}
export function CogIcon({
style,
size,
@@ -263,160 +87,6 @@ export function CogIcon({
)
}
export function CogIconSolid({
style,
size,
strokeWidth = 1.5,
}: {
style?: StyleProp<ViewStyle>
size?: string | number
strokeWidth: number
}) {
return (
<Svg
fill="none"
viewBox="0 0 24 24"
width={size || 32}
height={size || 32}
strokeWidth={strokeWidth}
stroke="currentColor"
style={style}>
<Path
strokeLinecap="round"
strokeLinejoin="round"
d="M 9.594 3.94 C 9.684 3.398 10.154 3 10.704 3 L 13.297 3 C 13.847 3 14.317 3.398 14.407 3.94 L 14.62 5.221 C 14.683 5.595 14.933 5.907 15.265 6.091 C 15.339 6.131 15.412 6.174 15.485 6.218 C 15.809 6.414 16.205 6.475 16.56 6.342 L 17.777 5.886 C 18.292 5.692 18.872 5.9 19.147 6.376 L 20.443 8.623 C 20.718 9.099 20.608 9.705 20.183 10.054 L 19.18 10.881 C 18.887 11.121 18.742 11.494 18.749 11.873 C 18.751 11.958 18.751 12.043 18.749 12.128 C 18.742 12.506 18.887 12.878 19.179 13.118 L 20.184 13.946 C 20.608 14.296 20.718 14.9 20.444 15.376 L 19.146 17.623 C 18.871 18.099 18.292 18.307 17.777 18.114 L 16.56 17.658 C 16.205 17.525 15.81 17.586 15.484 17.782 C 15.412 17.826 15.338 17.869 15.264 17.91 C 14.933 18.093 14.683 18.405 14.62 18.779 L 14.407 20.059 C 14.317 20.602 13.847 21 13.297 21 L 10.703 21 C 10.153 21 9.683 20.602 9.593 20.06 L 9.38 18.779 C 9.318 18.405 9.068 18.093 8.736 17.909 C 8.662 17.868 8.589 17.826 8.516 17.782 C 8.191 17.586 7.796 17.525 7.44 17.658 L 6.223 18.114 C 5.708 18.307 5.129 18.1 4.854 17.624 L 3.557 15.377 C 3.282 14.901 3.392 14.295 3.817 13.946 L 4.821 13.119 C 5.113 12.879 5.258 12.506 5.251 12.127 C 5.249 12.042 5.249 11.957 5.251 11.872 C 5.258 11.494 5.113 11.122 4.821 10.882 L 3.817 10.054 C 3.393 9.705 3.283 9.1 3.557 8.624 L 4.854 6.377 C 5.129 5.9 5.709 5.692 6.224 5.886 L 7.44 6.342 C 7.796 6.475 8.191 6.414 8.516 6.218 C 8.588 6.174 8.662 6.131 8.736 6.09 C 9.068 5.907 9.318 5.595 9.38 5.221 Z M 13.5 9.402 C 11.5 8.247 9 9.691 9 12 C 9 13.072 9.572 14.062 10.5 14.598 C 12.5 15.753 15 14.309 15 12 C 15 10.928 14.428 9.938 13.5 9.402 Z"
fill="currentColor"
/>
</Svg>
)
}
// Copyright (c) 2020 Refactoring UI Inc.
// https://github.com/tailwindlabs/heroicons/blob/master/LICENSE
export function MoonIcon({
style,
size,
strokeWidth = 1.5,
}: {
style?: StyleProp<ViewStyle>
size?: string | number
strokeWidth?: number
}) {
return (
<Svg
fill="none"
viewBox="0 0 24 24"
width={size || 32}
height={size || 32}
strokeWidth={strokeWidth}
stroke="currentColor"
style={style}>
<Path
strokeLinecap="round"
strokeLinejoin="round"
d="M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z"
/>
</Svg>
)
}
// Copyright (c) 2020 Refactoring UI Inc.
// https://github.com/tailwindlabs/heroicons/blob/master/LICENSE
export function SunIcon({
style,
size,
strokeWidth = 1.5,
}: {
style?: StyleProp<ViewStyle>
size?: string | number
strokeWidth?: number
}) {
return (
<Svg
fill="none"
viewBox="0 0 24 24"
width={size || 32}
height={size || 32}
strokeWidth={strokeWidth}
stroke="currentColor"
style={style}>
<Path
d="M12 3V5.25M18.364 5.63604L16.773 7.22703M21 12H18.75M18.364 18.364L16.773 16.773M12 18.75V21M7.22703 16.773L5.63604 18.364M5.25 12H3M7.22703 7.22703L5.63604 5.63604M15.75 12C15.75 14.0711 14.0711 15.75 12 15.75C9.92893 15.75 8.25 14.0711 8.25 12C8.25 9.92893 9.92893 8.25 12 8.25C14.0711 8.25 15.75 9.92893 15.75 12Z"
strokeLinecap="round"
strokeLinejoin="round"
/>
</Svg>
)
}
// Copyright (c) 2020 Refactoring UI Inc.
// https://github.com/tailwindlabs/heroicons/blob/master/LICENSE
export function UserIcon({
style,
size,
strokeWidth = 1.5,
}: {
style?: StyleProp<ViewStyle>
size?: string | number
strokeWidth?: number
}) {
return (
<Svg
fill="none"
viewBox="0 0 24 24"
width={size || 32}
height={size || 32}
strokeWidth={strokeWidth}
stroke="currentColor"
style={style}>
<Path
strokeLinecap="round"
strokeLinejoin="round"
d="M17.982 18.725A7.488 7.488 0 0012 15.75a7.488 7.488 0 00-5.982 2.975m11.963 0a9 9 0 10-11.963 0m11.963 0A8.966 8.966 0 0112 21a8.966 8.966 0 01-5.982-2.275M15 9.75a3 3 0 11-6 0 3 3 0 016 0z"
/>
</Svg>
)
}
export function UserIconSolid({
style,
size,
strokeWidth = 1.5,
}: {
style?: StyleProp<ViewStyle>
size?: string | number
strokeWidth?: number
}) {
return (
<Svg
fill="none"
viewBox="0 0 24 24"
width={size || 32}
height={size || 32}
strokeWidth={strokeWidth}
stroke="currentColor"
style={style}>
<Path
strokeLinecap="round"
strokeLinejoin="round"
fill="currentColor"
d="M 15 9.75 C 15 12.059 12.5 13.503 10.5 12.348 C 9.572 11.812 9 10.822 9 9.75 C 9 7.441 11.5 5.997 13.5 7.152 C 14.428 7.688 15 8.678 15 9.75 Z"
/>
<Path
strokeLinecap="round"
strokeLinejoin="round"
fill="currentColor"
d="M 17.982 18.725 C 16.565 16.849 14.35 15.748 12 15.75 C 9.65 15.748 7.435 16.849 6.018 18.725 M 17.981 18.725 C 16.335 20.193 14.206 21.003 12 21 C 9.794 21.003 7.664 20.193 6.018 18.725"
/>
<Path
strokeLinecap="round"
strokeLinejoin="round"
d="M 17.981 18.725 C 23.158 14.12 21.409 5.639 14.833 3.458 C 8.257 1.277 1.786 7.033 3.185 13.818 C 3.576 15.716 4.57 17.437 6.018 18.725 M 17.981 18.725 C 16.335 20.193 14.206 21.003 12 21 C 9.794 21.003 7.664 20.193 6.018 18.725"
/>
</Svg>
)
}
// Copyright (c) 2020 Refactoring UI Inc.
// https://github.com/tailwindlabs/heroicons/blob/master/LICENSE
export function UserGroupIcon({
@@ -444,208 +114,6 @@ export function UserGroupIcon({
)
}
export function RepostIcon({
style,
size = 24,
strokeWidth = 1.5,
}: {
style?: StyleProp<TextStyle>
size?: string | number
strokeWidth: number
}) {
return (
<Svg viewBox="0 0 24 24" width={size} height={size} style={style}>
<Path
stroke="currentColor"
strokeWidth={strokeWidth}
strokeLinejoin="round"
fill="none"
d="M 14.437 18.081 L 5.475 18.095 C 4.7 18.095 4.072 17.467 4.072 16.692 L 4.082 6.65 L 1.22 10.854 M 4.082 6.65 L 7.006 10.854 M 9.859 6.65 L 18.625 6.654 C 19.4 6.654 20.028 7.282 20.028 8.057 L 20.031 18.081 L 17.167 13.646 M 20.031 18.081 L 22.866 13.646"
/>
</Svg>
)
}
// Font Awesome Pro 6.2.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc.
export function HeartIcon({
style,
size = 24,
strokeWidth = 1.5,
}: {
style?: StyleProp<TextStyle>
size?: string | number
strokeWidth: number
}) {
return (
<Svg viewBox="0 0 24 24" width={size} height={size} style={style}>
<Path
strokeWidth={strokeWidth}
stroke="currentColor"
fill="none"
d="M 3.859 13.537 L 10.918 20.127 C 11.211 20.4 11.598 20.552 12 20.552 C 12.402 20.552 12.789 20.4 13.082 20.127 L 20.141 13.537 C 21.328 12.431 22 10.88 22 9.259 L 22 9.033 C 22 6.302 20.027 3.974 17.336 3.525 C 15.555 3.228 13.742 3.81 12.469 5.084 L 12 5.552 L 11.531 5.084 C 10.258 3.81 8.445 3.228 6.664 3.525 C 3.973 3.974 2 6.302 2 9.033 L 2 9.259 C 2 10.88 2.672 12.431 3.859 13.537 Z"
/>
</Svg>
)
}
// Font Awesome Pro 6.2.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc.
export function HeartIconSolid({
style,
size = 24,
}: {
style?: StyleProp<TextStyle>
size?: string | number
}) {
return (
<Svg viewBox="0 0 24 24" width={size} height={size} style={style}>
<Path
fill="currentColor"
stroke="currentColor"
strokeWidth={1}
d="M 3.859 13.537 L 10.918 20.127 C 11.211 20.4 11.598 20.552 12 20.552 C 12.402 20.552 12.789 20.4 13.082 20.127 L 20.141 13.537 C 21.328 12.431 22 10.88 22 9.259 L 22 9.033 C 22 6.302 20.027 3.974 17.336 3.525 C 15.555 3.228 13.742 3.81 12.469 5.084 L 12 5.552 L 11.531 5.084 C 10.258 3.81 8.445 3.228 6.664 3.525 C 3.973 3.974 2 6.302 2 9.033 L 2 9.259 C 2 10.88 2.672 12.431 3.859 13.537 Z"
/>
</Svg>
)
}
export function UpIcon({
style,
size,
strokeWidth = 1.3,
}: {
style?: StyleProp<ViewStyle>
size?: string | number
strokeWidth: number
}) {
return (
<Svg
viewBox="0 0 14 14"
width={size || 24}
height={size || 24}
style={style}>
<Path
strokeWidth={strokeWidth}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
d="M 7 3 L 2 8 L 4.5 8 L 4.5 11.5 L 9.5 11.5 L 9.5 8 L 12 8 L 7 3 Z"
/>
</Svg>
)
}
export function UpIconSolid({
style,
size,
}: {
style?: StyleProp<ViewStyle>
size?: string | number
}) {
return (
<Svg
viewBox="0 0 14 14"
width={size || 24}
height={size || 24}
style={style}>
<Path
strokeWidth={1.3}
stroke="currentColor"
fill="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
d="M 7 3 L 2 8 L 4.5 8 L 4.5 11.5 L 9.5 11.5 L 9.5 8 L 12 8 L 7 3 Z"
/>
</Svg>
)
}
export function DownIcon({
style,
size,
}: {
style?: StyleProp<ViewStyle>
size?: string | number
}) {
return (
<Svg
viewBox="0 0 14 14"
width={size || 24}
height={size || 24}
style={style}>
<Path
strokeWidth={1.3}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
d="M 7 11.5 L 2 6.5 L 4.5 6.5 L 4.5 3 L 9.5 3 L 9.5 6.5 L 12 6.5 L 7 11.5 Z"
/>
</Svg>
)
}
export function DownIconSolid({
style,
size,
}: {
style?: StyleProp<ViewStyle>
size?: string | number
}) {
return (
<Svg
viewBox="0 0 14 14"
width={size || 24}
height={size || 24}
style={style}>
<Path
strokeWidth={1.3}
stroke="currentColor"
fill="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
d="M 7 11.5 L 2 6.5 L 4.5 6.5 L 4.5 3 L 9.5 3 L 9.5 6.5 L 12 6.5 L 7 11.5 Z"
/>
</Svg>
)
}
// Copyright (c) 2020 Refactoring UI Inc.
// https://github.com/tailwindlabs/heroicons/blob/master/LICENSE
export function CommentBottomArrow({
style,
size,
strokeWidth = 1.3,
}: {
style?: StyleProp<TextStyle>
size?: string | number
strokeWidth?: number
}) {
let color = 'currentColor'
if (
style &&
typeof style === 'object' &&
'color' in style &&
typeof style.color === 'string'
) {
color = style.color
}
return (
<Svg
fill="none"
viewBox="0 0 24 24"
strokeWidth={strokeWidth || 2.5}
stroke={color}
width={size || 24}
height={size || 24}
style={style}>
<Path
strokeLinecap="round"
strokeLinejoin="round"
d="M2.25 12.76c0 1.6 1.123 2.994 2.707 3.227 1.068.157 2.148.279 3.238.364.466.037.893.281 1.153.671L12 21l2.652-3.978c.26-.39.687-.634 1.153-.67 1.09-.086 2.17-.208 3.238-.365 1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0012 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018z"
/>
</Svg>
)
}
export function SquareIcon({
style,
size,
@@ -715,33 +183,6 @@ export function RectTallIcon({
)
}
export function ComposeIcon({
style,
size,
strokeWidth = 1.5,
}: {
style?: StyleProp<TextStyle>
size?: string | number
strokeWidth?: number
}) {
return (
<Svg
fill="none"
viewBox="0 0 24 24"
strokeWidth={strokeWidth}
stroke="currentColor"
width={size || 24}
height={size || 24}
style={style}>
<Path
strokeLinecap="round"
strokeLinejoin="round"
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
/>
</Svg>
)
}
export function ComposeIcon2({
style,
size,
@@ -783,54 +224,6 @@ export function ComposeIcon2({
)
}
export function SquarePlusIcon({
style,
size,
strokeWidth = 1.5,
}: {
style?: StyleProp<TextStyle>
size?: string | number
strokeWidth?: number
}) {
return (
<Svg
viewBox="0 0 24 24"
strokeWidth={strokeWidth}
stroke="currentColor"
width={size || 24}
height={size || 24}
style={style}>
<Line
strokeLinecap="round"
strokeLinejoin="round"
x1="12"
y1="5.5"
x2="12"
y2="18.5"
strokeWidth={strokeWidth * 1.5}
/>
<Line
strokeLinecap="round"
strokeLinejoin="round"
x1="5.5"
y1="12"
x2="18.5"
y2="12"
strokeWidth={strokeWidth * 1.5}
/>
<Rect
strokeWidth={strokeWidth}
x="4"
y="4"
width="16"
height="16"
rx="4"
ry="4"
/>
</Svg>
)
}
export function InfoCircleIcon({
style,
size,
@@ -947,30 +340,3 @@ export function ShieldExclamation({
</Svg>
)
}
export function ListIcon({
style,
size,
strokeWidth = 1.5,
}: {
style?: StyleProp<TextStyle>
size?: string | number
strokeWidth?: number
}) {
return (
<Svg
fill="none"
viewBox="0 0 24 24"
strokeWidth={strokeWidth || 1.5}
stroke="currentColor"
width={size}
height={size}
style={style}>
<Path
strokeLinecap="round"
strokeLinejoin="round"
d="M8.25 6.75h12M8.25 12h12m-12 5.25h12M3.75 6.75h.007v.008H3.75V6.75zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zM3.75 12h.007v.008H3.75V12zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm-.375 5.25h.007v.008H3.75v-.008zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z"
/>
</Svg>
)
}
@@ -6,15 +6,15 @@ import {
} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {getDefinition, getLabelStrings} from './useLabelInfo'
import {useLabelDefinitions} from '#/state/preferences'
import {useGlobalLabelStrings} from './useGlobalLabelStrings'
import {Props as SVGIconProps} from '#/components/icons/common'
import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning'
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/EyeSlash'
import {useLabelDefinitions} from '#/state/preferences'
import {CircleBanSign_Stroke2_Corner0_Rounded as CircleBanSign} from '#/components/icons/CircleBanSign'
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
import {Props as SVGIconProps} from '#/components/icons/common'
import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/EyeSlash'
import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning'
import {useGlobalLabelStrings} from './useGlobalLabelStrings'
import {getDefinition, getLabelStrings} from './useLabelInfo'
export interface ModerationCauseDescription {
icon: React.ComponentType<SVGIconProps>
@@ -22,6 +22,8 @@ export interface ModerationCauseDescription {
description: string
source?: string
sourceType?: ModerationCauseSource['type']
sourceAvi?: string
sourceDid?: string
}
export function useModerationCauseDescription(
@@ -138,6 +140,8 @@ export function useModerationCauseDescription(
description: strings.description,
source,
sourceType: cause.source.type,
sourceAvi: labeler?.creator.avatar,
sourceDid: cause.label.src,
}
}
// should never happen
+31 -36
View File
@@ -71,46 +71,41 @@ export function useNotificationsRegistration() {
export function useRequestNotificationsPermission() {
const gate = useGate()
const {currentAccount} = useSession()
return React.useCallback(
async (context: 'StartOnboarding' | 'AfterOnboarding' | 'Login') => {
const permissions = await Notifications.getPermissionsAsync()
return async (context: 'StartOnboarding' | 'AfterOnboarding' | 'Login') => {
const permissions = await Notifications.getPermissionsAsync()
if (
!currentAccount ||
!isNative ||
permissions?.status === 'granted' ||
(permissions?.status === 'denied' && !permissions?.canAskAgain)
) {
return
}
if (
context === 'StartOnboarding' &&
gate('request_notifications_permission_after_onboarding')
) {
return
}
if (
context === 'AfterOnboarding' &&
!gate('request_notifications_permission_after_onboarding')
) {
return
}
if (
!isNative ||
permissions?.status === 'granted' ||
permissions?.status === 'denied'
) {
return
}
if (
context === 'StartOnboarding' &&
gate('request_notifications_permission_after_onboarding_v2')
) {
return
}
if (
context === 'AfterOnboarding' &&
!gate('request_notifications_permission_after_onboarding_v2')
) {
return
}
const res = await Notifications.requestPermissionsAsync()
logEvent('notifications:request', {
context: context,
status: res.status,
})
const res = await Notifications.requestPermissionsAsync()
logEvent('notifications:request', {
context: context,
status: res.status,
})
if (res.granted) {
// This will fire a pushTokenEvent, which will handle registration of the token
getPushToken(true)
}
},
[gate, currentAccount],
)
if (res.granted) {
// This will fire a pushTokenEvent, which will handle registration of the token
getPushToken(true)
}
}
}
export async function decrementBadgeCount(by: number | 'reset' = 1) {
+1 -1
View File
@@ -38,7 +38,7 @@ export type CommonNavigatorParams = {
AccessibilitySettings: undefined
Search: {q?: string}
Hashtag: {tag: string; author?: string}
MessagesConversation: {conversation: string}
MessagesConversation: {conversation: string; embed?: string}
MessagesSettings: undefined
}
+14 -8
View File
@@ -13,17 +13,17 @@ export type LogEvents = {
withPassword: boolean
}
'account:loggedOut': {
logContext: 'SwitchAccount' | 'Settings' | 'Deactivated'
logContext: 'SwitchAccount' | 'Settings' | 'SignupQueued' | 'Deactivated'
}
'notifications:openApp': {}
'notifications:request': {
context: 'StartOnboarding' | 'AfterOnboarding' | 'Login'
status: 'granted' | 'denied' | 'undetermined'
}
'state:background': {
'state:background:sampled': {
secondsActive: number
}
'state:foreground': {}
'state:foreground:sampled': {}
'router:navigate:sampled': {}
// Screen events
@@ -57,18 +57,18 @@ export type LogEvents = {
'onboarding:finished:avatarResult': {
avatarResult: 'default' | 'created' | 'uploaded'
}
'home:feedDisplayed': {
'home:feedDisplayed:sampled': {
feedUrl: string
feedType: string
index: number
reason: 'focus' | 'tabbar-click' | 'pager-swipe' | 'desktop-sidebar-click'
}
'feed:endReached': {
'feed:endReached:sampled': {
feedUrl: string
feedType: string
itemCount: number
}
'feed:refresh': {
'feed:refresh:sampled': {
feedUrl: string
feedType: string
reason: 'pull-to-refresh' | 'soft-reset' | 'load-latest'
@@ -115,6 +115,7 @@ export type LogEvents = {
| 'ProfileHeaderSuggestedFollows'
| 'ProfileMenu'
| 'ProfileHoverCard'
| 'AvatarButton'
}
'profile:unfollow': {
logContext:
@@ -126,12 +127,17 @@ export type LogEvents = {
| 'ProfileMenu'
| 'ProfileHoverCard'
| 'Chat'
| 'AvatarButton'
}
'chat:create': {
logContext: 'ProfileHeader' | 'NewChatDialog'
logContext: 'ProfileHeader' | 'NewChatDialog' | 'SendViaChatDialog'
}
'chat:open': {
logContext: 'ProfileHeader' | 'NewChatDialog' | 'ChatsList'
logContext:
| 'ProfileHeader'
| 'NewChatDialog'
| 'ChatsList'
| 'SendViaChatDialog'
}
'test:all:always': {}
+2 -1
View File
@@ -1,4 +1,5 @@
export type Gate =
// Keep this alphabetic please.
| 'request_notifications_permission_after_onboarding'
| 'request_notifications_permission_after_onboarding_v2'
| 'show_avi_follow_button'
| 'show_follow_back_label_v2'
+20 -3
View File
@@ -87,7 +87,14 @@ export function toClout(n: number | null | undefined): number | undefined {
}
}
const DOWNSAMPLED_EVENTS = new Set(['router:navigate:sampled'])
const DOWNSAMPLED_EVENTS: Set<keyof LogEvents> = new Set([
'router:navigate:sampled',
'state:background:sampled',
'state:foreground:sampled',
'home:feedDisplayed:sampled',
'feed:endReached:sampled',
'feed:refresh:sampled',
])
const isDownsampledSession = Math.random() < 0.9 // 90% likely
export function logEvent<E extends keyof LogEvents>(
@@ -95,6 +102,16 @@ export function logEvent<E extends keyof LogEvents>(
rawMetadata: LogEvents[E] & FlatJSONRecord,
) {
try {
if (
process.env.NODE_ENV === 'development' &&
eventName.endsWith(':sampled') &&
!DOWNSAMPLED_EVENTS.has(eventName)
) {
logger.error(
'Did you forget to add ' + eventName + ' to DOWNSAMPLED_EVENTS?',
)
}
if (isDownsampledSession && DOWNSAMPLED_EVENTS.has(eventName)) {
return
}
@@ -199,14 +216,14 @@ AppState.addEventListener('change', (state: AppStateStatus) => {
lastState = state
if (state === 'active') {
lastActive = performance.now()
logEvent('state:foreground', {})
logEvent('state:foreground:sampled', {})
} else {
let secondsActive = 0
if (lastActive != null) {
secondsActive = Math.round((performance.now() - lastActive) / 1e3)
}
lastActive = null
logEvent('state:background', {
logEvent('state:background:sampled', {
secondsActive,
})
}
+76
View File
@@ -23,6 +23,7 @@ export const embedPlayerSources = [
'vimeo',
'giphy',
'tenor',
'flickr',
] as const
export type EmbedPlayerSource = (typeof embedPlayerSources)[number]
@@ -42,6 +43,7 @@ export type EmbedPlayerType =
| 'vimeo_video'
| 'giphy_gif'
| 'tenor_gif'
| 'flickr_album'
export const externalEmbedLabels: Record<EmbedPlayerSource, string> = {
youtube: 'YouTube',
@@ -53,6 +55,7 @@ export const externalEmbedLabels: Record<EmbedPlayerSource, string> = {
spotify: 'Spotify',
appleMusic: 'Apple Music',
soundcloud: 'SoundCloud',
flickr: 'Flickr',
}
export interface EmbedPlayerParams {
@@ -375,6 +378,79 @@ export function parseEmbedPlayerFromUrl(
}
}
}
// this is a standard flickr path! we can use the embedder for albums and groups, so validate the path
if (urlp.hostname === 'www.flickr.com' || urlp.hostname === 'flickr.com') {
let i = urlp.pathname.length - 1
while (i > 0 && urlp.pathname.charAt(i) === '/') {
--i
}
const path_components = urlp.pathname.slice(1, i + 1).split('/')
if (path_components.length === 4) {
// discard username - it's not relevant
const [photos, _, albums, id] = path_components
if (photos === 'photos' && albums === 'albums') {
// this at least has the shape of a valid photo-album URL!
return {
type: 'flickr_album',
source: 'flickr',
playerUri: `https://embedr.flickr.com/photosets/${id}`,
}
}
}
if (path_components.length === 3) {
const [groups, id, pool] = path_components
if (groups === 'groups' && pool === 'pool') {
return {
type: 'flickr_album',
source: 'flickr',
playerUri: `https://embedr.flickr.com/groups/${id}`,
}
}
}
// not an album or a group pool, don't know what to do with this!
return undefined
}
// link shortened flickr path
if (urlp.hostname === 'flic.kr') {
const b58alph = '123456789abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ'
let [_, type, idBase58Enc] = urlp.pathname.split('/')
let id = 0n
for (const char of idBase58Enc) {
const nextIdx = b58alph.indexOf(char)
if (nextIdx >= 0) {
id = id * 58n + BigInt(nextIdx)
} else {
// not b58 encoded, ergo not a valid link to embed
return undefined
}
}
switch (type) {
case 'go':
const formattedGroupId = `${id}`
return {
type: 'flickr_album',
source: 'flickr',
playerUri: `https://embedr.flickr.com/groups/${formattedGroupId.slice(
0,
-2,
)}@N${formattedGroupId.slice(-2)}`,
}
case 's':
return {
type: 'flickr_album',
source: 'flickr',
playerUri: `https://embedr.flickr.com/photosets/${id}`,
}
default:
// we don't know what this is so we can't embed it
return undefined
}
}
}
export function getPlayerAspect({
+20 -4
View File
@@ -1,4 +1,4 @@
import {RichText, UnicodeString} from '@atproto/api'
import {AppBskyRichtextFacet, RichText, UnicodeString} from '@atproto/api'
import {toShortUrl} from './url-helpers'
@@ -10,9 +10,7 @@ export function shortenLinks(rt: RichText): RichText {
// enumerate the link facets
if (rt.facets) {
for (const facet of rt.facets) {
const isLink = !!facet.features.find(
f => f.$type === 'app.bsky.richtext.facet#link',
)
const isLink = !!facet.features.find(AppBskyRichtextFacet.isLink)
if (!isLink) {
continue
}
@@ -33,3 +31,21 @@ export function shortenLinks(rt: RichText): RichText {
}
return rt
}
// filter out any mention facets that didn't map to a user
export function stripInvalidMentions(rt: RichText): RichText {
if (!rt.facets?.length) {
return rt
}
rt = rt.clone()
if (rt.facets) {
rt.facets = rt.facets?.filter(facet => {
const mention = facet.features.find(AppBskyRichtextFacet.isMention)
if (mention && !mention.did) {
return false
}
return true
})
}
return rt
}
+21
View File
@@ -3,6 +3,7 @@ import psl from 'psl'
import TLDs from 'tlds'
import {BSKY_SERVICE} from 'lib/constants'
import {isInvalidHandle} from 'lib/strings/handles'
export const BSKY_APP_HOST = 'https://bsky.app'
const BSKY_TRUSTED_HOSTS = [
@@ -83,6 +84,10 @@ export function toShareUrl(url: string): string {
return url
}
export function toBskyAppUrl(url: string): string {
return new URL(url, BSKY_APP_HOST).toString()
}
export function isBskyAppUrl(url: string): boolean {
return url.startsWith('https://bsky.app/')
}
@@ -183,6 +188,22 @@ export function feedUriToHref(url: string): string {
}
}
export function postUriToRelativePath(
uri: string,
options?: {handle?: string},
): string | undefined {
try {
const {hostname, rkey} = new AtUri(uri)
const handleOrDid =
options?.handle && !isInvalidHandle(options.handle)
? options.handle
: hostname
return `/profile/${handleOrDid}/post/${rkey}`
} catch {
return undefined
}
}
/**
* Checks if the label in the post text matches the host of the link facet.
*
+138 -183
View File
@@ -1,19 +1,22 @@
import React from 'react'
import {View} from 'react-native'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {msg, plural, Trans} from '@lingui/macro'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useFocusEffect} from '@react-navigation/native'
import {logger} from '#/logger'
import {useAccountSwitcher} from '#/lib/hooks/useAccountSwitcher'
import {isWeb} from '#/platform/detection'
import {isSessionDeactivated, useAgent, useSessionApi} from '#/state/session'
import {useOnboardingDispatch} from '#/state/shell'
import {type SessionAccount, useSession, useSessionApi} from '#/state/session'
import {useSetMinimalShellMode} from '#/state/shell'
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
import {ScrollView} from '#/view/com/util/Views'
import {Logo} from '#/view/icons/Logo'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {Loader} from '#/components/Loader'
import {P, Text} from '#/components/Typography'
import {atoms as a, useTheme} from '#/alf'
import {AccountList} from '#/components/AccountList'
import {Button, ButtonText} from '#/components/Button'
import {Divider} from '#/components/Divider'
import {Text} from '#/components/Typography'
const COL_WIDTH = 400
@@ -21,199 +24,151 @@ export function Deactivated() {
const {_} = useLingui()
const t = useTheme()
const insets = useSafeAreaInsets()
const {gtMobile} = useBreakpoints()
const onboardingDispatch = useOnboardingDispatch()
const {currentAccount, accounts} = useSession()
const {onPressSwitchAccount, pendingDid} = useAccountSwitcher()
const {setShowLoggedOut} = useLoggedOutViewControls()
const hasOtherAccounts = accounts.length > 1
const setMinimalShellMode = useSetMinimalShellMode()
const {logout} = useSessionApi()
const agent = useAgent()
const [isProcessing, setProcessing] = React.useState(false)
const [estimatedTime, setEstimatedTime] = React.useState<string | undefined>(
undefined,
)
const [placeInQueue, setPlaceInQueue] = React.useState<number | undefined>(
undefined,
useFocusEffect(
React.useCallback(() => {
setMinimalShellMode(true)
}, [setMinimalShellMode]),
)
const checkStatus = React.useCallback(async () => {
setProcessing(true)
try {
const res = await agent.com.atproto.temp.checkSignupQueue()
if (res.data.activated) {
// ready to go, exchange the access token for a usable one and kick off onboarding
await agent.refreshSession()
if (!isSessionDeactivated(agent.session?.accessJwt)) {
onboardingDispatch({type: 'start'})
}
} else {
// not ready, update UI
setEstimatedTime(msToString(res.data.estimatedTimeMs))
if (typeof res.data.placeInQueue !== 'undefined') {
setPlaceInQueue(Math.max(res.data.placeInQueue, 1))
}
const onSelectAccount = React.useCallback(
(account: SessionAccount) => {
if (account.did !== currentAccount?.did) {
onPressSwitchAccount(account, 'SwitchAccount')
}
} catch (e: any) {
logger.error('Failed to check signup queue', {err: e.toString()})
} finally {
setProcessing(false)
}
}, [
setProcessing,
setEstimatedTime,
setPlaceInQueue,
onboardingDispatch,
agent,
])
React.useEffect(() => {
checkStatus()
const interval = setInterval(checkStatus, 60e3)
return () => clearInterval(interval)
}, [checkStatus])
const checkBtn = (
<Button
variant="solid"
color="primary"
size="large"
label={_(msg`Check my status`)}
onPress={checkStatus}
disabled={isProcessing}>
<ButtonText>
<Trans>Check my status</Trans>
</ButtonText>
{isProcessing && <ButtonIcon icon={Loader} />}
</Button>
},
[currentAccount, onPressSwitchAccount],
)
const onPressAddAccount = React.useCallback(() => {
setShowLoggedOut(true)
}, [setShowLoggedOut])
const onPressLogout = React.useCallback(() => {
if (isWeb) {
// We're switching accounts, which remounts the entire app.
// On mobile, this gets us Home, but on the web we also need reset the URL.
// We can't change the URL via a navigate() call because the navigator
// itself is about to unmount, and it calls pushState() too late.
// So we change the URL ourselves. The navigator will pick it up on remount.
history.pushState(null, '', '/')
}
logout('Deactivated')
}, [logout])
return (
<View
aria-modal
role="dialog"
aria-role="dialog"
aria-label={_(msg`You're in line`)}
accessibilityLabel={_(msg`You're in line`)}
accessibilityHint=""
style={[a.absolute, a.inset_0, a.flex_1, t.atoms.bg]}>
<View style={[a.h_full_vh, a.flex_1, t.atoms.bg]}>
<ScrollView
style={[a.h_full, a.w_full]}
contentContainerStyle={{borderWidth: 0}}>
<View
style={[a.flex_row, a.justify_center, gtMobile ? a.pt_4xl : a.px_xl]}>
<View style={[a.flex_1, {maxWidth: COL_WIDTH}]}>
<View
style={[a.w_full, a.justify_center, a.align_center, a.my_4xl]}>
<Logo width={120} />
</View>
<Text style={[a.text_4xl, a.font_bold, a.pb_sm]}>
<Trans>You're in line</Trans>
</Text>
<P style={[t.atoms.text_contrast_medium]}>
<Trans>
There's been a rush of new users to Bluesky! We'll activate your
account as soon as we can.
</Trans>
</P>
<View
style={[
a.rounded_sm,
a.px_2xl,
a.py_4xl,
a.mt_2xl,
t.atoms.bg_contrast_50,
]}>
{typeof placeInQueue === 'number' && (
<Text
style={[a.text_5xl, a.text_center, a.font_bold, a.mb_2xl]}>
{placeInQueue}
</Text>
)}
<P style={[a.text_center]}>
{typeof placeInQueue === 'number' ? (
<Trans>left to go.</Trans>
) : (
<Trans>You are in line.</Trans>
)}{' '}
{estimatedTime ? (
<Trans>
We estimate {estimatedTime} until your account is ready.
</Trans>
) : (
<Trans>
We will let you know when your account is ready.
</Trans>
)}
</P>
</View>
{isWeb && gtMobile && (
<View style={[a.w_full, a.flex_row, a.justify_between, a.pt_5xl]}>
<Button
variant="ghost"
size="large"
label={_(msg`Log out`)}
onPress={() => logout('Deactivated')}>
<ButtonText style={[{color: t.palette.primary_500}]}>
<Trans>Log out</Trans>
</ButtonText>
</Button>
{checkBtn}
</View>
)}
</View>
<View style={{height: 200}} />
</View>
</ScrollView>
{(!isWeb || !gtMobile) && (
<View
style={[
a.align_center,
gtMobile ? a.px_5xl : a.px_xl,
a.px_2xl,
{
paddingBottom: Math.max(insets.bottom, a.pb_5xl.paddingBottom),
paddingTop: isWeb ? 64 : insets.top,
paddingBottom: isWeb ? 64 : insets.bottom,
},
]}>
<View style={[a.w_full, a.gap_sm, {maxWidth: COL_WIDTH}]}>
{checkBtn}
<Button
variant="ghost"
size="large"
label={_(msg`Log out`)}
onPress={() => logout('Deactivated')}>
<ButtonText style={[{color: t.palette.primary_500}]}>
<Trans>Log out</Trans>
</ButtonText>
</Button>
<View style={[a.flex_row, a.justify_center]}>
<View style={[a.w_full, {maxWidth: COL_WIDTH}]}>
<View
style={[a.w_full, a.justify_center, a.align_center, a.pb_5xl]}>
<Logo width={40} />
</View>
<View style={[a.gap_xs, a.pb_3xl]}>
<Text style={[a.text_xl, a.font_bold, a.leading_snug]}>
<Trans>Welcome back!</Trans>
</Text>
<Text style={[a.text_sm, a.leading_snug]}>
<Trans>
You previously deactivated @{currentAccount?.handle}.
</Trans>
</Text>
<Text style={[a.text_sm, a.leading_snug, a.pb_md]}>
<Trans>
You can reactivate your account to continue logging in. Your
profile and posts will be visible to other users.
</Trans>
</Text>
<View style={[a.gap_sm]}>
<Button
label={_(msg`Reactivate your account`)}
size="medium"
variant="solid"
color="primary"
onPress={() => setShowLoggedOut(true)}>
<ButtonText>
<Trans>Yes, reactivate my account</Trans>
</ButtonText>
</Button>
<Button
label={_(msg`Cancel reactivation and log out`)}
size="medium"
variant="solid"
color="secondary"
onPress={onPressLogout}>
<ButtonText>
<Trans>Cancel</Trans>
</ButtonText>
</Button>
</View>
</View>
<View style={[a.pb_3xl]}>
<Divider />
</View>
{hasOtherAccounts ? (
<>
<Text
style={[
t.atoms.text_contrast_medium,
a.pb_md,
a.leading_snug,
]}>
<Trans>Or, log into one of your other accounts.</Trans>
</Text>
<AccountList
onSelectAccount={onSelectAccount}
onSelectOther={onPressAddAccount}
otherLabel={_(msg`Add account`)}
pendingDid={pendingDid}
/>
</>
) : (
<>
<Text
style={[
t.atoms.text_contrast_medium,
a.pb_md,
a.leading_snug,
]}>
<Trans>Or, continue with another account.</Trans>
</Text>
<Button
label={_(msg`Log in or sign up`)}
size="medium"
variant="solid"
color="secondary"
onPress={() => setShowLoggedOut(true)}>
<ButtonText>
<Trans>Log in or sign up</Trans>
</ButtonText>
</Button>
</>
)}
</View>
</View>
</View>
)}
</ScrollView>
</View>
)
}
function msToString(ms: number | undefined): string | undefined {
if (ms && ms > 0) {
const estimatedTimeMins = Math.ceil(ms / 60e3)
if (estimatedTimeMins > 59) {
const estimatedTimeHrs = Math.round(estimatedTimeMins / 60)
if (estimatedTimeHrs > 6) {
// dont even bother
return undefined
}
// hours
return `${estimatedTimeHrs} ${plural(estimatedTimeHrs, {
one: 'hour',
other: 'hours',
})}`
}
// minutes
return `${estimatedTimeMins} ${plural(estimatedTimeMins, {
one: 'minute',
other: 'minutes',
})}`
}
return undefined
}
@@ -27,13 +27,20 @@ import * as Toast from '#/view/com/util/Toast'
import {atoms as a, useTheme} from '#/alf'
import {useSharedInputStyles} from '#/components/forms/TextField'
import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlane} from '#/components/icons/PaperPlane'
import {useExtractEmbedFromFacets} from './MessageInputEmbed'
const AnimatedTextInput = Animated.createAnimatedComponent(TextInput)
export function MessageInput({
onSendMessage,
hasEmbed,
setEmbed,
children,
}: {
onSendMessage: (message: string) => void
hasEmbed: boolean
setEmbed: (embedUrl: string | undefined) => void
children?: React.ReactNode
}) {
const {_} = useLingui()
const t = useTheme()
@@ -53,9 +60,10 @@ export function MessageInput({
const inputRef = useAnimatedRef<TextInput>()
useSaveMessageDraft(message)
useExtractEmbedFromFacets(message, setEmbed)
const onSubmit = React.useCallback(() => {
if (message.trim() === '') {
if (!hasEmbed && message.trim() === '') {
return
}
if (new Graphemer().countGraphemes(message) > MAX_DM_GRAPHEME_LENGTH) {
@@ -63,16 +71,26 @@ export function MessageInput({
return
}
clearDraft()
onSendMessage(message.trimEnd())
onSendMessage(message)
playHaptic()
setMessage('')
setEmbed(undefined)
// Pressing the send button causes the text input to lose focus, so we need to
// re-focus it after sending
setTimeout(() => {
inputRef.current?.focus()
}, 100)
}, [message, clearDraft, onSendMessage, playHaptic, _, inputRef])
}, [
hasEmbed,
message,
clearDraft,
onSendMessage,
playHaptic,
setEmbed,
_,
inputRef,
])
useFocusedInputHandler(
{
@@ -101,6 +119,7 @@ export function MessageInput({
return (
<View style={[a.px_md, a.pb_sm, a.pt_xs]}>
{children}
<View
style={[
a.w_full,
@@ -16,11 +16,18 @@ import * as Toast from '#/view/com/util/Toast'
import {atoms as a, useTheme} from '#/alf'
import {useSharedInputStyles} from '#/components/forms/TextField'
import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlane} from '#/components/icons/PaperPlane'
import {useExtractEmbedFromFacets} from './MessageInputEmbed'
export function MessageInput({
onSendMessage,
hasEmbed,
setEmbed,
children,
}: {
onSendMessage: (message: string) => void
hasEmbed: boolean
setEmbed: (embedUrl: string | undefined) => void
children?: React.ReactNode
}) {
const {isTabletOrDesktop} = useWebMediaQueries()
const {_} = useLingui()
@@ -35,7 +42,7 @@ export function MessageInput({
const [textAreaHeight, setTextAreaHeight] = React.useState(38)
const onSubmit = React.useCallback(() => {
if (message.trim() === '') {
if (!hasEmbed && message.trim() === '') {
return
}
if (new Graphemer().countGraphemes(message) > MAX_DM_GRAPHEME_LENGTH) {
@@ -43,9 +50,10 @@ export function MessageInput({
return
}
clearDraft()
onSendMessage(message.trimEnd())
onSendMessage(message)
setMessage('')
}, [message, onSendMessage, _, clearDraft])
setEmbed(undefined)
}, [message, onSendMessage, _, clearDraft, hasEmbed, setEmbed])
const onKeyDown = React.useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
@@ -87,9 +95,11 @@ export function MessageInput({
)
useSaveMessageDraft(message)
useExtractEmbedFromFacets(message, setEmbed)
return (
<View style={a.p_sm}>
{children}
<View
style={[
a.flex_row,
@@ -0,0 +1,231 @@
import React, {useCallback, useEffect, useMemo, useState} from 'react'
import {LayoutAnimation, View} from 'react-native'
import {
AppBskyEmbedImages,
AppBskyEmbedRecordWithMedia,
AppBskyFeedPost,
AppBskyRichtextFacet,
AtUri,
RichText as RichTextAPI,
} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {RouteProp, useNavigation, useRoute} from '@react-navigation/native'
import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped'
import {makeProfileLink} from '#/lib/routes/links'
import {CommonNavigatorParams, NavigationProp} from '#/lib/routes/types'
import {
convertBskyAppUrlIfNeeded,
isBskyPostUrl,
makeRecordUri,
} from '#/lib/strings/url-helpers'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {usePostQuery} from '#/state/queries/post'
import {ImageHorzList} from '#/view/com/util/images/ImageHorzList'
import {PostMeta} from '#/view/com/util/PostMeta'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonIcon} from '#/components/Button'
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
import {Loader} from '#/components/Loader'
import {ContentHider} from '#/components/moderation/ContentHider'
import {PostAlerts} from '#/components/moderation/PostAlerts'
import {RichText} from '#/components/RichText'
import {Text} from '#/components/Typography'
export function useMessageEmbed() {
const route =
useRoute<RouteProp<CommonNavigatorParams, 'MessagesConversation'>>()
const navigation = useNavigation<NavigationProp>()
const embedFromParams = route.params.embed
const [embedUri, setEmbed] = useState(embedFromParams)
if (embedFromParams && embedUri !== embedFromParams) {
setEmbed(embedFromParams)
}
return {
embedUri,
setEmbed: useCallback(
(embedUrl: string | undefined) => {
if (!embedUrl) {
navigation.setParams({embed: ''})
setEmbed(undefined)
return
}
if (embedFromParams) return
const url = convertBskyAppUrlIfNeeded(embedUrl)
const [_0, user, _1, rkey] = url.split('/').filter(Boolean)
const uri = makeRecordUri(user, 'app.bsky.feed.post', rkey)
setEmbed(uri)
},
[embedFromParams, navigation],
),
}
}
export function useExtractEmbedFromFacets(
message: string,
setEmbed: (embedUrl: string | undefined) => void,
) {
const rt = new RichTextAPI({text: message})
rt.detectFacetsWithoutResolution()
let uriFromFacet: string | undefined
for (const facet of rt.facets ?? []) {
for (const feature of facet.features) {
if (AppBskyRichtextFacet.isLink(feature) && isBskyPostUrl(feature.uri)) {
uriFromFacet = feature.uri
break
}
}
}
useEffect(() => {
if (uriFromFacet) {
setEmbed(uriFromFacet)
}
}, [uriFromFacet, setEmbed])
}
export function MessageInputEmbed({
embedUri,
setEmbed,
}: {
embedUri: string | undefined
setEmbed: (embedUrl: string | undefined) => void
}) {
const t = useTheme()
const {_} = useLingui()
const {data: post, status} = usePostQuery(embedUri)
const moderationOpts = useModerationOpts()
const moderation = useMemo(
() =>
moderationOpts && post ? moderatePost(post, moderationOpts) : undefined,
[moderationOpts, post],
)
const {rt, record} = useMemo(() => {
if (
post &&
AppBskyFeedPost.isRecord(post.record) &&
AppBskyFeedPost.validateRecord(post.record).success
) {
return {
rt: new RichTextAPI({
text: post.record.text,
facets: post.record.facets,
}),
record: post.record,
}
}
return {rt: undefined, record: undefined}
}, [post])
if (!embedUri) {
return null
}
let content = null
switch (status) {
case 'pending':
content = (
<View
style={[a.flex_1, {minHeight: 64}, a.justify_center, a.align_center]}>
<Loader />
</View>
)
break
case 'error':
content = (
<View
style={[a.flex_1, {minHeight: 64}, a.justify_center, a.align_center]}>
<Text style={a.text_center}>Could not fetch post</Text>
</View>
)
break
case 'success':
const itemUrip = new AtUri(post.uri)
const itemHref = makeProfileLink(post.author, 'post', itemUrip.rkey)
if (!post || !moderation || !rt || !record) {
return null
}
const images = AppBskyEmbedImages.isView(post.embed)
? post.embed.images
: AppBskyEmbedRecordWithMedia.isView(post.embed) &&
AppBskyEmbedImages.isView(post.embed.media)
? post.embed.media.images
: undefined
content = (
<View
style={[
a.flex_1,
t.atoms.bg,
t.atoms.border_contrast_low,
a.rounded_md,
a.border,
a.p_sm,
a.mb_sm,
]}
pointerEvents="none">
<PostMeta
showAvatar
author={post.author}
moderation={moderation}
authorHasWarning={!!post.author.labels?.length}
timestamp={post.indexedAt}
postHref={itemHref}
style={a.flex_0}
/>
<ContentHider modui={moderation.ui('contentView')}>
<PostAlerts modui={moderation.ui('contentView')} style={a.py_xs} />
{rt.text && (
<View style={a.mt_xs}>
<RichText
enableTags
testID="postText"
value={rt}
style={[a.text_sm, t.atoms.text_contrast_high]}
authorHandle={post.author.handle}
numberOfLines={3}
/>
</View>
)}
{images && images?.length > 0 && (
<ImageHorzList images={images} style={a.mt_xs} />
)}
</ContentHider>
</View>
)
break
}
return (
<View style={[a.flex_row, a.gap_sm]}>
{content}
<Button
label={_(msg`Remove embed`)}
onPress={() => {
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
setEmbed(undefined)
}}
size="tiny"
variant="solid"
color="secondary"
shape="round">
<ButtonIcon icon={X} />
</Button>
</View>
)
}
@@ -13,12 +13,18 @@ import {
} from 'react-native-reanimated'
import {ReanimatedScrollEvent} from 'react-native-reanimated/lib/typescript/reanimated2/hook/commonTypes'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {AppBskyRichtextFacet, RichText} from '@atproto/api'
import {AppBskyEmbedRecord, AppBskyRichtextFacet, RichText} from '@atproto/api'
import {shortenLinks} from '#/lib/strings/rich-text-manip'
import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip'
import {
convertBskyAppUrlIfNeeded,
isBskyPostUrl,
} from '#/lib/strings/url-helpers'
import {logger} from '#/logger'
import {isNative} from '#/platform/detection'
import {isConvoActive, useConvoActive} from '#/state/messages/convo'
import {ConvoItem, ConvoStatus} from '#/state/messages/convo/types'
import {useGetPost} from '#/state/queries/post'
import {useAgent} from '#/state/session'
import {clamp} from 'lib/numbers'
import {ScrollProvider} from 'lib/ScrollContext'
@@ -32,6 +38,7 @@ import {MessageItem} from '#/components/dms/MessageItem'
import {NewMessagesPill} from '#/components/dms/NewMessagesPill'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
import {MessageInputEmbed, useMessageEmbed} from './MessageInputEmbed'
function MaybeLoader({isLoading}: {isLoading: boolean}) {
return (
@@ -80,6 +87,8 @@ export function MessagesList({
}) {
const convoState = useConvoActive()
const agent = useAgent()
const getPost = useGetPost()
const {embedUri, setEmbed} = useMessageEmbed()
const flatListRef = useAnimatedRef<FlatList>()
@@ -264,20 +273,69 @@ export function MessagesList({
// -- Message sending
const onSendMessage = useCallback(
async (text: string) => {
let rt = new RichText({text}, {cleanNewlines: true})
await rt.detectFacets(agent)
rt = shortenLinks(rt)
let rt = new RichText({text: text.trimEnd()}, {cleanNewlines: true})
// filter out any mention facets that didn't map to a user
rt.facets = rt.facets?.filter(facet => {
const mention = facet.features.find(feature =>
AppBskyRichtextFacet.isMention(feature),
)
if (mention && !mention.did) {
return false
// detect facets without resolution first - this is used to see if there's
// any post links in the text that we can embed. We do this first because
// we want to remove the post link from the text, re-trim, then detect facets
rt.detectFacetsWithoutResolution()
let embed: AppBskyEmbedRecord.Main | undefined
if (embedUri) {
try {
const post = await getPost({uri: embedUri})
if (post) {
embed = {
$type: 'app.bsky.embed.record',
record: {
uri: post.uri,
cid: post.cid,
},
}
// look for the embed uri in the facets, so we can remove it from the text
const postLinkFacet = rt.facets?.find(facet => {
return facet.features.find(feature => {
if (AppBskyRichtextFacet.isLink(feature)) {
if (isBskyPostUrl(feature.uri)) {
const url = convertBskyAppUrlIfNeeded(feature.uri)
const [_0, _1, _2, rkey] = url.split('/').filter(Boolean)
// this might have a handle instead of a DID
// so just compare the rkey - not particularly dangerous
return post.uri.endsWith(rkey)
}
}
return false
})
})
if (postLinkFacet) {
const isAtStart = postLinkFacet.index.byteStart === 0
const isAtEnd =
postLinkFacet.index.byteEnd === rt.unicodeText.graphemeLength
// remove the post link from the text
if (isAtStart || isAtEnd) {
rt.delete(
postLinkFacet.index.byteStart,
postLinkFacet.index.byteEnd,
)
}
rt = new RichText({text: rt.text.trim()}, {cleanNewlines: true})
}
}
} catch (error) {
logger.error('Failed to get post as quote for DM', {error})
}
return true
})
}
await rt.detectFacets(agent)
rt = shortenLinks(rt)
rt = stripInvalidMentions(rt)
if (!hasScrolled) {
setHasScrolled(true)
@@ -286,9 +344,10 @@ export function MessagesList({
convoState.sendMessage({
text: rt.text,
facets: rt.facets,
embed,
})
},
[convoState, agent, hasScrolled, setHasScrolled],
[agent, convoState, embedUri, getPost, hasScrolled, setHasScrolled],
)
// -- List layout changes (opening emoji keyboard, etc.)
@@ -328,6 +387,9 @@ export function MessagesList({
renderItem={renderItem}
keyExtractor={keyExtractor}
containWeb={true}
// Prevents wrong position in Firefox when sending a message
// as well as scroll getting stuck on Chome when scrolling upwards.
disableContentVisibility={true}
disableVirtualization={true}
style={animatedListStyle}
// The extra two items account for the header and the footer components
@@ -360,7 +422,12 @@ export function MessagesList({
{isConvoActive(convoState) &&
!convoState.isFetchingHistory &&
convoState.items.length === 0 && <ChatEmptyPill />}
<MessageInput onSendMessage={onSendMessage} />
<MessageInput
onSendMessage={onSendMessage}
hasEmbed={!!embedUri}
setEmbed={setEmbed}>
<MessageInputEmbed embedUri={embedUri} setEmbed={setEmbed} />
</MessageInput>
</>
)}
</KeyboardStickyView>
+70 -14
View File
@@ -2,6 +2,7 @@ import React, {useCallback, useState} from 'react'
import {GestureResponderEvent, View} from 'react-native'
import {
AppBskyActorDefs,
AppBskyEmbedRecord,
ChatBskyConvoDefs,
moderateProfile,
ModerationOpts,
@@ -9,6 +10,11 @@ import {
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {
postUriToRelativePath,
toBskyAppUrl,
toShortUrl,
} from '#/lib/strings/url-helpers'
import {isNative} from '#/platform/detection'
import {useProfileShadow} from '#/state/cache/profile-shadow'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
@@ -23,6 +29,7 @@ import {ConvoMenu} from '#/components/dms/ConvoMenu'
import {Bell2Off_Filled_Corner0_Rounded as BellStroke} from '#/components/icons/Bell2'
import {Link} from '#/components/Link'
import {useMenuControl} from '#/components/Menu'
import {PostAlerts} from '#/components/moderation/PostAlerts'
import {Text} from '#/components/Typography'
export let ChatListItem = ({
@@ -94,21 +101,64 @@ function ChatListItemReady({
const isDimStyle = convo.muted || moderation.blocked || isDeletedAccount
let lastMessage = _(msg`No messages yet`)
let lastMessageSentAt: string | null = null
if (ChatBskyConvoDefs.isMessageView(convo.lastMessage)) {
if (convo.lastMessage.sender?.did === currentAccount?.did) {
lastMessage = _(msg`You: ${convo.lastMessage.text}`)
} else {
lastMessage = convo.lastMessage.text
const {lastMessage, lastMessageSentAt} = React.useMemo(() => {
let lastMessage = _(msg`No messages yet`)
let lastMessageSentAt: string | null = null
if (ChatBskyConvoDefs.isMessageView(convo.lastMessage)) {
const isFromMe = convo.lastMessage.sender?.did === currentAccount?.did
if (convo.lastMessage.text) {
if (isFromMe) {
lastMessage = _(msg`You: ${convo.lastMessage.text}`)
} else {
lastMessage = convo.lastMessage.text
}
} else if (convo.lastMessage.embed) {
const defaultEmbeddedContentMessage = _(
msg`(contains embedded content)`,
)
if (AppBskyEmbedRecord.isView(convo.lastMessage.embed)) {
const embed = convo.lastMessage.embed
if (AppBskyEmbedRecord.isViewRecord(embed.record)) {
const record = embed.record
const path = postUriToRelativePath(record.uri, {
handle: record.author.handle,
})
const href = path ? toBskyAppUrl(path) : undefined
const short = href
? toShortUrl(href)
: defaultEmbeddedContentMessage
if (isFromMe) {
lastMessage = _(msg`You: ${short}`)
} else {
lastMessage = short
}
}
} else {
if (isFromMe) {
lastMessage = _(msg`You: ${defaultEmbeddedContentMessage}`)
} else {
lastMessage = defaultEmbeddedContentMessage
}
}
}
lastMessageSentAt = convo.lastMessage.sentAt
}
lastMessageSentAt = convo.lastMessage.sentAt
}
if (ChatBskyConvoDefs.isDeletedMessageView(convo.lastMessage)) {
lastMessage = isDeletedAccount
? _(msg`Conversation deleted`)
: _(msg`Message deleted`)
}
if (ChatBskyConvoDefs.isDeletedMessageView(convo.lastMessage)) {
lastMessage = isDeletedAccount
? _(msg`Conversation deleted`)
: _(msg`Message deleted`)
}
return {
lastMessage,
lastMessageSentAt,
}
}, [_, convo.lastMessage, currentAccount?.did, isDeletedAccount])
const [showActions, setShowActions] = useState(false)
@@ -264,6 +314,12 @@ function ChatListItemReady({
]}>
{lastMessage}
</Text>
<PostAlerts
modui={moderation.ui('contentList')}
size="large"
style={[a.pt_xs]}
/>
</View>
{convo.unreadCount > 0 && (
+1 -1
View File
@@ -21,8 +21,8 @@ import {CenteredView} from '#/view/com/util/Views'
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {DialogControlProps, useDialogControl} from '#/components/Dialog'
import {NewChat} from '#/components/dms/dialogs/NewChatDialog'
import {MessagesNUX} from '#/components/dms/MessagesNUX'
import {NewChat} from '#/components/dms/NewChatDialog'
import {useRefreshOnFocus} from '#/components/hooks/useRefreshOnFocus'
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as Retry} from '#/components/icons/ArrowRotateCounterClockwise'
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
+12 -1
View File
@@ -13,6 +13,7 @@ import {RQKEY as profileRQKey} from '#/state/queries/profile'
import {useAgent} from '#/state/session'
import {useOnboardingDispatch} from '#/state/shell'
import {uploadBlob} from 'lib/api'
import {useRequestNotificationsPermission} from 'lib/notifications/notifications'
import {
DescriptionText,
OnboardingControls,
@@ -39,6 +40,7 @@ export function StepFinished() {
const [saving, setSaving] = React.useState(false)
const queryClient = useQueryClient()
const agent = useAgent()
const requestNotificationsPermission = useRequestNotificationsPermission()
const finishOnboarding = React.useCallback(async () => {
setSaving(true)
@@ -72,6 +74,7 @@ export function StepFinished() {
: 'default',
})
})(),
requestNotificationsPermission('AfterOnboarding'),
])
} catch (e: any) {
logger.info(`onboarding: bulk save failed`)
@@ -98,7 +101,15 @@ export function StepFinished() {
track('OnboardingV2:StepFinished:End')
track('OnboardingV2:Complete')
logEvent('onboarding:finished:nextPressed', {})
}, [state, dispatch, onboardDispatch, setSaving, track, agent, queryClient])
}, [
state,
queryClient,
agent,
dispatch,
onboardDispatch,
track,
requestNotificationsPermission,
])
React.useEffect(() => {
track('OnboardingV2:StepFinished:Start')
@@ -0,0 +1,60 @@
import React from 'react'
import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {atoms as a, useTheme} from '#/alf'
import {DialogOuterProps} from '#/components/Dialog'
import {Divider} from '#/components/Divider'
import * as Prompt from '#/components/Prompt'
import {Text} from '#/components/Typography'
export function DeactivateAccountDialog({
control,
}: {
control: DialogOuterProps['control']
}) {
const t = useTheme()
const {_} = useLingui()
return (
<Prompt.Outer control={control} testID="confirmModal">
<Prompt.TitleText>{_(msg`Deactivate account`)}</Prompt.TitleText>
<Prompt.DescriptionText>
<Trans>
Your profile, posts, feeds, and lists will no longer be visible to
other Bluesky users. You can reactivate your account at any time by
logging in.
</Trans>
</Prompt.DescriptionText>
<View style={[a.pb_xl]}>
<Divider />
<View style={[a.gap_sm, a.pt_lg, a.pb_xl]}>
<Text style={[t.atoms.text_contrast_medium, a.leading_snug]}>
<Trans>
There is no time limit for account deactivation, come back any
time.
</Trans>
</Text>
<Text style={[t.atoms.text_contrast_medium, a.leading_snug]}>
<Trans>
If you're trying to change your handle or email, do so before you
deactivate.
</Trans>
</Text>
</View>
<Divider />
</View>
<Prompt.Actions>
<Prompt.Action
cta={_(msg`Yes, deactivate`)}
onPress={() => {}}
color="negative"
/>
<Prompt.Cancel />
</Prompt.Actions>
</Prompt.Outer>
)
}
+219
View File
@@ -0,0 +1,219 @@
import React from 'react'
import {View} from 'react-native'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {msg, plural, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {logger} from '#/logger'
import {isWeb} from '#/platform/detection'
import {isSignupQueued, useAgent, useSessionApi} from '#/state/session'
import {useOnboardingDispatch} from '#/state/shell'
import {ScrollView} from '#/view/com/util/Views'
import {Logo} from '#/view/icons/Logo'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {Loader} from '#/components/Loader'
import {P, Text} from '#/components/Typography'
const COL_WIDTH = 400
export function SignupQueued() {
const {_} = useLingui()
const t = useTheme()
const insets = useSafeAreaInsets()
const {gtMobile} = useBreakpoints()
const onboardingDispatch = useOnboardingDispatch()
const {logout} = useSessionApi()
const agent = useAgent()
const [isProcessing, setProcessing] = React.useState(false)
const [estimatedTime, setEstimatedTime] = React.useState<string | undefined>(
undefined,
)
const [placeInQueue, setPlaceInQueue] = React.useState<number | undefined>(
undefined,
)
const checkStatus = React.useCallback(async () => {
setProcessing(true)
try {
const res = await agent.com.atproto.temp.checkSignupQueue()
if (res.data.activated) {
// ready to go, exchange the access token for a usable one and kick off onboarding
await agent.refreshSession()
if (!isSignupQueued(agent.session?.accessJwt)) {
onboardingDispatch({type: 'start'})
}
} else {
// not ready, update UI
setEstimatedTime(msToString(res.data.estimatedTimeMs))
if (typeof res.data.placeInQueue !== 'undefined') {
setPlaceInQueue(Math.max(res.data.placeInQueue, 1))
}
}
} catch (e: any) {
logger.error('Failed to check signup queue', {err: e.toString()})
} finally {
setProcessing(false)
}
}, [
setProcessing,
setEstimatedTime,
setPlaceInQueue,
onboardingDispatch,
agent,
])
React.useEffect(() => {
checkStatus()
const interval = setInterval(checkStatus, 60e3)
return () => clearInterval(interval)
}, [checkStatus])
const checkBtn = (
<Button
variant="solid"
color="primary"
size="large"
label={_(msg`Check my status`)}
onPress={checkStatus}
disabled={isProcessing}>
<ButtonText>
<Trans>Check my status</Trans>
</ButtonText>
{isProcessing && <ButtonIcon icon={Loader} />}
</Button>
)
return (
<View
aria-modal
role="dialog"
aria-role="dialog"
aria-label={_(msg`You're in line`)}
accessibilityLabel={_(msg`You're in line`)}
accessibilityHint=""
style={[a.absolute, a.inset_0, a.flex_1, t.atoms.bg]}>
<ScrollView
style={[a.h_full, a.w_full]}
contentContainerStyle={{borderWidth: 0}}>
<View
style={[a.flex_row, a.justify_center, gtMobile ? a.pt_4xl : a.px_xl]}>
<View style={[a.flex_1, {maxWidth: COL_WIDTH}]}>
<View
style={[a.w_full, a.justify_center, a.align_center, a.my_4xl]}>
<Logo width={120} />
</View>
<Text style={[a.text_4xl, a.font_bold, a.pb_sm]}>
<Trans>You're in line</Trans>
</Text>
<P style={[t.atoms.text_contrast_medium]}>
<Trans>
There's been a rush of new users to Bluesky! We'll activate your
account as soon as we can.
</Trans>
</P>
<View
style={[
a.rounded_sm,
a.px_2xl,
a.py_4xl,
a.mt_2xl,
t.atoms.bg_contrast_50,
]}>
{typeof placeInQueue === 'number' && (
<Text
style={[a.text_5xl, a.text_center, a.font_bold, a.mb_2xl]}>
{placeInQueue}
</Text>
)}
<P style={[a.text_center]}>
{typeof placeInQueue === 'number' ? (
<Trans>left to go.</Trans>
) : (
<Trans>You are in line.</Trans>
)}{' '}
{estimatedTime ? (
<Trans>
We estimate {estimatedTime} until your account is ready.
</Trans>
) : (
<Trans>
We will let you know when your account is ready.
</Trans>
)}
</P>
</View>
{isWeb && gtMobile && (
<View style={[a.w_full, a.flex_row, a.justify_between, a.pt_5xl]}>
<Button
variant="ghost"
size="large"
label={_(msg`Log out`)}
onPress={() => logout('SignupQueued')}>
<ButtonText style={[{color: t.palette.primary_500}]}>
<Trans>Log out</Trans>
</ButtonText>
</Button>
{checkBtn}
</View>
)}
</View>
<View style={{height: 200}} />
</View>
</ScrollView>
{(!isWeb || !gtMobile) && (
<View
style={[
a.align_center,
gtMobile ? a.px_5xl : a.px_xl,
{
paddingBottom: Math.max(insets.bottom, a.pb_5xl.paddingBottom),
},
]}>
<View style={[a.w_full, a.gap_sm, {maxWidth: COL_WIDTH}]}>
{checkBtn}
<Button
variant="ghost"
size="large"
label={_(msg`Log out`)}
onPress={() => logout('SignupQueued')}>
<ButtonText style={[{color: t.palette.primary_500}]}>
<Trans>Log out</Trans>
</ButtonText>
</Button>
</View>
</View>
)}
</View>
)
}
function msToString(ms: number | undefined): string | undefined {
if (ms && ms > 0) {
const estimatedTimeMins = Math.ceil(ms / 60e3)
if (estimatedTimeMins > 59) {
const estimatedTimeHrs = Math.round(estimatedTimeMins / 60)
if (estimatedTimeHrs > 6) {
// dont even bother
return undefined
}
// hours
return `${estimatedTimeHrs} ${plural(estimatedTimeHrs, {
one: 'hour',
other: 'hours',
})}`
}
// minutes
return `${estimatedTimeMins} ${plural(estimatedTimeMins, {
one: 'minute',
other: 'minutes',
})}`
}
return undefined
}
+4
View File
@@ -9,8 +9,10 @@ import {findAllProfilesInQueryData as findAllProfilesInListMembersQueryData} fro
import {findAllProfilesInQueryData as findAllProfilesInListConvosQueryData} from '../queries/messages/list-converations'
import {findAllProfilesInQueryData as findAllProfilesInMyBlockedAccountsQueryData} from '../queries/my-blocked-accounts'
import {findAllProfilesInQueryData as findAllProfilesInMyMutedAccountsQueryData} from '../queries/my-muted-accounts'
import {findAllProfilesInQueryData as findAllProfilesInFeedsQueryData} from '../queries/post-feed'
import {findAllProfilesInQueryData as findAllProfilesInPostLikedByQueryData} from '../queries/post-liked-by'
import {findAllProfilesInQueryData as findAllProfilesInPostRepostedByQueryData} from '../queries/post-reposted-by'
import {findAllProfilesInQueryData as findAllProfilesInPostThreadQueryData} from '../queries/post-thread'
import {findAllProfilesInQueryData as findAllProfilesInProfileQueryData} from '../queries/profile'
import {findAllProfilesInQueryData as findAllProfilesInProfileFollowersQueryData} from '../queries/profile-followers'
import {findAllProfilesInQueryData as findAllProfilesInProfileFollowsQueryData} from '../queries/profile-follows'
@@ -107,4 +109,6 @@ function* findProfilesInCache(
yield* findAllProfilesInSuggestedFollowsQueryData(queryClient, did)
yield* findAllProfilesInActorSearchQueryData(queryClient, did)
yield* findAllProfilesInListConvosQueryData(queryClient, did)
yield* findAllProfilesInFeedsQueryData(queryClient, did)
yield* findAllProfilesInPostThreadQueryData(queryClient, did)
}
+2 -1
View File
@@ -753,7 +753,7 @@ export class Convo {
sendMessage(message: ChatBskyConvoSendMessage.InputSchema['message']) {
// Ignore empty messages for now since they have no other purpose atm
if (!message.text.trim()) return
if (!message.text.trim() && !message.embed) return
logger.debug('Convo: send message', {}, logger.DebugContext.convo)
@@ -1018,6 +1018,7 @@ export class Convo {
key: m.id,
message: {
...m.message,
embed: undefined,
$type: 'chat.bsky.convo.defs#messageView',
id: nanoid(),
rev: '__fake__',
-8
View File
@@ -60,13 +60,6 @@ export interface DeleteAccountModal {
name: 'delete-account'
}
export interface RepostModal {
name: 'repost'
onRepost: () => void
onQuote: () => void
isReposted: boolean
}
export interface SelfLabelModal {
name: 'self-label'
labels: string[]
@@ -154,7 +147,6 @@ export type Modal =
| AltTextImageModal
| CropImageModal
| EditImageModal
| RepostModal
| SelfLabelModal
| ThreadgateModal
+5 -1
View File
@@ -17,7 +17,10 @@ const accountSchema = z.object({
emailAuthFactor: z.boolean().optional(),
refreshJwt: z.string().optional(), // optional because it can expire
accessJwt: z.string().optional(), // optional because it can expire
deactivated: z.boolean().optional(),
signupQueued: z.boolean().optional(),
status: z
.enum(['active', 'takendown', 'suspended', 'deactivated'])
.optional(),
pdsUrl: z.string().optional(),
})
export type PersistedAccount = z.infer<typeof accountSchema>
@@ -65,6 +68,7 @@ export const schema = z.object({
spotify: z.enum(externalEmbedOptions).optional(),
appleMusic: z.enum(externalEmbedOptions).optional(),
soundcloud: z.enum(externalEmbedOptions).optional(),
flickr: z.enum(externalEmbedOptions).optional(),
})
.optional(),
mutedThreads: z.array(z.string()), // should move to server
+13 -2
View File
@@ -1,7 +1,7 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
import {Schema, schema} from '#/state/persisted/schema'
import {logger} from '#/logger'
import {Schema, schema} from '#/state/persisted/schema'
const BSKY_STORAGE = 'BSKY_STORAGE'
@@ -13,8 +13,19 @@ export async function write(value: Schema) {
export async function read(): Promise<Schema | undefined> {
const rawData = await AsyncStorage.getItem(BSKY_STORAGE)
const objData = rawData ? JSON.parse(rawData) : undefined
if (schema.safeParse(objData).success) {
const parsed = schema.safeParse(objData)
if (parsed.success) {
return objData
} else {
const errors =
parsed.error?.errors?.map(e => ({
code: e.code,
// @ts-ignore exists on some types
expected: e?.expected,
path: e.path,
})) || []
logger.error(`persisted store: data failed validation on read`, {errors})
return undefined
}
}
@@ -26,10 +26,15 @@ import {useAgent, useSession} from '#/state/session'
export const RQKEY = ['convo-list']
type RQPageParam = string | undefined
export function useListConvosQuery() {
export function useListConvosQuery({
enabled,
}: {
enabled?: boolean
} = {}) {
const agent = useAgent()
return useInfiniteQuery({
enabled,
queryKey: RQKEY,
queryFn: async ({pageParam}) => {
const {data} = await agent.api.chat.bsky.convo.listConvos(
+39 -6
View File
@@ -17,7 +17,7 @@
*/
import {useEffect, useRef} from 'react'
import {AppBskyFeedDefs} from '@atproto/api'
import {AppBskyActorDefs, AppBskyFeedDefs, AtUri} from '@atproto/api'
import {
InfiniteData,
QueryClient,
@@ -30,7 +30,11 @@ import {useMutedThreads} from '#/state/muted-threads'
import {useAgent} from '#/state/session'
import {useModerationOpts} from '../../preferences/moderation-opts'
import {STALE} from '..'
import {embedViewRecordToPostView, getEmbeddedPost} from '../util'
import {
didOrHandleUriMatches,
embedViewRecordToPostView,
getEmbeddedPost,
} from '../util'
import {FeedPage} from './types'
import {useUnreadNotificationsApi} from './unread'
import {fetchPage} from './util'
@@ -142,6 +146,35 @@ export function* findAllPostsInQueryData(
queryClient: QueryClient,
uri: string,
): Generator<AppBskyFeedDefs.PostView, void> {
const atUri = new AtUri(uri)
const queryDatas = queryClient.getQueriesData<InfiniteData<FeedPage>>({
queryKey: [RQKEY_ROOT],
})
for (const [_queryKey, queryData] of queryDatas) {
if (!queryData?.pages) {
continue
}
for (const page of queryData?.pages) {
for (const item of page.items) {
if (item.subject && didOrHandleUriMatches(atUri, item.subject)) {
yield item.subject
}
const quotedPost = getEmbeddedPost(item.subject?.embed)
if (quotedPost && didOrHandleUriMatches(atUri, quotedPost)) {
yield embedViewRecordToPostView(quotedPost!)
}
}
}
}
}
export function* findAllProfilesInQueryData(
queryClient: QueryClient,
did: string,
): Generator<AppBskyActorDefs.ProfileView, void> {
const queryDatas = queryClient.getQueriesData<InfiniteData<FeedPage>>({
queryKey: [RQKEY_ROOT],
})
@@ -151,12 +184,12 @@ export function* findAllPostsInQueryData(
}
for (const page of queryData?.pages) {
for (const item of page.items) {
if (item.subject?.uri === uri) {
yield item.subject
if (item.subject?.author.did === did) {
yield item.subject.author
}
const quotedPost = getEmbeddedPost(item.subject?.embed)
if (quotedPost?.uri === uri) {
yield embedViewRecordToPostView(quotedPost)
if (quotedPost?.author.did === did) {
yield quotedPost.author
}
}
}
+1 -1
View File
@@ -145,7 +145,7 @@ async function fetchSubjects(
): Promise<Map<string, AppBskyFeedDefs.PostView>> {
const uris = new Set<string>()
for (const notif of groupedNotifs) {
if (notif.subjectUri && !notif.subjectUri.includes('feed.generator')) {
if (notif.subjectUri?.includes('app.bsky.feed.post')) {
uris.add(notif.subjectUri)
}
}
+66 -7
View File
@@ -35,7 +35,11 @@ import {KnownError} from '#/view/com/posts/FeedErrorMessage'
import {useFeedTuners} from '../preferences/feed-tuners'
import {useModerationOpts} from '../preferences/moderation-opts'
import {usePreferencesQuery} from './preferences'
import {embedViewRecordToPostView, getEmbeddedPost} from './util'
import {
didOrHandleUriMatches,
embedViewRecordToPostView,
getEmbeddedPost,
} from './util'
type ActorDid = string
type AuthorFilter =
@@ -448,6 +452,8 @@ export function* findAllPostsInQueryData(
queryClient: QueryClient,
uri: string,
): Generator<AppBskyFeedDefs.PostView, undefined> {
const atUri = new AtUri(uri)
const queryDatas = queryClient.getQueriesData<
InfiniteData<FeedPageUnselected>
>({
@@ -459,24 +465,77 @@ export function* findAllPostsInQueryData(
}
for (const page of queryData?.pages) {
for (const item of page.feed) {
if (item.post.uri === uri) {
if (didOrHandleUriMatches(atUri, item.post)) {
yield item.post
}
const quotedPost = getEmbeddedPost(item.post.embed)
if (quotedPost?.uri === uri) {
if (quotedPost && didOrHandleUriMatches(atUri, quotedPost)) {
yield embedViewRecordToPostView(quotedPost)
}
if (AppBskyFeedDefs.isPostView(item.reply?.parent)) {
if (didOrHandleUriMatches(atUri, item.reply.parent)) {
yield item.reply.parent
}
const parentQuotedPost = getEmbeddedPost(item.reply.parent.embed)
if (
parentQuotedPost &&
didOrHandleUriMatches(atUri, parentQuotedPost)
) {
yield embedViewRecordToPostView(parentQuotedPost)
}
}
if (AppBskyFeedDefs.isPostView(item.reply?.root)) {
if (didOrHandleUriMatches(atUri, item.reply.root)) {
yield item.reply.root
}
const rootQuotedPost = getEmbeddedPost(item.reply.root.embed)
if (rootQuotedPost && didOrHandleUriMatches(atUri, rootQuotedPost)) {
yield embedViewRecordToPostView(rootQuotedPost)
}
}
}
}
}
}
export function* findAllProfilesInQueryData(
queryClient: QueryClient,
did: string,
): Generator<AppBskyActorDefs.ProfileView, undefined> {
const queryDatas = queryClient.getQueriesData<
InfiniteData<FeedPageUnselected>
>({
queryKey: [RQKEY_ROOT],
})
for (const [_queryKey, queryData] of queryDatas) {
if (!queryData?.pages) {
continue
}
for (const page of queryData?.pages) {
for (const item of page.feed) {
if (item.post.author.did === did) {
yield item.post.author
}
const quotedPost = getEmbeddedPost(item.post.embed)
if (quotedPost?.author.did === did) {
yield quotedPost.author
}
if (
AppBskyFeedDefs.isPostView(item.reply?.parent) &&
item.reply?.parent?.uri === uri
item.reply?.parent?.author.did === did
) {
yield item.reply.parent
yield item.reply.parent.author
}
if (
AppBskyFeedDefs.isPostView(item.reply?.root) &&
item.reply?.root?.uri === uri
item.reply?.root?.author.did === did
) {
yield item.reply.root
yield item.reply.root.author
}
}
}
+60 -14
View File
@@ -1,8 +1,10 @@
import {
AppBskyActorDefs,
AppBskyEmbedRecord,
AppBskyFeedDefs,
AppBskyFeedGetPostThread,
AppBskyFeedPost,
AtUri,
ModerationDecision,
ModerationOpts,
} from '@atproto/api'
@@ -11,10 +13,23 @@ import {QueryClient, useQuery, useQueryClient} from '@tanstack/react-query'
import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped'
import {UsePreferencesQueryResponse} from '#/state/queries/preferences/types'
import {useAgent} from '#/state/session'
import {findAllPostsInQueryData as findAllPostsInSearchQueryData} from 'state/queries/search-posts'
import {findAllPostsInQueryData as findAllPostsInNotifsQueryData} from './notifications/feed'
import {findAllPostsInQueryData as findAllPostsInFeedQueryData} from './post-feed'
import {embedViewRecordToPostView, getEmbeddedPost} from './util'
import {
findAllPostsInQueryData as findAllPostsInSearchQueryData,
findAllProfilesInQueryData as findAllProfilesInSearchQueryData,
} from 'state/queries/search-posts'
import {
findAllPostsInQueryData as findAllPostsInNotifsQueryData,
findAllProfilesInQueryData as findAllProfilesInNotifsQueryData,
} from './notifications/feed'
import {
findAllPostsInQueryData as findAllPostsInFeedQueryData,
findAllProfilesInQueryData as findAllProfilesInFeedQueryData,
} from './post-feed'
import {
didOrHandleUriMatches,
embedViewRecordToPostView,
getEmbeddedPost,
} from './util'
const RQKEY_ROOT = 'post-thread'
export const RQKEY = (uri: string) => [RQKEY_ROOT, uri]
@@ -81,14 +96,10 @@ export function usePostThreadQuery(uri: string | undefined) {
},
enabled: !!uri,
placeholderData: () => {
if (!uri) {
return undefined
}
{
const post = findPostInQueryData(queryClient, uri)
if (post) {
return post
}
if (!uri) return
const post = findPostInQueryData(queryClient, uri)
if (post) {
return post
}
return undefined
},
@@ -261,6 +272,8 @@ export function* findAllPostsInQueryData(
queryClient: QueryClient,
uri: string,
): Generator<ThreadNode, void> {
const atUri = new AtUri(uri)
const queryDatas = queryClient.getQueriesData<ThreadNode>({
queryKey: [RQKEY_ROOT],
})
@@ -269,7 +282,7 @@ export function* findAllPostsInQueryData(
continue
}
for (const item of traverseThread(queryData)) {
if (item.uri === uri) {
if (item.type === 'post' && didOrHandleUriMatches(atUri, item.post)) {
const placeholder = threadNodeToPlaceholderThread(item)
if (placeholder) {
yield placeholder
@@ -277,7 +290,7 @@ export function* findAllPostsInQueryData(
}
const quotedPost =
item.type === 'post' ? getEmbeddedPost(item.post.embed) : undefined
if (quotedPost?.uri === uri) {
if (quotedPost && didOrHandleUriMatches(atUri, quotedPost)) {
yield embedViewRecordToPlaceholderThread(quotedPost)
}
}
@@ -293,6 +306,39 @@ export function* findAllPostsInQueryData(
}
}
export function* findAllProfilesInQueryData(
queryClient: QueryClient,
did: string,
): Generator<AppBskyActorDefs.ProfileView, void> {
const queryDatas = queryClient.getQueriesData<ThreadNode>({
queryKey: [RQKEY_ROOT],
})
for (const [_queryKey, queryData] of queryDatas) {
if (!queryData) {
continue
}
for (const item of traverseThread(queryData)) {
if (item.type === 'post' && item.post.author.did === did) {
yield item.post.author
}
const quotedPost =
item.type === 'post' ? getEmbeddedPost(item.post.embed) : undefined
if (quotedPost?.author.did === did) {
yield quotedPost?.author
}
}
}
for (let profile of findAllProfilesInFeedQueryData(queryClient, did)) {
yield profile
}
for (let profile of findAllProfilesInNotifsQueryData(queryClient, did)) {
yield profile
}
for (let profile of findAllProfilesInSearchQueryData(queryClient, did)) {
yield profile
}
}
function* traverseThread(node: ThreadNode): Generator<ThreadNode, void> {
if (node.type === 'post') {
if (node.parent) {
+11 -2
View File
@@ -18,7 +18,16 @@ export function usePostQuery(uri: string | undefined) {
return useQuery<AppBskyFeedDefs.PostView>({
queryKey: RQKEY(uri || ''),
async queryFn() {
const res = await agent.getPosts({uris: [uri!]})
const urip = new AtUri(uri!)
if (!urip.host.startsWith('did:')) {
const res = await agent.resolveHandle({
handle: urip.host,
})
urip.host = res.data.did
}
const res = await agent.getPosts({uris: [urip.toString()]})
if (res.success && res.data.posts[0]) {
return res.data.posts[0]
}
@@ -47,7 +56,7 @@ export function useGetPost() {
}
const res = await agent.getPosts({
uris: [urip.toString()!],
uris: [urip.toString()],
})
if (res.success && res.data.posts[0]) {
+43 -4
View File
@@ -1,4 +1,9 @@
import {AppBskyFeedDefs, AppBskyFeedSearchPosts} from '@atproto/api'
import {
AppBskyActorDefs,
AppBskyFeedDefs,
AppBskyFeedSearchPosts,
AtUri,
} from '@atproto/api'
import {
InfiniteData,
QueryClient,
@@ -7,7 +12,11 @@ import {
} from '@tanstack/react-query'
import {useAgent} from '#/state/session'
import {embedViewRecordToPostView, getEmbeddedPost} from './util'
import {
didOrHandleUriMatches,
embedViewRecordToPostView,
getEmbeddedPost,
} from './util'
const searchPostsQueryKeyRoot = 'search-posts'
const searchPostsQueryKey = ({query, sort}: {query: string; sort?: string}) => [
@@ -58,20 +67,50 @@ export function* findAllPostsInQueryData(
>({
queryKey: [searchPostsQueryKeyRoot],
})
const atUri = new AtUri(uri)
for (const [_queryKey, queryData] of queryDatas) {
if (!queryData?.pages) {
continue
}
for (const page of queryData?.pages) {
for (const post of page.posts) {
if (post.uri === uri) {
if (didOrHandleUriMatches(atUri, post)) {
yield post
}
const quotedPost = getEmbeddedPost(post.embed)
if (quotedPost?.uri === uri) {
if (quotedPost && didOrHandleUriMatches(atUri, quotedPost)) {
yield embedViewRecordToPostView(quotedPost)
}
}
}
}
}
export function* findAllProfilesInQueryData(
queryClient: QueryClient,
did: string,
): Generator<AppBskyActorDefs.ProfileView, undefined> {
const queryDatas = queryClient.getQueriesData<
InfiniteData<AppBskyFeedSearchPosts.OutputSchema>
>({
queryKey: [searchPostsQueryKeyRoot],
})
for (const [_queryKey, queryData] of queryDatas) {
if (!queryData?.pages) {
continue
}
for (const page of queryData?.pages) {
for (const post of page.posts) {
if (post.author.did === did) {
yield post.author
}
const quotedPost = getEmbeddedPost(post.embed)
if (quotedPost?.author.did === did) {
yield quotedPost.author
}
}
}
}
}
+19
View File
@@ -1,8 +1,10 @@
import {
AppBskyActorDefs,
AppBskyEmbedRecord,
AppBskyEmbedRecordWithMedia,
AppBskyFeedDefs,
AppBskyFeedPost,
AtUri,
} from '@atproto/api'
import {InfiniteData, QueryClient, QueryKey} from '@tanstack/react-query'
@@ -22,6 +24,23 @@ export function truncateAndInvalidate<T = any>(
queryClient.invalidateQueries({queryKey})
}
// Given an AtUri, this function will check if the AtUri matches a
// hit regardless of whether the AtUri uses a DID or handle as a host.
//
// AtUri should be the URI that is being searched for, while currentUri
// is the URI that is being checked. currentAuthor is the author
// of the currentUri that is being checked.
export function didOrHandleUriMatches(
atUri: AtUri,
record: {uri: string; author: AppBskyActorDefs.ProfileViewBasic},
) {
if (atUri.host.startsWith('did:')) {
return atUri.href === record.uri
}
return atUri.host === record.author.handle && record.uri.endsWith(atUri.rkey)
}
export function getEmbeddedPost(
v: unknown,
): AppBskyEmbedRecord.ViewRecord | undefined {
+58 -29
View File
@@ -50,7 +50,6 @@ describe('session', () => {
"accounts": [
{
"accessJwt": "alice-access-jwt-1",
"deactivated": false,
"did": "alice-did",
"email": undefined,
"emailAuthFactor": false,
@@ -59,6 +58,8 @@ describe('session', () => {
"pdsUrl": undefined,
"refreshJwt": "alice-refresh-jwt-1",
"service": "https://alice.com/",
"signupQueued": false,
"status": undefined,
},
],
"currentAgentState": {
@@ -87,7 +88,6 @@ describe('session', () => {
"accounts": [
{
"accessJwt": undefined,
"deactivated": false,
"did": "alice-did",
"email": undefined,
"emailAuthFactor": false,
@@ -96,6 +96,8 @@ describe('session', () => {
"pdsUrl": undefined,
"refreshJwt": undefined,
"service": "https://alice.com/",
"signupQueued": false,
"status": undefined,
},
],
"currentAgentState": {
@@ -136,7 +138,6 @@ describe('session', () => {
"accounts": [
{
"accessJwt": "alice-access-jwt-1",
"deactivated": false,
"did": "alice-did",
"email": undefined,
"emailAuthFactor": false,
@@ -145,6 +146,8 @@ describe('session', () => {
"pdsUrl": undefined,
"refreshJwt": "alice-refresh-jwt-1",
"service": "https://alice.com/",
"signupQueued": false,
"status": undefined,
},
],
"currentAgentState": {
@@ -183,7 +186,6 @@ describe('session', () => {
"accounts": [
{
"accessJwt": "bob-access-jwt-1",
"deactivated": false,
"did": "bob-did",
"email": undefined,
"emailAuthFactor": false,
@@ -192,10 +194,11 @@ describe('session', () => {
"pdsUrl": undefined,
"refreshJwt": "bob-refresh-jwt-1",
"service": "https://bob.com/",
"signupQueued": false,
"status": undefined,
},
{
"accessJwt": "alice-access-jwt-1",
"deactivated": false,
"did": "alice-did",
"email": undefined,
"emailAuthFactor": false,
@@ -204,6 +207,8 @@ describe('session', () => {
"pdsUrl": undefined,
"refreshJwt": "alice-refresh-jwt-1",
"service": "https://alice.com/",
"signupQueued": false,
"status": undefined,
},
],
"currentAgentState": {
@@ -242,7 +247,6 @@ describe('session', () => {
"accounts": [
{
"accessJwt": "alice-access-jwt-2",
"deactivated": false,
"did": "alice-did",
"email": undefined,
"emailAuthFactor": false,
@@ -251,10 +255,11 @@ describe('session', () => {
"pdsUrl": undefined,
"refreshJwt": "alice-refresh-jwt-2",
"service": "https://alice.com/",
"signupQueued": false,
"status": undefined,
},
{
"accessJwt": "bob-access-jwt-1",
"deactivated": false,
"did": "bob-did",
"email": undefined,
"emailAuthFactor": false,
@@ -263,6 +268,8 @@ describe('session', () => {
"pdsUrl": undefined,
"refreshJwt": "bob-refresh-jwt-1",
"service": "https://bob.com/",
"signupQueued": false,
"status": undefined,
},
],
"currentAgentState": {
@@ -299,7 +306,6 @@ describe('session', () => {
"accounts": [
{
"accessJwt": "jay-access-jwt-1",
"deactivated": false,
"did": "jay-did",
"email": undefined,
"emailAuthFactor": false,
@@ -308,10 +314,11 @@ describe('session', () => {
"pdsUrl": undefined,
"refreshJwt": "jay-refresh-jwt-1",
"service": "https://jay.com/",
"signupQueued": false,
"status": undefined,
},
{
"accessJwt": "alice-access-jwt-2",
"deactivated": false,
"did": "alice-did",
"email": undefined,
"emailAuthFactor": false,
@@ -320,10 +327,11 @@ describe('session', () => {
"pdsUrl": undefined,
"refreshJwt": "alice-refresh-jwt-2",
"service": "https://alice.com/",
"signupQueued": false,
"status": undefined,
},
{
"accessJwt": "bob-access-jwt-1",
"deactivated": false,
"did": "bob-did",
"email": undefined,
"emailAuthFactor": false,
@@ -332,6 +340,8 @@ describe('session', () => {
"pdsUrl": undefined,
"refreshJwt": "bob-refresh-jwt-1",
"service": "https://bob.com/",
"signupQueued": false,
"status": undefined,
},
],
"currentAgentState": {
@@ -364,7 +374,6 @@ describe('session', () => {
"accounts": [
{
"accessJwt": undefined,
"deactivated": false,
"did": "jay-did",
"email": undefined,
"emailAuthFactor": false,
@@ -373,10 +382,11 @@ describe('session', () => {
"pdsUrl": undefined,
"refreshJwt": undefined,
"service": "https://jay.com/",
"signupQueued": false,
"status": undefined,
},
{
"accessJwt": undefined,
"deactivated": false,
"did": "alice-did",
"email": undefined,
"emailAuthFactor": false,
@@ -385,10 +395,11 @@ describe('session', () => {
"pdsUrl": undefined,
"refreshJwt": undefined,
"service": "https://alice.com/",
"signupQueued": false,
"status": undefined,
},
{
"accessJwt": undefined,
"deactivated": false,
"did": "bob-did",
"email": undefined,
"emailAuthFactor": false,
@@ -397,6 +408,8 @@ describe('session', () => {
"pdsUrl": undefined,
"refreshJwt": undefined,
"service": "https://bob.com/",
"signupQueued": false,
"status": undefined,
},
],
"currentAgentState": {
@@ -446,7 +459,6 @@ describe('session', () => {
"accounts": [
{
"accessJwt": undefined,
"deactivated": false,
"did": "alice-did",
"email": undefined,
"emailAuthFactor": false,
@@ -455,6 +467,8 @@ describe('session', () => {
"pdsUrl": undefined,
"refreshJwt": undefined,
"service": "https://alice.com/",
"signupQueued": false,
"status": undefined,
},
],
"currentAgentState": {
@@ -490,7 +504,6 @@ describe('session', () => {
"accounts": [
{
"accessJwt": "alice-access-jwt-2",
"deactivated": false,
"did": "alice-did",
"email": undefined,
"emailAuthFactor": false,
@@ -499,6 +512,8 @@ describe('session', () => {
"pdsUrl": undefined,
"refreshJwt": "alice-refresh-jwt-2",
"service": "https://alice.com/",
"signupQueued": false,
"status": undefined,
},
],
"currentAgentState": {
@@ -601,7 +616,6 @@ describe('session', () => {
"accounts": [
{
"accessJwt": "bob-access-jwt-1",
"deactivated": false,
"did": "bob-did",
"email": undefined,
"emailAuthFactor": false,
@@ -610,6 +624,8 @@ describe('session', () => {
"pdsUrl": undefined,
"refreshJwt": "bob-refresh-jwt-1",
"service": "https://bob.com/",
"signupQueued": false,
"status": undefined,
},
],
"currentAgentState": {
@@ -681,7 +697,6 @@ describe('session', () => {
"accounts": [
{
"accessJwt": "alice-access-jwt-2",
"deactivated": false,
"did": "alice-did",
"email": "alice@foo.bar",
"emailAuthFactor": false,
@@ -690,6 +705,8 @@ describe('session', () => {
"pdsUrl": undefined,
"refreshJwt": "alice-refresh-jwt-2",
"service": "https://alice.com/",
"signupQueued": false,
"status": undefined,
},
],
"currentAgentState": {
@@ -731,7 +748,6 @@ describe('session', () => {
"accounts": [
{
"accessJwt": "alice-access-jwt-3",
"deactivated": false,
"did": "alice-did",
"email": "alice@foo.baz",
"emailAuthFactor": true,
@@ -740,6 +756,8 @@ describe('session', () => {
"pdsUrl": undefined,
"refreshJwt": "alice-refresh-jwt-3",
"service": "https://alice.com/",
"signupQueued": false,
"status": undefined,
},
],
"currentAgentState": {
@@ -781,7 +799,6 @@ describe('session', () => {
"accounts": [
{
"accessJwt": "alice-access-jwt-4",
"deactivated": false,
"did": "alice-did",
"email": "alice@foo.baz",
"emailAuthFactor": false,
@@ -790,6 +807,8 @@ describe('session', () => {
"pdsUrl": undefined,
"refreshJwt": "alice-refresh-jwt-4",
"service": "https://alice.com/",
"signupQueued": false,
"status": undefined,
},
],
"currentAgentState": {
@@ -937,7 +956,6 @@ describe('session', () => {
"accounts": [
{
"accessJwt": "bob-access-jwt-1",
"deactivated": false,
"did": "bob-did",
"email": undefined,
"emailAuthFactor": false,
@@ -946,10 +964,11 @@ describe('session', () => {
"pdsUrl": undefined,
"refreshJwt": "bob-refresh-jwt-1",
"service": "https://bob.com/",
"signupQueued": false,
"status": undefined,
},
{
"accessJwt": "alice-access-jwt-2",
"deactivated": false,
"did": "alice-did",
"email": "alice@foo.bar",
"emailAuthFactor": false,
@@ -958,6 +977,8 @@ describe('session', () => {
"pdsUrl": undefined,
"refreshJwt": "alice-refresh-jwt-2",
"service": "https://alice.com/",
"signupQueued": false,
"status": undefined,
},
],
"currentAgentState": {
@@ -997,7 +1018,6 @@ describe('session', () => {
"accounts": [
{
"accessJwt": "bob-access-jwt-2",
"deactivated": false,
"did": "bob-did",
"email": undefined,
"emailAuthFactor": false,
@@ -1006,10 +1026,11 @@ describe('session', () => {
"pdsUrl": undefined,
"refreshJwt": "bob-refresh-jwt-2",
"service": "https://bob.com/",
"signupQueued": false,
"status": undefined,
},
{
"accessJwt": "alice-access-jwt-2",
"deactivated": false,
"did": "alice-did",
"email": "alice@foo.bar",
"emailAuthFactor": false,
@@ -1018,6 +1039,8 @@ describe('session', () => {
"pdsUrl": undefined,
"refreshJwt": "alice-refresh-jwt-2",
"service": "https://alice.com/",
"signupQueued": false,
"status": undefined,
},
],
"currentAgentState": {
@@ -1156,7 +1179,6 @@ describe('session', () => {
"accounts": [
{
"accessJwt": "alice-access-jwt-1",
"deactivated": false,
"did": "alice-did",
"email": undefined,
"emailAuthFactor": false,
@@ -1165,6 +1187,8 @@ describe('session', () => {
"pdsUrl": undefined,
"refreshJwt": "alice-refresh-jwt-1",
"service": "https://alice.com/",
"signupQueued": false,
"status": undefined,
},
],
"currentAgentState": {
@@ -1218,7 +1242,6 @@ describe('session', () => {
"accounts": [
{
"accessJwt": undefined,
"deactivated": false,
"did": "alice-did",
"email": undefined,
"emailAuthFactor": false,
@@ -1227,6 +1250,8 @@ describe('session', () => {
"pdsUrl": undefined,
"refreshJwt": undefined,
"service": "https://alice.com/",
"signupQueued": false,
"status": undefined,
},
],
"currentAgentState": {
@@ -1280,7 +1305,6 @@ describe('session', () => {
"accounts": [
{
"accessJwt": undefined,
"deactivated": false,
"did": "alice-did",
"email": undefined,
"emailAuthFactor": false,
@@ -1289,6 +1313,8 @@ describe('session', () => {
"pdsUrl": undefined,
"refreshJwt": undefined,
"service": "https://alice.com/",
"signupQueued": false,
"status": undefined,
},
],
"currentAgentState": {
@@ -1371,7 +1397,6 @@ describe('session', () => {
"accounts": [
{
"accessJwt": "jay-access-jwt-1",
"deactivated": false,
"did": "jay-did",
"email": undefined,
"emailAuthFactor": false,
@@ -1380,10 +1405,11 @@ describe('session', () => {
"pdsUrl": undefined,
"refreshJwt": "jay-refresh-jwt-1",
"service": "https://jay.com/",
"signupQueued": false,
"status": undefined,
},
{
"accessJwt": "bob-access-jwt-2",
"deactivated": false,
"did": "bob-did",
"email": undefined,
"emailAuthFactor": false,
@@ -1392,6 +1418,8 @@ describe('session', () => {
"pdsUrl": undefined,
"refreshJwt": "bob-refresh-jwt-2",
"service": "https://alice.com/",
"signupQueued": false,
"status": undefined,
},
],
"currentAgentState": {
@@ -1429,7 +1457,6 @@ describe('session', () => {
"accounts": [
{
"accessJwt": "clarence-access-jwt-2",
"deactivated": false,
"did": "clarence-did",
"email": undefined,
"emailAuthFactor": false,
@@ -1438,6 +1465,8 @@ describe('session', () => {
"pdsUrl": undefined,
"refreshJwt": "clarence-refresh-jwt-2",
"service": "https://clarence.com/",
"signupQueued": false,
"status": undefined,
},
],
"currentAgentState": {
+6 -4
View File
@@ -16,7 +16,7 @@ import {
configureModerationForGuest,
} from './moderation'
import {SessionAccount} from './types'
import {isSessionDeactivated, isSessionExpired} from './util'
import {isSessionExpired, isSignupQueued} from './util'
export function createPublicAgent() {
configureModerationForGuest() // Side effect but only relevant for tests
@@ -51,7 +51,7 @@ export async function createAgentAndResume(
await networkRetry(1, () => agent.resumeSession(prevSession))
} else {
agent.session = prevSession
if (!storedAccount.deactivated) {
if (!storedAccount.signupQueued) {
// Intentionally not awaited to unblock the UI:
networkRetry(3, () => agent.resumeSession(prevSession)).catch(
(e: any) => {
@@ -135,7 +135,7 @@ export async function createAgentAndCreateAccount(
const account = agentToSessionAccountOrThrow(agent)
const gates = tryFetchGates(account.did, 'prefer-fresh-gates')
const moderation = configureModerationForAccount(agent, account)
if (!account.deactivated) {
if (!account.signupQueued) {
/*dont await*/ agent.upsertProfile(_existing => {
return {
displayName: '',
@@ -234,7 +234,9 @@ export function agentToSessionAccount(
emailAuthFactor: agent.session.emailAuthFactor || false,
refreshJwt: agent.session.refreshJwt,
accessJwt: agent.session.accessJwt,
deactivated: isSessionDeactivated(agent.session.accessJwt),
signupQueued: isSignupQueued(agent.session.accessJwt),
// @ts-expect-error TODO remove when backend is ready
status: agent.session.status,
pdsUrl: agent.pdsUrl?.toString(),
}
}
+1 -1
View File
@@ -17,7 +17,7 @@ import {
} from './agent'
import {getInitialState, reducer} from './reducer'
export {isSessionDeactivated} from './util'
export {isSignupQueued} from './util'
export type {SessionAccount} from '#/state/session/types'
import {SessionApiContext, SessionStateContext} from '#/state/session/types'
+3 -2
View File
@@ -10,11 +10,12 @@ export function readLastActiveAccount() {
return accounts.find(a => a.did === currentAccount?.did)
}
export function isSessionDeactivated(accessJwt: string | undefined) {
export function isSignupQueued(accessJwt: string | undefined) {
if (accessJwt) {
const sessData = jwtDecode(accessJwt)
return (
hasProp(sessData, 'scope') && sessData.scope === 'com.atproto.deactivated'
hasProp(sessData, 'scope') &&
sessData.scope === 'com.atproto.signupQueued'
)
}
return false
+276 -149
View File
@@ -1,9 +1,15 @@
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'
import React, {
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react'
import {
ActivityIndicator,
BackHandler,
Keyboard,
ScrollView,
LayoutChangeEvent,
StyleSheet,
TouchableOpacity,
View,
@@ -12,6 +18,12 @@ import {
KeyboardAvoidingView,
KeyboardStickyView,
} from 'react-native-keyboard-controller'
import Animated, {
interpolateColor,
useAnimatedStyle,
useSharedValue,
withTiming,
} from 'react-native-reanimated'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {LinearGradient} from 'expo-linear-gradient'
import {RichText} from '@atproto/api'
@@ -24,6 +36,7 @@ import {
createGIFDescription,
parseAltFromGIFDescription,
} from '#/lib/gif-alt-text'
import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED'
import {LikelyType} from '#/lib/link-meta/link-meta'
import {logEvent} from '#/lib/statsig/statsig'
import {logger} from '#/logger'
@@ -42,7 +55,7 @@ import {useAgent, useSession} from '#/state/session'
import {useComposerControls} from '#/state/shell/composer'
import {useAnalytics} from 'lib/analytics/analytics'
import * as apilib from 'lib/api/index'
import {MAX_GRAPHEME_LENGTH} from 'lib/constants'
import {HITSLOP_10, MAX_GRAPHEME_LENGTH} from 'lib/constants'
import {useIsKeyboardVisible} from 'lib/hooks/useIsKeyboardVisible'
import {usePalette} from 'lib/hooks/usePalette'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
@@ -55,7 +68,7 @@ import {useDialogStateControlContext} from 'state/dialogs'
import {GalleryModel} from 'state/models/media/gallery'
import {ComposerOpts} from 'state/shell/composer'
import {ComposerReplyTo} from 'view/com/composer/ComposerReplyTo'
import {atoms as a} from '#/alf'
import {atoms as a, useTheme} from '#/alf'
import {Button} from '#/components/Button'
import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons/Emoji'
import * as Prompt from '#/components/Prompt'
@@ -78,6 +91,11 @@ import {SuggestedLanguage} from './select-language/SuggestedLanguage'
import {TextInput, TextInputRef} from './text-input/TextInput'
import {ThreadgateBtn} from './threadgate/ThreadgateBtn'
import {useExternalLinkFetch} from './useExternalLinkFetch'
import hairlineWidth = StyleSheet.hairlineWidth
type CancelRef = {
onPressCancel: () => void
}
type Props = ComposerOpts
export const ComposePost = observer(function ComposePost({
@@ -88,7 +106,10 @@ export const ComposePost = observer(function ComposePost({
openPicker,
text: initText,
imageUris: initImageUris,
}: Props) {
cancelRef,
}: Props & {
cancelRef?: React.RefObject<CancelRef>
}) {
const {currentAccount} = useSession()
const agent = useAgent()
const {data: currentProfile} = useProfileQuery({did: currentAccount!.did})
@@ -96,7 +117,7 @@ export const ComposePost = observer(function ComposePost({
const {closeComposer} = useComposerControls()
const {track} = useAnalytics()
const pal = usePalette('default')
const {isDesktop, isMobile} = useWebMediaQueries()
const {isMobile} = useWebMediaQueries()
const {_} = useLingui()
const requireAltTextEnabled = useRequireAltTextEnabled()
const langPrefs = useLanguagePrefs()
@@ -104,6 +125,7 @@ export const ComposePost = observer(function ComposePost({
const textInput = useRef<TextInputRef>(null)
const discardPromptControl = Prompt.usePromptControl()
const {closeAllDialogs} = useDialogStateControlContext()
const t = useTheme()
const [isKeyboardVisible] = useIsKeyboardVisible({iosUseWillEvents: true})
const [isProcessing, setIsProcessing] = useState(false)
@@ -145,13 +167,12 @@ export const ComposePost = observer(function ComposePost({
() => ({
paddingBottom:
isAndroid || (isIOS && !isKeyboardVisible) ? insets.bottom : 0,
paddingTop: isAndroid ? insets.top : isMobile ? 15 : 0,
}),
[insets, isKeyboardVisible, isMobile],
[insets, isKeyboardVisible],
)
const onPressCancel = useCallback(() => {
if (graphemeLength > 0 || !gallery.isEmpty) {
if (graphemeLength > 0 || !gallery.isEmpty || extGif) {
closeAllDialogs()
if (Keyboard) {
Keyboard.dismiss()
@@ -161,29 +182,15 @@ export const ComposePost = observer(function ComposePost({
onClose()
}
}, [
extGif,
graphemeLength,
gallery.isEmpty,
closeAllDialogs,
discardPromptControl,
onClose,
])
// android back button
useEffect(() => {
if (!isAndroid) {
return
}
const backHandler = BackHandler.addEventListener(
'hardwareBackPress',
() => {
onPressCancel()
return true
},
)
return () => {
backHandler.remove()
}
}, [onPressCancel])
useImperativeHandle(cancelRef, () => ({onPressCancel}))
// listen to escape key on desktop web
const onEscape = useCallback(
@@ -374,113 +381,130 @@ export const ComposePost = observer(function ComposePost({
[setExtLink],
)
const {
scrollHandler,
onScrollViewContentSizeChange,
onScrollViewLayout,
topBarAnimatedStyle,
bottomBarAnimatedStyle,
} = useAnimatedBorders()
return (
<>
<KeyboardAvoidingView
testID="composePostView"
behavior="padding"
style={s.flex1}
keyboardVerticalOffset={replyTo ? 60 : isAndroid ? 120 : 100}>
<View style={[s.flex1, viewStyles]} aria-modal accessibilityViewIsModal>
<View style={[styles.topbar, isDesktop && styles.topbarDesktop]}>
<TouchableOpacity
testID="composerDiscardButton"
onPress={onPressCancel}
onAccessibilityEscape={onPressCancel}
accessibilityRole="button"
accessibilityLabel={_(msg`Cancel`)}
accessibilityHint={_(
msg`Closes post composer and discards post draft`,
)}>
<Text style={[pal.link, s.f18]}>
<Trans>Cancel</Trans>
</Text>
</TouchableOpacity>
<View style={s.flex1} />
{isProcessing ? (
<>
<Text style={pal.textLight}>{processingState}</Text>
<View style={styles.postBtn}>
<ActivityIndicator />
</View>
</>
) : (
<>
<LabelsBtn
labels={labels}
onChange={setLabels}
hasMedia={hasMedia}
/>
{canPost ? (
<TouchableOpacity
testID="composerPublishBtn"
onPress={onPressPublish}
accessibilityRole="button"
accessibilityLabel={
replyTo ? _(msg`Publish reply`) : _(msg`Publish post`)
}
accessibilityHint="">
<LinearGradient
colors={[
gradients.blueLight.start,
gradients.blueLight.end,
]}
start={{x: 0, y: 0}}
end={{x: 1, y: 1}}
style={styles.postBtn}>
<Text style={[s.white, s.f16, s.bold]}>
{replyTo ? (
<Trans context="action">Reply</Trans>
) : (
<Trans context="action">Post</Trans>
)}
</Text>
</LinearGradient>
</TouchableOpacity>
) : (
<View style={[styles.postBtn, pal.btn]}>
<Text style={[pal.textLight, s.f16, s.bold]}>
<Trans context="action">Post</Trans>
</Text>
</View>
style={a.flex_1}
keyboardVerticalOffset={replyTo ? 115 : isAndroid ? 180 : 162}>
<View
style={[a.flex_1, viewStyles]}
aria-modal
accessibilityViewIsModal>
<Animated.View style={topBarAnimatedStyle}>
<View style={styles.topbarInner}>
<TouchableOpacity
testID="composerDiscardButton"
onPress={onPressCancel}
onAccessibilityEscape={onPressCancel}
accessibilityRole="button"
accessibilityLabel={_(msg`Cancel`)}
accessibilityHint={_(
msg`Closes post composer and discards post draft`,
)}
</>
hitSlop={HITSLOP_10}>
<Text style={[pal.link, s.f18]}>
<Trans>Cancel</Trans>
</Text>
</TouchableOpacity>
<View style={a.flex_1} />
{isProcessing ? (
<>
<Text style={pal.textLight}>{processingState}</Text>
<View style={styles.postBtn}>
<ActivityIndicator />
</View>
</>
) : (
<>
<LabelsBtn
labels={labels}
onChange={setLabels}
hasMedia={hasMedia}
/>
{canPost ? (
<TouchableOpacity
testID="composerPublishBtn"
onPress={onPressPublish}
accessibilityRole="button"
accessibilityLabel={
replyTo ? _(msg`Publish reply`) : _(msg`Publish post`)
}
accessibilityHint="">
<LinearGradient
colors={[
gradients.blueLight.start,
gradients.blueLight.end,
]}
start={{x: 0, y: 0}}
end={{x: 1, y: 1}}
style={styles.postBtn}>
<Text style={[s.white, s.f16, s.bold]}>
{replyTo ? (
<Trans context="action">Reply</Trans>
) : (
<Trans context="action">Post</Trans>
)}
</Text>
</LinearGradient>
</TouchableOpacity>
) : (
<View style={[styles.postBtn, pal.btn]}>
<Text style={[pal.textLight, s.f16, s.bold]}>
<Trans context="action">Post</Trans>
</Text>
</View>
)}
</>
)}
</View>
{isAltTextRequiredAndMissing && (
<View style={[styles.reminderLine, pal.viewLight]}>
<View style={styles.errorIcon}>
<FontAwesomeIcon
icon="exclamation"
style={{color: colors.red4}}
size={10}
/>
</View>
<Text style={[pal.text, a.flex_1]}>
<Trans>One or more images is missing alt text.</Trans>
</Text>
</View>
)}
</View>
{isAltTextRequiredAndMissing && (
<View style={[styles.reminderLine, pal.viewLight]}>
<View style={styles.errorIcon}>
<FontAwesomeIcon
icon="exclamation"
style={{color: colors.red4}}
size={10}
/>
{error !== '' && (
<View style={styles.errorLine}>
<View style={styles.errorIcon}>
<FontAwesomeIcon
icon="exclamation"
style={{color: colors.red4}}
size={10}
/>
</View>
<Text style={[s.red4, a.flex_1]}>{error}</Text>
</View>
<Text style={[pal.text, s.flex1]}>
<Trans>One or more images is missing alt text.</Trans>
</Text>
</View>
)}
{error !== '' && (
<View style={styles.errorLine}>
<View style={styles.errorIcon}>
<FontAwesomeIcon
icon="exclamation"
style={{color: colors.red4}}
size={10}
/>
</View>
<Text style={[s.red4, s.flex1]}>{error}</Text>
</View>
)}
<ScrollView
)}
</Animated.View>
<Animated.ScrollView
onScroll={scrollHandler}
style={styles.scrollView}
keyboardShouldPersistTaps="always">
keyboardShouldPersistTaps="always"
onContentSizeChange={onScrollViewContentSizeChange}
onLayout={onScrollViewLayout}>
{replyTo ? <ComposerReplyTo replyTo={replyTo} /> : undefined}
<View
style={[
pal.border,
styles.textInputLayout,
isNative && styles.textInputLayoutMobile,
]}>
@@ -535,16 +559,25 @@ export const ComposePost = observer(function ComposePost({
)}
</View>
) : undefined}
</ScrollView>
</Animated.ScrollView>
<SuggestedLanguage text={richtext.text} />
</View>
</KeyboardAvoidingView>
<KeyboardStickyView
offset={{closed: isIOS ? -insets.bottom : 0, opened: 0}}>
{replyTo ? null : (
<ThreadgateBtn threadgate={threadgate} onChange={setThreadgate} />
<ThreadgateBtn
threadgate={threadgate}
onChange={setThreadgate}
style={bottomBarAnimatedStyle}
/>
)}
<View style={[pal.border, styles.bottomBar]}>
<View
style={[
t.atoms.bg,
t.atoms.border_contrast_medium,
styles.bottomBar,
]}>
<View style={[a.flex_row, a.align_center, a.gap_xs]}>
<SelectPhotoBtn gallery={gallery} disabled={!canSelectImages} />
<OpenCameraBtn gallery={gallery} disabled={!canSelectImages} />
@@ -566,7 +599,7 @@ export const ComposePost = observer(function ComposePost({
</Button>
) : null}
</View>
<View style={s.flex1} />
<View style={a.flex_1} />
<SelectLangBtn />
<CharProgress count={graphemeLength} />
</View>
@@ -583,25 +616,119 @@ export const ComposePost = observer(function ComposePost({
)
})
export function useComposerCancelRef() {
return useRef<CancelRef>(null)
}
function useAnimatedBorders() {
const t = useTheme()
const hasScrolledTop = useSharedValue(0)
const hasScrolledBottom = useSharedValue(0)
const contentOffset = useSharedValue(0)
const scrollViewHeight = useSharedValue(Infinity)
const contentHeight = useSharedValue(0)
/**
* Make sure to run this on the UI thread!
*/
const showHideBottomBorder = useCallback(
({
newContentHeight,
newContentOffset,
newScrollViewHeight,
}: {
newContentHeight?: number
newContentOffset?: number
newScrollViewHeight?: number
}) => {
'worklet'
if (typeof newContentHeight === 'number')
contentHeight.value = Math.floor(newContentHeight)
if (typeof newContentOffset === 'number')
contentOffset.value = Math.floor(newContentOffset)
if (typeof newScrollViewHeight === 'number')
scrollViewHeight.value = Math.floor(newScrollViewHeight)
hasScrolledBottom.value = withTiming(
contentHeight.value - contentOffset.value - 5 > scrollViewHeight.value
? 1
: 0,
)
},
[contentHeight, contentOffset, scrollViewHeight, hasScrolledBottom],
)
const scrollHandler = useAnimatedScrollHandler({
onScroll: event => {
'worklet'
hasScrolledTop.value = withTiming(event.contentOffset.y > 0 ? 1 : 0)
showHideBottomBorder({
newContentOffset: event.contentOffset.y,
newContentHeight: event.contentSize.height,
newScrollViewHeight: event.layoutMeasurement.height,
})
},
})
const onScrollViewContentSizeChange = useCallback(
(_width: number, height: number) => {
'worklet'
showHideBottomBorder({
newContentHeight: height,
})
},
[showHideBottomBorder],
)
const onScrollViewLayout = useCallback(
(evt: LayoutChangeEvent) => {
'worklet'
showHideBottomBorder({
newScrollViewHeight: evt.nativeEvent.layout.height,
})
},
[showHideBottomBorder],
)
const topBarAnimatedStyle = useAnimatedStyle(() => {
return {
borderBottomWidth: hairlineWidth,
borderColor: interpolateColor(
hasScrolledTop.value,
[0, 1],
['transparent', t.atoms.border_contrast_medium.borderColor],
),
}
})
const bottomBarAnimatedStyle = useAnimatedStyle(() => {
return {
borderTopWidth: hairlineWidth,
borderColor: interpolateColor(
hasScrolledBottom.value,
[0, 1],
['transparent', t.atoms.border_contrast_medium.borderColor],
),
}
})
return {
scrollHandler,
onScrollViewContentSizeChange,
onScrollViewLayout,
topBarAnimatedStyle,
bottomBarAnimatedStyle,
}
}
const styles = StyleSheet.create({
outer: {
flexDirection: 'column',
flex: 1,
height: '100%',
},
topbar: {
topbarInner: {
flexDirection: 'row',
alignItems: 'center',
paddingTop: 6,
paddingBottom: 4,
paddingHorizontal: 20,
height: 55,
paddingHorizontal: 16,
height: 54,
gap: 4,
},
topbarDesktop: {
paddingTop: 10,
paddingBottom: 10,
},
postBtn: {
borderRadius: 20,
paddingHorizontal: 20,
@@ -612,22 +739,22 @@ const styles = StyleSheet.create({
flexDirection: 'row',
backgroundColor: colors.red1,
borderRadius: 6,
marginHorizontal: 15,
marginHorizontal: 16,
paddingHorizontal: 8,
paddingVertical: 6,
marginVertical: 6,
marginBottom: 8,
},
reminderLine: {
flexDirection: 'row',
alignItems: 'center',
borderRadius: 6,
marginHorizontal: 15,
marginHorizontal: 16,
paddingHorizontal: 8,
paddingVertical: 6,
marginBottom: 6,
marginBottom: 8,
},
errorIcon: {
borderWidth: 1,
borderWidth: hairlineWidth,
borderColor: colors.red4,
color: colors.red4,
borderRadius: 30,
@@ -639,12 +766,11 @@ const styles = StyleSheet.create({
},
scrollView: {
flex: 1,
paddingHorizontal: 15,
paddingHorizontal: 16,
},
textInputLayout: {
flexDirection: 'row',
borderTopWidth: 1,
paddingTop: 16,
paddingTop: 4,
},
textInputLayoutMobile: {
flex: 1,
@@ -660,9 +786,10 @@ const styles = StyleSheet.create({
bottomBar: {
flexDirection: 'row',
paddingVertical: 4,
paddingLeft: 15,
paddingRight: 20,
// should be 8 but due to visual alignment we have to fudge it
paddingLeft: 7,
paddingRight: 16,
alignItems: 'center',
borderTopWidth: 1,
borderTopWidth: hairlineWidth,
},
})
+8 -7
View File
@@ -10,16 +10,16 @@ import {
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {usePalette} from 'lib/hooks/usePalette'
import {sanitizeDisplayName} from 'lib/strings/display-names'
import {sanitizeHandle} from 'lib/strings/handles'
import {ComposerOptsPostRef} from 'state/shell/composer'
import {QuoteEmbed} from 'view/com/util/post-embeds/QuoteEmbed'
import {Text} from 'view/com/util/text/Text'
import {PreviewableUserAvatar} from 'view/com/util/UserAvatar'
import {useTheme} from '#/alf'
export function ComposerReplyTo({replyTo}: {replyTo: ComposerOptsPostRef}) {
const pal = usePalette('default')
const t = useTheme()
const {_} = useLingui()
const {embed} = replyTo
@@ -75,7 +75,7 @@ export function ComposerReplyTo({replyTo}: {replyTo: ComposerOptsPostRef}) {
return (
<Pressable
style={[pal.border, styles.replyToLayout]}
style={[t.atoms.border_contrast_medium, styles.replyToLayout]}
onPress={onPress}
accessibilityRole="button"
accessibilityLabel={_(
@@ -91,7 +91,7 @@ export function ComposerReplyTo({replyTo}: {replyTo: ComposerOptsPostRef}) {
type={replyTo.author.associated?.labeler ? 'labeler' : 'user'}
/>
<View style={styles.replyToPost}>
<Text type="xl-medium" style={[pal.text]}>
<Text type="xl-medium" style={t.atoms.text}>
{sanitizeDisplayName(
replyTo.author.displayName || sanitizeHandle(replyTo.author.handle),
)}
@@ -100,7 +100,7 @@ export function ComposerReplyTo({replyTo}: {replyTo: ComposerOptsPostRef}) {
<View style={styles.replyToText}>
<Text
type="post-text"
style={pal.text}
style={t.atoms.text}
numberOfLines={!showFull ? 6 : undefined}>
{replyTo.text}
</Text>
@@ -218,9 +218,10 @@ const styles = StyleSheet.create({
replyToLayout: {
flexDirection: 'row',
alignItems: 'flex-start',
borderTopWidth: 1,
paddingTop: 16,
borderBottomWidth: StyleSheet.hairlineWidth,
paddingTop: 4,
paddingBottom: 16,
marginBottom: 12,
},
replyToPost: {
flex: 1,
@@ -0,0 +1,34 @@
import React from 'react'
import {View} from 'react-native'
import {KeyboardStickyView} from 'react-native-keyboard-controller'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {isWeb} from '#/platform/detection'
import {atoms as a, useTheme} from '#/alf'
export function KeyboardAccessory({children}: {children: React.ReactNode}) {
const t = useTheme()
const {bottom} = useSafeAreaInsets()
const style = [
a.flex_row,
a.py_xs,
a.pl_sm,
a.pr_xl,
a.align_center,
a.border_t,
t.atoms.border_contrast_medium,
t.atoms.bg,
]
// todo: when iPad support is added, it should also not use the KeyboardStickyView
if (isWeb) {
return <View style={style}>{children}</View>
}
return (
<KeyboardStickyView offset={{closed: -bottom}} style={style}>
{children}
</KeyboardStickyView>
)
}
+9 -7
View File
@@ -1,13 +1,15 @@
import React from 'react'
import {StyleSheet, TouchableOpacity} from 'react-native'
import {UserAvatar} from '../util/UserAvatar'
import {Text} from '../util/text/Text'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useProfileQuery} from '#/state/queries/profile'
import {useSession} from '#/state/session'
import {usePalette} from 'lib/hooks/usePalette'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useSession} from '#/state/session'
import {useProfileQuery} from '#/state/queries/profile'
import {Text} from '../util/text/Text'
import {UserAvatar} from '../util/UserAvatar'
import hairlineWidth = StyleSheet.hairlineWidth
export function ComposePrompt({onPressCompose}: {onPressCompose: () => void}) {
const {currentAccount} = useSession()
@@ -47,7 +49,7 @@ const styles = StyleSheet.create({
paddingBottom: 10,
flexDirection: 'row',
alignItems: 'center',
borderTopWidth: 1,
borderTopWidth: hairlineWidth,
},
labelMobile: {
paddingLeft: 12,
+7 -5
View File
@@ -1,14 +1,15 @@
import React from 'react'
import {Keyboard, StyleSheet} from 'react-native'
import {Button} from 'view/com/util/forms/Button'
import {usePalette} from 'lib/hooks/usePalette'
import {ShieldExclamation} from 'lib/icons'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {FontAwesomeIconStyle} from '@fortawesome/react-native-fontawesome'
import {isNative} from 'platform/detection'
import {useLingui} from '@lingui/react'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useModalControls} from '#/state/modals'
import {usePalette} from 'lib/hooks/usePalette'
import {ShieldExclamation} from 'lib/icons'
import {isNative} from 'platform/detection'
import {Button} from 'view/com/util/forms/Button'
export function LabelsBtn({
labels,
@@ -54,6 +55,7 @@ const styles = StyleSheet.create({
button: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 2,
paddingHorizontal: 6,
},
dimmed: {
@@ -1,4 +1,4 @@
import React, {useCallback} from 'react'
import React, {useCallback, useRef} from 'react'
import {Keyboard} from 'react-native'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
@@ -7,7 +7,6 @@ import {logEvent} from '#/lib/statsig/statsig'
import {Gif} from '#/state/queries/tenor'
import {atoms as a, useTheme} from '#/alf'
import {Button} from '#/components/Button'
import {useDialogControl} from '#/components/Dialog'
import {GifSelectDialog} from '#/components/dialogs/GifSelect'
import {GifSquare_Stroke2_Corner0_Rounded as GifIcon} from '#/components/icons/Gif'
@@ -19,14 +18,14 @@ type Props = {
export function SelectGifBtn({onClose, onSelectGif, disabled}: Props) {
const {_} = useLingui()
const control = useDialogControl()
const ref = useRef<{open: () => void}>(null)
const t = useTheme()
const onPressSelectGif = useCallback(async () => {
logEvent('composer:gif:open', {})
Keyboard.dismiss()
control.open()
}, [control])
ref.current?.open()
}, [])
return (
<>
@@ -44,7 +43,7 @@ export function SelectGifBtn({onClose, onSelectGif, disabled}: Props) {
</Button>
<GifSelectDialog
control={control}
controlRef={ref}
onClose={onClose}
onSelectGif={onSelectGif}
/>
@@ -1,27 +1,28 @@
import React, {useCallback, useMemo} from 'react'
import {StyleSheet, Keyboard} from 'react-native'
import {Keyboard, StyleSheet} from 'react-native'
import {
FontAwesomeIcon,
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
import {Text} from 'view/com/util/text/Text'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useModalControls} from '#/state/modals'
import {
hasPostLanguage,
toPostLanguages,
useLanguagePrefs,
useLanguagePrefsApi,
} from '#/state/preferences/languages'
import {usePalette} from 'lib/hooks/usePalette'
import {isNative} from 'platform/detection'
import {
DropdownButton,
DropdownItem,
DropdownItemButton,
} from 'view/com/util/forms/DropdownButton'
import {usePalette} from 'lib/hooks/usePalette'
import {isNative} from 'platform/detection'
import {Text} from 'view/com/util/text/Text'
import {codeToLanguageName} from '../../../../locale/helpers'
import {useModalControls} from '#/state/modals'
import {
useLanguagePrefs,
useLanguagePrefsApi,
toPostLanguages,
hasPostLanguage,
} from '#/state/preferences/languages'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
export function SelectLangBtn() {
const pal = usePalette('default')
@@ -1,5 +1,6 @@
import React from 'react'
import {Keyboard, View} from 'react-native'
import {Keyboard, StyleProp, ViewStyle} from 'react-native'
import Animated, {AnimatedStyle} from 'react-native-reanimated'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
@@ -7,7 +8,7 @@ import {isNative} from '#/platform/detection'
import {useModalControls} from '#/state/modals'
import {ThreadgateSetting} from '#/state/queries/threadgate'
import {useAnalytics} from 'lib/analytics/analytics'
import {atoms as a} from '#/alf'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {CircleBanSign_Stroke2_Corner0_Rounded as CircleBanSign} from '#/components/icons/CircleBanSign'
import {Earth_Stroke2_Corner0_Rounded as Earth} from '#/components/icons/Globe'
@@ -16,12 +17,15 @@ import {Group3_Stroke2_Corner0_Rounded as Group} from '#/components/icons/Group'
export function ThreadgateBtn({
threadgate,
onChange,
style,
}: {
threadgate: ThreadgateSetting[]
onChange: (v: ThreadgateSetting[]) => void
style?: StyleProp<AnimatedStyle<ViewStyle>>
}) {
const {track} = useAnalytics()
const {_} = useLingui()
const t = useTheme()
const {openModal} = useModalControls()
const onPress = () => {
@@ -45,11 +49,11 @@ export function ThreadgateBtn({
: _(msg`Some people can reply`)
return (
<View style={[a.flex_row, a.pb_sm, a.px_md]}>
<Animated.View style={[a.flex_row, a.p_sm, t.atoms.bg, style]}>
<Button
variant="solid"
color="secondary"
size="small"
size="xsmall"
testID="openReplyGateButton"
onPress={onPress}
label={label}>
@@ -58,6 +62,6 @@ export function ThreadgateBtn({
/>
<ButtonText>{label}</ButtonText>
</Button>
</View>
</Animated.View>
)
}
+2 -2
View File
@@ -76,7 +76,7 @@ export function FeedPage({
scrollToTop()
truncateAndInvalidate(queryClient, FEED_RQKEY(feed))
setHasNew(false)
logEvent('feed:refresh', {
logEvent('feed:refresh:sampled', {
feedType: feed.split('|')[0],
feedUrl: feed,
reason: 'soft-reset',
@@ -102,7 +102,7 @@ export function FeedPage({
scrollToTop()
truncateAndInvalidate(queryClient, FEED_RQKEY(feed))
setHasNew(false)
logEvent('feed:refresh', {
logEvent('feed:refresh:sampled', {
feedType: feed.split('|')[0],
feedUrl: feed,
reason: 'load-latest',
+13 -3
View File
@@ -25,6 +25,7 @@ import * as Prompt from '#/components/Prompt'
import {RichText} from '#/components/RichText'
import {Text} from '../util/text/Text'
import {UserAvatar} from '../util/UserAvatar'
import hairlineWidth = StyleSheet.hairlineWidth
export function FeedSourceCard({
feedUri,
@@ -34,6 +35,7 @@ export function FeedSourceCard({
showLikes = false,
pinOnSave = false,
showMinimalPlaceholder,
hideTopBorder,
}: {
feedUri: string
style?: StyleProp<ViewStyle>
@@ -42,6 +44,7 @@ export function FeedSourceCard({
showLikes?: boolean
pinOnSave?: boolean
showMinimalPlaceholder?: boolean
hideTopBorder?: boolean
}) {
const {data: preferences} = usePreferencesQuery()
const {data: feed} = useFeedSourceInfoQuery({uri: feedUri})
@@ -57,6 +60,7 @@ export function FeedSourceCard({
showLikes={showLikes}
pinOnSave={pinOnSave}
showMinimalPlaceholder={showMinimalPlaceholder}
hideTopBorder={hideTopBorder}
/>
)
}
@@ -71,6 +75,7 @@ export function FeedSourceCardLoaded({
showLikes = false,
pinOnSave = false,
showMinimalPlaceholder,
hideTopBorder,
}: {
feedUri: string
feed?: FeedSourceInfo
@@ -81,6 +86,7 @@ export function FeedSourceCardLoaded({
showLikes?: boolean
pinOnSave?: boolean
showMinimalPlaceholder?: boolean
hideTopBorder?: boolean
}) {
const t = useTheme()
const pal = usePalette('default')
@@ -149,7 +155,7 @@ export function FeedSourceCardLoaded({
style={[
pal.border,
{
borderTopWidth: showMinimalPlaceholder ? 0 : 1,
borderTopWidth: showMinimalPlaceholder || hideTopBorder ? 0 : 1,
flexDirection: 'row',
alignItems: 'center',
flex: 1,
@@ -191,7 +197,12 @@ export function FeedSourceCardLoaded({
<Pressable
testID={`feed-${feed.displayName}`}
accessibilityRole="button"
style={[styles.container, pal.border, style]}
style={[
styles.container,
pal.border,
style,
{borderTopWidth: hideTopBorder ? 0 : hairlineWidth},
]}
onPress={() => {
if (feed.type === 'feed') {
navigation.push('ProfileFeed', {
@@ -295,7 +306,6 @@ const styles = StyleSheet.create({
paddingVertical: 20,
flexDirection: 'column',
flex: 1,
borderTopWidth: 1,
gap: 14,
},
headerContainer: {
+11 -13
View File
@@ -1,28 +1,28 @@
import React from 'react'
import {
findNodeHandle,
ListRenderItemInfo,
StyleProp,
StyleSheet,
View,
ViewStyle,
} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useQueryClient} from '@tanstack/react-query'
import {cleanError} from '#/lib/strings/errors'
import {useTheme} from '#/lib/ThemeContext'
import {logger} from '#/logger'
import {isNative} from '#/platform/detection'
import {isNative, isWeb} from '#/platform/detection'
import {hydrateFeedGenerator} from '#/state/queries/feed'
import {usePreferencesQuery} from '#/state/queries/preferences'
import {RQKEY, useProfileFeedgensQuery} from '#/state/queries/profile-feedgens'
import {usePalette} from 'lib/hooks/usePalette'
import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
import {EmptyState} from 'view/com/util/EmptyState'
import {ErrorMessage} from '../util/error/ErrorMessage'
import {List, ListRef} from '../util/List'
import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn'
import {Text} from '../util/text/Text'
import {FeedSourceCardLoaded} from './FeedSourceCard'
const LOADING = {_reactKey: '__loading__'}
@@ -51,7 +51,6 @@ export const ProfileFeedgens = React.forwardRef<
{did, scrollElRef, headerOffset, enabled, style, testID, setScrollViewTag},
ref,
) {
const pal = usePalette('default')
const {_} = useLingui()
const theme = useTheme()
const [isPTRing, setIsPTRing] = React.useState(false)
@@ -134,16 +133,14 @@ export const ProfileFeedgens = React.forwardRef<
// =
const renderItemInner = React.useCallback(
({item}: {item: any}) => {
({item, index}: ListRenderItemInfo<any>) => {
if (item === EMPTY) {
return (
<View
<EmptyState
icon="hashtag"
message={_(msg`You have no feeds.`)}
testID="listsEmpty"
style={[{padding: 18, borderTopWidth: 1}, pal.border]}>
<Text style={pal.textLight}>
<Trans>You have no feeds.</Trans>
</Text>
</View>
/>
)
} else if (item === ERROR_ITEM) {
return (
@@ -169,12 +166,13 @@ export const ProfileFeedgens = React.forwardRef<
preferences={preferences}
style={styles.item}
showLikes
hideTopBorder={index === 0 && !isWeb}
/>
)
}
return null
},
[error, refetch, onPressRetryLoadMore, pal, preferences, _],
[error, refetch, onPressRetryLoadMore, preferences, _],
)
React.useEffect(() => {
+12 -10
View File
@@ -1,18 +1,20 @@
import React from 'react'
import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
import {AtUri, AppBskyGraphDefs, RichText} from '@atproto/api'
import {Link} from '../util/Link'
import {Text} from '../util/text/Text'
import {RichText as RichTextCom} from '#/components/RichText'
import {UserAvatar} from '../util/UserAvatar'
import {s} from 'lib/styles'
import {usePalette} from 'lib/hooks/usePalette'
import {AppBskyGraphDefs, AtUri, RichText} from '@atproto/api'
import {Trans} from '@lingui/macro'
import {useSession} from '#/state/session'
import {usePalette} from 'lib/hooks/usePalette'
import {makeProfileLink} from 'lib/routes/links'
import {sanitizeDisplayName} from 'lib/strings/display-names'
import {sanitizeHandle} from 'lib/strings/handles'
import {makeProfileLink} from 'lib/routes/links'
import {Trans} from '@lingui/macro'
import {s} from 'lib/styles'
import {atoms as a} from '#/alf'
import {RichText as RichTextCom} from '#/components/RichText'
import {Link} from '../util/Link'
import {Text} from '../util/text/Text'
import {UserAvatar} from '../util/UserAvatar'
import hairlineWidth = StyleSheet.hairlineWidth
export const ListCard = ({
testID,
@@ -132,7 +134,7 @@ export const ListCard = ({
const styles = StyleSheet.create({
outer: {
borderTopWidth: 1,
borderTopWidth: hairlineWidth,
paddingHorizontal: 6,
},
outerNoBorder: {

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