Compare commits

..

110 Commits

Author SHA1 Message Date
Eric Bailey d6c5044c1d format 2024-05-06 15:34:51 -05:00
Eric Bailey c35c840c3d Remove unused error type 2024-05-06 15:05:11 -05:00
Eric Bailey 9054ba6979 Handle failed polling 2024-05-06 15:05:11 -05:00
Eric Bailey 6903b68557 Handle two common errors, provide more clarity around error states 2024-05-06 15:05:10 -05:00
Hailey b34d224207 remove hide_vertical_scrollbars test (#3855) 2024-05-06 20:59:36 +01:00
Hailey db0b030359 show spinner when preferences has not loaded yet (#3756) 2024-05-06 20:58:03 +01:00
Samuel Newman c33c3b7d1e Alt text for gifs (#3876)
* add alt text dialog

* multiline alt text input

* add pressable alt text badge

* rename `ALT: ` to `Alt text: ` to avoid including old bad ones

* reuse alt text reminder

* reuse alt text reminder in gallery

* add alt text reminder in the dialog itself

* autofocus text input

* reorder components to fix tab order

* fix close btn position
2024-05-06 17:28:38 +01:00
Hailey ae7626ce6e [🐴] Finalize web message screen (#3868)
* add `onStartReached` to web list

* fix `rootMargin`

* Add `contain`, handle scroll events

* improve types, fix typo

* simplify

* adjust `scrollToTop` and `scrollToOffset` to support `contain`, add `scrollToEnd`

* rename `handleWindowScroll` to `handleScroll`

* support basic `maintainVisibleContentPosition`

* rename `contain` to `containWeb`

* remove unnecessary `flex: 1`

* add missing props

* add root prop to `Visibility`

* add root prop to `Visibility`

* revert adding `maintainVisibleContentPosition`

* remove unnecessary wrapper

* add style

* oops

* maintain position for web

* always apply `flex: 1` to styles when contained

* add a contained list to storybook

* make `onScroll` a worklet in storybook

* revert test code

* remove unnecessary `flex: 1`
2024-05-06 08:48:08 -07:00
Hailey bc07019911 Further align web List with FlatList, add contain mode to web list implementation (#3867)
* add `onStartReached` to web list

* fix `rootMargin`

* Add `contain`, handle scroll events

* improve types, fix typo

* simplify

* adjust `scrollToTop` and `scrollToOffset` to support `contain`, add `scrollToEnd`

* rename `handleWindowScroll` to `handleScroll`

* support basic `maintainVisibleContentPosition`

* rename `contain` to `containWeb`

* remove unnecessary `flex: 1`

* add missing props

* add root prop to `Visibility`

* add root prop to `Visibility`

* revert adding `maintainVisibleContentPosition`

* oops

* always apply `flex: 1` to styles when contained

* add a contained list to storybook

* make `onScroll` a worklet in storybook

* revert test code

* add scrolling to storybook

* simplify getting scrollable node

* nit: extra whitespace

* nit: random comment

* foolproof the logic

* typecheck
2024-05-06 08:34:32 -07:00
Hailey 594b40c3ae Fix IntersectionObserver rootMargin in web List implementation, add onStartReached (#3866)
* add `onStartReached` to web list

* fix `rootMargin`
2024-05-05 04:24:01 -07:00
Hailey 6b615f3720 only use KeyboardProvider in conversation screen (#3869) 2024-05-05 03:14:14 -07:00
Hailey c4160c25a3 [Clipclops] Delete message in dialog (#3849)
* delete and copy

* add retry dialog if message send fails

* add layout animation

* fix `nextMessage` being incorrect

---------

Co-authored-by: Samuel Newman <mozzius@protonmail.com>
2024-05-05 02:06:10 +01:00
Samuel Newman 7448c8f785 [🐴] Improve message pressing (#3864)
* allow tapping message when keyboard open

* dismiss keyboard on open msg menu

* show message info on press

* Revert "show message info on press"

This reverts commit 61d039a702.
2024-05-05 01:13:57 +01:00
Hailey 91f8202549 fix typo in app-info (#3863) 2024-05-04 16:45:59 -07:00
Samuel Newman 9b7818d358 [🐴] Dismiss keyboard before opening convo menu (#3862)
* Dismiss keyboard before opening convo menu

* call `props.onPress()` instead
2024-05-05 00:39:52 +01:00
Hailey 4862bc2ba8 add bundle identifiers to app-info (#3861)
* add bundle identifiers to `app-info`

* add them to the `.env.example`

* add environment variables for docker build

* add environment variables for native builds and bundles

* also include the hour in bundle date

* organize app info better in settings
2024-05-04 16:33:10 -07:00
Hailey eb55bdf172 [🐴] List Adjustments (#3857) 2024-05-04 13:22:14 -07:00
Samuel Newman c223bcdaf7 Assorted clipclop fixes (#3853)
* empty state for new chat dialog

* use terniary

* dark mode pending state

* copy message text option

* fix service url input (scrollview ftw)

* whoops, fix equality

* slightly reduce horizontal message padding
2024-05-04 01:49:49 +01:00
Hailey feff55a14a Clipclop tweaks (#3851)
* scroll on input resize

* add back the padding 🙊

* fix timestamps
2024-05-03 15:08:59 -07:00
Hailey 876816675e [Clipclops] Refactor message list (#3832)
* rework the list for accessibility

* Reverse reverse

* progress

* good to start testing

* memo `MessageItem`

* small hack

* use our custom `List` impl

* use `ScrollProvider` for `onScroll` event

* remove use of `runOnJS`

* actually, let's keep it

* add some comments

---------

Co-authored-by: Eric Bailey <git@esb.lol>
2024-05-03 14:18:01 -07:00
Samuel Newman 6a4199febb [Clipclops] Pending message style with layout animation (#3844)
* decrease group gap to 3 mins

* pending style with layout animation

* make pending state lighter
2024-05-03 20:19:48 +01:00
Samuel Newman 55f3df5596 reenable tabs (#3847) 2024-05-03 20:14:10 +01:00
Hailey 19a6bdcc86 Version bump (#3848) 2024-05-03 11:21:02 -07:00
Samuel Newman ce02a41163 add focus refresh + polling (#3846) 2024-05-03 17:57:20 +01:00
dan 4a2d4253e5 [Session] Align state and global agent switchpoints (#3845)
* Adopt synced accounts unconditionally

* Remove try/catch around resuming session

* Move to login form on resume failure

* Restructure code flow for easier reading

---------

Co-authored-by: Eric Bailey <git@esb.lol>
2024-05-03 17:57:09 +01:00
dan 85b34418ef Don't clear toasts when changing users (#3843)
* Move ThemeProvider to the top

* Move RootSiblingParent above the remounted part

* Move ToastContainer outside the remounted part

* Remove setTimeout around toasts

* Consistently handle dropped session
2024-05-03 16:36:58 +01:00
Samuel Newman aeed4786db make service url gate friendlier (#3841) 2024-05-03 16:32:20 +01:00
Samuel Newman 67b0cdf649 remove broken keyboard offset code (#3842) 2024-05-03 16:14:25 +01:00
Samuel Newman 997e621ce3 [Clipclops] Header tweaks (#3839)
* wrap text

* add dividers to convo menu
2024-05-03 15:55:35 +01:00
Samuel Newman a5511e3c22 update date logic to account for timezones (#3840) 2024-05-03 15:55:24 +01:00
Eric Bailey 051e897a2b [Clipclops] Moar error (#3837)
* Add history error

* Log error

* Add period
2024-05-03 09:39:40 -05:00
dan 1e484c6318 [Session] Reset global agent on expire (#3838) 2024-05-03 13:46:34 +01:00
dan cdf7a1957a [Session] Rely on agent session change event for persisting resumed session (#3836)
* Rely on agent session change handler for resumption

* Add a fast path for noop resumes
2024-05-03 03:47:11 +01:00
Eric Bailey c9cf608f78 [Clipclops] External store, suspend/resume (#3829)
* Initial working external store

* Clean up WIP, explore suspend/resume

* Clean up state, bindings, snapshots, add some logs

* Reduce snapshots, add better logic check

* Bump interval a smidge

* Remove unused type
2024-05-02 20:57:51 -05:00
dan c13685a0cf [Session] Fix stale emailAuthFactor and emailConfirmed on the client (#3835)
* Fix account refresh to not clobber falsy values

* Use ?? in more places
2024-05-03 02:52:37 +01:00
dan 0f827c3213 [Session] Track agent in state (#3833)
* Expand currentAccountDid into currentAgentState

* Inline all callsites of upsertAccount

* Inline all internal callsites of clearCurrentAccount

* Add agent to currentAgentState

* Collapse extra setStates

* Add TODOs
2024-05-03 02:04:36 +01:00
Samuel Newman a9af83692c visually align icon (#3831) 2024-05-03 01:28:03 +01:00
dan 6405ad7cba [Session] Call persist handler directly (#3828) 2024-05-02 23:05:53 +01:00
dan b86c3b486f Improve account switcher pending state (#3827)
* Protect against races

* Reduce UI jank when switching accounts

* Add pending state to selected account

* Disable presses while pending
2024-05-02 21:55:50 +01:00
Hailey 8ba1b10ce0 [Clipclops] Message actions for native and web (#3807)
* haptic on long press

* add animation to press and hold

* eslint disable for now

* adjust styles

* dont trigger if animation is cancelled

* organize

* add a delete menu

* reset scale automatically

* message actions dialog

cleanup

center the trigger

handle focus/unfocus better

make triggers accessible

weg dropdown menu

add a wep specific wrapper

decrease press delay

add report button

improve shrink logic

use `self_end` instead of `margin: auto`

rm extra `?`

move `MessageItem` to `components`

add delete button

* rm some padding

* update after merge

* fix merge

* web only types

* fix crash

* add an explanation

* fix web types

---------

Co-authored-by: Samuel Newman <mozzius@protonmail.com>
2024-05-02 13:54:17 -07:00
Samuel Newman 6da18e3dcf Only run "disable min shell on foregrounding" behaviour if on the home screen (#3825)
* change useEffect to useFocusEffect

* v2 -> v3
2024-05-02 21:27:54 +01:00
Samuel Newman 96c5db3e69 create keyboardverticaloffset hook (#3823) 2024-05-02 21:05:11 +01:00
Samuel Newman aca55cb192 rename "MessagesList" to "Messages" (#3826) 2024-05-02 21:02:45 +01:00
Eric Bailey a20fe4c9a0 [Clipclops] Deletions (#3824)
* Handle message deletions

* Handle failures
2024-05-02 13:12:34 -05:00
dan 1a07e23192 [Session] Extract selectAccount out (#3812) 2024-05-02 18:25:09 +01:00
dan 5ec945b762 [Session] Extract resumeSession out (#3811) 2024-05-02 18:17:53 +01:00
Samuel Newman dadf27fd2f [Clipclops] Minor web tweaks (#3822)
* add a11y + onPress to web send button

* only adjust leading on web, due to overflow issue
2024-05-02 18:00:42 +01:00
Samuel Newman bff055f618 [Clipclop] Input polish (#3819)
* tweak input styles + add min-shell mode

* android tweaks + hitslop
2024-05-02 17:08:41 +01:00
Hailey e977c99c96 [Clipclops] rm <Trans> from header (#3818) 2024-05-02 08:54:12 -07:00
Eric Bailey c2062753c5 Allow unmuting and unblocking of curate lists, but not the opposite (#3817) 2024-05-02 10:17:17 -05:00
Hailey cc9727a844 [Clipclops] Remove viewability config (#3805)
* remove viewability config

* use `onScroll` for keeping track of content offset
2024-05-01 16:26:19 -07:00
Samuel Newman e19f882450 [Clipclops] Clop menu, leave clop, mute/unmute clop (#3804)
* convo menu

* memoize convomenu

* add convoId to useChat + memoize value

* leave convo

* Create mute-conversation.ts

* add mutes, remove changes to useChat and use chat.convo instead

* add todo comments

* leave convo confirm prompt

* remove dependency on useChat and pass in props instead

* show menu on long press

* optimistic update

* optimistic update leave + add error capture

* don't `popToTop` when unnecessary

---------

Co-authored-by: Hailey <me@haileyok.com>
2024-05-02 00:15:10 +01:00
Paul Frazee d3fafdc066 Fix: put label defs provider above moderation opts provider (#3806) 2024-05-01 15:59:19 -07:00
Eric Bailey 5148b957b7 [Clipclops] Pending messages (#3803)
* Make pending message like normal message

* Remove getter

* Make props public
2024-05-01 16:29:34 -05:00
Samuel Newman e2556d9f40 [Clipclops] Replace 🐴 with real icon (#3802)
* replace 🐴 with paper plane icon

* replace envelope with plus
2024-05-01 21:49:10 +01:00
Eric Bailey fc0eab2d03 Retry clops (#3800)
* Add convo retries, sketch out tests

* Only append nextMessage to messages

* Remove debug code
2024-05-01 15:24:56 -05:00
Hailey 333ccdad39 [Clipclops] Dynamic input height (#3778)
* input max height/scrollability

* remove unused imports

* add a web-specific version

* enter and shift enter for web

* missing onSubmit for native

* missing attributes

* improve layout of input on web

* use the correct text color in the input

* trim messages

* remove `onSubmit`

* move prop up

* trim message on web

* remove extra function call

---------

Co-authored-by: Samuel Newman <mozzius@protonmail.com>
2024-05-01 20:42:11 +01:00
Hailey 6f9993ca55 [Clipclops] Fix list, rework structure (#3799)
* proper min index

* move keyextractor out of react

* move onSendMessage out

* don't render the flatlist conditionally

* add loader

* rework structure

* remove some unneeded logic
2024-05-01 11:48:19 -07:00
Samuel Newman 8304ad91ac add header to empty chat list view (#3791) 2024-05-01 18:43:54 +01:00
Eric Bailey 538ca8dff1 [Clipclops] 2 Clipped 2 Clopped (#3796)
* Add new pkg

* copy queries over to new file

* useConvoQuery

* useListConvos

* Use useListConvos

* extract useConvoQuery

* useGetConvoForMembers

* Delete unused

* exract useListConvos

* Replace imports

* Messages/List/index.tsx

* extract getconvoformembers

* MessageItem

* delete chatLog and rename query.ts

* Update import

* Clipclop service (#3794)

* Add Chat service

* Better handle deletions

* Rollback unneeded changes

* Better insertion order

* Use clipclops

* don't show FAB if error

* clean up imports

* Update Convo service

* Remove temp files

---------

Co-authored-by: Samuel Newman <mozzius@protonmail.com>
2024-05-01 10:14:41 -07:00
dan d61b366b26 [Session] Derive currentAccount from accounts + currentAccountDid (#3795)
* Derive currentAccount from accounts and currentAccountDid

* Add TODOs for divergence with __globalAgent
2024-05-01 17:35:27 +01:00
dan df9af92eb2 [Session] Use flag on state for persistence (#3793)
* Move isInitialLoad and isSwitchingAccounts out of main state

* Remove spreads, order object keys

* Track need to persist on state object

* Reoder state variables
2024-05-01 17:26:33 +01:00
Samuel Newman a6061489ff replace getDate with toIsoString().slice(0,10) (#3792) 2024-05-01 15:50:30 +01:00
Samuel Newman 80a1687dae exclude bskyweb bskyembed and web-build (#3790) 2024-05-01 15:29:30 +01:00
Samuel Newman 09f3b2ae14 [Clipclops] Improve message list behaviour (#3789)
* improve message list behaviour

* replace useAgent with useSession

* add explicit types to appease linter
2024-05-01 15:16:54 +01:00
Hailey b8d8bec388 sentry errors for captcha web views and registration attempts (#3761)
* sentry errors for captcha web views

* include handles with errors

* log all registration request failures

* rm

* use a better trigger for web captcha errors

* add another trigger for recording a possible signup error

* unknown error type

* don't needlessly log on href errors

* honestly i probably cant always do a captcha in 20 seconds

* rm log

* timeout on back

* remove unnecessary colons
2024-05-01 01:08:59 -07:00
Samuel Newman 81ae7e425d Add kawaii mode (#3773) 2024-05-01 08:59:40 +01:00
dan 181e61bedb Memoize context value (#3786) 2024-05-01 05:35:52 +01:00
Paul Frazee 31cb3e5422 Performance improvements: structural sharing & moderation opts context (#3785)
* Fix: correctly apply structural sharing to preferences object

* Move moderation opts into a context

* Fix import

* Remove log

* Pass userdid directly

* Pass moderationPrefs directly
2024-04-30 21:06:51 -07:00
dan 39807a8630 [Session] Refactor to prepare for V2 (#3781)
* Move types to another file

Co-authored-by: dan <dan.abramov@gmail.com>

* Move utilities out

Co-authored-by: dan <dan.abramov@gmail.com>

* Move PUBLIC_BSKY_AGENT

Co-authored-by: dan <dan.abramov@gmail.com>

* Move createPersistSessionHandler inline

Co-authored-by: dan <dan.abramov@gmail.com>

* Call configureModeration when clearing account too

This ensures that the app labelers get reset in a test environment.

Co-authored-by: dan <dan.abramov@gmail.com>

* Make guest configureModeration sync, non-guest async

* Extract isSessionExpired

Co-authored-by: dan <dan.abramov@gmail.com>

* Flip isSessionExpired condition

Co-authored-by: dan <dan.abramov@gmail.com>

* Extract agentToSessionAccount

Co-authored-by: dan <dan.abramov@gmail.com>

* Extract createAgent*

Co-authored-by: dan <dan.abramov@gmail.com>

* Simplify isSessionExpired

---------

Co-authored-by: Eric Bailey <git@esb.lol>
2024-05-01 02:55:43 +01:00
dan 66ad5543f1 [Session] Move deactivated field off the session object (#3780) 2024-05-01 01:36:56 +01:00
dan 339f26567a [Session] Consistently copy SessionAgent fields (#3779)
Co-authored-by: Eric Bailey <git@esb.lol>
2024-05-01 01:13:55 +01:00
Samuel Newman 80bcf81dc5 [Clipclops] Separate messages if there's a time gap (#3777)
* separate msg groups by age

* clean up logic and add comments
2024-04-30 23:40:26 +01:00
Hailey db968b7610 [Clipclops] Header for chat (#3775)
* add temp `getchat` query

* properly get the other profile

* add basic header

* normalize layout on all devices

* remove unused imports, adjust style

* remove unnecessary log

* remove another log

* remove some more imports

* cleanup

* use `Button` instead in the header

* lint
2024-04-30 15:04:17 -07:00
Hailey 268e30d21a [Clipclops] adjust scroll position, keyboard color (#3771)
* use the correct keyboard appearance based on theme

* i guess the min index should be 1
2024-04-30 11:33:41 -07:00
Samuel Newman 611ff0c7e4 [Clipclops] Add clop sent time to clipclop (#3772)
* add message sent time to message

* fix last message in group logic
2024-04-30 19:31:30 +01:00
Samuel Newman 7b694fd860 [Clipclops] Use API data for clipclop list (#3769)
* use real API

* remove extra tab icon

* messages list web layout + style improvements

* use style's text color for input

* make new chat button way more obvious

---------

Co-authored-by: Hailey <me@haileyok.com>
2024-04-30 18:15:48 +01:00
Samuel Newman bcd3678067 [Clipclops] New clipclop dialog (#3750)
* add new routes with placeholder screens

* add clops list

* add a clop input

* add some better padding to the clops

* some more adjustments

* add rnkc

* implement rnkc

* implement rnkc

* be a little less weird about it

* rename clop stuff

* rename more clop

* one more

* add codegenerated lexicon

* replace hailey's types

* use codegen'd types in components

* fix error + throw if fetch failed

* remove bad imports

* update messageslist and messageitem

* import useState

* replace hailey's types

* use codegen'd types in components

* add FAB

* new chat dialog

* error + default search term

* fix typo

* fix web styles

* optimistically set chat data

* use cursor instead of last rev

* [Clipclops] Temp codegenerated lexicon (#3749)

* add codegenerated lexicon

* replace hailey's types

* use codegen'd types in components

* fix error + throw if fetch failed

* remove bad imports

* update messageslist and messageitem

* import useState

* add clop service URL hook

* add dm service url storage

* use context

* use context for service url (temp)

* remove log

* cleanup merge

* fix merge error

* disable hack

* sender-based message styles

* temporary filter

* merge cleanup

* add `hideBackButton`

* rm unneeded return

* tried to be smart

* hide go back button

* use `searchActorTypeahead` instead

---------

Co-authored-by: Hailey <me@haileyok.com>
2024-04-30 17:43:57 +01:00
dan 2b7d796ca9 Session fixes, pt. 1 (#3762)
* Update persisted schema for new source of truth, implement in existing session

(cherry picked from commit b1e5f12bae)

* Improve toasts, log caught error, during switch account

(cherry picked from commit fe0d150706)

* Handle thrown errors from initSession during login

(cherry picked from commit 2c85c04591)

---------

Co-authored-by: Eric Bailey <git@esb.lol>
2024-04-30 17:38:05 +01:00
Samuel Newman 4de78fb69e use keyboardDismissMode rather than onScrollBeginDrag (#3767) 2024-04-30 16:11:41 +01:00
Hailey eb8bfd11d1 [Clipclops] Add screen to view and send clip clops (#3754)
* add new routes with placeholder screens

* add clops list

* add a clop input

* add some better padding to the clops

* some more adjustments

* add rnkc

* implement rnkc

* implement rnkc

* be a little less weird about it

* rename clop stuff

* rename more clop

* one more

* [Clipclops] Temp codegenerated lexicon (#3749)

* add codegenerated lexicon

* replace hailey's types

* use codegen'd types in components

* fix error + throw if fetch failed

* remove bad imports

* update messageslist and messageitem

* import useState

* add clop service URL hook

* add dm service url storage

* use context

* use context for service url (temp)

* remove log

* nits

---------

Co-authored-by: Samuel Newman <mozzius@protonmail.com>
2024-04-29 23:34:26 -07:00
Hailey 5d19f27052 switch branch for installing react-native-bottom-sheet (#3760) 2024-04-29 16:13:41 -07:00
dan 42966fb49e Fix List onMomentumScrollEnd (#3759) 2024-04-29 23:38:03 +01:00
Paul Frazee 28fc98d942 Release 1.80 (#3757)
* Test fixes

* Intl extract
2024-04-29 15:00:25 -07:00
Frudrax Cheng 599dab1c7a Update zh-TW translations (#3678)
* consistent improvements

* update messages.po

* improve translation

* improve translation

* fix punctuation issues

* remove superseded strings

* GIFs to GIF

* modify according to review suggestions

* fix some misnomers

* Update src/locale/locales/zh-TW/messages.po

Co-authored-by: cirx <133132480+cirx1e@users.noreply.github.com>

* Update src/locale/locales/zh-TW/messages.po

Co-authored-by: cirx <133132480+cirx1e@users.noreply.github.com>

* Update src/locale/locales/zh-TW/messages.po

Co-authored-by: cirx <133132480+cirx1e@users.noreply.github.com>

* Update src/locale/locales/zh-TW/messages.po

Co-authored-by: cirx <133132480+cirx1e@users.noreply.github.com>

* modify according to review suggestions

* modify according to review suggestions

* Update src/locale/locales/zh-TW/messages.po

Co-authored-by: Yuki Cheung <13027432+snowleo208@users.noreply.github.com>

* Update src/locale/locales/zh-TW/messages.po

Co-authored-by: Yuki Cheung <13027432+snowleo208@users.noreply.github.com>

* Update src/locale/locales/zh-TW/messages.po

Co-authored-by: Yuki Cheung <13027432+snowleo208@users.noreply.github.com>

* Update src/locale/locales/zh-TW/messages.po

Co-authored-by: Yuki Cheung <13027432+snowleo208@users.noreply.github.com>

* Update src/locale/locales/zh-TW/messages.po

Co-authored-by: Yuki Cheung <13027432+snowleo208@users.noreply.github.com>

* Update src/locale/locales/zh-TW/messages.po

Co-authored-by: Yuki Cheung <13027432+snowleo208@users.noreply.github.com>

* Update src/locale/locales/zh-TW/messages.po

Co-authored-by: Yuki Cheung <13027432+snowleo208@users.noreply.github.com>

* Update src/locale/locales/zh-TW/messages.po

Co-authored-by: Yuki Cheung <13027432+snowleo208@users.noreply.github.com>

* Update src/locale/locales/zh-TW/messages.po

Co-authored-by: Yuki Cheung <13027432+snowleo208@users.noreply.github.com>

* Update src/locale/locales/zh-TW/messages.po

Co-authored-by: Yuki Cheung <13027432+snowleo208@users.noreply.github.com>

* Update src/locale/locales/zh-TW/messages.po

Co-authored-by: Yuki Cheung <13027432+snowleo208@users.noreply.github.com>

* Update src/locale/locales/zh-TW/messages.po

Co-authored-by: Yuki Cheung <13027432+snowleo208@users.noreply.github.com>

* Update src/locale/locales/zh-TW/messages.po

Co-authored-by: Yuki Cheung <13027432+snowleo208@users.noreply.github.com>

* Update messages.po

Co-authored-by: cirx <133132480+cirx1e@users.noreply.github.com>

* Update messages.po

Co-authored-by: cirx <133132480+cirx1e@users.noreply.github.com>

* Update messages.po

Co-authored-by: cirx <133132480+cirx1e@users.noreply.github.com>

* modify according to review suggestions

* a small adjustment

---------

Co-authored-by: cirx <133132480+cirx1e@users.noreply.github.com>
Co-authored-by: Yuki Cheung <13027432+snowleo208@users.noreply.github.com>
2024-04-29 14:54:17 -07:00
Gabriella 6dd2e07ab9 Italian localization (#3684)
* Add files via upload

* Update messages.po

Changed the grama of string

* Update messages.po

Following the input of @niconick89.bsky.social I have added additional correction to some of the strings.
2024-04-29 14:53:45 -07:00
Ivan Beà 983d9e55b3 Update catalan messages.po (#3697)
* Update catalan messages.po

New lines translated
You can check it @jordimas @darccio @ferranrego @MiquelAdell @mvdan @johnnydement @Mejans @surfdude29

* Update messages.po

Remove extra space

* Update messages.po
2024-04-29 14:53:13 -07:00
Minseo Lee c8aa16e90e Update Korean localization (#3698)
* Update messages.po

* Update messages.po

* Update messages.po

* Update messages.po

* Update messages.po

* Update messages.po

* Update messages.po
2024-04-29 14:52:39 -07:00
Takayuki KUSANO c2fff6a861 Update Japanese translation (#3734)
* Remove old old onboarding related entries

cf. https://github.com/bluesky-social/social-app/pull/3674

* Update Japanese translation

(accessibilityLabel & accessibilityHint)
cf. https://github.com/bluesky-social/social-app/pull/3670

* Update Japanese translation

* Fix Japanase translation related to block
2024-04-29 14:52:15 -07:00
Frudrax Cheng f3c32f9996 Update zh-CN translations (#3716)
* fix punctuation issues

* improve translation

* remove superseded strings

* update messages.po

* fix typo

* Update src/locale/locales/zh-CN/messages.po

Co-authored-by: IceCodeNew <32576256+IceCodeNew@users.noreply.github.com>

* Update src/locale/locales/zh-CN/messages.po

Co-authored-by: IceCodeNew <32576256+IceCodeNew@users.noreply.github.com>

* Update src/locale/locales/zh-CN/messages.po

Co-authored-by: IceCodeNew <32576256+IceCodeNew@users.noreply.github.com>

* Update src/locale/locales/zh-CN/messages.po

Co-authored-by: IceCodeNew <32576256+IceCodeNew@users.noreply.github.com>

* Update src/locale/locales/zh-CN/messages.po

Co-authored-by: IceCodeNew <32576256+IceCodeNew@users.noreply.github.com>

* Update src/locale/locales/zh-CN/messages.po

Co-authored-by: IceCodeNew <32576256+IceCodeNew@users.noreply.github.com>

* Update src/locale/locales/zh-CN/messages.po

Co-authored-by: IceCodeNew <32576256+IceCodeNew@users.noreply.github.com>

* Update src/locale/locales/zh-CN/messages.po

Co-authored-by: IceCodeNew <32576256+IceCodeNew@users.noreply.github.com>

* Update src/locale/locales/zh-CN/messages.po

Co-authored-by: IceCodeNew <32576256+IceCodeNew@users.noreply.github.com>

* Update src/locale/locales/zh-CN/messages.po

Co-authored-by: IceCodeNew <32576256+IceCodeNew@users.noreply.github.com>

* Update src/locale/locales/zh-CN/messages.po

Co-authored-by: IceCodeNew <32576256+IceCodeNew@users.noreply.github.com>

* update language-team

* Update messages.po

Co-authored-by: Leonid <kasumi@bangdream.moe>

* Update messages.po

Co-authored-by: Leonid <kasumi@bangdream.moe>

* Update messages.po

Co-authored-by: Leonid <kasumi@bangdream.moe>

* Update messages.po

Co-authored-by: Leonid <kasumi@bangdream.moe>

* Update messages.po

Co-authored-by: Leonid <kasumi@bangdream.moe>

* Update messages.po

Co-authored-by: Leonid <kasumi@bangdream.moe>

* Update messages.po

Co-authored-by: Leonid <kasumi@bangdream.moe>

---------

Co-authored-by: IceCodeNew <32576256+IceCodeNew@users.noreply.github.com>
Co-authored-by: Leonid <kasumi@bangdream.moe>
2024-04-29 14:51:41 -07:00
Eric Bailey a4e34537ce Send Bluesky feeds and suggested follows more data (#3695)
* WIP

* Fix constructors

* Clean up

* Tweak

* Rm extra assignment

* Narrow down the argument

---------

Co-authored-by: Dan Abramov <dan.abramov@gmail.com>
2024-04-29 22:04:35 +01:00
Kisaragi Hiu d893fe005d android: fix various places still using default Material Teal (#3555)
These places include TextInput cursor, TextInput selection, and the
spinner (ActivityIndicator) --- the default Material Teal is out of
place wherever it shows up.

This sets Expo's primaryColor to #1083fe, which is the color that
defaultTheme.palette.default.brandText resolves to, then applies it as
the native accent color via a plugin because Expo doesn't apply the
accent color.
2024-04-29 11:36:05 -07:00
Hailey 2feea51ae3 clear search on tab press (#3753)
* clear search on tab press

* don't need to call `onPressCancelSearch`
2024-04-29 17:32:20 +01:00
dan 5d715ae1d0 Improve search screen perf (#3752)
* Extract SearchHistory to a component

* Extract AutocompleteResults to a component

* Extract SearchInputBox to a component

* Add a bunch of memoization

* Optimize switching by rendering both

* Remove subdomain matching

This is only ever useful if you type it exactly correct. Search now does a better job anyway.

* Give recent search decent hitslops
2024-04-29 16:52:24 +01:00
dan 3c2d73909b Remove layout animation from search (#3751) 2024-04-29 16:11:59 +01:00
Hailey 388c4f79cf Increase search TextInput hit area and improve the related UI (#3748)
* improve hit area of search text input

use text cursor on web

use a pressable instead

use a vertical padding of 9

oops

move vertical padding to `TextInput` to increase hit area

* Hide it from a11y tree, change cursor

* Hide clear on empty text

* Render either Clear or Cancel

* Remove Clear button

* Animate it

* Better animation

---------

Co-authored-by: Dan Abramov <dan.abramov@gmail.com>
2024-04-29 05:12:20 +01:00
dan dfce190cb6 [iOS] Fix selecting search input on focus (#3746)
* Select search text on focus

* Scope to iOS only
2024-04-28 22:52:40 +01:00
dan 361d255e95 Remove useEffect from TimeElapsed (#3741) 2024-04-28 22:37:00 +01:00
dan 256bb33de0 Remove test gating code (#3742) 2024-04-28 22:36:50 +01:00
dan 2a08931127 Fix dropdown immediately closing on Enter (#3745)
* Move dropdown content into separate component

* Fix dropdown with keyboard

* No-op is sufficient
2024-04-28 21:29:43 +01:00
dan 1dd3d6657c Account for momentum when hiding minimal shell (#3740)
* Add optional momentum events to scroll context

* If there is a velocity, don't snap until momentum end

* Don't show bar on scroll down

* Rm onMomentumBegin
2024-04-28 17:48:20 +01:00
dan 3b4848ba59 Reduce hovercard false positives, add them to display names and handles (#3739)
* Don't trigger hovercards on scroll

* Add display name / handle hovercards

* Increase hovercard delay
2024-04-28 13:59:18 +01:00
Mary 74cd1d4458 YouTube Music as supported player (#3736) 2024-04-27 22:11:08 -07:00
dan 21e5a87fab Patch lexicon for perf (#3733) 2024-04-27 07:54:47 +01:00
Hailey 61d7e6014b ensure dialog is closed on unmount (#3650)
Revert "properly check if the ref is null"

This reverts commit 8f563808a5d39389b0bc47a31e73cd147d1e7e8b.

properly check if the ref is null

ensure dialog is closed on unmount
2024-04-27 07:42:49 +01:00
Hailey 7eb1444f2c remove precacheThreadPostProfiles (#3729)
* remove `precacheThreadPostProfiles`

* add `displayName` to `PreviewableUserAvatar`

* memo

* use `precacheProfile`

* pass `profile` directly to `PreviewableUserAvatar`

* update the `UserAvatar`'s props

* remove feed cache

* one more spot

* rm unused queryClient

* Don't call fn unnecessarily

* Preload for display name too

* try notification item

* add to feeditem

* and finally, precache for post threads

* timestamp

* Fix

* onBeforePress

---------

Co-authored-by: Dan Abramov <dan.abramov@gmail.com>
2024-04-27 06:31:07 +01:00
Samuel Newman ce85375c85 [Clipclops] New routes with placeholder screens (#3725)
* add new routes with placeholder screens

* gate content

* add filled envelope style

* swap filled state

* switch to `useAgent`
2024-04-27 05:54:18 +01:00
Samuel Newman 1af59ca8a7 Special-case bsky.app/download to open share sheet or copy to clipboard (#3710)
* special-case bsky.app/download to share

* Address feedback

* Improve detection

---------

Co-authored-by: Dan Abramov <dan.abramov@gmail.com>
2024-04-27 05:25:28 +01:00
Hailey b10c96f28d ver bump (#3731) 2024-04-27 04:38:51 +01:00
Hailey 5f9136479b Improve usability of search on web (#3663)
* dont select the text on web

* TODO REVERT THESE CHANGES

* use `usethrottledvalue` for autocomplete

* use `isFetching` from query

* rm setTimeout

* getting there

* improve functionality of cancel button

* rm todo

* add comment back

* encode `searchText` rather than `queryTerm`

* use "back" on web in some cases

* don't flash results in autocomplete

* remove unnecesary usestate

* rename everything to `query` temporarily

* revert accidental lint

* rm todo

* rm comment

* use `useFocusEffect` to update the query term on back navigation

* `searchText` is always defined here

* Fix race

* remove back functionality

* use `keepPreviousData` for query

* rename `q` to `queryParam`

* remove hack

* remove `q=` on cancel

* blur on submit

* use `setParams` instead of `replace`

* use `replace` on web still

* clear the search input when we clear `q` on native

* onPress dismiss attempt

* Adjustments

* Fix search history

* Always hide autocomplete

* Clear right pane search on select

* `blur` on autosuggestion press

* Rename to reduce diff

* Fixes

* Unify codepaths

* Fixes

* precache the autosuggestion

* do the cache in the link card

* Revert "precache the autosuggestion"

This reverts commit 79c433e984.

* use `throttledValue` and `keepPreviousData` in sidebar search

* show spinner when fetching pt 1

* show spinner when fetching pt 2

* show spinner properly for autocomplete

* Fix extra border

* Position fixed

* TS

* Revert "TS"

This reverts commit df187ea2d7.

* Revert "Position fixed"

This reverts commit 9c721c952b.

* Maybe fix iPad

* Revert "TODO REVERT THESE CHANGES"

This reverts commit 279f717f30.

* Rename var

---------

Co-authored-by: Dan Abramov <dan.abramov@gmail.com>
2024-04-27 04:34:53 +01:00
188 changed files with 17328 additions and 11686 deletions
+2
View File
@@ -4,3 +4,5 @@ SENTRY_AUTH_TOKEN=
EXPO_PUBLIC_ENV=development
EXPO_PUBLIC_LOG_LEVEL=debug
EXPO_PUBLIC_LOG_DEBUG=
EXPO_PUBLIC_BUNDLE_IDENTIFIER=
EXPO_PUBLIC_BUNDLE_DATE=0
@@ -1,9 +1,9 @@
name: build-and-push-bskyweb-aws
on:
workflow_dispatch:
push:
branches:
- main
- 3p-moderators
env:
REGISTRY: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_REGISTRY }}
@@ -54,3 +54,6 @@ jobs:
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
env:
EXPO_PUBLIC_BUNDLE_IDENTIFIER: $(git rev-parse --short HEAD)
EXPO_PUBLIC_BUNDLE_DATE: $(date -u +"%y%m%d%H")
@@ -57,6 +57,8 @@ jobs:
run: |
export json='${{ secrets.GOOGLE_SERVICES_TOKEN }}'
echo "${{ secrets.ENV_TOKEN }}" > .env
echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$(git rev-parse --short HEAD)" >> .env
echo "EXPO_PUBLIC_BUNDLE_DATE=$(date -u +"%y%m%d%H")" >> .env
echo "$json" > google-services.json
- name: 🏗️ EAS Build
+2
View File
@@ -65,6 +65,8 @@ jobs:
- name: ✏️ Write environment variables
run: |
echo "${{ secrets.ENV_TOKEN }}" > .env
echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$(git rev-parse --short HEAD)" >> .env
echo "EXPO_PUBLIC_BUNDLE_DATE=$(date -u +"%y%m%d%H")" >> .env
echo "${{ secrets.GOOGLE_SERVICES_TOKEN }}" > google-services.json
- name: 🏗️ EAS Build
@@ -144,6 +144,8 @@ jobs:
run: |
export json='${{ secrets.GOOGLE_SERVICES_TOKEN }}'
echo "${{ secrets.ENV_TOKEN }}" > .env
echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$(git rev-parse --short HEAD)" >> .env
echo "EXPO_PUBLIC_BUNDLE_DATE=$(date -u +"%y%m%d%H")" >> .env
echo "$json" > google-services.json
- name: 🏗️ Create Bundle
@@ -222,6 +224,8 @@ jobs:
- name: ✏️ Write environment variables
run: |
echo "${{ secrets.ENV_TOKEN }}" > .env
echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$(git rev-parse --short HEAD)" >> .env
echo "EXPO_PUBLIC_BUNDLE_DATE=$(date -u +"%y%m%d%H")" >> .env
echo "${{ secrets.GOOGLE_SERVICES_TOKEN }}" > google-services.json
- name: 🏗️ EAS Build
@@ -283,6 +287,8 @@ jobs:
run: |
export json='${{ secrets.GOOGLE_SERVICES_TOKEN }}'
echo "${{ secrets.ENV_TOKEN }}" > .env
echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$(git rev-parse --short HEAD)" >> .env
echo "EXPO_PUBLIC_BUNDLE_DATE=$(date -u +"%y%m%d%H")" >> .env
echo "$json" > google-services.json
- name: 🏗️ EAS Build
+4 -4
View File
@@ -20,8 +20,8 @@ jobs:
uses: actions/setup-go@v3
with:
go-version: '1.21'
- name: Dummy JS File
run: touch bskyweb/static/js/blah.js
- name: Dummy Static Files
run: touch bskyweb/static/js/blah.js && touch bskyweb/static/media/blah.txt
- name: Check
run: cd bskyweb/ && make check
- name: Build (binary)
@@ -37,7 +37,7 @@ jobs:
uses: actions/setup-go@v3
with:
go-version: '1.21'
- name: Dummy JS File
run: touch bskyweb/static/js/blah.js
- name: Dummy Static Files
run: touch bskyweb/static/js/blah.js && touch bskyweb/static/media/blah.txt
- name: Lint
run: cd bskyweb/ && make lint
+1
View File
@@ -40,6 +40,7 @@ RUN find ./bskyweb/embedr-static && find ./bskyweb/embedr-templates && find ./bs
# hack around issue with empty directory and go:embed
RUN touch bskyweb/static/js/empty.txt
RUN touch bskyweb/static/media/empty.txt
#
# Generate the embedr Go binary.
+4 -2
View File
@@ -1,8 +1,9 @@
/* eslint-env detox/detox */
import {describe, beforeAll, it} from '@jest/globals'
import {beforeAll, describe, it} from '@jest/globals'
import {expect} from 'detox'
import {openApp, loginAsAlice, loginAsBob, createServer, sleep} from '../util'
import {createServer, loginAsAlice, loginAsBob, openApp, sleep} from '../util'
describe('Curate lists', () => {
beforeAll(async () => {
@@ -115,6 +116,7 @@ describe('Curate lists', () => {
await element(by.text('About')).tap()
await element(by.id('addUserBtn')).tap()
await expect(element(by.id('listAddUserModal'))).toBeVisible()
await element(by.id('searchInput')).typeText('b')
await waitFor(element(by.id('user-bob.test-addBtn')))
.toBeVisible()
.withTimeout(5000)
+6
View File
@@ -396,6 +396,7 @@ describe('parseEmbedPlayerFromUrl', () => {
'https://youtube.com/watch?v=videoId&feature=share',
'https://youtube.com/shorts/videoId',
'https://m.youtube.com/watch?v=videoId',
'https://music.youtube.com/watch?v=videoId',
'https://youtube.com/shorts/',
'https://youtube.com/',
@@ -504,6 +505,11 @@ describe('parseEmbedPlayerFromUrl', () => {
source: 'youtube',
playerUri: 'https://bsky.app/iframe/youtube.html?videoId=videoId&start=0',
},
{
type: 'youtube_video',
source: 'youtube',
playerUri: 'https://bsky.app/iframe/youtube.html?videoId=videoId&start=0',
},
undefined,
undefined,
+4 -3
View File
@@ -1,10 +1,10 @@
import {it, describe, expect} from '@jest/globals'
import {describe, expect, it} from '@jest/globals'
import {
linkRequiresWarning,
isPossiblyAUrl,
splitApexDomain,
isTrustedUrl,
linkRequiresWarning,
splitApexDomain,
} from '../../../src/lib/strings/url-helpers'
describe('linkRequiresWarning', () => {
@@ -170,6 +170,7 @@ describe('isTrustedUrl', () => {
['https://google.com', false],
['https://docs.google.com', false],
['https://google.com/#', false],
['https://blueskywebxzendesk.com', false],
]
it.each(cases)('given input uri %p, returns %p', (str, expected) => {
+3
View File
@@ -64,6 +64,8 @@ module.exports = function (config) {
icon: './assets/icon.png',
userInterfaceStyle: 'automatic',
splash: SPLASH_CONFIG,
// hsl(211, 99%, 53%), same as palette.default.brandText
primaryColor: '#1083fe',
ios: {
supportsTablet: false,
bundleIdentifier: 'xyz.blueskyweb.app',
@@ -180,6 +182,7 @@ module.exports = function (config) {
'./plugins/withAndroidManifestPlugin.js',
'./plugins/withAndroidManifestFCMIconPlugin.js',
'./plugins/withAndroidStylesWindowBackgroundPlugin.js',
'./plugins/withAndroidStylesAccentColorPlugin.js',
'./plugins/withAndroidSplashScreenStatusBarTranslucentPlugin.js',
'./plugins/shareExtension/withShareExtensions.js',
].filter(Boolean),
@@ -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="M3.293 3.293A1 1 0 0 1 4 3h7.25a1 1 0 1 1 0 2H5v14h6.25a1 1 0 1 1 0 2H4a1 1 0 0 1-1-1V4a1 1 0 0 1 .293-.707Zm11.5 3.5a1 1 0 0 1 1.414 0l4.5 4.5a1 1 0 0 1 0 1.414l-4.5 4.5a1 1 0 0 1-1.414-1.414L17.586 13H8.75a1 1 0 1 1 0-2h8.836l-2.793-2.793a1 1 0 0 1 0-1.414Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 402 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 11.708 2.654 4.06A.998.998 0 0 1 3 4h18c.122 0 .238.022.346.061L12 11.708ZM2 19V6.11l9.367 7.664a1 1 0 0 0 1.266 0L22 6.11V19a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 303 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="M3.374 3.22a1 1 0 0 1 1.073-.114l16 8a1 1 0 0 1 0 1.788l-16 8a1 1 0 0 1-1.417-1.136L4.97 12 3.03 4.243a1 1 0 0 1 .344-1.023ZM6.781 13l-1.284 5.133L17.764 12 5.497 5.867 6.781 11H9a1 1 0 1 1 0 2H6.78Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 342 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 6a1 1 0 0 1 1 1v4h4a1 1 0 1 1 0 2h-4v4a1 1 0 1 1-2 0v-4H7a1 1 0 1 1 0-2h4V7a1 1 0 0 1 1-1Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 237 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 3a1 1 0 0 1 1 1v1.126a4 4 0 0 1 0 7.748V20a1 1 0 1 1-2 0v-7.126a4 4 0 0 1 0-7.748V4a1 1 0 0 1 1-1Zm10 0a1 1 0 0 1 1 1v9.126a4 4 0 1 1-2 0V4a1 1 0 0 1 1-1ZM7 7a2 2 0 1 0 0 4 2 2 0 1 0 0-4Zm10 8a2 2 0 1 0 0 4 2 2 0 1 0 0-4Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 367 B

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

+2
View File
@@ -10,6 +10,8 @@ static/js/*.js
static/js/*.map
static/js/*.js.LICENSE.txt
static/js/empty.txt
static/media/*.png
static/media/empty.txt
templates/scripts.html
templates/*-embed.html
static/embed/*.html
+3 -1
View File
@@ -158,7 +158,7 @@ func serve(cctx *cli.Context) error {
// Cache javascript and images files for 1 week, which works because
// they're always versioned (e.g. /static/js/main.64c14927.js)
if strings.HasPrefix(path, "/static/js/") || strings.HasPrefix(path, "/static/images/") {
if strings.HasPrefix(path, "/static/js/") || strings.HasPrefix(path, "/static/images/") || strings.HasPrefix(path, "/static/media/") {
maxAge = 7 * (60 * 60 * 24) // 1 week
}
@@ -200,6 +200,8 @@ func serve(cctx *cli.Context) error {
e.GET("/support/community-guidelines", server.WebGeneric)
e.GET("/support/copyright", server.WebGeneric)
e.GET("/intent/compose", server.WebGeneric)
e.GET("/messages", server.WebGeneric)
e.GET("/messages/:conversation", server.WebGeneric)
// profile endpoints; only first populates info
e.GET("/profile/:handleOrDID", server.WebProfile)
View File
+6 -3
View File
@@ -1,6 +1,6 @@
{
"name": "bsky.app",
"version": "1.79.0",
"version": "1.81.0",
"private": true,
"engines": {
"node": ">=18"
@@ -15,7 +15,7 @@
"web": "expo start --web",
"use-build-number": "./scripts/useBuildNumberEnv.sh",
"use-build-number-with-bump": "./scripts/useBuildNumberEnvWithBump.sh",
"build-web": "expo export:web && node ./scripts/post-web-build.js && cp -v ./web-build/static/js/*.* ./bskyweb/static/js/",
"build-web": "expo export:web && node ./scripts/post-web-build.js && cp -v ./web-build/static/js/*.* ./bskyweb/static/js/ && cp -v ./web-build/static/media/*.png ./bskyweb/static/media/",
"build-all": "yarn intl:build && yarn use-build-number-with-bump eas build --platform all",
"build-ios": "yarn use-build-number-with-bump eas build -p ios",
"build-android": "yarn use-build-number-with-bump eas build -p android",
@@ -50,10 +50,11 @@
"open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web"
},
"dependencies": {
"@atproto-labs/api": "^0.12.8-clipclops.0",
"@atproto/api": "^0.12.5",
"@bam.tech/react-native-image-resizer": "^3.0.4",
"@braintree/sanitize-url": "^6.0.2",
"@discord/bottom-sheet": "https://github.com/bluesky-social/react-native-bottom-sheet.git#discord-fork-4.6.1",
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
"@emoji-mart/react": "^1.1.1",
"@expo/html-elements": "^0.4.2",
"@expo/webpack-config": "^19.0.0",
@@ -168,6 +169,7 @@
"react-native-get-random-values": "~1.11.0",
"react-native-image-crop-picker": "^0.38.1",
"react-native-ios-context-menu": "^1.15.3",
"react-native-keyboard-controller": "^1.11.7",
"react-native-pager-view": "6.2.3",
"react-native-picker-select": "^8.1.0",
"react-native-progress": "bluesky-social/react-native-progress",
@@ -183,6 +185,7 @@
"react-native-web-webview": "^1.0.2",
"react-native-webview": "13.6.4",
"react-responsive": "^9.0.2",
"react-textarea-autosize": "^8.5.3",
"rn-fetch-blob": "^0.12.0",
"sentry-expo": "~7.0.1",
"statsig-react-native-expo": "^4.6.1",
+28
View File
@@ -0,0 +1,28 @@
diff --git a/node_modules/@atproto/lexicon/dist/validators/complex.js b/node_modules/@atproto/lexicon/dist/validators/complex.js
index 32d7798..9d688b7 100644
--- a/node_modules/@atproto/lexicon/dist/validators/complex.js
+++ b/node_modules/@atproto/lexicon/dist/validators/complex.js
@@ -113,7 +113,22 @@ function object(lexicons, path, def, value) {
if (value[key] === null && nullableProps.has(key)) {
continue;
}
- const propDef = def.properties[key];
+ const propDef = def.properties[key]
+ if (typeof value[key] === 'undefined' && !requiredProps.has(key)) {
+ // Fast path for non-required undefined props.
+ if (
+ propDef.type === 'integer' ||
+ propDef.type === 'boolean' ||
+ propDef.type === 'string'
+ ) {
+ if (typeof propDef.default === 'undefined') {
+ continue
+ }
+ } else {
+ // Other types have no defaults.
+ continue
+ }
+ }
const propPath = `${path}/${key}`;
const validated = (0, util_1.validateOneOf)(lexicons, propPath, propDef, value[key]);
const propValue = validated.success ? validated.value : value[key];
@@ -0,0 +1,25 @@
/**
* @file Set accent color to primaryColor from app.config.js.
* This way we get a sane default color for spinners, text inputs, etc.
*/
const {withAndroidStyles, AndroidConfig} = require('@expo/config-plugins')
module.exports = function withAndroidStylesAccentColorPlugin(appConfig) {
return withAndroidStyles(appConfig, function (decoratedAppConfig) {
try {
decoratedAppConfig.modResults = AndroidConfig.Styles.assignStylesValue(
decoratedAppConfig.modResults,
{
add: true,
parent: AndroidConfig.Styles.getAppThemeLightNoActionBarGroup(),
name: 'colorAccent',
value: '@color/colorPrimary',
},
)
} catch (e) {
console.error(`withAndroidStylesAccentColorPlugin failed`, e)
}
return decoratedAppConfig
})
}
+56 -37
View File
@@ -15,9 +15,11 @@ import {useLingui} from '@lingui/react'
import {useQueryClient} from '@tanstack/react-query'
import {Provider as StatsigProvider} from '#/lib/statsig/statsig'
import {logger} from '#/logger'
import {init as initPersistedState} from '#/state/persisted'
import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs'
import {readLastActiveAccount} from '#/state/session/util/readLastActiveAccount'
import {Provider as ModerationOptsProvider} from '#/state/preferences/moderation-opts'
import {readLastActiveAccount} from '#/state/session/util'
import {useIntentHandler} from 'lib/hooks/useIntentHandler'
import {useNotificationsListener} from 'lib/notifications/notifications'
import {QueryProvider} from 'lib/react-query'
@@ -32,6 +34,7 @@ import {Provider as PrefsStateProvider} from 'state/preferences'
import {Provider as UnreadNotifsProvider} from 'state/queries/notifications/unread'
import {
Provider as SessionProvider,
SessionAccount,
useSession,
useSessionApi,
} from 'state/session'
@@ -51,8 +54,9 @@ import {listenSessionDropped} from './state/events'
SplashScreen.preventAutoHideAsync()
function InnerApp() {
const {isInitialLoad, currentAccount} = useSession()
const {resumeSession} = useSessionApi()
const [isReady, setIsReady] = React.useState(false)
const {currentAccount} = useSession()
const {initSession} = useSessionApi()
const theme = useColorModeTheme()
const {_} = useLingui()
@@ -60,46 +64,61 @@ function InnerApp() {
// init
useEffect(() => {
listenSessionDropped(() => {
Toast.show(_(msg`Sorry! Your session expired. Please log in again.`))
})
async function resumeSession(account?: SessionAccount) {
try {
if (account) {
await initSession(account)
}
} catch (e) {
logger.error(`session: resumeSession failed`, {message: e})
} finally {
setIsReady(true)
}
}
const account = readLastActiveAccount()
resumeSession(account)
}, [resumeSession, _])
}, [initSession])
useEffect(() => {
return listenSessionDropped(() => {
Toast.show(_(msg`Sorry! Your session expired. Please log in again.`))
})
}, [_])
return (
<SafeAreaProvider initialMetrics={initialWindowMetrics}>
<Alf theme={theme}>
<Splash isReady={!isInitialLoad}>
<React.Fragment
// Resets the entire tree below when it changes:
key={currentAccount?.did}>
<QueryProvider currentDid={currentAccount?.did}>
<PushNotificationsListener>
<StatsigProvider>
<LabelDefsProvider>
<LoggedOutViewProvider>
<SelectedFeedProvider>
<UnreadNotifsProvider>
<ThemeProvider theme={theme}>
{/* All components should be within this provider */}
<RootSiblingParent>
<GestureHandlerRootView style={s.h100pct}>
<TestCtrls />
<Shell />
</GestureHandlerRootView>
</RootSiblingParent>
</ThemeProvider>
</UnreadNotifsProvider>
</SelectedFeedProvider>
</LoggedOutViewProvider>
</LabelDefsProvider>
</StatsigProvider>
</PushNotificationsListener>
</QueryProvider>
</React.Fragment>
</Splash>
<ThemeProvider theme={theme}>
<Splash isReady={isReady}>
<RootSiblingParent>
<React.Fragment
// Resets the entire tree below when it changes:
key={currentAccount?.did}>
<QueryProvider currentDid={currentAccount?.did}>
<PushNotificationsListener>
<StatsigProvider>
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
<LabelDefsProvider>
<ModerationOptsProvider>
<LoggedOutViewProvider>
<SelectedFeedProvider>
<UnreadNotifsProvider>
<GestureHandlerRootView style={s.h100pct}>
<TestCtrls />
<Shell />
</GestureHandlerRootView>
</UnreadNotifsProvider>
</SelectedFeedProvider>
</LoggedOutViewProvider>
</ModerationOptsProvider>
</LabelDefsProvider>
</StatsigProvider>
</PushNotificationsListener>
</QueryProvider>
</React.Fragment>
</RootSiblingParent>
</Splash>
</ThemeProvider>
</Alf>
</SafeAreaProvider>
)
+58 -30
View File
@@ -4,11 +4,15 @@ import 'view/icons'
import React, {useEffect, useState} from 'react'
import {RootSiblingParent} from 'react-native-root-siblings'
import {SafeAreaProvider} from 'react-native-safe-area-context'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {Provider as StatsigProvider} from '#/lib/statsig/statsig'
import {logger} from '#/logger'
import {init as initPersistedState} from '#/state/persisted'
import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs'
import {readLastActiveAccount} from '#/state/session/util/readLastActiveAccount'
import {Provider as ModerationOptsProvider} from '#/state/preferences/moderation-opts'
import {readLastActiveAccount} from '#/state/session/util'
import {useIntentHandler} from 'lib/hooks/useIntentHandler'
import {QueryProvider} from 'lib/react-query'
import {ThemeProvider} from 'lib/ThemeContext'
@@ -21,61 +25,85 @@ import {Provider as PrefsStateProvider} from 'state/preferences'
import {Provider as UnreadNotifsProvider} from 'state/queries/notifications/unread'
import {
Provider as SessionProvider,
SessionAccount,
useSession,
useSessionApi,
} from 'state/session'
import {Provider as ShellStateProvider} from 'state/shell'
import {Provider as LoggedOutViewProvider} from 'state/shell/logged-out'
import {Provider as SelectedFeedProvider} from 'state/shell/selected-feed'
import * as Toast from 'view/com/util/Toast'
import {ToastContainer} from 'view/com/util/Toast.web'
import {Shell} from 'view/shell/index'
import {ThemeProvider as Alf} from '#/alf'
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
import {Provider as PortalProvider} from '#/components/Portal'
import I18nProvider from './locale/i18nProvider'
import {listenSessionDropped} from './state/events'
function InnerApp() {
const {isInitialLoad, currentAccount} = useSession()
const {resumeSession} = useSessionApi()
const [isReady, setIsReady] = React.useState(false)
const {currentAccount} = useSession()
const {initSession} = useSessionApi()
const theme = useColorModeTheme()
const {_} = useLingui()
useIntentHandler()
// init
useEffect(() => {
async function resumeSession(account?: SessionAccount) {
try {
if (account) {
await initSession(account)
}
} catch (e) {
logger.error(`session: resumeSession failed`, {message: e})
} finally {
setIsReady(true)
}
}
const account = readLastActiveAccount()
resumeSession(account)
}, [resumeSession])
}, [initSession])
useEffect(() => {
return listenSessionDropped(() => {
Toast.show(_(msg`Sorry! Your session expired. Please log in again.`))
})
}, [_])
// wait for session to resume
if (isInitialLoad) return null
if (!isReady) return null
return (
<Alf theme={theme}>
<React.Fragment
// Resets the entire tree below when it changes:
key={currentAccount?.did}>
<QueryProvider currentDid={currentAccount?.did}>
<StatsigProvider>
<LabelDefsProvider>
<LoggedOutViewProvider>
<SelectedFeedProvider>
<UnreadNotifsProvider>
<ThemeProvider theme={theme}>
{/* All components should be within this provider */}
<RootSiblingParent>
<SafeAreaProvider>
<Shell />
</SafeAreaProvider>
</RootSiblingParent>
<ToastContainer />
</ThemeProvider>
</UnreadNotifsProvider>
</SelectedFeedProvider>
</LoggedOutViewProvider>
</LabelDefsProvider>
</StatsigProvider>
</QueryProvider>
</React.Fragment>
<ThemeProvider theme={theme}>
<RootSiblingParent>
<React.Fragment
// Resets the entire tree below when it changes:
key={currentAccount?.did}>
<QueryProvider currentDid={currentAccount?.did}>
<StatsigProvider>
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
<LabelDefsProvider>
<ModerationOptsProvider>
<LoggedOutViewProvider>
<SelectedFeedProvider>
<UnreadNotifsProvider>
<SafeAreaProvider>
<Shell />
</SafeAreaProvider>
</UnreadNotifsProvider>
</SelectedFeedProvider>
</LoggedOutViewProvider>
</ModerationOptsProvider>
</LabelDefsProvider>
</StatsigProvider>
</QueryProvider>
</React.Fragment>
<ToastContainer />
</RootSiblingParent>
</ThemeProvider>
</Alf>
)
}
+50
View File
@@ -25,6 +25,7 @@ import {
FeedsTabNavigatorParams,
FlatNavigatorParams,
HomeTabNavigatorParams,
MessagesTabNavigatorParams,
MyProfileTabNavigatorParams,
NotificationsTabNavigatorParams,
SearchTabNavigatorParams,
@@ -46,6 +47,9 @@ import {init as initAnalytics} from './lib/analytics/analytics'
import {useWebScrollRestoration} from './lib/hooks/useWebScrollRestoration'
import {attachRouteToLogEvents, logEvent} from './lib/statsig/statsig'
import {router} from './routes'
import {MessagesConversationScreen} from './screens/Messages/Conversation'
import {MessagesScreen} from './screens/Messages/List'
import {MessagesSettingsScreen} from './screens/Messages/Settings'
import {useModalControls} from './state/modals'
import {useUnreadNotifications} from './state/queries/notifications/unread'
import {useSession} from './state/session'
@@ -92,6 +96,8 @@ const NotificationsTab =
createNativeStackNavigatorWithAuth<NotificationsTabNavigatorParams>()
const MyProfileTab =
createNativeStackNavigatorWithAuth<MyProfileTabNavigatorParams>()
const MessagesTab =
createNativeStackNavigatorWithAuth<MessagesTabNavigatorParams>()
const Flat = createNativeStackNavigatorWithAuth<FlatNavigatorParams>()
const Tab = createBottomTabNavigator<BottomTabNavigatorParams>()
@@ -290,6 +296,16 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
getComponent={() => HashtagScreen}
options={{title: title(msg`Hashtag`)}}
/>
<Stack.Screen
name="MessagesConversation"
getComponent={() => MessagesConversationScreen}
options={{title: title(msg`Chat`), requireAuth: true}}
/>
<Stack.Screen
name="MessagesSettings"
getComponent={() => MessagesSettingsScreen}
options={{title: title(msg`Messaging settings`), requireAuth: true}}
/>
</>
)
}
@@ -323,6 +339,10 @@ function TabsNavigator() {
name="MyProfileTab"
getComponent={() => MyProfileTabNavigator}
/>
<Tab.Screen
name="MessagesTab"
getComponent={() => MessagesTabNavigator}
/>
</Tab.Navigator>
)
}
@@ -429,6 +449,28 @@ function MyProfileTabNavigator() {
)
}
function MessagesTabNavigator() {
const pal = usePalette('default')
return (
<MessagesTab.Navigator
screenOptions={{
animation: isAndroid ? 'none' : undefined,
gestureEnabled: true,
fullScreenGestureEnabled: true,
headerShown: false,
animationDuration: 250,
contentStyle: pal.view,
}}>
<MessagesTab.Screen
name="Messages"
getComponent={() => MessagesScreen}
options={{requireAuth: true}}
/>
{commonScreens(MessagesTab as typeof HomeTab)}
</MessagesTab.Navigator>
)
}
/**
* The FlatNavigator is used by Web to represent the routes
* in a single ("flat") stack.
@@ -469,6 +511,11 @@ const FlatNavigator = () => {
getComponent={() => NotificationsScreen}
options={{title: title(msg`Notifications`), requireAuth: true}}
/>
<Flat.Screen
name="Messages"
getComponent={() => MessagesScreen}
options={{title: title(msg`Messages`), requireAuth: true}}
/>
{commonScreens(Flat as typeof HomeTab, numUnread)}
</Flat.Navigator>
)
@@ -522,6 +569,9 @@ const LINKING = {
if (name === 'Home') {
return buildStateObject('HomeTab', 'Home', params)
}
if (name === 'Messages') {
return buildStateObject('MessagesTab', 'Messages', params)
}
// if the path is something else, like a post, profile, or even settings, we need to initialize the home tab as pre-existing state otherwise the back button will not work
return buildStateObject('HomeTab', name, params, [
{
+10 -5
View File
@@ -16,12 +16,14 @@ export function AccountList({
onSelectAccount,
onSelectOther,
otherLabel,
pendingDid,
}: {
onSelectAccount: (account: SessionAccount) => void
onSelectOther: () => void
otherLabel?: string
pendingDid: string | null
}) {
const {isSwitchingAccounts, currentAccount, accounts} = useSession()
const {currentAccount, accounts} = useSession()
const t = useTheme()
const {_} = useLingui()
@@ -31,6 +33,7 @@ export function AccountList({
return (
<View
pointerEvents={pendingDid ? 'none' : 'auto'}
style={[
a.rounded_md,
a.overflow_hidden,
@@ -43,6 +46,7 @@ export function AccountList({
account={account}
onSelect={onSelectAccount}
isCurrentAccount={account.did === currentAccount?.did}
isPendingAccount={account.did === pendingDid}
/>
<View style={[a.border_b, t.atoms.border_contrast_low]} />
</React.Fragment>
@@ -50,7 +54,7 @@ export function AccountList({
<Button
testID="chooseAddAccountBtn"
style={[a.flex_1]}
onPress={isSwitchingAccounts ? undefined : onPressAddAccount}
onPress={pendingDid ? undefined : onPressAddAccount}
label={_(msg`Login to account that is not listed`)}>
{({hovered, pressed}) => (
<View
@@ -59,8 +63,7 @@ export function AccountList({
a.flex_row,
a.align_center,
{height: 48},
(hovered || pressed || isSwitchingAccounts) &&
t.atoms.bg_contrast_25,
(hovered || pressed) && t.atoms.bg_contrast_25,
]}>
<Text
style={[
@@ -84,10 +87,12 @@ function AccountItem({
account,
onSelect,
isCurrentAccount,
isPendingAccount,
}: {
account: SessionAccount
onSelect: (account: SessionAccount) => void
isCurrentAccount: boolean
isPendingAccount: boolean
}) {
const t = useTheme()
const {_} = useLingui()
@@ -115,7 +120,7 @@ function AccountItem({
a.flex_row,
a.align_center,
{height: 48},
(hovered || pressed) && t.atoms.bg_contrast_25,
(hovered || pressed || isPendingAccount) && t.atoms.bg_contrast_25,
]}>
<View style={a.p_md}>
<UserAvatar avatar={profile?.avatar} size={24} />
+1 -1
View File
@@ -64,7 +64,7 @@ type NonTextElements =
export type ButtonProps = Pick<
PressableProps,
'disabled' | 'onPress' | 'testID'
'disabled' | 'onPress' | 'testID' | 'onLongPress'
> &
AccessibilityProps &
VariantProps & {
+6
View File
@@ -152,6 +152,12 @@ export function Outer({
[open, close],
)
React.useEffect(() => {
return () => {
setDialogIsOpen(control.id, false)
}
}, [control.id, setDialogIsOpen])
const context = React.useMemo(() => ({close}), [close])
return (
+16 -12
View File
@@ -17,12 +17,14 @@ export function Error({
message,
onRetry,
onGoBack: onGoBackProp,
hideBackButton,
sideBorders = true,
}: {
title?: string
message?: string
onRetry?: () => unknown
onGoBack?: () => unknown
hideBackButton?: boolean
sideBorders?: boolean
}) {
const navigation = useNavigation<NavigationProp>()
@@ -70,7 +72,7 @@ export function Error({
a.text_center,
t.atoms.text_contrast_high,
{lineHeight: 1.4},
gtMobile && {width: 450},
gtMobile ? {width: 450} : [a.w_full, a.px_lg],
]}>
{message}
</Text>
@@ -89,17 +91,19 @@ export function Error({
</ButtonText>
</Button>
)}
<Button
variant="solid"
color={onRetry ? 'secondary' : 'primary'}
label={_(msg`Return to previous page`)}
onPress={onGoBack}
size="large"
style={[a.rounded_sm, a.overflow_hidden, {paddingVertical: 10}]}>
<ButtonText>
<Trans>Go Back</Trans>
</ButtonText>
</Button>
{!hideBackButton && (
<Button
variant="solid"
color={onRetry ? 'secondary' : 'primary'}
label={_(msg`Return to previous page`)}
onPress={onGoBack}
size="large"
style={[a.rounded_sm, a.overflow_hidden, {paddingVertical: 10}]}>
<ButtonText>
<Trans>Go Back</Trans>
</ButtonText>
</Button>
)}
</View>
</CenteredView>
)
+5 -1
View File
@@ -3,10 +3,12 @@ import {GestureResponderEvent} from 'react-native'
import {sanitizeUrl} from '@braintree/sanitize-url'
import {StackActions, useLinkProps} from '@react-navigation/native'
import {BSKY_DOWNLOAD_URL} from '#/lib/constants'
import {AllNavigatorParams} from '#/lib/routes/types'
import {shareUrl} from '#/lib/sharing'
import {
convertBskyAppUrlIfNeeded,
isBskyDownloadUrl,
isExternalUrl,
linkRequiresWarning,
} from '#/lib/strings/url-helpers'
@@ -125,7 +127,9 @@ export function useLink({
(event.metaKey || event.altKey || event.ctrlKey || event.shiftKey)
const shouldOpenInNewTab = isMetaKey || isMiddleClick
if (
if (isBskyDownloadUrl(href)) {
shareUrl(BSKY_DOWNLOAD_URL)
} else if (
shouldOpenInNewTab ||
href.startsWith('http') ||
href.startsWith('mailto')
+4
View File
@@ -134,6 +134,7 @@ let ListMaybePlaceholder = ({
emptyType = 'page',
onRetry,
onGoBack,
hideBackButton,
sideBorders,
}: {
isLoading: boolean
@@ -146,6 +147,7 @@ let ListMaybePlaceholder = ({
emptyType?: 'page' | 'results'
onRetry?: () => Promise<unknown>
onGoBack?: () => void
hideBackButton?: boolean
sideBorders?: boolean
}): React.ReactNode => {
const t = useTheme()
@@ -179,6 +181,7 @@ let ListMaybePlaceholder = ({
onRetry={onRetry}
onGoBack={onGoBack}
sideBorders={sideBorders}
hideBackButton={hideBackButton}
/>
)
}
@@ -198,6 +201,7 @@ let ListMaybePlaceholder = ({
}
onRetry={onRetry}
onGoBack={onGoBack}
hideBackButton={hideBackButton}
sideBorders={sideBorders}
/>
)
+13 -11
View File
@@ -1,27 +1,29 @@
import React from 'react'
import {View, Pressable, ViewStyle, StyleProp} from 'react-native'
import {Pressable, StyleProp, View, ViewStyle} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import flattenReactChildren from 'react-keyed-flatten-children'
import {isNative} from 'platform/detection'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import {Text} from '#/components/Typography'
import {Context} from '#/components/Menu/context'
import {
ContextType,
TriggerProps,
ItemProps,
GroupProps,
ItemTextProps,
ItemIconProps,
ItemProps,
ItemTextProps,
TriggerProps,
} from '#/components/Menu/types'
import {Button, ButtonText} from '#/components/Button'
import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {isNative} from 'platform/detection'
import {Text} from '#/components/Typography'
export {useDialogControl as useMenuControl} from '#/components/Dialog'
export {
type DialogControlProps as MenuControlProps,
useDialogControl as useMenuControl,
} from '#/components/Dialog'
export function useMemoControlContext() {
return React.useContext(Context)
+24 -13
View File
@@ -1,27 +1,26 @@
/* eslint-disable react/prop-types */
import React from 'react'
import {View, Pressable, ViewStyle, StyleProp} from 'react-native'
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
import {Pressable, StyleProp, View, ViewStyle} from 'react-native'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
import {atoms as a, flatten, useTheme, web} from '#/alf'
import * as Dialog from '#/components/Dialog'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import {atoms as a, useTheme, flatten, web} from '#/alf'
import {Text} from '#/components/Typography'
import {Context} from '#/components/Menu/context'
import {
ContextType,
TriggerProps,
ItemProps,
GroupProps,
ItemTextProps,
ItemIconProps,
ItemProps,
ItemTextProps,
RadixPassThroughTriggerProps,
TriggerProps,
} from '#/components/Menu/types'
import {Context} from '#/components/Menu/context'
import {Portal} from '#/components/Portal'
import {Text} from '#/components/Typography'
export function useMenuControl(): Dialog.DialogControlProps {
const id = React.useId()
@@ -135,10 +134,22 @@ export function Trigger({children, label}: TriggerProps) {
},
props: {
...props,
// disable on web, use `onPress`
onPointerDown: () => false,
onPress: () =>
control.isOpen ? control.close() : control.open(),
// No-op override to prevent false positive that interprets mobile scroll as a tap.
// This requires the custom onPress handler below to compensate.
// https://github.com/radix-ui/primitives/issues/1912
onPointerDown: undefined,
onPress: () => {
if (window.event instanceof KeyboardEvent) {
// The onPointerDown hack above is not relevant to this press, so don't do anything.
return
}
// Compensate for the disabled onPointerDown above by triggering it manually.
if (control.isOpen) {
control.close()
} else {
control.open()
}
},
onFocus: onFocus,
onBlur: onBlur,
onMouseEnter,
+10 -3
View File
@@ -34,10 +34,17 @@ export function createPortalGroup() {
setOutlet(<>{Object.values(map.current)}</>)
}, [])
const contextValue = React.useMemo(
() => ({
outlet,
append,
remove,
}),
[outlet, append, remove],
)
return (
<Context.Provider value={{outlet, append, remove}}>
{props.children}
</Context.Provider>
<Context.Provider value={contextValue}>{props.children}</Context.Provider>
)
}
+14 -6
View File
@@ -9,7 +9,7 @@ import {makeProfileLink} from '#/lib/routes/links'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
import {pluralize} from '#/lib/strings/helpers'
import {useModerationOpts} from '#/state/queries/preferences'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {usePrefetchProfileQuery, useProfileQuery} from '#/state/queries/profile'
import {useSession} from '#/state/session'
import {useProfileShadow} from 'state/cache/profile-shadow'
@@ -72,10 +72,10 @@ type Action =
| 'unhovered-long-enough'
| 'finished-animating-hide'
const SHOW_DELAY = 400
const SHOW_DELAY = 500
const SHOW_DURATION = 300
const HIDE_DELAY = 150
const HIDE_DURATION = 150
const HIDE_DURATION = 200
export function ProfileHoverCardInner(props: ProfileHoverCardProps) {
const {refs, floatingStyles} = useFloating({
@@ -244,12 +244,20 @@ export function ProfileHoverCardInner(props: ProfileHoverCardProps) {
}
}, [prefetchProfileQuery, props.did])
const onPointerEnterTarget = React.useCallback(() => {
const didFireHover = React.useRef(false)
const onPointerMoveTarget = React.useCallback(() => {
prefetchIfNeeded()
dispatch('hovered-target')
// Conceptually we want something like onPointerEnter,
// but we want to ignore entering only due to scrolling.
// So instead we hover on the first onPointerMove.
if (!didFireHover.current) {
didFireHover.current = true
dispatch('hovered-target')
}
}, [prefetchIfNeeded])
const onPointerLeaveTarget = React.useCallback(() => {
didFireHover.current = false
dispatch('unhovered-target')
}, [])
@@ -280,7 +288,7 @@ export function ProfileHoverCardInner(props: ProfileHoverCardProps) {
return (
<div
ref={refs.setReference}
onPointerEnter={onPointerEnterTarget}
onPointerMove={onPointerMoveTarget}
onPointerLeave={onPointerLeaveTarget}
onMouseUp={onPress}
style={{
+3 -1
View File
@@ -43,7 +43,9 @@ export function Outer({
<Dialog.ScrollableInner
accessibilityLabelledBy={titleId}
accessibilityDescribedBy={descriptionId}
style={[gtMobile ? {width: 'auto', maxWidth: 400} : a.w_full]}>
style={[
gtMobile ? {width: 'auto', maxWidth: 400, minWidth: 200} : a.w_full,
]}>
{children}
</Dialog.ScrollableInner>
</Context.Provider>
+2 -2
View File
@@ -1,5 +1,5 @@
import React, {useCallback, useMemo, useRef, useState} from 'react'
import {Keyboard, TextInput, View} from 'react-native'
import {TextInput, View} from 'react-native'
import {Image} from 'expo-image'
import {BottomSheetFlatListMethods} from '@discord/bottom-sheet'
import {msg, Trans} from '@lingui/macro'
@@ -216,7 +216,7 @@ function GifList({
keyExtractor={(item: Gif) => item.id}
// @ts-expect-error web only
style={isWeb && {minHeight: '100vh'}}
onScrollBeginDrag={() => Keyboard.dismiss()}
keyboardDismissMode="on-drag"
ListFooterComponent={
hasData ? (
<ListFooter
+2 -2
View File
@@ -37,12 +37,12 @@ export function MutedWordsDialog() {
return (
<Dialog.Outer control={control}>
<Dialog.Handle />
<MutedWordsInner control={control} />
<MutedWordsInner />
</Dialog.Outer>
)
}
function MutedWordsInner({}: {control: Dialog.DialogOuterProps['control']}) {
function MutedWordsInner() {
const t = useTheme()
const {_} = useLingui()
const {gtMobile} = useBreakpoints()
+2 -1
View File
@@ -18,7 +18,7 @@ export function SwitchAccountDialog({
}) {
const {_} = useLingui()
const {currentAccount} = useSession()
const {onPressSwitchAccount} = useAccountSwitcher()
const {onPressSwitchAccount, pendingDid} = useAccountSwitcher()
const {setShowLoggedOut} = useLoggedOutViewControls()
const onSelectAccount = useCallback(
@@ -54,6 +54,7 @@ export function SwitchAccountDialog({
onSelectAccount={onSelectAccount}
onSelectOther={onPressAddAccount}
otherLabel={_(msg`Add account`)}
pendingDid={pendingDid}
/>
</View>
</Dialog.ScrollableInner>
+83
View File
@@ -0,0 +1,83 @@
import React, {useCallback} from 'react'
import {Keyboard, Pressable, View} from 'react-native'
import Animated, {
cancelAnimation,
runOnJS,
useAnimatedStyle,
useSharedValue,
withTiming,
} from 'react-native-reanimated'
import {ChatBskyConvoDefs} from '@atproto-labs/api'
import {useHaptics} from 'lib/haptics'
import {atoms as a} from '#/alf'
import {MessageMenu} from '#/components/dms/MessageMenu'
import {useMenuControl} from '#/components/Menu'
const AnimatedPressable = Animated.createAnimatedComponent(Pressable)
export function ActionsWrapper({
message,
isFromSelf,
children,
}: {
message: ChatBskyConvoDefs.MessageView
isFromSelf: boolean
children: React.ReactNode
}) {
const playHaptic = useHaptics()
const menuControl = useMenuControl()
const scale = useSharedValue(1)
const animationDidComplete = useSharedValue(false)
const animatedStyle = useAnimatedStyle(() => ({
transform: [{scale: scale.value}],
}))
// Reanimated's `runOnJS` doesn't like refs, so we can't use `runOnJS(menuControl.open)()`. Instead, we'll use this
// function
const open = useCallback(() => {
Keyboard.dismiss()
menuControl.open()
}, [menuControl])
const shrink = useCallback(() => {
'worklet'
cancelAnimation(scale)
scale.value = withTiming(1, {duration: 200}, () => {
animationDidComplete.value = false
})
}, [animationDidComplete, scale])
const grow = React.useCallback(() => {
'worklet'
scale.value = withTiming(1.05, {duration: 750}, finished => {
if (!finished) return
animationDidComplete.value = true
runOnJS(playHaptic)()
runOnJS(open)()
shrink()
})
}, [scale, animationDidComplete, playHaptic, shrink, open])
return (
<View
style={[
{
maxWidth: '65%',
},
isFromSelf ? a.self_end : a.self_start,
]}>
<AnimatedPressable
style={animatedStyle}
unstable_pressDelay={200}
onPressIn={grow}
onTouchEnd={shrink}>
{children}
</AnimatedPressable>
<MessageMenu message={message} control={menuControl} hideTrigger={true} />
</View>
)
}
+86
View File
@@ -0,0 +1,86 @@
import React from 'react'
import {StyleSheet, View} from 'react-native'
import {ChatBskyConvoDefs} from '@atproto-labs/api'
import {atoms as a} from '#/alf'
import {MessageMenu} from '#/components/dms/MessageMenu'
import {useMenuControl} from '#/components/Menu'
export function ActionsWrapper({
message,
isFromSelf,
children,
}: {
message: ChatBskyConvoDefs.MessageView
isFromSelf: boolean
children: React.ReactNode
}) {
const menuControl = useMenuControl()
const viewRef = React.useRef(null)
const [showActions, setShowActions] = React.useState(false)
const onMouseEnter = React.useCallback(() => {
setShowActions(true)
}, [])
const onMouseLeave = React.useCallback(() => {
setShowActions(false)
}, [])
// We need to handle the `onFocus` separately because we want to know if there is a related target (the element
// that is losing focus). If there isn't that means the focus is coming from a dropdown that is now closed.
const onFocus = React.useCallback<React.FocusEventHandler>(e => {
if (e.nativeEvent.relatedTarget == null) return
setShowActions(true)
}, [])
return (
<View
// @ts-expect-error web only
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
onFocus={onFocus}
onBlur={onMouseLeave}
style={StyleSheet.flatten([a.flex_1, a.flex_row])}
ref={viewRef}>
{isFromSelf && (
<View
style={[
a.mr_xl,
a.justify_center,
{
marginLeft: 'auto',
},
]}>
<MessageMenu
message={message}
control={menuControl}
triggerOpacity={showActions || menuControl.isOpen ? 1 : 0}
onTriggerPress={onMouseEnter}
// @ts-expect-error web only
onMouseLeave={onMouseLeave}
/>
</View>
)}
<View
style={{
maxWidth: '65%',
}}>
{children}
</View>
{!isFromSelf && (
<View style={[a.flex_row, a.align_center, a.ml_xl]}>
<MessageMenu
message={message}
control={menuControl}
triggerOpacity={showActions || menuControl.isOpen ? 1 : 0}
onTriggerPress={onMouseEnter}
// @ts-expect-error web only
onMouseLeave={onMouseLeave}
/>
</View>
)}
</View>
)
}
+184
View File
@@ -0,0 +1,184 @@
import React, {useCallback} from 'react'
import {Keyboard, Pressable} from 'react-native'
import {AppBskyActorDefs} from '@atproto/api'
import {ChatBskyConvoDefs} from '@atproto-labs/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
import {NavigationProp} from '#/lib/routes/types'
import {useLeaveConvo} from '#/state/queries/messages/leave-conversation'
import {
useMuteConvo,
useUnmuteConvo,
} from '#/state/queries/messages/mute-conversation'
import * as Toast from '#/view/com/util/Toast'
import {atoms as a, useTheme} from '#/alf'
import {ArrowBoxLeft_Stroke2_Corner0_Rounded as ArrowBoxLeft} from '#/components/icons/ArrowBoxLeft'
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 {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/icons/Speaker'
import * as Menu from '#/components/Menu'
import * as Prompt from '#/components/Prompt'
let ConvoMenu = ({
convo,
profile,
onUpdateConvo,
control,
hideTrigger,
currentScreen,
}: {
convo: ChatBskyConvoDefs.ConvoView
profile: AppBskyActorDefs.ProfileViewBasic
onUpdateConvo?: (convo: ChatBskyConvoDefs.ConvoView) => void
control?: Menu.MenuControlProps
hideTrigger?: boolean
currentScreen: 'list' | 'conversation'
}): React.ReactNode => {
const navigation = useNavigation<NavigationProp>()
const {_} = useLingui()
const t = useTheme()
const leaveConvoControl = Prompt.usePromptControl()
const onNavigateToProfile = useCallback(() => {
navigation.navigate('Profile', {name: profile.did})
}, [navigation, profile.did])
const {mutate: muteConvo} = useMuteConvo(convo.id, {
onSuccess: data => {
onUpdateConvo?.(data.convo)
Toast.show(_(msg`Chat muted`))
},
onError: () => {
Toast.show(_(msg`Could not mute chat`))
},
})
const {mutate: unmuteConvo} = useUnmuteConvo(convo.id, {
onSuccess: data => {
onUpdateConvo?.(data.convo)
Toast.show(_(msg`Chat unmuted`))
},
onError: () => {
Toast.show(_(msg`Could not unmute chat`))
},
})
const {mutate: leaveConvo} = useLeaveConvo(convo.id, {
onSuccess: () => {
if (currentScreen === 'conversation') {
navigation.replace('Messages')
}
},
onError: () => {
Toast.show(_(msg`Could not leave chat`))
},
})
return (
<>
<Menu.Root control={control}>
{!hideTrigger && (
<Menu.Trigger label={_(msg`Chat settings`)}>
{({props, state}) => (
<Pressable
{...props}
onPress={() => {
Keyboard.dismiss()
// eslint-disable-next-line react/prop-types -- eslint is confused by the name `props`
props.onPress()
}}
style={[
a.p_sm,
a.rounded_sm,
(state.hovered || state.pressed) && t.atoms.bg_contrast_25,
// make sure pfp is in the middle
{marginLeft: -10},
]}>
<DotsHorizontal size="lg" style={t.atoms.text} />
</Pressable>
)}
</Menu.Trigger>
)}
<Menu.Outer>
<Menu.Group>
<Menu.Item
label={_(msg`Go to user's profile`)}
onPress={onNavigateToProfile}>
<Menu.ItemText>
<Trans>Go to profile</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={Person} />
</Menu.Item>
<Menu.Item
label={_(msg`Mute notifications`)}
onPress={() => (convo?.muted ? unmuteConvo() : muteConvo())}>
<Menu.ItemText>
{convo?.muted ? (
<Trans>Unmute notifications</Trans>
) : (
<Trans>Mute notifications</Trans>
)}
</Menu.ItemText>
<Menu.ItemIcon icon={convo?.muted ? Unmute : Mute} />
</Menu.Item>
</Menu.Group>
<Menu.Divider />
{/* TODO(samuel): implement these */}
<Menu.Group>
<Menu.Item
label={_(msg`Block account`)}
onPress={() => {}}
disabled>
<Menu.ItemText>
<Trans>Block account</Trans>
</Menu.ItemText>
<Menu.ItemIcon
icon={profile.viewer?.blocking ? PersonCheck : PersonX}
/>
</Menu.Item>
<Menu.Item
label={_(msg`Report account`)}
onPress={() => {}}
disabled>
<Menu.ItemText>
<Trans>Report account</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={Flag} />
</Menu.Item>
</Menu.Group>
<Menu.Divider />
<Menu.Group>
<Menu.Item
label={_(msg`Leave conversation`)}
onPress={leaveConvoControl.open}>
<Menu.ItemText>
<Trans>Leave conversation</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={ArrowBoxLeft} />
</Menu.Item>
</Menu.Group>
</Menu.Outer>
</Menu.Root>
<Prompt.Basic
control={leaveConvoControl}
title={_(msg`Leave conversation`)}
description={_(
msg`Are you sure you want to leave this conversation? Your messages will be deleted for you, but not for other participants.`,
)}
confirmButtonCta={_(msg`Leave`)}
confirmButtonColor="negative"
onConfirm={() => leaveConvo()}
/>
</>
)
}
ConvoMenu = React.memo(ConvoMenu)
export {ConvoMenu}
+198
View File
@@ -0,0 +1,198 @@
import React, {useCallback, useMemo, useRef} from 'react'
import {LayoutAnimation, StyleProp, TextStyle, View} from 'react-native'
import {ChatBskyConvoDefs} from '@atproto-labs/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useSession} from '#/state/session'
import {TimeElapsed} from 'view/com/util/TimeElapsed'
import {atoms as a, useTheme} from '#/alf'
import {ActionsWrapper} from '#/components/dms/ActionsWrapper'
import {Text} from '#/components/Typography'
export let MessageItem = ({
item,
next,
pending,
}: {
item: ChatBskyConvoDefs.MessageView
next:
| ChatBskyConvoDefs.MessageView
| ChatBskyConvoDefs.DeletedMessageView
| null
pending?: boolean
}): React.ReactNode => {
const t = useTheme()
const {currentAccount} = useSession()
const isFromSelf = item.sender?.did === currentAccount?.did
const isNextFromSelf =
ChatBskyConvoDefs.isMessageView(next) &&
next.sender?.did === currentAccount?.did
const isLastInGroup = useMemo(() => {
// TODO this means it's a placeholder. Let's figure out the right way to do this though!
if (item.id.length > 13) {
return false
}
// if the next message is from a different sender, then it's the last in the group
if (isFromSelf ? !isNextFromSelf : isNextFromSelf) {
return true
}
// or, if there's a 3 minute gap between this message and the next
if (ChatBskyConvoDefs.isMessageView(next)) {
const thisDate = new Date(item.sentAt)
const nextDate = new Date(next.sentAt)
const diff = nextDate.getTime() - thisDate.getTime()
// 3 minutes
return diff > 3 * 60 * 1000
}
return true
}, [item, next, isFromSelf, isNextFromSelf])
const lastInGroupRef = useRef(isLastInGroup)
if (lastInGroupRef.current !== isLastInGroup) {
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
lastInGroupRef.current = isLastInGroup
}
const pendingColor =
t.name === 'light' ? t.palette.primary_200 : t.palette.primary_800
return (
<View>
<ActionsWrapper isFromSelf={isFromSelf} message={item}>
<View
style={[
a.py_sm,
a.my_2xs,
a.rounded_md,
{
paddingLeft: 14,
paddingRight: 14,
backgroundColor: isFromSelf
? pending
? pendingColor
: t.palette.primary_500
: t.palette.contrast_50,
borderRadius: 17,
},
isFromSelf
? {borderBottomRightRadius: isLastInGroup ? 2 : 17}
: {borderBottomLeftRadius: isLastInGroup ? 2 : 17},
]}>
<Text
style={[
a.text_md,
a.leading_snug,
isFromSelf && {color: t.palette.white},
pending && t.name !== 'light' && {color: t.palette.primary_300},
]}>
{item.text}
</Text>
</View>
</ActionsWrapper>
<MessageItemMetadata
message={item}
isLastInGroup={isLastInGroup}
style={isFromSelf ? a.text_right : a.text_left}
/>
</View>
)
}
MessageItem = React.memo(MessageItem)
let MessageItemMetadata = ({
message,
isLastInGroup,
style,
}: {
message: ChatBskyConvoDefs.MessageView
isLastInGroup: boolean
style: StyleProp<TextStyle>
}): React.ReactNode => {
const t = useTheme()
const {_} = useLingui()
const relativeTimestamp = useCallback(
(timestamp: string) => {
const date = new Date(timestamp)
const now = new Date()
const time = new Intl.DateTimeFormat(undefined, {
hour: 'numeric',
minute: 'numeric',
hour12: true,
}).format(date)
const diff = now.getTime() - date.getTime()
// if under 1 minute
if (diff < 1000 * 60) {
return _(msg`Now`)
}
// if in the last day
if (localDateString(now) === localDateString(date)) {
return time
}
// if yesterday
const yesterday = new Date(now)
yesterday.setDate(yesterday.getDate() - 1)
if (localDateString(yesterday) === localDateString(date)) {
return _(msg`Yesterday, ${time}`)
}
return new Intl.DateTimeFormat(undefined, {
hour: 'numeric',
minute: 'numeric',
hour12: true,
day: 'numeric',
month: 'numeric',
year: 'numeric',
}).format(date)
},
[_],
)
if (!isLastInGroup) {
return null
}
return (
<TimeElapsed timestamp={message.sentAt} timeToString={relativeTimestamp}>
{({timeElapsed}) => (
<Text
style={[
t.atoms.text_contrast_medium,
a.text_xs,
a.mt_2xs,
a.mb_lg,
style,
]}>
{timeElapsed}
</Text>
)}
</TimeElapsed>
)
}
MessageItemMetadata = React.memo(MessageItemMetadata)
function localDateString(date: Date) {
// can't use toISOString because it should be in local time
const mm = date.getMonth()
const dd = date.getDate()
const yyyy = date.getFullYear()
// not padding with 0s because it's not necessary, it's just used for comparison
return `${yyyy}-${mm}-${dd}`
}
+141
View File
@@ -0,0 +1,141 @@
import React from 'react'
import {LayoutAnimation, Pressable, View} from 'react-native'
import * as Clipboard from 'expo-clipboard'
import {ChatBskyConvoDefs} from '@atproto-labs/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useChat} from 'state/messages'
import {ConvoStatus} 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 {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'
import * as Menu from '#/components/Menu'
import * as Prompt from '#/components/Prompt'
import {usePromptControl} from '#/components/Prompt'
import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '../icons/Clipboard'
export let MessageMenu = ({
message,
control,
hideTrigger,
triggerOpacity,
}: {
hideTrigger?: boolean
triggerOpacity?: number
onTriggerPress?: () => void
message: ChatBskyConvoDefs.MessageView
control: Menu.MenuControlProps
}): React.ReactNode => {
const {_} = useLingui()
const t = useTheme()
const {currentAccount} = useSession()
const chat = useChat()
const deleteControl = usePromptControl()
const retryDeleteControl = usePromptControl()
const isFromSelf = message.sender?.did === currentAccount?.did
const onCopyPostText = React.useCallback(() => {
// use when we have rich text
// const str = richTextToString(richText, true)
Clipboard.setStringAsync(message.text)
Toast.show(_(msg`Copied to clipboard`))
}, [_, message.text])
const onDelete = React.useCallback(() => {
if (chat.status !== ConvoStatus.Ready) return
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
chat
.deleteMessage(message.id)
.then(() => Toast.show(_(msg`Message deleted`)))
.catch(() => retryDeleteControl.open())
}, [_, chat, message.id, retryDeleteControl])
const onReport = React.useCallback(() => {
// TODO report the message
}, [])
return (
<>
<Menu.Root control={control}>
{!hideTrigger && (
<View style={{opacity: triggerOpacity}}>
<Menu.Trigger label={_(msg`Chat settings`)}>
{({props, state}) => (
<Pressable
{...props}
style={[
a.p_sm,
a.rounded_full,
(state.hovered || state.pressed) && t.atoms.bg_contrast_25,
]}>
<DotsHorizontal size="sm" style={t.atoms.text} />
</Pressable>
)}
</Menu.Trigger>
</View>
)}
<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 />
<Menu.Group>
<Menu.Item
testID="messageDropdownDeleteBtn"
label={_(msg`Delete message for me`)}
onPress={deleteControl.open}>
<Menu.ItemText>{_(msg`Delete for me`)}</Menu.ItemText>
<Menu.ItemIcon icon={Trash} position="right" />
</Menu.Item>
{!isFromSelf && (
<Menu.Item
testID="messageDropdownReportBtn"
label={_(msg`Report message`)}
onPress={onReport}>
<Menu.ItemText>{_(msg`Report`)}</Menu.ItemText>
<Menu.ItemIcon icon={Warning} position="right" />
</Menu.Item>
)}
</Menu.Group>
</Menu.Outer>
</Menu.Root>
<Prompt.Basic
control={deleteControl}
title={_(msg`Delete message`)}
description={_(
msg`Are you sure you want to delete this message? The message will be deleted for you, but not for other participants.`,
)}
confirmButtonCta={_(msg`Delete`)}
confirmButtonColor="negative"
onConfirm={onDelete}
/>
<Prompt.Basic
control={retryDeleteControl}
title={_(msg`Failed to delete message`)}
description={_(
msg`An error occurred while trying to delete the message. Please try again.`,
)}
confirmButtonCta={_(msg`Retry`)}
confirmButtonColor="negative"
onConfirm={onDelete}
/>
</>
)
}
MessageMenu = React.memo(MessageMenu)
+255
View File
@@ -0,0 +1,255 @@
import React, {useCallback, useMemo, useRef, useState} from 'react'
import {Keyboard, View} from 'react-native'
import {AppBskyActorDefs, moderateProfile} from '@atproto/api'
import {BottomSheetFlatListMethods} from '@discord/bottom-sheet'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
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 {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, useTheme, web} from '#/alf'
import * as Dialog from '#/components/Dialog'
import * as TextField from '#/components/forms/TextField'
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2'
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
import {Button} from '../Button'
import {Envelope_Stroke2_Corner0_Rounded as Envelope} from '../icons/Envelope'
import {ListMaybePlaceholder} from '../Lists'
import {Text} from '../Typography'
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)
},
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%']}}}>
<Dialog.Handle />
<SearchablePeopleList onCreateChat={onCreateChat} />
</Dialog.Outer>
</>
)
}
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 [searchText, setSearchText] = useState('')
const {
data: actorAutocompleteData,
isFetching,
isError,
refetch,
} = useActorAutocompleteQuery(searchText, true)
const renderItem = useCallback(
({item: profile}: {item: AppBskyActorDefs.ProfileView}) => {
if (!moderationOpts) return null
const moderation = moderateProfile(profile, moderationOpts)
return (
<Button
label={profile.displayName || sanitizeHandle(profile.handle)}
onPress={() => onCreateChat(profile.did)}>
{({hovered, pressed}) => (
<View
style={[
a.flex_1,
a.px_md,
a.py_sm,
a.gap_md,
a.align_center,
a.flex_row,
a.rounded_sm,
pressed
? t.atoms.bg_contrast_25
: hovered
? t.atoms.bg_contrast_50
: t.atoms.bg,
]}>
<UserAvatar
size={40}
avatar={profile.avatar}
moderation={moderation.ui('avatar')}
type={profile.associated?.labeler ? 'labeler' : 'user'}
/>
<View style={{flex: 1}}>
<Text
style={[t.atoms.text, a.font_bold, a.leading_snug]}
numberOfLines={1}>
{sanitizeDisplayName(
profile.displayName || sanitizeHandle(profile.handle),
moderation.ui('displayName'),
)}
</Text>
<Text style={t.atoms.text_contrast_high} numberOfLines={1}>
{sanitizeHandle(profile.handle, '@')}
</Text>
</View>
</View>
)}
</Button>
)
},
[
moderationOpts,
onCreateChat,
t.atoms.bg_contrast_25,
t.atoms.bg_contrast_50,
t.atoms.bg,
t.atoms.text,
t.atoms.text_contrast_high,
],
)
const listHeader = useMemo(() => {
return (
<View style={[a.relative, a.mb_lg]}>
{/* cover top corners */}
<View
style={[
a.absolute,
a.inset_0,
{
borderBottomLeftRadius: 8,
borderBottomRightRadius: 8,
},
t.atoms.bg,
]}
/>
<Dialog.Close />
<Text
style={[
a.text_2xl,
a.font_bold,
a.leading_tight,
a.pb_lg,
web(a.pt_lg),
]}>
<Trans>Start a new chat</Trans>
</Text>
<TextField.Root>
<TextField.Icon icon={Search} />
<Dialog.Input
label={_(msg`Search profiles`)}
placeholder={_(msg`Search`)}
value={searchText}
onChangeText={text => {
setSearchText(text)
listRef.current?.scrollToOffset({offset: 0, animated: false})
}}
returnKeyType="search"
clearButtonMode="while-editing"
maxLength={50}
onKeyPress={({nativeEvent}) => {
if (nativeEvent.key === 'Escape') {
control.close()
}
}}
autoCorrect={false}
autoComplete="off"
autoCapitalize="none"
autoFocus
/>
</TextField.Root>
</View>
)
}, [t.atoms.bg, _, control, searchText])
return (
<Dialog.InnerFlatList
ref={listRef}
data={actorAutocompleteData}
renderItem={renderItem}
ListHeaderComponent={
<>
{listHeader}
{searchText.length === 0 ? (
<View style={[a.pt_4xl, a.align_center, a.px_lg]}>
<Envelope width={64} fill={t.palette.contrast_200} />
<Text
style={[
a.text_lg,
a.text_center,
a.mt_md,
t.atoms.text_contrast_low,
]}>
<Trans>Search for someone to start a conversation with.</Trans>
</Text>
</View>
) : (
!actorAutocompleteData?.length && (
<ListMaybePlaceholder
isLoading={isFetching}
isError={isError}
onRetry={refetch}
hideBackButton={true}
emptyType="results"
sideBorders={false}
emptyMessage={
isError
? _(msg`No search results found for "${searchText}".`)
: _(msg`Could not load profiles. Please try again later.`)
}
/>
)
)}
</>
}
stickyHeaderIndices={[0]}
keyExtractor={(item: AppBskyActorDefs.ProfileView) => item.did}
// @ts-expect-error web only
style={isWeb && {minHeight: '100vh'}}
onScrollBeginDrag={() => Keyboard.dismiss()}
/>
)
}
+17
View File
@@ -0,0 +1,17 @@
import {useCallback, useRef} from 'react'
import {useFocusEffect} from '@react-navigation/native'
export function useRefreshOnFocus<T>(refetch: () => Promise<T>) {
const firstTimeRef = useRef(true)
useFocusEffect(
useCallback(() => {
if (firstTimeRef.current) {
firstTimeRef.current = false
return
}
refetch()
}, [refetch]),
)
}
+5
View File
@@ -0,0 +1,5 @@
import {createSinglePathSVG} from './TEMPLATE'
export const ArrowBoxLeft_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M3.293 3.293A1 1 0 0 1 4 3h7.25a1 1 0 1 1 0 2H5v14h6.25a1 1 0 1 1 0 2H4a1 1 0 0 1-1-1V4a1 1 0 0 1 .293-.707Zm11.5 3.5a1 1 0 0 1 1.414 0l4.5 4.5a1 1 0 0 1 0 1.414l-4.5 4.5a1 1 0 0 1-1.414-1.414L17.586 13H8.75a1 1 0 1 1 0-2h8.836l-2.793-2.793a1 1 0 0 1 0-1.414Z',
})
+4
View File
@@ -3,3 +3,7 @@ import {createSinglePathSVG} from './TEMPLATE'
export const Envelope_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M4.568 4h14.864c.252 0 .498 0 .706.017.229.019.499.063.77.201a2 2 0 0 1 .874.874c.138.271.182.541.201.77.017.208.017.454.017.706v10.864c0 .252 0 .498-.017.706a2.022 2.022 0 0 1-.201.77 2 2 0 0 1-.874.874 2.022 2.022 0 0 1-.77.201c-.208.017-.454.017-.706.017H4.568c-.252 0-.498 0-.706-.017a2.022 2.022 0 0 1-.77-.201 2 2 0 0 1-.874-.874 2.022 2.022 0 0 1-.201-.77C2 17.93 2 17.684 2 17.432V6.568c0-.252 0-.498.017-.706.019-.229.063-.499.201-.77a2 2 0 0 1 .874-.874c.271-.138.541-.182.77-.201C4.07 4 4.316 4 4.568 4Zm.456 2L12 11.708 18.976 6H5.024ZM20 7.747l-6.733 5.509a2 2 0 0 1-2.534 0L4 7.746V17.4a8.187 8.187 0 0 0 .011.589h.014c.116.01.278.011.575.011h14.8a8.207 8.207 0 0 0 .589-.012v-.013c.01-.116.011-.279.011-.575V7.747Z',
})
export const Envelope_Filled_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M12 11.708 2.654 4.06A.998.998 0 0 1 3 4h18c.122 0 .238.022.346.061L12 11.708ZM2 19V6.11l9.367 7.664a1 1 0 0 0 1.266 0L22 6.11V19a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1Z',
})
+5
View File
@@ -0,0 +1,5 @@
import {createSinglePathSVG} from './TEMPLATE'
export const PaperPlane_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M3.374 3.22a1 1 0 0 1 1.073-.114l16 8a1 1 0 0 1 0 1.788l-16 8a1 1 0 0 1-1.417-1.136L4.97 12 3.03 4.243a1 1 0 0 1 .344-1.023ZM6.781 13l-1.284 5.133L17.764 12 5.497 5.867 6.781 11H9a1 1 0 1 1 0 2H6.78Z',
})
+4
View File
@@ -3,3 +3,7 @@ import {createSinglePathSVG} from './TEMPLATE'
export const PlusLarge_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M12 3a1 1 0 0 1 1 1v7h7a1 1 0 1 1 0 2h-7v7a1 1 0 1 1-2 0v-7H4a1 1 0 1 1 0-2h7V4a1 1 0 0 1 1-1Z',
})
export const PlusSmall_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M12 6a1 1 0 0 1 1 1v4h4a1 1 0 1 1 0 2h-4v4a1 1 0 1 1-2 0v-4H7a1 1 0 1 1 0-2h4V7a1 1 0 0 1 1-1Z',
})
+6
View File
@@ -0,0 +1,6 @@
import {createSinglePathSVG} from './TEMPLATE'
export const SettingsSliderVertical_Stroke2_Corner0_Rounded =
createSinglePathSVG({
path: 'M7 3a1 1 0 0 1 1 1v1.126a4 4 0 0 1 0 7.748V20a1 1 0 1 1-2 0v-7.126a4 4 0 0 1 0-7.748V4a1 1 0 0 1 1-1Zm10 0a1 1 0 0 1 1 1v9.126a4 4 0 1 1-2 0V4a1 1 0 0 1 1-1ZM7 7a2 2 0 1 0 0 4 2 2 0 1 0 0-4Zm10 8a2 2 0 1 0 0 4 2 2 0 1 0 0-4Z',
})
+1 -1
View File
@@ -1,7 +1,7 @@
import React from 'react'
import Svg, {Path} from 'react-native-svg'
import {useCommonSVGProps, Props} from '#/components/icons/common'
import {Props, useCommonSVGProps} from '#/components/icons/common'
export const IconTemplate_Stroke2_Corner0_Rounded = React.forwardRef(
function LogoImpl(props: Props, ref) {
+16 -7
View File
@@ -1,25 +1,27 @@
import React, {ComponentProps} from 'react'
import {StyleSheet, Pressable, View, ViewStyle, StyleProp} from 'react-native'
import {ModerationUI} from '@atproto/api'
import {Pressable, StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
import {AppBskyActorDefs, ModerationUI} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {Trans, msg} from '@lingui/macro'
import {useQueryClient} from '@tanstack/react-query'
import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription'
import {addStyle} from 'lib/styles'
import {useTheme, atoms as a} from '#/alf'
import {precacheProfile} from 'state/queries/profile'
// import {Link} from '#/components/Link' TODO this imposes some styles that screw things up
import {Link} from '#/view/com/util/Link'
import {atoms as a, useTheme} from '#/alf'
import {
ModerationDetailsDialog,
useModerationDetailsDialogControl,
} from '#/components/moderation/ModerationDetailsDialog'
import {Text} from '#/components/Typography'
// import {Link} from '#/components/Link' TODO this imposes some styles that screw things up
import {Link} from '#/view/com/util/Link'
interface Props extends ComponentProps<typeof Link> {
iconSize: number
iconStyles: StyleProp<ViewStyle>
modui: ModerationUI
profile: AppBskyActorDefs.ProfileViewBasic
}
export function PostHider({
@@ -30,8 +32,10 @@ export function PostHider({
children,
iconSize,
iconStyles,
profile,
...props
}: Props) {
const queryClient = useQueryClient()
const t = useTheme()
const {_} = useLingui()
const [override, setOverride] = React.useState(false)
@@ -39,6 +43,10 @@ export function PostHider({
const blur = modui.blurs[0]
const desc = useModerationCauseDescription(blur)
const onBeforePress = React.useCallback(() => {
precacheProfile(queryClient, profile)
}, [queryClient, profile])
if (!blur) {
return (
<Link
@@ -46,6 +54,7 @@ export function PostHider({
style={style}
href={href}
accessible={false}
onBeforePress={onBeforePress}
{...props}>
{children}
</Link>
+4 -1
View File
@@ -5,6 +5,7 @@ const ScrollContext = createContext<ScrollHandlers<any>>({
onBeginDrag: undefined,
onEndDrag: undefined,
onScroll: undefined,
onMomentumEnd: undefined,
})
export function useScrollHandlers(): ScrollHandlers<any> {
@@ -20,14 +21,16 @@ export function ScrollProvider({
onBeginDrag,
onEndDrag,
onScroll,
onMomentumEnd,
}: ProviderProps) {
const handlers = useMemo(
() => ({
onBeginDrag,
onEndDrag,
onScroll,
onMomentumEnd,
}),
[onBeginDrag, onEndDrag, onScroll],
[onBeginDrag, onEndDrag, onScroll, onMomentumEnd],
)
return (
<ScrollContext.Provider value={handlers}>{children}</ScrollContext.Provider>
+1
View File
@@ -76,6 +76,7 @@ export type TrackPropertiesMap = {
'MobileShell:SearchButtonPressed': {}
'MobileShell:NotificationsButtonPressed': {}
'MobileShell:FeedsButtonPressed': {}
'MobileShell:MessagesButtonPressed': {}
// NOTIFICATIONS events
'Notificatons:OpenApp': {}
// LISTS events
+10
View File
@@ -7,20 +7,25 @@ import {
import {getContentLanguages} from '#/state/preferences/languages'
import {FeedAPI, FeedAPIResponse} from './types'
import {createBskyTopicsHeader, isBlueskyOwnedFeed} from './utils'
export class CustomFeedAPI implements FeedAPI {
getAgent: () => BskyAgent
params: GetCustomFeed.QueryParams
userInterests?: string
constructor({
getAgent,
feedParams,
userInterests,
}: {
getAgent: () => BskyAgent
feedParams: GetCustomFeed.QueryParams
userInterests?: string
}) {
this.getAgent = getAgent
this.params = feedParams
this.userInterests = userInterests
}
async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
@@ -44,6 +49,8 @@ export class CustomFeedAPI implements FeedAPI {
}): Promise<FeedAPIResponse> {
const contentLangs = getContentLanguages().join(',')
const agent = this.getAgent()
const isBlueskyOwned = isBlueskyOwnedFeed(this.params.feed)
const res = agent.session
? await this.getAgent().app.bsky.feed.getFeed(
{
@@ -53,6 +60,9 @@ export class CustomFeedAPI implements FeedAPI {
},
{
headers: {
...(isBlueskyOwned
? createBskyTopicsHeader(this.userInterests)
: {}),
'Accept-Language': contentLangs,
},
},
+10 -1
View File
@@ -32,14 +32,22 @@ export class HomeFeedAPI implements FeedAPI {
discover: CustomFeedAPI
usingDiscover = false
itemCursor = 0
userInterests?: string
constructor({getAgent}: {getAgent: () => BskyAgent}) {
constructor({
userInterests,
getAgent,
}: {
userInterests?: string
getAgent: () => BskyAgent
}) {
this.getAgent = getAgent
this.following = new FollowingFeedAPI({getAgent})
this.discover = new CustomFeedAPI({
getAgent,
feedParams: {feed: PROD_DEFAULT_FEED('whats-hot')},
})
this.userInterests = userInterests
}
reset() {
@@ -47,6 +55,7 @@ export class HomeFeedAPI implements FeedAPI {
this.discover = new CustomFeedAPI({
getAgent: this.getAgent,
feedParams: {feed: PROD_DEFAULT_FEED('whats-hot')},
userInterests: this.userInterests,
})
this.usingDiscover = false
this.itemCursor = 0
+14
View File
@@ -9,11 +9,13 @@ import {feedUriToHref} from 'lib/strings/url-helpers'
import {FeedTuner} from '../feed-manip'
import {FeedTunerFn} from '../feed-manip'
import {FeedAPI, FeedAPIResponse, ReasonFeedSource} from './types'
import {createBskyTopicsHeader, isBlueskyOwnedFeed} from './utils'
const REQUEST_WAIT_MS = 500 // 500ms
const POST_AGE_CUTOFF = 60e3 * 60 * 24 // 24hours
export class MergeFeedAPI implements FeedAPI {
userInterests?: string
getAgent: () => BskyAgent
params: FeedParams
feedTuners: FeedTunerFn[]
@@ -27,14 +29,17 @@ export class MergeFeedAPI implements FeedAPI {
getAgent,
feedParams,
feedTuners,
userInterests,
}: {
getAgent: () => BskyAgent
feedParams: FeedParams
feedTuners: FeedTunerFn[]
userInterests?: string
}) {
this.getAgent = getAgent
this.params = feedParams
this.feedTuners = feedTuners
this.userInterests = userInterests
this.following = new MergeFeedSource_Following({
getAgent: this.getAgent,
feedTuners: this.feedTuners,
@@ -58,6 +63,7 @@ export class MergeFeedAPI implements FeedAPI {
getAgent: this.getAgent,
feedUri,
feedTuners: this.feedTuners,
userInterests: this.userInterests,
}),
),
)
@@ -254,15 +260,18 @@ class MergeFeedSource_Custom extends MergeFeedSource {
getAgent: () => BskyAgent
minDate: Date
feedUri: string
userInterests?: string
constructor({
getAgent,
feedUri,
feedTuners,
userInterests,
}: {
getAgent: () => BskyAgent
feedUri: string
feedTuners: FeedTunerFn[]
userInterests?: string
}) {
super({
getAgent,
@@ -270,6 +279,7 @@ class MergeFeedSource_Custom extends MergeFeedSource {
})
this.getAgent = getAgent
this.feedUri = feedUri
this.userInterests = userInterests
this.sourceInfo = {
$type: 'reasonFeedSource',
uri: feedUri,
@@ -284,6 +294,7 @@ class MergeFeedSource_Custom extends MergeFeedSource {
): Promise<AppBskyFeedGetTimeline.Response> {
try {
const contentLangs = getContentLanguages().join(',')
const isBlueskyOwned = isBlueskyOwnedFeed(this.feedUri)
const res = await this.getAgent().app.bsky.feed.getFeed(
{
cursor,
@@ -292,6 +303,9 @@ class MergeFeedSource_Custom extends MergeFeedSource {
},
{
headers: {
...(isBlueskyOwned
? createBskyTopicsHeader(this.userInterests)
: {}),
'Accept-Language': contentLangs,
},
},
+21
View File
@@ -0,0 +1,21 @@
import {AtUri} from '@atproto/api'
import {BSKY_FEED_OWNER_DIDS} from '#/lib/constants'
import {UsePreferencesQueryResponse} from '#/state/queries/preferences'
export function createBskyTopicsHeader(userInterests?: string) {
return {
'X-Bsky-Topics': userInterests || '',
}
}
export function aggregateUserInterests(
preferences?: UsePreferencesQueryResponse,
) {
return preferences?.interests?.tags?.join(',') || ''
}
export function isBlueskyOwnedFeed(feedUri: string) {
const uri = new AtUri(feedUri)
return BSKY_FEED_OWNER_DIDS.includes(uri.host)
}
+13 -3
View File
@@ -4,7 +4,17 @@ export const BUILD_ENV = process.env.EXPO_PUBLIC_ENV
export const IS_DEV = process.env.EXPO_PUBLIC_ENV === 'development'
export const IS_TESTFLIGHT = process.env.EXPO_PUBLIC_ENV === 'testflight'
const UPDATES_CHANNEL = IS_TESTFLIGHT ? 'testflight' : 'production'
export const appVersion = `${nativeApplicationVersion} (${nativeBuildVersion}, ${
IS_DEV ? 'development' : UPDATES_CHANNEL
// This is the commit hash that the current bundle was made from. The user can see the commit hash in the app's settings
// along with the other version info. Useful for debugging/reporting.
export const BUNDLE_IDENTIFIER =
process.env.EXPO_PUBLIC_BUNDLE_IDENTIFIER ?? 'dev'
// This will always be in the format of YYMMDD, so that it always increases for each build. This should only be used
// for Statsig reporting and shouldn't be used to identify a specific bundle.
export const BUNDLE_DATE =
IS_TESTFLIGHT || IS_DEV ? 0 : Number(process.env.EXPO_PUBLIC_BUNDLE_DATE)
export const appVersion = `${nativeApplicationVersion}.${nativeBuildVersion}`
export const bundleInfo = `${BUNDLE_IDENTIFIER} (${
IS_DEV ? 'dev' : IS_TESTFLIGHT ? 'tf' : 'prod'
})`
+15
View File
@@ -1,2 +1,17 @@
import {version} from '../../package.json'
export const IS_DEV = process.env.EXPO_PUBLIC_ENV === 'development'
// This is the commit hash that the current bundle was made from. The user can see the commit hash in the app's settings
// along with the other version info. Useful for debugging/reporting.
export const BUNDLE_IDENTIFIER =
process.env.EXPO_PUBLIC_BUNDLE_IDENTIFIER ?? 'dev'
// This will always be in the format of YYMMDD, so that it always increases for each build. This should only be used
// for Statsig reporting and shouldn't be used to identify a specific bundle.
export const BUNDLE_DATE = IS_DEV
? 0
: Number(process.env.EXPO_PUBLIC_BUNDLE_DATE)
export const appVersion = version
export const bundleInfo = `${BUNDLE_IDENTIFIER} (${IS_DEV ? 'dev' : 'prod'})`
+1
View File
@@ -10,6 +10,7 @@ const HELP_DESK_LANG = 'en-us'
export const HELP_DESK_URL = `https://blueskyweb.zendesk.com/hc/${HELP_DESK_LANG}`
export const EMBED_SERVICE = 'https://embed.bsky.app'
export const EMBED_SCRIPT = `${EMBED_SERVICE}/static/embed.js`
export const BSKY_DOWNLOAD_URL = 'https://bsky.app/download'
const BASE_FEEDBACK_FORM_URL = `${HELP_DESK_URL}/requests/new`
export function FEEDBACK_FORM_URL({
+87
View File
@@ -9,3 +9,90 @@ export function dedupArray<T>(arr: T[]): T[] {
const s = new Set(arr)
return [...s]
}
/**
* Taken from @tanstack/query-core utils.ts
* Modified to support Date object comparisons
*
* This function returns `a` if `b` is deeply equal.
* If not, it will replace any deeply equal children of `b` with those of `a`.
* This can be used for structural sharing between JSON values for example.
*/
export function replaceEqualDeep(a: any, b: any): any {
if (a === b) {
return a
}
if (a instanceof Date && b instanceof Date) {
return a.getTime() === b.getTime() ? a : b
}
const array = isPlainArray(a) && isPlainArray(b)
if (array || (isPlainObject(a) && isPlainObject(b))) {
const aItems = array ? a : Object.keys(a)
const aSize = aItems.length
const bItems = array ? b : Object.keys(b)
const bSize = bItems.length
const copy: any = array ? [] : {}
let equalItems = 0
for (let i = 0; i < bSize; i++) {
const key = array ? i : bItems[i]
if (
!array &&
a[key] === undefined &&
b[key] === undefined &&
aItems.includes(key)
) {
copy[key] = undefined
equalItems++
} else {
copy[key] = replaceEqualDeep(a[key], b[key])
if (copy[key] === a[key] && a[key] !== undefined) {
equalItems++
}
}
}
return aSize === bSize && equalItems === aSize ? a : copy
}
return b
}
export function isPlainArray(value: unknown) {
return Array.isArray(value) && value.length === Object.keys(value).length
}
// Copied from: https://github.com/jonschlinkert/is-plain-object
export function isPlainObject(o: any): o is Object {
if (!hasObjectPrototype(o)) {
return false
}
// If has no constructor
const ctor = o.constructor
if (ctor === undefined) {
return true
}
// If has modified prototype
const prot = ctor.prototype
if (!hasObjectPrototype(prot)) {
return false
}
// If constructor does not have an Object-specific method
if (!prot.hasOwnProperty('isPrototypeOf')) {
return false
}
// Most likely a plain Object
return true
}
function hasObjectPrototype(o: any): boolean {
return Object.prototype.toString.call(o) === '[object Object]'
}
+25 -13
View File
@@ -1,15 +1,21 @@
import {useCallback} from 'react'
import {useCallback, useState} from 'react'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useAnalytics} from '#/lib/analytics/analytics'
import {logger} from '#/logger'
import {isWeb} from '#/platform/detection'
import {SessionAccount, useSessionApi} from '#/state/session'
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
import * as Toast from '#/view/com/util/Toast'
import {logEvent} from '../statsig/statsig'
import {LogEvents} from '../statsig/statsig'
export function useAccountSwitcher() {
const [pendingDid, setPendingDid] = useState<string | null>(null)
const {_} = useLingui()
const {track} = useAnalytics()
const {selectAccount, clearCurrentAccount} = useSessionApi()
const {initSession} = useSessionApi()
const {requestSwitchToAccount} = useLoggedOutViewControls()
const onPressSwitchAccount = useCallback(
@@ -18,8 +24,12 @@ export function useAccountSwitcher() {
logContext: LogEvents['account:loggedIn']['logContext'],
) => {
track('Settings:SwitchAccountButtonClicked')
if (pendingDid) {
// The session API isn't resilient to race conditions so let's just ignore this.
return
}
try {
setPendingDid(account.did)
if (account.accessJwt) {
if (isWeb) {
// We're switching accounts, which remounts the entire app.
@@ -29,24 +39,26 @@ export function useAccountSwitcher() {
// So we change the URL ourselves. The navigator will pick it up on remount.
history.pushState(null, '', '/')
}
await selectAccount(account, logContext)
setTimeout(() => {
Toast.show(`Signed in as @${account.handle}`)
}, 100)
await initSession(account)
logEvent('account:loggedIn', {logContext, withPassword: false})
Toast.show(_(msg`Signed in as @${account.handle}`))
} else {
requestSwitchToAccount({requestedAccount: account.did})
Toast.show(
`Please sign in as @${account.handle}`,
_(msg`Please sign in as @${account.handle}`),
'circle-exclamation',
)
}
} catch (e) {
Toast.show('Sorry! We need you to enter your password.')
clearCurrentAccount() // back user out to login
} catch (e: any) {
logger.error(`switch account: selectAccount failed`, {
message: e.message,
})
} finally {
setPendingDid(null)
}
},
[track, clearCurrentAccount, selectAccount, requestSwitchToAccount],
[_, track, initSession, requestSwitchToAccount, pendingDid],
)
return {onPressSwitchAccount}
return {onPressSwitchAccount, pendingDid}
}
+5 -1
View File
@@ -1,4 +1,5 @@
import {useNavigationState} from '@react-navigation/native'
import {getTabState, TabState} from 'lib/routes/helpers'
export function useNavigationTabState() {
@@ -10,13 +11,16 @@ export function useNavigationTabState() {
isAtNotifications:
getTabState(state, 'Notifications') !== TabState.Outside,
isAtMyProfile: getTabState(state, 'MyProfile') !== TabState.Outside,
isAtMessages: getTabState(state, 'Messages') !== TabState.Outside,
}
if (
!res.isAtHome &&
!res.isAtSearch &&
!res.isAtFeeds &&
!res.isAtNotifications &&
!res.isAtMyProfile
!res.isAtMyProfile &&
!res.isAtMessages
) {
// HACK for some reason useNavigationState will give us pre-hydration results
// and not update after, so we force isAtHome if all came back false
@@ -1,4 +1,5 @@
import {useNavigationState} from '@react-navigation/native'
import {getCurrentRoute} from 'lib/routes/helpers'
export function useNavigationTabState() {
@@ -9,6 +10,7 @@ export function useNavigationTabState() {
isAtSearch: currentRoute === 'Search',
isAtNotifications: currentRoute === 'Notifications',
isAtMyProfile: currentRoute === 'MyProfile',
isAtMessages: currentRoute === 'Messages',
}
})
}
+1 -1
View File
@@ -1,4 +1,4 @@
import {RouteParams, Route} from './types'
import {Route, RouteParams} from './types'
export class Router {
routes: [string, Route][] = []
+10
View File
@@ -38,6 +38,8 @@ export type CommonNavigatorParams = {
AccessibilitySettings: undefined
Search: {q?: string}
Hashtag: {tag: string; author?: string}
MessagesConversation: {conversation: string}
MessagesSettings: undefined
}
export type BottomTabNavigatorParams = CommonNavigatorParams & {
@@ -46,6 +48,7 @@ export type BottomTabNavigatorParams = CommonNavigatorParams & {
FeedsTab: undefined
NotificationsTab: undefined
MyProfileTab: undefined
MessagesTab: undefined
}
export type HomeTabNavigatorParams = CommonNavigatorParams & {
@@ -68,12 +71,17 @@ export type MyProfileTabNavigatorParams = CommonNavigatorParams & {
MyProfile: undefined
}
export type MessagesTabNavigatorParams = CommonNavigatorParams & {
Messages: undefined
}
export type FlatNavigatorParams = CommonNavigatorParams & {
Home: undefined
Search: {q?: string}
Feeds: undefined
Notifications: undefined
Hashtag: {tag: string; author?: string}
Messages: undefined
}
export type AllNavigatorParams = CommonNavigatorParams & {
@@ -87,6 +95,8 @@ export type AllNavigatorParams = CommonNavigatorParams & {
Notifications: undefined
MyProfileTab: undefined
Hashtag: {tag: string; author?: string}
MessagesTab: undefined
Messages: undefined
}
// NOTE
+2 -2
View File
@@ -1,9 +1,9 @@
export type Gate =
// Keep this alphabetic please.
| 'autoexpand_suggestions_on_profile_follow_v2'
| 'disable_min_shell_on_foregrounding_v2'
| 'disable_min_shell_on_foregrounding_v3'
| 'disable_poll_on_discover_v2'
| 'hide_vertical_scroll_indicators'
| 'dms'
| 'show_follow_back_label_v2'
| 'start_session_with_following_v2'
| 'test_gate_1'
+2 -1
View File
@@ -95,7 +95,8 @@ export function parseEmbedPlayerFromUrl(
if (
urlp.hostname === 'www.youtube.com' ||
urlp.hostname === 'youtube.com' ||
urlp.hostname === 'm.youtube.com'
urlp.hostname === 'm.youtube.com' ||
urlp.hostname === 'music.youtube.com'
) {
const [_, page, shortVideoId] = urlp.pathname.split('/')
const videoId =
+14 -6
View File
@@ -1,14 +1,15 @@
import {AtUri} from '@atproto/api'
import {BSKY_SERVICE} from 'lib/constants'
import TLDs from 'tlds'
import psl from 'psl'
import TLDs from 'tlds'
import {BSKY_SERVICE} from 'lib/constants'
export const BSKY_APP_HOST = 'https://bsky.app'
const BSKY_TRUSTED_HOSTS = [
'bsky.app',
'bsky.social',
'blueskyweb.xyz',
'blueskyweb.zendesk.com',
'bsky\\.app',
'bsky\\.social',
'blueskyweb\\.xyz',
'blueskyweb\\.zendesk\\.com',
...(__DEV__ ? ['localhost:19006', 'localhost:8100'] : []),
]
@@ -145,6 +146,13 @@ export function isBskyListUrl(url: string): boolean {
return false
}
export function isBskyDownloadUrl(url: string): boolean {
if (isExternalUrl(url)) {
return false
}
return url === '/download' || url.startsWith('/download?')
}
export function convertBskyAppUrlIfNeeded(url: string): string {
if (isBskyAppUrl(url)) {
try {
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1
View File
@@ -9,4 +9,5 @@ export const DebugContext = {
// e.g. composer: 'composer'
session: 'session',
notifications: 'notifications',
convo: 'convo',
} as const
+3
View File
@@ -37,4 +37,7 @@ export const router = new Router({
CommunityGuidelines: '/support/community-guidelines',
CopyrightPolicy: '/support/copyright',
Hashtag: '/hashtag/:tag',
Messages: '/messages',
MessagesSettings: '/messages/settings',
MessagesConversation: '/messages/:conversation',
})
+42 -17
View File
@@ -5,6 +5,7 @@ import {useLingui} from '@lingui/react'
import {useAnalytics} from '#/lib/analytics/analytics'
import {logEvent} from '#/lib/statsig/statsig'
import {logger} from '#/logger'
import {SessionAccount, useSession, useSessionApi} from '#/state/session'
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
import * as Toast from '#/view/com/util/Toast'
@@ -21,6 +22,7 @@ export const ChooseAccountForm = ({
onSelectAccount: (account?: SessionAccount) => void
onPressBack: () => void
}) => {
const [pendingDid, setPendingDid] = React.useState<string | null>(null)
const {track, screen} = useAnalytics()
const {_} = useLingui()
const {currentAccount} = useSession()
@@ -33,26 +35,48 @@ export const ChooseAccountForm = ({
const onSelect = React.useCallback(
async (account: SessionAccount) => {
if (account.accessJwt) {
if (account.did === currentAccount?.did) {
setShowLoggedOut(false)
Toast.show(_(msg`Already signed in as @${account.handle}`))
} else {
await initSession(account)
logEvent('account:loggedIn', {
logContext: 'ChooseAccountForm',
withPassword: false,
})
track('Sign In', {resumedSession: true})
setTimeout(() => {
Toast.show(_(msg`Signed in as @${account.handle}`))
}, 100)
}
} else {
if (pendingDid) {
// The session API isn't resilient to race conditions so let's just ignore this.
return
}
if (!account.accessJwt) {
// Move to login form.
onSelectAccount(account)
return
}
if (account.did === currentAccount?.did) {
setShowLoggedOut(false)
Toast.show(_(msg`Already signed in as @${account.handle}`))
return
}
try {
setPendingDid(account.did)
await initSession(account)
logEvent('account:loggedIn', {
logContext: 'ChooseAccountForm',
withPassword: false,
})
track('Sign In', {resumedSession: true})
Toast.show(_(msg`Signed in as @${account.handle}`))
} catch (e: any) {
logger.error('choose account: initSession failed', {
message: e.message,
})
// Move to login form.
onSelectAccount(account)
} finally {
setPendingDid(null)
}
},
[currentAccount, track, initSession, onSelectAccount, setShowLoggedOut, _],
[
currentAccount,
track,
initSession,
pendingDid,
onSelectAccount,
setShowLoggedOut,
_,
],
)
return (
@@ -66,6 +90,7 @@ export const ChooseAccountForm = ({
<AccountList
onSelectAccount={onSelect}
onSelectOther={() => onSelectAccount()}
pendingDid={pendingDid}
/>
</View>
<View style={[a.flex_row]}>
@@ -0,0 +1,111 @@
import React from 'react'
import {
Dimensions,
Keyboard,
NativeSyntheticEvent,
Pressable,
TextInput,
TextInputContentSizeChangeEventData,
View,
} from 'react-native'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {HITSLOP_10} from '#/lib/constants'
import {useHaptics} from 'lib/haptics'
import {atoms as a, useTheme} from '#/alf'
import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlane} from '#/components/icons/PaperPlane'
export function MessageInput({
onSendMessage,
scrollToEnd,
}: {
onSendMessage: (message: string) => void
scrollToEnd: () => void
}) {
const {_} = useLingui()
const t = useTheme()
const playHaptic = useHaptics()
const [message, setMessage] = React.useState('')
const [maxHeight, setMaxHeight] = React.useState<number | undefined>()
const [isInputScrollable, setIsInputScrollable] = React.useState(false)
const {top: topInset} = useSafeAreaInsets()
const inputRef = React.useRef<TextInput>(null)
const onSubmit = React.useCallback(() => {
if (message.trim() === '') {
return
}
onSendMessage(message.trimEnd())
playHaptic()
setMessage('')
setTimeout(() => {
inputRef.current?.focus()
}, 100)
}, [message, onSendMessage, playHaptic])
const onInputLayout = React.useCallback(
(e: NativeSyntheticEvent<TextInputContentSizeChangeEventData>) => {
const keyboardHeight = Keyboard.metrics()?.height ?? 0
const windowHeight = Dimensions.get('window').height
const max = windowHeight - keyboardHeight - topInset - 100
const availableSpace = max - e.nativeEvent.contentSize.height
setMaxHeight(max)
setIsInputScrollable(availableSpace < 30)
scrollToEnd()
},
[scrollToEnd, topInset],
)
return (
<View style={a.p_sm}>
<View
style={[
a.w_full,
a.flex_row,
a.py_sm,
a.px_sm,
a.pl_md,
t.atoms.bg_contrast_25,
{borderRadius: 23},
]}>
<TextInput
accessibilityLabel={_(msg`Message input field`)}
accessibilityHint={_(msg`Type your message here`)}
placeholder={_(msg`Write a message`)}
placeholderTextColor={t.palette.contrast_500}
value={message}
multiline={true}
onChangeText={setMessage}
style={[a.flex_1, a.text_md, a.px_sm, t.atoms.text, {maxHeight}]}
keyboardAppearance={t.name === 'light' ? 'light' : 'dark'}
scrollEnabled={isInputScrollable}
blurOnSubmit={false}
onFocus={scrollToEnd}
onContentSizeChange={onInputLayout}
ref={inputRef}
/>
<Pressable
accessibilityRole="button"
accessibilityLabel={_(msg`Send message`)}
accessibilityHint=""
hitSlop={HITSLOP_10}
style={[
a.rounded_full,
a.align_center,
a.justify_center,
{height: 30, width: 30, backgroundColor: t.palette.primary_500},
]}
onPress={onSubmit}>
<PaperPlane fill={t.palette.white} style={[a.relative, {left: 1}]} />
</Pressable>
</View>
</View>
)
}
@@ -0,0 +1,94 @@
import React from 'react'
import {Pressable, StyleSheet, View} from 'react-native'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import TextareaAutosize from 'react-textarea-autosize'
import {atoms as a, useTheme} from '#/alf'
import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlane} from '#/components/icons/PaperPlane'
export function MessageInput({
onSendMessage,
}: {
onSendMessage: (message: string) => void
scrollToEnd: () => void
}) {
const {_} = useLingui()
const t = useTheme()
const [message, setMessage] = React.useState('')
const onSubmit = React.useCallback(() => {
if (message.trim() === '') {
return
}
onSendMessage(message.trimEnd())
setMessage('')
}, [message, onSendMessage])
const onKeyDown = React.useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter') {
if (e.shiftKey) return
e.preventDefault()
onSubmit()
}
},
[onSubmit],
)
const onChange = React.useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
setMessage(e.target.value)
},
[],
)
return (
<View style={a.p_sm}>
<View
style={[
a.flex_row,
a.py_sm,
a.px_sm,
a.pl_md,
t.atoms.bg_contrast_25,
{borderRadius: 23},
]}>
<TextareaAutosize
style={StyleSheet.flatten([
a.flex_1,
a.px_sm,
a.border_0,
t.atoms.text,
{
backgroundColor: 'transparent',
resize: 'none',
paddingTop: 4,
},
])}
maxRows={12}
placeholder={_(msg`Write a message`)}
defaultValue=""
value={message}
dirName="ltr"
autoFocus={true}
onChange={onChange}
onKeyDown={onKeyDown}
/>
<Pressable
accessibilityRole="button"
accessibilityLabel={_(msg`Send message`)}
accessibilityHint=""
style={[
a.rounded_full,
a.align_center,
a.justify_center,
{height: 30, width: 30, backgroundColor: t.palette.primary_500},
]}
onPress={onSubmit}>
<PaperPlane fill={t.palette.white} style={[a.relative, {left: 1}]} />
</Pressable>
</View>
</View>
)
}
@@ -0,0 +1,60 @@
import React from 'react'
import {View} from 'react-native'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {ConvoItem, ConvoItemError} from '#/state/messages/convo'
import {atoms as a, useTheme} from '#/alf'
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
import {InlineLinkText} from '#/components/Link'
import {Text} from '#/components/Typography'
export function MessageListError({
item,
}: {
item: ConvoItem & {type: 'error-recoverable'}
}) {
const t = useTheme()
const {_} = useLingui()
const message = React.useMemo(() => {
return {
[ConvoItemError.HistoryFailed]: _(msg`Failed to load past messages.`),
[ConvoItemError.ResumeFailed]: _(
msg`There was an issue connecting to the chat.`,
),
[ConvoItemError.PollFailed]: _(
msg`This chat was disconnected due to a network error.`,
),
}[item.code]
}, [_, item.code])
return (
<View style={[a.py_md, a.align_center]}>
<View
style={[
a.align_center,
a.pt_md,
a.pb_lg,
a.px_3xl,
a.rounded_md,
t.atoms.bg_contrast_25,
{maxWidth: 300},
]}>
<CircleInfo size="lg" fill={t.palette.negative_400} />
<Text style={[a.pt_sm, a.leading_snug]}>
{message}{' '}
<InlineLinkText
to="#"
label={_(msg`Press to retry`)}
onPress={e => {
e.preventDefault()
item.retry()
return false
}}>
{_(msg`Retry.`)}
</InlineLinkText>
</Text>
</View>
</View>
)
}
@@ -0,0 +1,258 @@
import React, {useCallback, useRef} from 'react'
import {FlatList, View} from 'react-native'
import {
KeyboardAvoidingView,
useKeyboardHandler,
} from 'react-native-keyboard-controller'
import {runOnJS, useSharedValue} from 'react-native-reanimated'
import {ReanimatedScrollEvent} from 'react-native-reanimated/lib/typescript/reanimated2/hook/commonTypes'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {isIOS} from '#/platform/detection'
import {useChat} from '#/state/messages'
import {ConvoItem, ConvoStatus} from '#/state/messages/convo'
import {ScrollProvider} from 'lib/ScrollContext'
import {isWeb} from 'platform/detection'
import {List} from 'view/com/util/List'
import {MessageInput} from '#/screens/Messages/Conversation/MessageInput'
import {MessageListError} from '#/screens/Messages/Conversation/MessageListError'
import {atoms as a, useBreakpoints} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import {MessageItem} from '#/components/dms/MessageItem'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
function MaybeLoader({isLoading}: {isLoading: boolean}) {
return (
<View
style={{
height: 50,
width: '100%',
alignItems: 'center',
justifyContent: 'center',
}}>
{isLoading && <Loader size="xl" />}
</View>
)
}
function RetryButton({onPress}: {onPress: () => unknown}) {
const {_} = useLingui()
return (
<View style={{alignItems: 'center'}}>
<Button
label={_(msg`Press to Retry`)}
onPress={onPress}
variant="ghost"
color="negative"
size="small">
<ButtonText>
<Trans>Press to Retry</Trans>
</ButtonText>
</Button>
</View>
)
}
function renderItem({item}: {item: ConvoItem}) {
if (item.type === 'message' || item.type === 'pending-message') {
return (
<MessageItem
item={item.message}
next={item.nextMessage}
pending={item.type === 'pending-message'}
/>
)
} else if (item.type === 'deleted-message') {
return <Text>Deleted message</Text>
} else if (item.type === 'pending-retry') {
return <RetryButton onPress={item.retry} />
} else if (item.type === 'error-recoverable') {
return <MessageListError item={item} />
}
return null
}
function keyExtractor(item: ConvoItem) {
return item.key
}
function onScrollToIndexFailed() {
// Placeholder function. You have to give FlatList something or else it will error.
}
export function MessagesList() {
const chat = useChat()
const flatListRef = useRef<FlatList>(null)
// We need to keep track of when the scroll offset is at the bottom of the list to know when to scroll as new items
// are added to the list. For example, if the user is scrolled up to 1iew older messages, we don't want to scroll to
// the bottom.
const isAtBottom = useSharedValue(true)
// This will be used on web to assist in determing if we need to maintain the content offset
const isAtTop = useSharedValue(true)
// Used to keep track of the current content height. We'll need this in `onScroll` so we know when to start allowing
// onStartReached to fire.
const contentHeight = useSharedValue(0)
// We don't want to call `scrollToEnd` again if we are already scolling to the end, because this creates a bit of jank
// Instead, we use `onMomentumScrollEnd` and this value to determine if we need to start scrolling or not.
const isMomentumScrolling = useSharedValue(false)
const [hasInitiallyScrolled, setHasInitiallyScrolled] = React.useState(false)
// Every time the content size changes, that means one of two things is happening:
// 1. New messages are being added from the log or from a message you have sent
// 2. Old messages are being prepended to the top
//
// The first time that the content size changes is when the initial items are rendered. Because we cannot rely on
// `initialScrollIndex`, we need to immediately scroll to the bottom of the list. That scroll will not be animated.
//
// Subsequent resizes will only scroll to the bottom if the user is at the bottom of the list (within 100 pixels of
// the bottom). Therefore, any new messages that come in or are sent will result in an animated scroll to end. However
// we will not scroll whenever new items get prepended to the top.
const onContentSizeChange = useCallback(
(_: number, height: number) => {
// Because web does not have `maintainVisibleContentPosition` support, we will need to manually scroll to the
// previous offset whenever we add new content to the previous offset whenever we add new content to the list.
if (isWeb && isAtTop.value && hasInitiallyScrolled) {
flatListRef.current?.scrollToOffset({
animated: false,
offset: height - contentHeight.value,
})
}
contentHeight.value = height
// This number _must_ be the height of the MaybeLoader component
if (height <= 50 || !isAtBottom.value) {
return
}
flatListRef.current?.scrollToOffset({
animated: hasInitiallyScrolled,
offset: height,
})
isMomentumScrolling.value = true
},
[
contentHeight,
hasInitiallyScrolled,
isAtBottom.value,
isAtTop.value,
isMomentumScrolling,
],
)
// The check for `hasInitiallyScrolled` prevents an initial fetch on mount. FlatList triggers `onStartReached`
// immediately on mount, since we are in fact at an offset of zero, so we have to ignore those initial calls.
const onStartReached = useCallback(() => {
if (chat.status === ConvoStatus.Ready && hasInitiallyScrolled) {
chat.fetchMessageHistory()
}
}, [chat, hasInitiallyScrolled])
const onSendMessage = useCallback(
(text: string) => {
if (chat.status === ConvoStatus.Ready) {
chat.sendMessage({
text,
})
}
},
[chat],
)
const onScroll = React.useCallback(
(e: ReanimatedScrollEvent) => {
'worklet'
const bottomOffset = e.contentOffset.y + e.layoutMeasurement.height
// Most apps have a little bit of space the user can scroll past while still automatically scrolling ot the bottom
// when a new message is added, hence the 100 pixel offset
isAtBottom.value = e.contentSize.height - 100 < bottomOffset
isAtTop.value = e.contentOffset.y <= 1
// This number _must_ be the height of the MaybeLoader component.
// We don't check for zero, because the `MaybeLoader` component is always present, even when not visible, which
// adds a 50 pixel offset.
if (contentHeight.value > 50 && !hasInitiallyScrolled) {
runOnJS(setHasInitiallyScrolled)(true)
}
},
[contentHeight.value, hasInitiallyScrolled, isAtBottom, isAtTop],
)
const onMomentumEnd = React.useCallback(() => {
'worklet'
isMomentumScrolling.value = false
}, [isMomentumScrolling])
const scrollToEnd = React.useCallback(() => {
requestAnimationFrame(() => {
if (isMomentumScrolling.value) return
flatListRef.current?.scrollToEnd({animated: true})
isMomentumScrolling.value = true
})
}, [isMomentumScrolling])
const {bottom: bottomInset, top: topInset} = useSafeAreaInsets()
const {gtMobile} = useBreakpoints()
const bottomBarHeight = gtMobile ? 0 : isIOS ? 40 : 60
// This is only used inside the useKeyboardHandler because the worklet won't work with a ref directly.
const scrollToEndNow = React.useCallback(() => {
flatListRef.current?.scrollToEnd({animated: false})
}, [])
useKeyboardHandler({
onMove: () => {
'worklet'
runOnJS(scrollToEndNow)()
},
})
return (
<KeyboardAvoidingView
style={[a.flex_1, {marginBottom: bottomInset + bottomBarHeight}]}
keyboardVerticalOffset={isIOS ? topInset : 0}
behavior="padding"
contentContainerStyle={a.flex_1}>
{/* Custom scroll provider so that we can use the `onScroll` event in our custom List implementation */}
<ScrollProvider onScroll={onScroll} onMomentumEnd={onMomentumEnd}>
<List
ref={flatListRef}
data={chat.items}
renderItem={renderItem}
keyExtractor={keyExtractor}
disableVirtualization={true}
initialNumToRender={isWeb ? 50 : 25}
maxToRenderPerBatch={isWeb ? 50 : 25}
keyboardDismissMode="on-drag"
keyboardShouldPersistTaps="handled"
maintainVisibleContentPosition={{
minIndexForVisible: 1,
}}
containWeb={true}
contentContainerStyle={{paddingHorizontal: 10}}
removeClippedSubviews={false}
onContentSizeChange={onContentSizeChange}
onStartReached={onStartReached}
onScrollToIndexFailed={onScrollToIndexFailed}
scrollEventThrottle={100}
ListHeaderComponent={
<MaybeLoader isLoading={chat.isFetchingHistory} />
}
/>
</ScrollProvider>
<MessageInput onSendMessage={onSendMessage} scrollToEnd={scrollToEnd} />
</KeyboardAvoidingView>
)
}
+148
View File
@@ -0,0 +1,148 @@
import React, {useCallback} from 'react'
import {TouchableOpacity, View} from 'react-native'
import {KeyboardProvider} from 'react-native-keyboard-controller'
import {AppBskyActorDefs} from '@atproto/api'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
import {NativeStackScreenProps} from '@react-navigation/native-stack'
import {CommonNavigatorParams, NavigationProp} from '#/lib/routes/types'
import {useGate} from '#/lib/statsig/statsig'
import {BACK_HITSLOP} from 'lib/constants'
import {isWeb} from 'platform/detection'
import {ChatProvider, useChat} from 'state/messages'
import {ConvoStatus} from 'state/messages/convo'
import {PreviewableUserAvatar} from 'view/com/util/UserAvatar'
import {CenteredView} from 'view/com/util/Views'
import {MessagesList} from '#/screens/Messages/Conversation/MessagesList'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {ConvoMenu} from '#/components/dms/ConvoMenu'
import {ListMaybePlaceholder} from '#/components/Lists'
import {Text} from '#/components/Typography'
import {ClipClopGate} from '../gate'
type Props = NativeStackScreenProps<
CommonNavigatorParams,
'MessagesConversation'
>
export function MessagesConversationScreen({route}: Props) {
const gate = useGate()
const convoId = route.params.conversation
if (!gate('dms')) return <ClipClopGate />
return (
<ChatProvider convoId={convoId}>
<Inner />
</ChatProvider>
)
}
function Inner() {
const chat = useChat()
if (
chat.status === ConvoStatus.Uninitialized ||
chat.status === ConvoStatus.Initializing
) {
return <ListMaybePlaceholder isLoading />
}
if (chat.status === ConvoStatus.Error) {
// TODO error
return null
}
/*
* Any other chat states (atm) are "ready" states
*/
return (
<KeyboardProvider>
<CenteredView style={{flex: 1}} sideBorders>
<Header profile={chat.recipients[0]} />
<MessagesList />
</CenteredView>
</KeyboardProvider>
)
}
let Header = ({
profile,
}: {
profile: AppBskyActorDefs.ProfileViewBasic
}): React.ReactNode => {
const t = useTheme()
const {_} = useLingui()
const {gtTablet} = useBreakpoints()
const navigation = useNavigation<NavigationProp>()
const chat = useChat()
const onPressBack = useCallback(() => {
if (isWeb) {
navigation.replace('Messages')
} else {
navigation.pop()
}
}, [navigation])
const onUpdateConvo = useCallback(() => {
// TODO eric update muted state
}, [])
return (
<View
style={[
t.atoms.bg,
t.atoms.border_contrast_low,
a.border_b,
a.flex_row,
a.justify_between,
a.align_start,
a.gap_lg,
a.px_lg,
a.py_sm,
]}>
{!gtTablet ? (
<TouchableOpacity
testID="conversationHeaderBackBtn"
onPress={onPressBack}
hitSlop={BACK_HITSLOP}
style={{width: 30, height: 30}}
accessibilityRole="button"
accessibilityLabel={_(msg`Back`)}
accessibilityHint="">
<FontAwesomeIcon
size={18}
icon="angle-left"
style={{
marginTop: 6,
}}
color={t.atoms.text.color}
/>
</TouchableOpacity>
) : (
<View style={{width: 30}} />
)}
<View style={[a.align_center, a.gap_sm, a.flex_1]}>
<PreviewableUserAvatar size={32} profile={profile} />
<Text style={[a.text_lg, a.font_bold, a.text_center]}>
{profile.displayName}
</Text>
</View>
{chat.status === ConvoStatus.Ready ? (
<ConvoMenu
convo={chat.convo}
profile={profile}
onUpdateConvo={onUpdateConvo}
currentScreen="conversation"
/>
) : (
<View style={{width: 30}} />
)}
</View>
)
}
Header = React.memo(Header)
+399
View File
@@ -0,0 +1,399 @@
/* eslint-disable react/prop-types */
import React, {useCallback, useMemo, useState} from 'react'
import {View} from 'react-native'
import {ChatBskyConvoDefs} from '@atproto-labs/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {NativeStackScreenProps} from '@react-navigation/native-stack'
import {sha256} from 'js-sha256'
import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
import {MessagesTabNavigatorParams} from '#/lib/routes/types'
import {useGate} from '#/lib/statsig/statsig'
import {cleanError} from '#/lib/strings/errors'
import {logger} from '#/logger'
import {isNative} from '#/platform/detection'
import {useListConvos} from '#/state/queries/messages/list-converations'
import {useSession} from '#/state/session'
import {List} from '#/view/com/util/List'
import {TimeElapsed} from '#/view/com/util/TimeElapsed'
import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
import {ViewHeader} from '#/view/com/util/ViewHeader'
import {CenteredView} from '#/view/com/util/Views'
import {ScrollView} 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 {ConvoMenu} from '#/components/dms/ConvoMenu'
import {NewChat} from '#/components/dms/NewChat'
import * as TextField from '#/components/forms/TextField'
import {useRefreshOnFocus} from '#/components/hooks/useRefreshOnFocus'
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
import {SettingsSliderVertical_Stroke2_Corner0_Rounded as SettingsSlider} from '#/components/icons/SettingsSlider'
import {Link} from '#/components/Link'
import {ListFooter, ListMaybePlaceholder} from '#/components/Lists'
import {useMenuControl} from '#/components/Menu'
import {Text} from '#/components/Typography'
import {ClipClopGate} from '../gate'
import {useDmServiceUrlStorage} from '../Temp/useDmServiceUrlStorage'
type Props = NativeStackScreenProps<MessagesTabNavigatorParams, 'Messages'>
export function MessagesScreen({navigation}: Props) {
const {_} = useLingui()
const t = useTheme()
const newChatControl = useDialogControl()
const {gtMobile} = useBreakpoints()
// TEMP
const {serviceUrl, setServiceUrl} = useDmServiceUrlStorage()
const hasValidServiceUrl = useMemo(() => {
const hash = sha256(serviceUrl)
return (
hash ===
'a32318b49dd3fe6aa6a35c66c13fcc4c1cb6202b24f5a852d9a2279acee4169f'
)
}, [serviceUrl])
const renderButton = useCallback(() => {
return (
<Link
to="/messages/settings"
accessibilityLabel={_(msg`Message settings`)}
accessibilityHint={_(msg`Opens the message settings page`)}>
<SettingsSlider size="lg" style={t.atoms.text} />
</Link>
)
}, [_, t.atoms.text])
const initialNumToRender = useInitialNumToRender()
const [isPTRing, setIsPTRing] = useState(false)
const {
data,
isLoading,
isFetchingNextPage,
hasNextPage,
fetchNextPage,
error,
refetch,
} = useListConvos({refetchInterval: 15_000})
useRefreshOnFocus(refetch)
const isError = !!error
const conversations = useMemo(() => {
if (data?.pages) {
return data.pages.flatMap(page => page.convos)
}
return []
}, [data])
const onRefresh = useCallback(async () => {
setIsPTRing(true)
try {
await refetch()
} catch (err) {
logger.error('Failed to refresh conversations', {message: err})
}
setIsPTRing(false)
}, [refetch, setIsPTRing])
const onEndReached = useCallback(async () => {
if (isFetchingNextPage || !hasNextPage || isError) return
try {
await fetchNextPage()
} catch (err) {
logger.error('Failed to load more conversations', {message: err})
}
}, [isFetchingNextPage, hasNextPage, isError, fetchNextPage])
const onNewChat = useCallback(
(conversation: string) =>
navigation.navigate('MessagesConversation', {conversation}),
[navigation],
)
const onNavigateToSettings = useCallback(() => {
navigation.navigate('MessagesSettings')
}, [navigation])
const renderItem = useCallback(
({item}: {item: ChatBskyConvoDefs.ConvoView}) => {
return <ChatListItem key={item.id} convo={item} />
},
[],
)
const gate = useGate()
if (!gate('dms')) return <ClipClopGate />
if (!hasValidServiceUrl) {
return (
<ScrollView contentContainerStyle={a.p_lg}>
<View>
<TextField.LabelText>Service URL</TextField.LabelText>
<TextField.Root>
<TextField.Input
value={serviceUrl}
onChangeText={text => setServiceUrl(text)}
autoCapitalize="none"
keyboardType="url"
label="https://"
/>
</TextField.Root>
</View>
</ScrollView>
)
}
if (conversations.length < 1) {
return (
<View style={a.flex_1}>
{gtMobile ? (
<CenteredView sideBorders>
<DesktopHeader
newChatControl={newChatControl}
onNavigateToSettings={onNavigateToSettings}
/>
</CenteredView>
) : (
<ViewHeader
title={_(msg`Messages`)}
renderButton={renderButton}
showBorder
canGoBack={false}
/>
)}
{!isError && <NewChat onNewChat={onNewChat} control={newChatControl} />}
<ListMaybePlaceholder
isLoading={isLoading}
isError={isError}
emptyType="results"
emptyTitle={_(msg`No messages yet`)}
emptyMessage={_(
msg`You have no messages yet. Start a conversation with someone!`,
)}
errorMessage={cleanError(error)}
onRetry={isError ? refetch : undefined}
hideBackButton
/>
</View>
)
}
return (
<View style={a.flex_1}>
{!gtMobile && (
<ViewHeader
title={_(msg`Messages`)}
renderButton={renderButton}
showBorder
canGoBack={false}
/>
)}
<NewChat onNewChat={onNewChat} control={newChatControl} />
<List
data={conversations}
renderItem={renderItem}
keyExtractor={item => item.id}
refreshing={isPTRing}
onRefresh={onRefresh}
onEndReached={onEndReached}
ListHeaderComponent={
<DesktopHeader
newChatControl={newChatControl}
onNavigateToSettings={onNavigateToSettings}
/>
}
ListFooterComponent={
<ListFooter
isFetchingNextPage={isFetchingNextPage}
error={cleanError(error)}
onRetry={fetchNextPage}
style={{borderColor: 'transparent'}}
/>
}
onEndReachedThreshold={3}
initialNumToRender={initialNumToRender}
windowSize={11}
// @ts-ignore our .web version only -sfn
desktopFixedHeight
/>
</View>
)
}
function ChatListItem({convo}: {convo: ChatBskyConvoDefs.ConvoView}) {
const t = useTheme()
const {_} = useLingui()
const {currentAccount} = useSession()
const menuControl = useMenuControl()
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
}
lastMessageSentAt = convo.lastMessage.sentAt
}
if (ChatBskyConvoDefs.isDeletedMessageView(convo.lastMessage)) {
lastMessage = _(msg`Message deleted`)
}
const otherUser = convo.members.find(
member => member.did !== currentAccount?.did,
)
if (!otherUser) {
return null
}
return (
<Link
to={`/messages/${convo.id}`}
style={a.flex_1}
onLongPress={isNative ? menuControl.open : undefined}>
{({hovered, pressed}) => (
<View
style={[
a.flex_row,
a.flex_1,
a.pl_md,
a.py_sm,
a.gap_md,
a.pr_2xl,
(hovered || pressed) && t.atoms.bg_contrast_25,
]}>
<View pointerEvents="none">
<PreviewableUserAvatar profile={otherUser} size={42} />
</View>
<View style={[a.flex_1]}>
<Text
numberOfLines={1}
style={[a.text_md, web([a.leading_normal, {marginTop: -4}])]}>
<Text
style={[t.atoms.text, convo.unreadCount > 0 && a.font_bold]}>
{otherUser.displayName || otherUser.handle}
</Text>{' '}
{lastMessageSentAt ? (
<TimeElapsed timestamp={lastMessageSentAt}>
{({timeElapsed}) => (
<Text style={t.atoms.text_contrast_medium}>
@{otherUser.handle} &middot; {timeElapsed}
</Text>
)}
</TimeElapsed>
) : (
<Text style={t.atoms.text_contrast_medium}>
@{otherUser.handle}
</Text>
)}
</Text>
<Text
numberOfLines={2}
style={[
a.text_sm,
a.leading_snug,
convo.unreadCount > 0
? a.font_bold
: t.atoms.text_contrast_medium,
]}>
{lastMessage}
</Text>
</View>
{convo.unreadCount > 0 && (
<View
style={[
a.flex_0,
a.ml_md,
a.mt_sm,
a.rounded_full,
{
backgroundColor: convo.muted
? t.palette.contrast_200
: t.palette.primary_500,
height: 7,
width: 7,
},
]}
/>
)}
<ConvoMenu
convo={convo}
profile={otherUser}
control={menuControl}
// TODO(sam) show on hover on web
// tricky because it captures the mouse event
hideTrigger
currentScreen="list"
/>
</View>
)}
</Link>
)
}
function DesktopHeader({
newChatControl,
onNavigateToSettings,
}: {
newChatControl: DialogControlProps
onNavigateToSettings: () => void
}) {
const t = useTheme()
const {_} = useLingui()
const {gtMobile, gtTablet} = useBreakpoints()
if (!gtMobile) {
return null
}
return (
<View
style={[
t.atoms.bg,
t.atoms.border_contrast_low,
a.border_b,
a.flex_row,
a.align_center,
a.justify_between,
a.gap_lg,
a.px_lg,
a.py_sm,
]}>
<Text style={[a.text_2xl, a.font_bold]}>
<Trans>Messages</Trans>
</Text>
<View style={[a.flex_row, a.align_center, a.gap_md]}>
<Button
label={_(msg`Message settings`)}
color="secondary"
size="large"
variant="ghost"
style={[{height: 'auto', width: 'auto'}, a.px_sm, a.py_sm]}
onPress={onNavigateToSettings}>
<ButtonIcon icon={SettingsSlider} />
</Button>
{gtTablet && (
<Button
label={_(msg`New chat`)}
color="primary"
size="large"
variant="solid"
style={[{height: 'auto', width: 'auto'}, a.px_md, a.py_sm]}
onPress={newChatControl.open}>
<ButtonIcon icon={Plus} position="right" />
<ButtonText>
<Trans>New chat</Trans>
</ButtonText>
</Button>
)}
</View>
</View>
)
}
+24
View File
@@ -0,0 +1,24 @@
import React from 'react'
import {View} from 'react-native'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {NativeStackScreenProps} from '@react-navigation/native-stack'
import {CommonNavigatorParams} from '#/lib/routes/types'
import {useGate} from '#/lib/statsig/statsig'
import {ViewHeader} from '#/view/com/util/ViewHeader'
import {ClipClopGate} from '../gate'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'MessagesSettings'>
export function MessagesSettingsScreen({}: Props) {
const {_} = useLingui()
const gate = useGate()
if (!gate('dms')) return <ClipClopGate />
return (
<View>
<ViewHeader title={_(msg`Settings`)} showOnDesktop />
</View>
)
}

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