Compare commits

...

90 Commits

Author SHA1 Message Date
Eric Bailey 6eae5dfcc0 Fix indent 2024-05-07 18:17:52 -05:00
Eric Bailey bd22e0fdcf Fix html structure 2024-05-07 18:17:04 -05:00
Eric Bailey 423d3f455b Let React app replace preload 2024-05-07 18:14:04 -05:00
Eric Bailey f78126e01a [🐴] State transitions (#3880)
* Handle init/resume/suspend/background and polling

* Add debug and temp guard

* Make state transitions sync

* Make init sync also

* Checkpoint: confusing but working state machine

* Reducer-esque

* Remove poll events

* Guard fetchConvo

(cherry picked from commit 8385579d31500bb4bfb60afeecdc1eb3ddd7e747)

* Clean up polling, make sync

(cherry picked from commit 7f75cd04c3bf81c94662785748698640a84bef51)

* Update history handling

(cherry picked from commit b82b552ba4040adf7ead2377541132a386964ff8)

* Check for screen focus in app state listener

* Get rid of ad-hoc status checks
2024-05-07 17:54:52 -05:00
Eric Bailey 87cb4c105e [🐴] Global event mgmt (#3897)
* Add global event bus for messages logs

* Add rev to state

* Better handle error

* Clean up polling, add backgrounding

* Add trailConvo method

* Extend polling until we're ready for this
2024-05-07 17:54:34 -05:00
Paul Frazee 0625a914bd Run intl:extract 2024-05-07 14:15:34 -07:00
Hailey 4678c4353b typo in if/else (#3899) 2024-05-07 13:17:40 -07:00
Hailey 0658dd053d create new cache key on each run when saving previous commit hash (#3898)
* create new cache key on each run

* dynamic restore-key
2024-05-07 13:08:44 -07:00
Minseo Lee c6d30f671a Remove unnecessary Trans tags (#3888) 2024-05-07 20:06:39 +01:00
Samuel Newman 7d72dfb1cb [GIFs] Restore default alt text (#3893)
* restore default alt text

* factor out gif alt logic + enable require alt text setting

* rm console.log

* don't prefill input + esc handling

* typo

* Nits

* shorten user alt prefix

* Remove unnecessary condition, rename for clarity

* Add comment

---------

Co-authored-by: Dan Abramov <dan.abramov@gmail.com>
2024-05-07 20:05:40 +01:00
Samuel Newman 77e6c75a2c Fix color of new web splash logo (#3896) 2024-05-07 19:28:51 +01:00
Minseo Lee af77574b3c Update Feed.tsx (#3715) 2024-05-07 11:09:22 -07:00
Minseo Lee b1a1ca98e8 Update Feeds.tsx (#3719) 2024-05-07 11:07:57 -07:00
kodebanget 3cca5f04d6 Update Indonesian translation (#3891)
* Update Indonesian translation

* Update id/messages.po

---------

Co-authored-by: Indonesian <github-actions@github.com>
2024-05-07 10:33:59 -07:00
Minseo Lee 7f88ec7ca5 Update WhoCanReply.tsx (#3720) 2024-05-07 10:20:05 -07:00
Hailey 790a9c4fad fix bad bool check in action (#3885)
* fix bad bool check in action

* add `fetch-depth` so we can get the commit hash

* `.env` should be in `.easignore` 🙃
2024-05-06 20:13:12 -07:00
Jan-Olof Eriksson 92f6665f93 Finnish translation update (#3755)
* Update messages.po

Translated new strings (GIF's etc) and cleaned up deprecated ones.

* Update messages.po

resolve conflict

* Update messages.po

removed double quotes. Thanks @lapanti
2024-05-06 20:08:36 -07:00
Minseo Lee 2ea9e2c929 Improve localization marks (#3285)
* Update Lightbox.tsx

* Change strings for easier localization

* Update DeleteAccount.tsx

* Update LabelsOnMeDialog.tsx

* Update FeedCard.tsx

* Update index.tsx

* Update LabelsOnMeDialog.tsx

* Update index.tsx

* Update FeedCard.tsx

* Update SelfLabel.tsx

* Update Hashtag.tsx

* Update index.tsx

* Update Hashtag.tsx

* Update ChangeHandle.tsx

* Update index.web.tsx

* Update index.web.tsx

* Update index.tsx

* Remove unnecessary `<Trans>` tags

* Update Drawer.tsx
2024-05-06 19:58:04 -07:00
Paul Frazee 4fad18b2fa Implement FeedFeedback API (#3498)
* Implement onViewableItemsChanged on List.web.tsx

* Introduce onItemSeen to List API

* Add FeedFeedback tracker

* Add clickthrough interaction tracking

* Add engagement interaction tracking

* Reduce duplicate sends, introduce a flushAndReset to be triggered on refreshes, and modify the api design a bit

* Wire up SDK types and feedContext

* Avoid needless function allocations

* Fix schema usage

* Add show more / show less buttons

* Fix minor rendering issue on mobile menu

* Wire up sendInteractions()

* Fix logic error

* Fix: it's item not uri

* Update 'seen' to mean 3 seconds on-screen with some significant portion visible

* Fix non-reactive debounce

* Move methods out

* Use a WeakSet for deduping

* Reset timeout

* 3 -> 2 seconds

* Oopsie

* Throttle instead

* Fix divider

* Remove explicit flush calls

* Rm unused

---------

Co-authored-by: dan <dan.abramov@gmail.com>
2024-05-07 03:08:33 +01:00
Paul Frazee e264dfbb87 Merge branch 'TuxPenguin09-web-loading' into main 2024-05-06 18:06:44 -07:00
Paul Frazee 032f849092 Protect against missing element 2024-05-06 18:06:31 -07:00
Paul Frazee 0a8dc289ff Merge branch 'web-loading' of https://github.com/TuxPenguin09/bsky-social-app into TuxPenguin09-web-loading 2024-05-06 17:51:51 -07:00
Paul Frazee 901feba6db Replace pluralize by plural by @tkusano (#3882)
* Replace pluralize with plural or Plural
* Replace all pluralize (defined by src/lib/strings/helpers.ts) with plural or Plural (defined by @lingui/macro) to make some UI elements translatable.
* Delete pluralize() and related test.

* Import @formatjs polyfill libraries for plural on ios and android

- ios and andorid: import `@formtjs/intl-locale` and `@formatjs/intl-pluralrules` to polyfill `Intl.Locale` and `Intl.PluralRules` which are used in `plural()` and '<Plural />'.
- update `plural` use in notification messages for better translation.

* Rewrite to pass lint

* Add Catalan plural polyfill

* more replacement

* import zh plural data for zh-CN

* Refactor feed header components (#2964)

* Move home-related files to view/com/home

* Add HomeHeader in front of FeedTabBar

* Move isDekstop check outside FeedsTabBar

* Remove PWI logic from tabbar

* Separate platform-specific layout from shared logic

* Rename Home Feed Prefs to Following Feed Prefs (#2965)

* use `useOpenLink` hook for links in ALF (#2975)

* use `useOpenLink` hook for links in ALF

* web only for `outline`

* increase timeout to 15s (#2958)

* Normalize relative day (#2874)

* fix: normalize relative date

* chore: add comments

* refactor: skip flooring normalized diff

* refactor: let -> const

* fix: get own copy of date to prevent mutating

* refactor: rounding does the same trick

* Add handle validation to create account UI (#2959)

* show uiState errors in the box as well

simplify copy

update ui for only letters and numbers

add ui validation to handle selection

* simplify names

* Fix accidental text-node render

---------

Co-authored-by: Paul Frazee <pfrazee@gmail.com>

* Make dim theme dim (#2966)

* Make dim color scheme dim

* Tweaks

* Overall tweaks

* We have to go darker

* Tweak saturation of blues in dim

* Increase contrast on dark-dark mode

* adjust dim

---------

Co-authored-by: Eric Bailey <git@esb.lol>
Co-authored-by: Paul Frazee <pfrazee@gmail.com>
Co-authored-by: Hailey <me@haileyok.com>

* Fix dim mode unread notif color

* use `showControls` to show/hide live text icon on ios (#2982)

* Update .po files

* fix reversed icons in validator 🤦 (#2991)

* Adjust `windowSize` on `PostThread` `FlatList` (#2989)

* adjust window size, cells batching period

* rm batching period change

* Pluralize 'follow(s)'

* Include a space between the msgid count and "follower(s)/following(s)" so the translator can adjust the translated count line to fit within the Drawer.

* pluralie '# following'

* Fix & Update

* Rewrite to use Plural

* rmeove unused import

* When commiting changes, disable 'simple-import-sort' plugin in .eslintrc.js to sync with bluesky-social:main

* Revert simple-import-sort/imports related changes

* Move ProfileHoverCard web to plural util

* Followings -> following

* Add plural following to hovercard

* Followings -> Following

---------

Co-authored-by: Takayuki KUSANO <kusano@tkusano.jp>
Co-authored-by: Takayuki KUSANO <65759+tkusano@users.noreply.github.com>
Co-authored-by: dan <dan.abramov@gmail.com>
Co-authored-by: Hailey <me@haileyok.com>
Co-authored-by: Mary <148872143+mary-ext@users.noreply.github.com>
Co-authored-by: Eric Bailey <git@esb.lol>
2024-05-06 16:55:57 -07:00
Paul Frazee 2ca4b74955 Merge branch 'piotrpalek-fix-banner-cropper' into main 2024-05-06 15:49:52 -07:00
Paul Frazee fe82257801 Dont leave promise unresolved 2024-05-06 15:44:19 -07:00
Paul Frazee e1940983a3 Merge branch 'fix-banner-cropper' of https://github.com/piotrpalek/social-app into piotrpalek-fix-banner-cropper 2024-05-06 15:30:54 -07:00
Samuel Newman 136b0e12ab make gif alt text prompt selectable (#3879) 2024-05-06 22:14:00 +01:00
Hailey fdb5ffff53 remove trailing period from query term in useActorAutocompleteQuery (#3765)
* remove trailing period from query term

* Rm useMemo

---------

Co-authored-by: Dan Abramov <dan.abramov@gmail.com>
2024-05-06 22:04:47 +01:00
Hailey b4015d10d1 Add PrivacyManifest info to comply with new App Store guidelines (#3784)
* add necessary privacy reasons

* bump expo to support creation of the manifest
2024-05-06 21:47:55 +01:00
Hailey 99f3f10fe7 A few containWeb List nits (#3877)
* use getters for returned values

* pass ref

* add log to `onScroll` in tester

* improve expect error
2024-05-06 21:45:14 +01:00
Eric Bailey 0b6ace990e [Clipclops] All my clops gone (#3850)
* Handle two common errors, provide more clarity around error states

* Handle failed polling

* Remove unused error type

* format
2024-05-06 15:35:05 -05:00
Ivan Beà 2a1dbd2756 Update catalan messages.po (#3764)
Translate new lines, messages are coming! 
You can check it @jordimas @darccio @ferranrego @MiquelAdell @mvdan @johnnydement @Mejans @surfdude29
2024-05-06 13:27:08 -07:00
Minseo Lee a9feba4efd Update Korean localization (#3809)
* Update messages.po

* Update messages.po

* Update messages.po
2024-05-06 13:26:32 -07:00
Takayuki KUSANO 5cfe5d4f58 Update Japanese translations (#3810)
* Update Japanese translations

* Updated Japanese translation

* Updated Japanese translation

* Update Japanese translations

* Updated based on @Hima-Zinn's suggestion
2024-05-06 13:25:59 -07:00
Frudrax Cheng c90443b37a Update zh-TW translations (#3873)
* 修正及改進翻譯

* Epic zh-TW rebrand

* small fix

* remove reundant "設定" for "偏好"

* legacy for "殘存"

* Update Translator

---------

Co-authored-by: Kuwa Lee <kuwalee1069@gmail.com>
2024-05-06 13:25:07 -07: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
aliamanuba 1d8fdbcc34 Add Preload for Web - Update base.html 2024-05-02 19:38:33 +08:00
aliamanuba c303df5bd6 Added Preload for Web
- Bluesky logo in the center when a new visitor or shift+refresh by browser client
2024-05-02 19:24:35 +08: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
Piotr P bf7aee19b8 add missing cropImageUtil file 2024-04-27 14:59:00 +02:00
Piotr P beddddcb57 hide alternative ratio buttons when using custom ratio 2024-04-27 14:31:51 +02:00
Piotr P bc956803b8 allow for custom cropper aspect ration based on image 2024-04-27 14:31:45 +02:00
Piotr P ebd333b331 Fix cropper crashing after clicking cancel 2024-04-27 14:31:39 +02:00
Piotr P ae26963b45 eslint autofixes 2024-04-27 14:31:33 +02:00
145 changed files with 20893 additions and 11904 deletions
+1 -2
View File
@@ -95,8 +95,7 @@ web-build/
/ios/
# environment variables
.env
.env.*
# Firebase (Android) Google services
# INCLUDED: google-services.json
# INCLUDED: google-services.json
+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")
@@ -25,6 +25,8 @@ jobs:
- name: ⬇️ Checkout
uses: actions/checkout@v4
with:
fetch-depth: 5
- name: 🔧 Setup Node
uses: actions/setup-node@v4
@@ -57,6 +59,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
View File
@@ -25,6 +25,8 @@ jobs:
- name: ⬇️ Checkout
uses: actions/checkout@v4
with:
fetch-depth: 5
- name: 🔧 Setup Node
uses: actions/setup-node@v4
@@ -65,6 +67,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
+14 -2
View File
@@ -54,7 +54,9 @@ jobs:
uses: actions/cache@v4
with:
path: last-successful-commit-hash.txt
key: last-successful-deployment-commit-${{ github.ref_name }}
key: last-successful-deployment-commit-${{ github.ref_name }}-${{ github.sha }}
restore-keys: |
last-successful-deployment-commit-${{ github.ref_name }}-
- name: Add the last successful deployment commit to the output
id: last-successful-commit
@@ -68,7 +70,7 @@ jobs:
- name: 🕵️ Get the base commit
id: base-commit
run: |
if [ -z "${{ inputs.channel == 'production' }}" ]; then
if ${{ inputs.channel == 'production' }}; then
echo base-commit=$(git show-ref -s ${{ inputs.runtimeVersion }}) >> "$GITHUB_OUTPUT"
else
echo base-commit=${{ steps.last-successful-commit.base-commit }} >> "$GITHUB_OUTPUT"
@@ -144,6 +146,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
@@ -182,6 +186,8 @@ jobs:
- name: ⬇️ Checkout
uses: actions/checkout@v4
with:
fetch-depth: 5
- name: 🔧 Setup Node
uses: actions/setup-node@v4
@@ -222,6 +228,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
@@ -251,6 +259,8 @@ jobs:
- name: ⬇️ Checkout
uses: actions/checkout@v4
with:
fetch-depth: 5
- name: 🔧 Setup Node
uses: actions/setup-node@v4
@@ -283,6 +293,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
+1 -30
View File
@@ -3,7 +3,7 @@ import {RichText} from '@atproto/api'
import {parseEmbedPlayerFromUrl} from 'lib/strings/embed-player'
import {cleanError} from '../../src/lib/strings/errors'
import {createFullHandle, makeValidHandle} from '../../src/lib/strings/handles'
import {enforceLen, pluralize} from '../../src/lib/strings/helpers'
import {enforceLen} from '../../src/lib/strings/helpers'
import {detectLinkables} from '../../src/lib/strings/rich-text-detection'
import {shortenLinks} from '../../src/lib/strings/rich-text-manip'
import {ago} from '../../src/lib/strings/time'
@@ -127,35 +127,6 @@ describe('detectLinkables', () => {
})
})
describe('pluralize', () => {
const inputs: [number, string, string?][] = [
[1, 'follower'],
[1, 'member'],
[100, 'post'],
[1000, 'repost'],
[10000, 'upvote'],
[100000, 'other'],
[2, 'man', 'men'],
]
const outputs = [
'follower',
'member',
'posts',
'reposts',
'upvotes',
'others',
'men',
]
it('correctly pluralizes a set of words', () => {
for (let i = 0; i < inputs.length; i++) {
const input = inputs[i]
const output = pluralize(...input)
expect(output).toEqual(outputs[i])
}
})
})
describe('makeRecordUri', () => {
const inputs: [string, string, string][] = [
['alice.test', 'app.bsky.feed.post', '3jk7x4irgv52r'],
+22
View File
@@ -91,6 +91,28 @@ module.exports = function (config) {
entitlements: {
'com.apple.security.application-groups': 'group.app.bsky',
},
privacyManifests: {
NSPrivacyAccessedAPITypes: [
{
NSPrivacyAccessedAPIType:
'NSPrivacyAccessedAPICategoryFileTimestamp',
NSPrivacyAccessedAPITypeReasons: ['C617.1', '3B52.1', '0A2A.1'],
},
{
NSPrivacyAccessedAPIType: 'NSPrivacyAccessedAPICategoryDiskSpace',
NSPrivacyAccessedAPITypeReasons: ['E174.1', '85F4.1'],
},
{
NSPrivacyAccessedAPIType: 'NSPrivacyAccessedAPICategoryBootTime',
NSPrivacyAccessedAPITypeReasons: ['35F9.1'],
},
{
NSPrivacyAccessedAPIType:
'NSPrivacyAccessedAPICategoryUserDefaults',
NSPrivacyAccessedAPITypeReasons: ['CA92.1'],
},
],
},
},
androidStatusBar: {
barStyle: 'light-content',
@@ -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="M17.657 6.343A8 8 0 1 0 6.343 17.657 8 8 0 0 0 17.657 6.343ZM4.929 4.93c3.905-3.905 10.237-3.905 14.142 0 3.905 3.905 3.905 10.237 0 14.142-3.905 3.905-10.237 3.905-14.142 0-3.905-3.905-3.905-10.237 0-14.142Zm3.536 9.192a1 1 0 0 1 1.414 0 3 3 0 0 0 4.243 0 1 1 0 0 1 1.414 1.415 5 5 0 0 1-7.071 0 1 1 0 0 1 0-1.415Z M10.5 9.5c0 .828-.56 1.5-1.25 1.5S8 10.328 8 9.5 8.56 8 9.25 8s1.25.672 1.25 1.5ZM16 9.5c0 .828-.56 1.5-1.25 1.5s-1.25-.672-1.25-1.5.56-1.5 1.25-1.5S16 8.672 16 9.5Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 623 B

+21 -7
View File
@@ -47,6 +47,14 @@
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Liberation Sans", Helvetica, Arial, sans-serif;
}
#preload {
width: 100px;
position: fixed;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
}
/* Buttons and inputs have a font set by UA, so we'll have to reset that */
button, input, textarea {
font: inherit;
@@ -262,13 +270,19 @@
</head>
<body>
{%- block body_all %}
<div id="root"></div>
<noscript>
<h1 lang="en">JavaScript Required</h1>
<p lang="en">This is a heavily interactive web application, and JavaScript is required. Simple HTML interfaces are possible, but that is not what this is.
<p lang="en">Learn more about Bluesky at <a href="https://bsky.social">bsky.social</a> and <a href="https://atproto.com">atproto.com</a>.
{% block noscript_extra %}{% endblock %}
</noscript>
<div id="root">
<div id="preload">
<!-- Bluesky SVG -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 360 320"><path fill="#0085ff" d="M180 142c-16.3-31.7-60.7-90.8-102-120C38.5-5.9 23.4-1 13.5 3.4 2.1 8.6 0 26.2 0 36.5c0 10.4 5.7 84.8 9.4 97.2 12.2 41 55.7 55 95.7 50.5-58.7 8.6-110.8 30-42.4 106.1 75.1 77.9 103-16.7 117.3-64.6 14.3 48 30.8 139 116 64.6 64-64.6 17.6-97.5-41.1-106.1 40 4.4 83.5-9.5 95.7-50.5 3.7-12.4 9.4-86.8 9.4-97.2 0-10.3-2-27.9-13.5-33C336.5-1 321.5-6 282 22c-41.3 29.2-85.7 88.3-102 120Z"/></svg>
</div>
</div>
<noscript>
<h1 lang="en">JavaScript Required</h1>
<p lang="en">This is a heavily interactive web application, and JavaScript is required. Simple HTML interfaces are possible, but that is not what this is.
<p lang="en">Learn more about Bluesky at <a href="https://bsky.social">bsky.social</a> and <a href="https://atproto.com">atproto.com</a>.
{% block noscript_extra %}{% endblock %}
</noscript>
{% endblock -%}
</body>
</html>
@@ -1,5 +1,6 @@
import {requireNativeViewManager} from 'expo-modules-core'
import * as React from 'react'
import {ExpoScrollForwarderViewProps} from './ExpoScrollForwarder.types'
const NativeView: React.ComponentType<ExpoScrollForwarderViewProps> =
+6 -2
View File
@@ -1,6 +1,6 @@
{
"name": "bsky.app",
"version": "1.80.0",
"version": "1.81.0",
"private": true,
"engines": {
"node": ">=18"
@@ -60,6 +60,8 @@
"@expo/webpack-config": "^19.0.0",
"@floating-ui/dom": "^1.6.3",
"@floating-ui/react-dom": "^2.0.8",
"@formatjs/intl-locale": "^3.4.3",
"@formatjs/intl-pluralrules": "^5.2.10",
"@fortawesome/fontawesome-svg-core": "^6.1.1",
"@fortawesome/free-regular-svg-icons": "^6.1.1",
"@fortawesome/free-solid-svg-icons": "^6.1.1",
@@ -98,6 +100,7 @@
"@tiptap/react": "^2.0.0-beta.220",
"@tiptap/suggestion": "^2.0.0-beta.220",
"@types/invariant": "^2.2.37",
"@types/lodash.throttle": "^4.1.9",
"@types/node": "^18.16.2",
"@zxing/text-encoding": "^0.9.0",
"array.prototype.findlast": "^1.2.3",
@@ -109,7 +112,7 @@
"email-validator": "^2.0.4",
"emoji-mart": "^5.5.2",
"eventemitter3": "^5.0.1",
"expo": "^50.0.8",
"expo": "^50.0.17",
"expo-application": "^5.8.3",
"expo-build-properties": "^0.11.1",
"expo-camera": "~14.0.4",
@@ -149,6 +152,7 @@
"lodash.samplesize": "^4.2.0",
"lodash.set": "^4.3.2",
"lodash.shuffle": "^4.2.0",
"lodash.throttle": "^4.1.1",
"mobx": "^6.6.1",
"mobx-react-lite": "^3.4.0",
"mobx-utils": "^6.0.6",
+58 -43
View File
@@ -4,7 +4,6 @@ import 'view/icons'
import React, {useEffect, useState} from 'react'
import {GestureHandlerRootView} from 'react-native-gesture-handler'
import {KeyboardProvider} from 'react-native-keyboard-controller'
import {RootSiblingParent} from 'react-native-root-siblings'
import {
initialWindowMetrics,
@@ -16,6 +15,8 @@ import {useLingui} from '@lingui/react'
import {useQueryClient} from '@tanstack/react-query'
import {Provider as StatsigProvider} from '#/lib/statsig/statsig'
import {logger} from '#/logger'
import {MessagesEventBusProvider} from '#/state/messages/events'
import {init as initPersistedState} from '#/state/persisted'
import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs'
import {Provider as ModerationOptsProvider} from '#/state/preferences/moderation-opts'
@@ -34,6 +35,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'
@@ -53,8 +55,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()
@@ -62,49 +65,63 @@ 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 MUST come before ModerationOptsProvider */}
<LabelDefsProvider>
<ModerationOptsProvider>
<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>
</ModerationOptsProvider>
</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}>
<MessagesEventBusProvider>
<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>
</MessagesEventBusProvider>
</QueryProvider>
</React.Fragment>
</RootSiblingParent>
</Splash>
</ThemeProvider>
</Alf>
</SafeAreaProvider>
)
@@ -142,9 +159,7 @@ function App() {
<LightboxStateProvider>
<I18nProvider>
<PortalProvider>
<KeyboardProvider>
<InnerApp />
</KeyboardProvider>
<InnerApp />
</PortalProvider>
</I18nProvider>
</LightboxStateProvider>
+59 -32
View File
@@ -4,8 +4,12 @@ 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 {MessagesEventBusProvider} from '#/state/messages/events'
import {init as initPersistedState} from '#/state/persisted'
import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs'
import {Provider as ModerationOptsProvider} from '#/state/preferences/moderation-opts'
@@ -22,64 +26,87 @@ 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 MUST come before ModerationOptsProvider */}
<LabelDefsProvider>
<ModerationOptsProvider>
<LoggedOutViewProvider>
<SelectedFeedProvider>
<UnreadNotifsProvider>
<ThemeProvider theme={theme}>
{/* All components should be within this provider */}
<RootSiblingParent>
<SafeAreaProvider>
<Shell />
</SafeAreaProvider>
</RootSiblingParent>
<ToastContainer />
</ThemeProvider>
</UnreadNotifsProvider>
</SelectedFeedProvider>
</LoggedOutViewProvider>
</ModerationOptsProvider>
</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}>
<MessagesEventBusProvider>
<StatsigProvider>
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
<LabelDefsProvider>
<ModerationOptsProvider>
<LoggedOutViewProvider>
<SelectedFeedProvider>
<UnreadNotifsProvider>
<SafeAreaProvider>
<Shell />
</SafeAreaProvider>
</UnreadNotifsProvider>
</SelectedFeedProvider>
</LoggedOutViewProvider>
</ModerationOptsProvider>
</LabelDefsProvider>
</StatsigProvider>
</MessagesEventBusProvider>
</QueryProvider>
</React.Fragment>
<ToastContainer />
</RootSiblingParent>
</ThemeProvider>
</Alf>
)
}
+6 -6
View File
@@ -48,7 +48,7 @@ import {useWebScrollRestoration} from './lib/hooks/useWebScrollRestoration'
import {attachRouteToLogEvents, logEvent} from './lib/statsig/statsig'
import {router} from './routes'
import {MessagesConversationScreen} from './screens/Messages/Conversation'
import {MessagesListScreen} from './screens/Messages/List'
import {MessagesScreen} from './screens/Messages/List'
import {MessagesSettingsScreen} from './screens/Messages/Settings'
import {useModalControls} from './state/modals'
import {useUnreadNotifications} from './state/queries/notifications/unread'
@@ -462,8 +462,8 @@ function MessagesTabNavigator() {
contentStyle: pal.view,
}}>
<MessagesTab.Screen
name="MessagesList"
getComponent={() => MessagesListScreen}
name="Messages"
getComponent={() => MessagesScreen}
options={{requireAuth: true}}
/>
{commonScreens(MessagesTab as typeof HomeTab)}
@@ -512,8 +512,8 @@ const FlatNavigator = () => {
options={{title: title(msg`Notifications`), requireAuth: true}}
/>
<Flat.Screen
name="MessagesList"
getComponent={() => MessagesListScreen}
name="Messages"
getComponent={() => MessagesScreen}
options={{title: title(msg`Messages`), requireAuth: true}}
/>
{commonScreens(Flat as typeof HomeTab, numUnread)}
@@ -570,7 +570,7 @@ const LINKING = {
return buildStateObject('HomeTab', 'Home', params)
}
if (name === 'Messages') {
return buildStateObject('MessagesTab', 'MessagesList', params)
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 & {
+2 -5
View File
@@ -1,6 +1,6 @@
import React from 'react'
import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {msg, Plural, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {AppBskyLabelerDefs} from '@atproto/api'
@@ -13,7 +13,6 @@ import {RichText} from '#/components/RichText'
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '../icons/Chevron'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {sanitizeHandle} from '#/lib/strings/handles'
import {pluralize} from '#/lib/strings/helpers'
type LabelingServiceProps = {
labeler: AppBskyLabelerDefs.LabelerViewDetailed
@@ -69,9 +68,7 @@ export function LikeCount({count}: {count: number}) {
t.atoms.text_contrast_medium,
{fontWeight: '500'},
]}>
<Trans>
Liked by {count} {pluralize(count, 'user')}
</Trans>
<Plural value={count} one="Liked by # user" other="Liked by # users" />
</Text>
)
}
+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)
+23 -15
View File
@@ -2,13 +2,12 @@ import React from 'react'
import {View} from 'react-native'
import {AppBskyActorDefs, moderateProfile, ModerationOpts} from '@atproto/api'
import {flip, offset, shift, size, useFloating} from '@floating-ui/react-dom'
import {msg, Trans} from '@lingui/macro'
import {msg, plural} from '@lingui/macro'
import {useLingui} from '@lingui/react'
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/preferences/moderation-opts'
import {usePrefetchProfileQuery, useProfileQuery} from '#/state/queries/profile'
import {useSession} from '#/state/session'
@@ -371,7 +370,14 @@ function Inner({
const blockHide = profile.viewer?.blocking || profile.viewer?.blockedBy
const following = formatCount(profile.followsCount || 0)
const followers = formatCount(profile.followersCount || 0)
const pluralizedFollowers = pluralize(profile.followersCount || 0, 'follower')
const pluralizedFollowers = plural(profile.followersCount || 0, {
one: 'follower',
other: 'followers',
})
const pluralizedFollowings = plural(profile.followsCount || 0, {
one: 'following',
other: 'following',
})
const profileURL = makeProfileLink({
did: profile.did,
handle: profile.handle,
@@ -398,7 +404,9 @@ function Inner({
color={profileShadow.viewer?.following ? 'secondary' : 'primary'}
variant="solid"
label={
profileShadow.viewer?.following ? _('Following') : _('Follow')
profileShadow.viewer?.following
? _(msg`Following`)
: _(msg`Follow`)
}
style={[a.rounded_full]}
onPress={profileShadow.viewer?.following ? unfollow : follow}>
@@ -407,7 +415,9 @@ function Inner({
icon={profileShadow.viewer?.following ? Check : Plus}
/>
<ButtonText>
{profileShadow.viewer?.following ? _('Following') : _('Follow')}
{profileShadow.viewer?.following
? _(msg`Following`)
: _(msg`Follow`)}
</ButtonText>
</Button>
)}
@@ -434,22 +444,20 @@ function Inner({
label={`${followers} ${pluralizedFollowers}`}
style={[t.atoms.text]}
onPress={hide}>
<Trans>
<Text style={[a.text_md, a.font_bold]}>{followers} </Text>
<Text style={[t.atoms.text_contrast_medium]}>
{pluralizedFollowers}
</Text>
</Trans>
<Text style={[a.text_md, a.font_bold]}>{followers} </Text>
<Text style={[t.atoms.text_contrast_medium]}>
{pluralizedFollowers}
</Text>
</InlineLinkText>
<InlineLinkText
to={makeProfileLink(profile, 'follows')}
label={_(msg`${following} following`)}
style={[t.atoms.text]}
onPress={hide}>
<Trans>
<Text style={[a.text_md, a.font_bold]}>{following} </Text>
<Text style={[t.atoms.text_contrast_medium]}>following</Text>
</Trans>
<Text style={[a.text_md, a.font_bold]}>{following} </Text>
<Text style={[t.atoms.text_contrast_medium]}>
{pluralizedFollowings}
</Text>
</InlineLinkText>
</View>
+8 -2
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>
@@ -60,12 +62,16 @@ export function TitleText({children}: React.PropsWithChildren<{}>) {
)
}
export function DescriptionText({children}: React.PropsWithChildren<{}>) {
export function DescriptionText({
children,
selectable,
}: React.PropsWithChildren<{selectable?: boolean}>) {
const t = useTheme()
const {descriptionId} = React.useContext(Context)
return (
<Text
nativeID={descriptionId}
selectable={selectable}
style={[a.text_md, a.leading_snug, t.atoms.text_contrast_high, a.pb_lg]}>
{children}
</Text>
+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}
@@ -1,24 +1,27 @@
import React, {useCallback, useMemo} from 'react'
import {StyleProp, TextStyle, View} from 'react-native'
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 {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 function MessageItem({
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()
@@ -29,55 +32,73 @@ export function MessageItem({
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 10 minute gap between this message and the next
// 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()
// 10 minutes
return diff > 10 * 60 * 1000
// 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>
<View
style={[
a.py_sm,
a.px_lg,
a.my_2xs,
a.rounded_md,
isFromSelf ? a.self_end : a.self_start,
{
maxWidth: '65%',
backgroundColor: isFromSelf
? t.palette.primary_500
: t.palette.contrast_50,
borderRadius: 17,
},
isFromSelf
? {borderBottomRightRadius: isLastInGroup ? 2 : 17}
: {borderBottomLeftRadius: isLastInGroup ? 2 : 17},
]}>
<Text
<ActionsWrapper isFromSelf={isFromSelf} message={item}>
<View
style={[
a.text_md,
a.leading_snug,
isFromSelf && {color: t.palette.white},
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},
]}>
{item.text}
</Text>
</View>
<Metadata
<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}
@@ -86,7 +107,9 @@ export function MessageItem({
)
}
function Metadata({
MessageItem = React.memo(MessageItem)
let MessageItemMetadata = ({
message,
isLastInGroup,
style,
@@ -94,7 +117,7 @@ function Metadata({
message: ChatBskyConvoDefs.MessageView
isLastInGroup: boolean
style: StyleProp<TextStyle>
}) {
}): React.ReactNode => {
const t = useTheme()
const {_} = useLingui()
@@ -117,16 +140,15 @@ function Metadata({
}
// if in the last day
if (now.toISOString().slice(0, 10) === date.toISOString().slice(0, 10)) {
if (localDateString(now) === localDateString(date)) {
return time
}
// if yesterday
const yesterday = new Date(now)
yesterday.setDate(yesterday.getDate() - 1)
if (
yesterday.toISOString().slice(0, 10) === date.toISOString().slice(0, 10)
) {
if (localDateString(yesterday) === localDateString(date)) {
return _(msg`Yesterday, ${time}`)
}
@@ -153,7 +175,7 @@ function Metadata({
style={[
t.atoms.text_contrast_medium,
a.text_xs,
a.mt_xs,
a.mt_2xs,
a.mb_lg,
style,
]}>
@@ -163,3 +185,14 @@ function Metadata({
</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)
+32 -15
View File
@@ -20,6 +20,7 @@ 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'
@@ -178,7 +179,7 @@ function SearchablePeopleList({
</Text>
<TextField.Root>
<TextField.Icon icon={Search} />
<TextField.Input
<Dialog.Input
label={_(msg`Search profiles`)}
placeholder={_(msg`Search`)}
value={searchText}
@@ -197,6 +198,7 @@ function SearchablePeopleList({
autoCorrect={false}
autoComplete="off"
autoCapitalize="none"
autoFocus
/>
</TextField.Root>
</View>
@@ -211,20 +213,35 @@ function SearchablePeopleList({
ListHeaderComponent={
<>
{listHeader}
{searchText.length > 0 && !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.`)
}
/>
{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.`)
}
/>
)
)}
</>
}
+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
@@ -4,6 +4,10 @@ export const EmojiSad_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M6.343 6.343a8 8 0 1 1 11.314 11.314A8 8 0 0 1 6.343 6.343ZM19.071 4.93c-3.905-3.905-10.237-3.905-14.142 0-3.905 3.905-3.905 10.237 0 14.142 3.905 3.905 10.237 3.905 14.142 0 3.905-3.905 3.905-10.237 0-14.142Zm-3.537 9.535a5 5 0 0 0-7.07 0 1 1 0 1 0 1.413 1.415 3 3 0 0 1 4.243 0 1 1 0 0 0 1.414-1.415ZM16 9.5c0 .828-.56 1.5-1.25 1.5s-1.25-.672-1.25-1.5.56-1.5 1.25-1.5S16 8.672 16 9.5ZM9.25 11c.69 0 1.25-.672 1.25-1.5S9.94 8 9.25 8 8 8.672 8 9.5 8.56 11 9.25 11Z',
})
export const EmojiSmile_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M17.657 6.343A8 8 0 1 0 6.343 17.657 8 8 0 0 0 17.657 6.343ZM4.929 4.93c3.905-3.905 10.237-3.905 14.142 0 3.905 3.905 3.905 10.237 0 14.142-3.905 3.905-10.237 3.905-14.142 0-3.905-3.905-3.905-10.237 0-14.142Zm3.536 9.192a1 1 0 0 1 1.414 0 3 3 0 0 0 4.243 0 1 1 0 0 1 1.414 1.415 5 5 0 0 1-7.071 0 1 1 0 0 1 0-1.415ZM10.5 9.5c0 .828-.56 1.5-1.25 1.5S8 10.328 8 9.5 8.56 8 9.25 8s1.25.672 1.25 1.5ZM16 9.5c0 .828-.56 1.5-1.25 1.5s-1.25-.672-1.25-1.5.56-1.5 1.25-1.5S16 8.672 16 9.5Z',
})
export const EmojiArc_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16ZM2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12Zm8-5a1 1 0 0 1 1 1v3a1 1 0 1 1-2 0V8a1 1 0 0 1 1-1Zm4 0a1 1 0 0 1 1 1v3a1 1 0 1 1-2 0V8a1 1 0 0 1 1-1Zm-5.894 7.803a1 1 0 0 1 1.341-.447c1.719.859 3.387.859 5.106 0a1 1 0 1 1 .894 1.788c-2.281 1.141-4.613 1.141-6.894 0a1 1 0 0 1-.447-1.341Z',
})
+12 -6
View File
@@ -1,7 +1,7 @@
import React from 'react'
import {StyleProp, View, ViewStyle} from 'react-native'
import {AppBskyFeedDefs, ComAtprotoLabelDefs} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {msg, Plural} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useSession} from '#/state/session'
@@ -39,7 +39,6 @@ export function LabelsOnMe({
return null
}
const labelTarget = isAccount ? _(msg`account`) : _(msg`content`)
return (
<View style={[a.flex_row, style]}>
<LabelsOnMeDialog control={control} subject={details} labels={labels} />
@@ -54,11 +53,18 @@ export function LabelsOnMe({
}}>
<ButtonIcon position="left" icon={CircleInfo} />
<ButtonText style={[a.leading_snug]}>
{labels.length}{' '}
{labels.length === 1 ? (
<Trans>label has been placed on this {labelTarget}</Trans>
{isAccount ? (
<Plural
value={labels.length}
one="# label has been placed on this account"
other="# labels has been placed on this account"
/>
) : (
<Trans>labels have been placed on this {labelTarget}</Trans>
<Plural
value={labels.length}
one="# label has been placed on this content"
other="# labels has been placed on this content"
/>
)}
</ButtonText>
</Button>
@@ -190,7 +190,7 @@ function AppealForm({
},
reason: details,
})
Toast.show(_(msg`Appeal submitted.`))
Toast.show(_(msg`Appeal submitted`))
} finally {
control.close()
}
@@ -121,20 +121,20 @@ function ModerationDetailsDialogInner({
<>
<Divider />
<Text style={[t.atoms.text, a.text_md, a.leading_snug, a.mt_lg]}>
<Trans>
This label was applied by{' '}
{modcause.source.type === 'user' ? (
<Trans>the author</Trans>
) : (
{modcause.source.type === 'user' ? (
<Trans>This label was applied by the author.</Trans>
) : (
<Trans>
This label was applied by{' '}
<InlineLinkText
to={makeProfileLink({did: modcause.label.src, handle: ''})}
onPress={() => control.close()}
style={a.text_md}>
{desc.source}
</InlineLinkText>
)}
.
</Trans>
.
</Trans>
)}
</Text>
</>
)}
+12 -3
View File
@@ -4,7 +4,16 @@ 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 ?? ''
// 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'})`
+36
View File
@@ -0,0 +1,36 @@
// Kind of a hack. We needed some way to distinguish these.
const USER_ALT_PREFIX = 'Alt: '
const DEFAULT_ALT_PREFIX = 'ALT: '
export function createGIFDescription(
tenorDescription: string,
preferredAlt: string = '',
) {
preferredAlt = preferredAlt.trim()
if (preferredAlt !== '') {
return USER_ALT_PREFIX + preferredAlt
} else {
return DEFAULT_ALT_PREFIX + tenorDescription
}
}
export function parseAltFromGIFDescription(description: string): {
isPreferred: boolean
alt: string
} {
if (description.startsWith(USER_ALT_PREFIX)) {
return {
isPreferred: true,
alt: description.replace(USER_ALT_PREFIX, ''),
}
} else if (description.startsWith(DEFAULT_ALT_PREFIX)) {
return {
isPreferred: false,
alt: description.replace(DEFAULT_ALT_PREFIX, ''),
}
}
return {
isPreferred: false,
alt: description,
}
}
+16 -13
View File
@@ -1,4 +1,4 @@
import {useCallback} from 'react'
import {useCallback, useState} from 'react'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
@@ -8,12 +8,14 @@ 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(
@@ -22,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.
@@ -33,10 +39,9 @@ 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(_(msg`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(
@@ -48,14 +53,12 @@ export function useAccountSwitcher() {
logger.error(`switch account: selectAccount failed`, {
message: e.message,
})
clearCurrentAccount() // back user out to login
setTimeout(() => {
Toast.show(_(msg`Sorry! We need you to enter your password.`))
}, 100)
} finally {
setPendingDid(null)
}
},
[_, track, clearCurrentAccount, selectAccount, requestSwitchToAccount],
[_, track, initSession, requestSwitchToAccount, pendingDid],
)
return {onPressSwitchAccount}
return {onPressSwitchAccount, pendingDid}
}
+2 -1
View File
@@ -11,8 +11,9 @@ export function useNavigationTabState() {
isAtNotifications:
getTabState(state, 'Notifications') !== TabState.Outside,
isAtMyProfile: getTabState(state, 'MyProfile') !== TabState.Outside,
isAtMessages: getTabState(state, 'MessagesList') !== TabState.Outside,
isAtMessages: getTabState(state, 'Messages') !== TabState.Outside,
}
if (
!res.isAtHome &&
!res.isAtSearch &&
@@ -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',
}
})
}
+2 -1
View File
@@ -1,8 +1,9 @@
import {
Image as RNImage,
openCamera as openCameraFn,
openCropper as openCropperFn,
Image as RNImage,
} from 'react-native-image-crop-picker'
import {CameraOpts, CropperOptions} from './types'
export {openPicker} from './picker.shared'
+6 -1
View File
@@ -1,7 +1,8 @@
/// <reference lib="dom" />
import {CameraOpts, CropperOptions} from './types'
import {Image as RNImage} from 'react-native-image-crop-picker'
import {CameraOpts, CropperOptions} from './types'
export {openPicker} from './picker.shared'
import {unstable__openModal} from '#/state/modals'
@@ -16,6 +17,10 @@ export async function openCropper(opts: CropperOptions): Promise<RNImage> {
unstable__openModal({
name: 'crop-image',
uri: opts.path,
dimensions:
opts.height && opts.width
? {width: opts.width, height: opts.height}
: undefined,
onSelect: (img?: RNImage) => {
if (img) {
resolve(img)
+3 -3
View File
@@ -72,7 +72,7 @@ export type MyProfileTabNavigatorParams = CommonNavigatorParams & {
}
export type MessagesTabNavigatorParams = CommonNavigatorParams & {
MessagesList: undefined
Messages: undefined
}
export type FlatNavigatorParams = CommonNavigatorParams & {
@@ -81,7 +81,7 @@ export type FlatNavigatorParams = CommonNavigatorParams & {
Feeds: undefined
Notifications: undefined
Hashtag: {tag: string; author?: string}
MessagesList: undefined
Messages: undefined
}
export type AllNavigatorParams = CommonNavigatorParams & {
@@ -96,7 +96,7 @@ export type AllNavigatorParams = CommonNavigatorParams & {
MyProfileTab: undefined
Hashtag: {tag: string; author?: string}
MessagesTab: undefined
MessagesList: undefined
Messages: undefined
}
// NOTE
+1 -2
View File
@@ -1,10 +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'
| 'dms'
| 'hide_vertical_scroll_indicators'
| 'show_follow_back_label_v2'
| 'start_session_with_following_v2'
| 'test_gate_1'
-10
View File
@@ -1,13 +1,3 @@
export function pluralize(n: number, base: string, plural?: string): string {
if (n === 1) {
return base
}
if (plural) {
return plural
}
return base + 's'
}
export function enforceLen(
str: string,
len: number,
+20
View File
@@ -1,3 +1,7 @@
import '@formatjs/intl-locale/polyfill'
import '@formatjs/intl-pluralrules/polyfill'
import '@formatjs/intl-pluralrules/locale-data/en'
import {useEffect} from 'react'
import {i18n} from '@lingui/core'
@@ -29,66 +33,82 @@ export async function dynamicActivate(locale: AppLanguage) {
switch (locale) {
case AppLanguage.ca: {
i18n.loadAndActivate({locale, messages: messagesCa})
await import('@formatjs/intl-pluralrules/locale-data/ca')
break
}
case AppLanguage.de: {
i18n.loadAndActivate({locale, messages: messagesDe})
await import('@formatjs/intl-pluralrules/locale-data/de')
break
}
case AppLanguage.es: {
i18n.loadAndActivate({locale, messages: messagesEs})
await import('@formatjs/intl-pluralrules/locale-data/es')
break
}
case AppLanguage.fi: {
i18n.loadAndActivate({locale, messages: messagesFi})
await import('@formatjs/intl-pluralrules/locale-data/fi')
break
}
case AppLanguage.fr: {
i18n.loadAndActivate({locale, messages: messagesFr})
await import('@formatjs/intl-pluralrules/locale-data/fr')
break
}
case AppLanguage.ga: {
i18n.loadAndActivate({locale, messages: messagesGa})
await import('@formatjs/intl-pluralrules/locale-data/ga')
break
}
case AppLanguage.hi: {
i18n.loadAndActivate({locale, messages: messagesHi})
await import('@formatjs/intl-pluralrules/locale-data/hi')
break
}
case AppLanguage.id: {
i18n.loadAndActivate({locale, messages: messagesId})
await import('@formatjs/intl-pluralrules/locale-data/id')
break
}
case AppLanguage.it: {
i18n.loadAndActivate({locale, messages: messagesIt})
await import('@formatjs/intl-pluralrules/locale-data/it')
break
}
case AppLanguage.ja: {
i18n.loadAndActivate({locale, messages: messagesJa})
await import('@formatjs/intl-pluralrules/locale-data/ja')
break
}
case AppLanguage.ko: {
i18n.loadAndActivate({locale, messages: messagesKo})
await import('@formatjs/intl-pluralrules/locale-data/ko')
break
}
case AppLanguage.pt_BR: {
i18n.loadAndActivate({locale, messages: messagesPt_BR})
await import('@formatjs/intl-pluralrules/locale-data/pt')
break
}
case AppLanguage.tr: {
i18n.loadAndActivate({locale, messages: messagesTr})
await import('@formatjs/intl-pluralrules/locale-data/tr')
break
}
case AppLanguage.uk: {
i18n.loadAndActivate({locale, messages: messagesUk})
await import('@formatjs/intl-pluralrules/locale-data/uk')
break
}
case AppLanguage.zh_CN: {
i18n.loadAndActivate({locale, messages: messagesZh_CN})
await import('@formatjs/intl-pluralrules/locale-data/zh')
break
}
case AppLanguage.zh_TW: {
i18n.loadAndActivate({locale, messages: messagesZh_TW})
await import('@formatjs/intl-pluralrules/locale-data/zh')
break
}
default: {
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
+1 -1
View File
@@ -37,7 +37,7 @@ export const router = new Router({
CommunityGuidelines: '/support/community-guidelines',
CopyrightPolicy: '/support/copyright',
Hashtag: '/hashtag/:tag',
MessagesList: '/messages',
Messages: '/messages',
MessagesSettings: '/messages/settings',
MessagesConversation: '/messages/:conversation',
})
+9 -4
View File
@@ -1,10 +1,9 @@
import React from 'react'
import {View} from 'react-native'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {msg, Trans} from '@lingui/macro'
import {msg, plural, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {pluralize} from '#/lib/strings/helpers'
import {logger} from '#/logger'
import {isWeb} from '#/platform/detection'
import {isSessionDeactivated, useAgent, useSessionApi} from '#/state/session'
@@ -205,10 +204,16 @@ function msToString(ms: number | undefined): string | undefined {
return undefined
}
// hours
return `${estimatedTimeHrs} ${pluralize(estimatedTimeHrs, 'hour')}`
return `${estimatedTimeHrs} ${plural(estimatedTimeHrs, {
one: 'hour',
other: 'hours',
})}`
}
// minutes
return `${estimatedTimeMins} ${pluralize(estimatedTimeMins, 'minute')}`
return `${estimatedTimeMins} ${plural(estimatedTimeMins, {
one: 'minute',
other: 'minutes',
})}`
}
return undefined
}
+41 -24
View File
@@ -22,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()
@@ -34,33 +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 {
try {
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)
} catch (e: any) {
logger.error('choose account: initSession failed', {
message: e.message,
})
onSelectAccount(account)
}
}
} 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 (
@@ -74,6 +90,7 @@ export const ChooseAccountForm = ({
<AccountList
onSelectAccount={onSelect}
onSelectOther={() => onSelectAccount()}
pendingDid={pendingDid}
/>
</View>
<View style={[a.flex_row]}>
@@ -12,20 +12,21 @@ 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,
onFocus,
onBlur,
scrollToEnd,
}: {
onSendMessage: (message: string) => void
onFocus: () => void
onBlur: () => 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)
@@ -39,11 +40,12 @@ export function MessageInput({
return
}
onSendMessage(message.trimEnd())
playHaptic()
setMessage('')
setTimeout(() => {
inputRef.current?.focus()
}, 100)
}, [message, onSendMessage])
}, [message, onSendMessage, playHaptic])
const onInputLayout = React.useCallback(
(e: NativeSyntheticEvent<TextInputContentSizeChangeEventData>) => {
@@ -55,49 +57,55 @@ export function MessageInput({
setMaxHeight(max)
setIsInputScrollable(availableSpace < 30)
scrollToEnd()
},
[topInset],
[scrollToEnd, topInset],
)
return (
<View
style={[
a.flex_row,
a.py_sm,
a.px_sm,
a.pl_md,
a.mt_sm,
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={onFocus}
onBlur={onBlur}
onContentSizeChange={onInputLayout}
ref={inputRef}
/>
<Pressable
accessibilityRole="button"
<View style={a.p_sm}>
<View
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} />
</Pressable>
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>
)
}
@@ -11,8 +11,7 @@ export function MessageInput({
onSendMessage,
}: {
onSendMessage: (message: string) => void
onFocus: () => void
onBlur: () => void
scrollToEnd: () => void
}) {
const {_} = useLingui()
const t = useTheme()
@@ -45,47 +44,51 @@ export function MessageInput({
)
return (
<View
style={[
a.flex_row,
a.py_sm,
a.px_sm,
a.pl_md,
a.mt_sm,
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: 6,
},
])}
maxRows={12}
placeholder={_(msg`Write a message`)}
defaultValue=""
value={message}
dirName="ltr"
autoFocus={true}
onChange={onChange}
onKeyDown={onKeyDown}
/>
<Pressable
accessibilityRole="button"
<View style={a.p_sm}>
<View
style={[
a.rounded_full,
a.align_center,
a.justify_center,
{height: 30, width: 30, backgroundColor: t.palette.primary_500},
a.flex_row,
a.py_sm,
a.px_sm,
a.pl_md,
t.atoms.bg_contrast_25,
{borderRadius: 23},
]}>
<PaperPlane fill={t.palette.white} />
</Pressable>
<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.Network]: _(
msg`There was an issue connecting to the chat.`,
),
[ConvoItemError.HistoryFailed]: _(msg`Failed to load past messages.`),
[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>
)
}
@@ -1,13 +1,26 @@
import React, {useCallback, useMemo, useRef} from 'react'
import {FlatList, View, ViewToken} from 'react-native'
import {KeyboardAvoidingView} from 'react-native-keyboard-controller'
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 {MessageItem} from '#/screens/Messages/Conversation/MessageItem'
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'
@@ -25,19 +38,40 @@ function MaybeLoader({isLoading}: {isLoading: boolean}) {
)
}
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} />
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 (
<View>
<Button label="Retry" onPress={item.retry}>
<ButtonText>Retry</ButtonText>
</Button>
</View>
)
return <RetryButton onPress={item.retry} />
} else if (item.type === 'error-recoverable') {
return <MessageListError item={item} />
}
return null
@@ -47,105 +81,178 @@ function keyExtractor(item: ConvoItem) {
return item.key
}
function onScrollToEndFailed() {
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 use this to know if we should scroll after a new clop is added to the list
const isAtBottom = useRef(false)
const [onViewableItemsChanged, viewabilityConfig] = useMemo(() => {
return [
(info: {viewableItems: Array<ViewToken>; changed: Array<ViewToken>}) => {
const firstVisibleIndex = info.viewableItems[0]?.index
// 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)
isAtBottom.current = Number(firstVisibleIndex) < 2
},
{
itemVisiblePercentThreshold: 50,
minimumViewTime: 10,
},
]
}, [])
// This will be used on web to assist in determing if we need to maintain the content offset
const isAtTop = useSharedValue(true)
const onContentSizeChange = useCallback(() => {
if (isAtBottom.current) {
flatListRef.current?.scrollToOffset({offset: 0, animated: 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()
}
}, [])
const onEndReached = useCallback(() => {
chat.service.fetchMessageHistory()
}, [chat])
const onInputFocus = useCallback(() => {
if (!isAtBottom.current) {
flatListRef.current?.scrollToOffset({offset: 0, animated: true})
}
}, [])
const onInputBlur = useCallback(() => {}, [])
}, [chat, hasInitiallyScrolled])
const onSendMessage = useCallback(
(text: string) => {
chat.service.sendMessage({
text,
})
if (chat.status === ConvoStatus.Ready) {
chat.sendMessage({
text,
})
}
},
[chat.service],
[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={{flex: 1, marginBottom: isWeb ? 20 : 85}}
style={[a.flex_1, {marginBottom: bottomInset + bottomBarHeight}]}
keyboardVerticalOffset={isIOS ? topInset : 0}
behavior="padding"
keyboardVerticalOffset={70}
contentContainerStyle={{flex: 1}}>
<FlatList
data={
chat.state.status === ConvoStatus.Ready ? chat.state.items : undefined
}
keyExtractor={keyExtractor}
renderItem={renderItem}
contentContainerStyle={{paddingHorizontal: 10}}
// In the future, we might want to adjust this value. Not very concerning right now as long as we are only
// dealing with text. But whenever we have images or other media and things are taller, we will want to lower
// this...probably.
initialNumToRender={20}
// Same with the max to render per batch. Let's be safe for now though.
maxToRenderPerBatch={25}
inverted={true}
onEndReached={onEndReached}
onScrollToIndexFailed={onScrollToEndFailed}
onContentSizeChange={onContentSizeChange}
onViewableItemsChanged={onViewableItemsChanged}
viewabilityConfig={viewabilityConfig}
maintainVisibleContentPosition={{
minIndexForVisible: 1,
}}
ListFooterComponent={
<MaybeLoader
isLoading={
chat.state.status === ConvoStatus.Ready &&
chat.state.isFetchingHistory
}
/>
}
removeClippedSubviews={true}
ref={flatListRef}
keyboardDismissMode="none"
/>
<View style={{paddingHorizontal: 10}}>
<MessageInput
onSendMessage={onSendMessage}
onFocus={onInputFocus}
onBlur={onInputBlur}
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} />
}
/>
</View>
</ScrollProvider>
<MessageInput onSendMessage={onSendMessage} scrollToEnd={scrollToEnd} />
</KeyboardAvoidingView>
)
}
+61 -46
View File
@@ -1,8 +1,9 @@
import React from 'react'
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, Trans} from '@lingui/macro'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
import {NativeStackScreenProps} from '@react-navigation/native-stack'
@@ -13,13 +14,12 @@ import {BACK_HITSLOP} from 'lib/constants'
import {isWeb} from 'platform/detection'
import {ChatProvider, useChat} from 'state/messages'
import {ConvoStatus} from 'state/messages/convo'
import {useSession} from 'state/session'
import {UserAvatar} from 'view/com/util/UserAvatar'
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 {Button, ButtonIcon} from '#/components/Button'
import {DotGrid_Stroke2_Corner0_Rounded} from '#/components/icons/DotGrid'
import {Button, ButtonText} from '#/components/Button'
import {ConvoMenu} from '#/components/dms/ConvoMenu'
import {ListMaybePlaceholder} from '#/components/Lists'
import {Text} from '#/components/Typography'
import {ClipClopGate} from '../gate'
@@ -43,29 +43,43 @@ export function MessagesConversationScreen({route}: Props) {
function Inner() {
const chat = useChat()
const {currentAccount} = useSession()
const myDid = currentAccount?.did
const otherProfile = React.useMemo(() => {
if (chat.state.status !== ConvoStatus.Ready) return
return chat.state.convo.members.find(m => m.did !== myDid)
}, [chat.state, myDid])
if (
chat.status === ConvoStatus.Uninitialized ||
chat.status === ConvoStatus.Initializing
) {
return <ListMaybePlaceholder isLoading />
}
// TODO whenever we have error messages, we should use them in here -hailey
if (chat.state.status !== ConvoStatus.Ready || !otherProfile) {
if (chat.status === ConvoStatus.Error) {
// TODO
return (
<ListMaybePlaceholder
isLoading={true}
isError={chat.state.status === ConvoStatus.Error}
/>
<View>
<CenteredView style={{flex: 1}} sideBorders>
<Text>Something went wrong</Text>
<Button
label="Retry"
onPress={() => {
chat.error.retry()
}}>
<ButtonText>Retry</ButtonText>
</Button>
</CenteredView>
</View>
)
}
/*
* Any other chat states (atm) are "ready" states
*/
return (
<CenteredView style={{flex: 1}} sideBorders>
<Header profile={otherProfile} />
<MessagesList />
</CenteredView>
<KeyboardProvider>
<CenteredView style={{flex: 1}} sideBorders>
<Header profile={chat.recipients[0]} />
<MessagesList />
</CenteredView>
</KeyboardProvider>
)
}
@@ -78,15 +92,20 @@ let Header = ({
const {_} = useLingui()
const {gtTablet} = useBreakpoints()
const navigation = useNavigation<NavigationProp>()
const chat = useChat()
const onPressBack = React.useCallback(() => {
const onPressBack = useCallback(() => {
if (isWeb) {
navigation.replace('MessagesList')
navigation.replace('Messages')
} else {
navigation.pop()
}
}, [navigation])
const onUpdateConvo = useCallback(() => {
// TODO eric update muted state
}, [])
return (
<View
style={[
@@ -95,22 +114,20 @@ let Header = ({
a.border_b,
a.flex_row,
a.justify_between,
a.align_start,
a.gap_lg,
a.px_lg,
a.py_sm,
]}>
{!gtTablet ? (
<TouchableOpacity
testID="viewHeaderDrawerBtn"
testID="conversationHeaderBackBtn"
onPress={onPressBack}
hitSlop={BACK_HITSLOP}
style={{
width: 30,
height: 30,
}}
style={{width: 30, height: 30}}
accessibilityRole="button"
accessibilityLabel={_(msg`Back`)}
accessibilityHint={_(msg`Access navigation links and settings`)}>
accessibilityHint="">
<FontAwesomeIcon
size={18}
icon="angle-left"
@@ -123,25 +140,23 @@ let Header = ({
) : (
<View style={{width: 30}} />
)}
<View style={[a.align_center, a.gap_sm]}>
<UserAvatar size={32} avatar={profile.avatar} />
<Text style={[a.text_lg, a.font_bold]}>
<Trans>{profile.displayName}</Trans>
<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>
<View>
<Button
label={_(msg`Chat settings`)}
color="secondary"
size="large"
variant="ghost"
style={[{height: 'auto', width: 'auto'}, a.px_sm, a.py_sm]}
onPress={() => {}}>
<ButtonIcon icon={DotGrid_Stroke2_Corner0_Rounded} />
</Button>
</View>
{chat.status === ConvoStatus.Ready ? (
<ConvoMenu
convo={chat.convo}
profile={profile}
onUpdateConvo={onUpdateConvo}
currentScreen="conversation"
/>
) : (
<View style={{width: 30}} />
)}
</View>
)
}
Header = React.memo(Header)
+67 -8
View File
@@ -6,12 +6,14 @@ 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'
@@ -19,24 +21,40 @@ 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 {atoms as a, useBreakpoints, useTheme} from '#/alf'
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, 'MessagesList'>
export function MessagesListScreen({navigation}: Props) {
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
@@ -59,7 +77,9 @@ export function MessagesListScreen({navigation}: Props) {
fetchNextPage,
error,
refetch,
} = useListConvos()
} = useListConvos({refetchInterval: 15_000})
useRefreshOnFocus(refetch)
const isError = !!error
@@ -109,6 +129,25 @@ export function MessagesListScreen({navigation}: Props) {
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}>
@@ -190,6 +229,7 @@ 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
@@ -214,7 +254,10 @@ function ChatListItem({convo}: {convo: ChatBskyConvoDefs.ConvoView}) {
}
return (
<Link to={`/messages/${convo.id}`} style={a.flex_1}>
<Link
to={`/messages/${convo.id}`}
style={a.flex_1}
onLongPress={isNative ? menuControl.open : undefined}>
{({hovered, pressed}) => (
<View
style={[
@@ -230,7 +273,9 @@ function ChatListItem({convo}: {convo: ChatBskyConvoDefs.ConvoView}) {
<PreviewableUserAvatar profile={otherUser} size={42} />
</View>
<View style={[a.flex_1]}>
<Text numberOfLines={1} style={[a.text_md, a.leading_normal]}>
<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}
@@ -267,12 +312,26 @@ function ChatListItem({convo}: {convo: ChatBskyConvoDefs.ConvoView}) {
a.flex_0,
a.ml_md,
a.mt_sm,
{backgroundColor: t.palette.primary_500},
a.rounded_full,
{height: 7, width: 7},
{
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>
@@ -2,7 +2,7 @@ import React from 'react'
import {View} from 'react-native'
import {Image} from 'expo-image'
import {LinearGradient} from 'expo-linear-gradient'
import {msg} from '@lingui/macro'
import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {FeedSourceInfo, useFeedSourceInfoQuery} from '#/state/queries/feed'
@@ -109,7 +109,7 @@ function PrimaryFeedCardInner({
a.py_xs,
ctx.selected && styles.textSelected,
]}>
by @{feed.creatorHandle}
<Trans>by @{feed.creatorHandle}</Trans>
</Text>
</View>
@@ -189,9 +189,9 @@ export function StepInterests() {
color: t.palette.negative_900,
},
]}>
Error:{' '}
<Trans>Error:</Trans>{' '}
</Text>
{error?.message || 'an unknown error occurred'}
{error?.message || _(msg`an unknown error occurred`)}
</Text>
</View>
) : (
+14 -10
View File
@@ -1,10 +1,9 @@
import React from 'react'
import {View} from 'react-native'
import {AppBskyActorDefs} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {msg, plural} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {pluralize} from '#/lib/strings/helpers'
import {Shadow} from '#/state/cache/types'
import {makeProfileLink} from 'lib/routes/links'
import {formatCount} from 'view/com/util/numeric/format'
@@ -21,7 +20,14 @@ export function ProfileHeaderMetrics({
const {_} = useLingui()
const following = formatCount(profile.followsCount || 0)
const followers = formatCount(profile.followersCount || 0)
const pluralizedFollowers = pluralize(profile.followersCount || 0, 'follower')
const pluralizedFollowers = plural(profile.followersCount || 0, {
one: 'follower',
other: 'followers',
})
const pluralizedFollowings = plural(profile.followsCount || 0, {
one: 'following',
other: 'following',
})
return (
<View
@@ -42,17 +48,15 @@ export function ProfileHeaderMetrics({
style={[a.flex_row, t.atoms.text]}
to={makeProfileLink(profile, 'follows')}
label={_(msg`${following} following`)}>
<Trans>
<Text style={[a.font_bold, a.text_md]}>{following} </Text>
<Text style={[t.atoms.text_contrast_medium, a.text_md]}>
following
</Text>
</Trans>
<Text style={[a.font_bold, a.text_md]}>{following} </Text>
<Text style={[t.atoms.text_contrast_medium, a.text_md]}>
{pluralizedFollowings}
</Text>
</InlineLinkText>
<Text style={[a.font_bold, t.atoms.text, a.text_md]}>
{formatCount(profile.postsCount || 0)}{' '}
<Text style={[t.atoms.text_contrast_medium, a.font_normal, a.text_md]}>
{pluralize(profile.postsCount || 0, 'post')}
{plural(profile.postsCount || 0, {one: 'post', other: 'posts'})}
</Text>
</Text>
</View>
@@ -7,11 +7,10 @@ import {
ModerationOpts,
RichText as RichTextAPI,
} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {msg, Plural, plural, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {isAppLabeler} from '#/lib/moderation'
import {pluralize} from '#/lib/strings/helpers'
import {logger} from '#/logger'
import {Shadow} from '#/state/cache/types'
import {useModalControls} from '#/state/modals'
@@ -283,12 +282,10 @@ let ProfileHeaderLabeler = ({
},
}}
size="tiny"
label={_(
msg`Liked by ${likeCount} ${pluralize(
likeCount,
'user',
)}`,
)}>
label={plural(likeCount, {
one: 'Liked by # user',
other: 'Liked by # users',
})}>
{({hovered, focused, pressed}) => (
<Text
style={[
@@ -298,9 +295,11 @@ let ProfileHeaderLabeler = ({
(hovered || focused || pressed) &&
t.atoms.text_contrast_high,
]}>
<Trans>
Liked by {likeCount} {pluralize(likeCount, 'user')}
</Trans>
<Plural
value={likeCount}
one="Liked by # user"
other="Liked by # users"
/>
</Text>
)}
</Link>
+5 -1
View File
@@ -95,7 +95,11 @@ function ProfileEndOfFeed() {
const pal = usePalette('default')
return (
<View style={[pal.border, {paddingTop: 32, borderTopWidth: 1}]}>
<View
style={[
pal.border,
{paddingTop: 32, paddingBottom: 32, borderTopWidth: 1},
]}>
<Text style={[pal.textLight, pal.border, {textAlign: 'center'}]}>
<Trans>End of feed</Trans>
</Text>
+7 -5
View File
@@ -151,11 +151,13 @@ export function Signup({onPressBack}: {onPressBack: () => void}) {
]}>
<View style={[a.gap_sm, a.pb_3xl]}>
<Text style={[a.font_semibold, t.atoms.text_contrast_medium]}>
<Trans>Step</Trans> {state.activeStep + 1} <Trans>of</Trans>{' '}
{state.serviceDescription &&
!state.serviceDescription.phoneVerificationRequired
? '2'
: '3'}
<Trans>
Step {state.activeStep + 1} of{' '}
{state.serviceDescription &&
!state.serviceDescription.phoneVerificationRequired
? '2'
: '3'}
</Trans>
</Text>
<Text style={[a.text_3xl, a.font_bold]}>
{state.activeStep === SignupStep.INFO ? (
+151
View File
@@ -0,0 +1,151 @@
import React from 'react'
import {AppState, AppStateStatus} from 'react-native'
import {AppBskyFeedDefs, BskyAgent} from '@atproto/api'
import throttle from 'lodash.throttle'
import {PROD_DEFAULT_FEED} from '#/lib/constants'
import {logger} from '#/logger'
import {
FeedDescriptor,
FeedPostSliceItem,
isFeedPostSlice,
} from '#/state/queries/post-feed'
import {useAgent} from './session'
type StateContext = {
enabled: boolean
onItemSeen: (item: any) => void
sendInteraction: (interaction: AppBskyFeedDefs.Interaction) => void
}
const stateContext = React.createContext<StateContext>({
enabled: false,
onItemSeen: (_item: any) => {},
sendInteraction: (_interaction: AppBskyFeedDefs.Interaction) => {},
})
export function useFeedFeedback(feed: FeedDescriptor, hasSession: boolean) {
const {getAgent} = useAgent()
const enabled = isDiscoverFeed(feed) && hasSession
const queue = React.useRef<Set<string>>(new Set())
const history = React.useRef<
// Use a WeakSet so that we don't need to clear it.
// This assumes that referential identity of slice items maps 1:1 to feed (re)fetches.
WeakSet<FeedPostSliceItem | AppBskyFeedDefs.Interaction>
>(new WeakSet())
const sendToFeedNoDelay = React.useCallback(() => {
const proxyAgent = getAgent().withProxy(
// @ts-ignore TODO need to update withProxy() to support this key -prf
'bsky_fg',
// TODO when we start sending to other feeds, we need to grab their DID -prf
'did:web:discover.bsky.app',
) as BskyAgent
const interactions = Array.from(queue.current).map(toInteraction)
queue.current.clear()
proxyAgent.app.bsky.feed
.sendInteractions({interactions})
.catch((e: any) => {
logger.warn('Failed to send feed interactions', {error: e})
})
}, [getAgent])
const sendToFeed = React.useMemo(
() =>
throttle(sendToFeedNoDelay, 15e3, {
leading: false,
trailing: true,
}),
[sendToFeedNoDelay],
)
React.useEffect(() => {
if (!enabled) {
return
}
const sub = AppState.addEventListener('change', (state: AppStateStatus) => {
if (state === 'background') {
sendToFeed.flush()
}
})
return () => sub.remove()
}, [enabled, sendToFeed])
const onItemSeen = React.useCallback(
(slice: any) => {
if (!enabled) {
return
}
if (!isFeedPostSlice(slice)) {
return
}
for (const postItem of slice.items) {
if (!history.current.has(postItem)) {
history.current.add(postItem)
queue.current.add(
toString({
item: postItem.uri,
event: 'app.bsky.feed.defs#interactionSeen',
feedContext: postItem.feedContext,
}),
)
sendToFeed()
}
}
},
[enabled, sendToFeed],
)
const sendInteraction = React.useCallback(
(interaction: AppBskyFeedDefs.Interaction) => {
if (!enabled) {
return
}
if (!history.current.has(interaction)) {
history.current.add(interaction)
queue.current.add(toString(interaction))
sendToFeed()
}
},
[enabled, sendToFeed],
)
return React.useMemo(() => {
return {
enabled,
// pass this method to the <List> onItemSeen
onItemSeen,
// call on various events
// queues the event to be sent with the throttled sendToFeed call
sendInteraction,
}
}, [enabled, onItemSeen, sendInteraction])
}
export const FeedFeedbackProvider = stateContext.Provider
export function useFeedFeedbackContext() {
return React.useContext(stateContext)
}
// TODO
// We will introduce a permissions framework for 3p feeds to
// take advantage of the feed feedback API. Until that's in
// place, we're hardcoding it to the discover feed.
// -prf
function isDiscoverFeed(feed: FeedDescriptor) {
return feed === `feedgen|${PROD_DEFAULT_FEED('whats-hot')}`
}
function toString(interaction: AppBskyFeedDefs.Interaction): string {
return `${interaction.item}|${interaction.event}|${
interaction.feedContext || ''
}`
}
function toInteraction(str: string): AppBskyFeedDefs.Interaction {
const [item, event, feedContext] = str.split('|')
return {item, event, feedContext}
}
+14 -6
View File
@@ -1,15 +1,23 @@
import {describe, it} from '@jest/globals'
describe(`#/state/messages/convo`, () => {
describe(`status states`, () => {
describe(`init`, () => {
it.todo(`fails if sender and recipients aren't found`)
it.todo(`cannot re-initialize from a non-unintialized state`)
it.todo(`can re-initialize from a failed state`)
})
describe(`destroy`, () => {
it.todo(`cannot be interacted with when destroyed`)
it.todo(`polling is stopped when destroyed`)
it.todo(`events are cleaned up when destroyed`)
})
describe(`resume`, () => {
it.todo(`restores previous state if resume fails`)
})
describe(`suspend`, () => {
it.todo(`cannot be interacted with when suspended`)
it.todo(`polling is stopped when suspended`)
})
describe(`read states`, () => {
it.todo(`should mark messages as read as they come in`)
})
describe(`history fetching`, () => {
File diff suppressed because it is too large Load Diff
+466
View File
@@ -0,0 +1,466 @@
import {BskyAgent, ChatBskyConvoGetLog} from '@atproto-labs/api'
import EventEmitter from 'eventemitter3'
import {nanoid} from 'nanoid/non-secure'
import {logger} from '#/logger'
import {
MessagesEventBusDispatch,
MessagesEventBusDispatchEvent,
MessagesEventBusError,
MessagesEventBusErrorCode,
MessagesEventBusParams,
MessagesEventBusState,
MessagesEventBusStatus,
} from '#/state/messages/events/types'
const LOGGER_CONTEXT = 'MessagesEventBus'
const ACTIVE_POLL_INTERVAL = 60e3
const BACKGROUND_POLL_INTERVAL = 60e3
export class MessagesEventBus {
private id: string
private agent: BskyAgent
private __tempFromUserDid: string
private emitter = new EventEmitter()
private status: MessagesEventBusStatus = MessagesEventBusStatus.Uninitialized
private pollInterval = ACTIVE_POLL_INTERVAL
private error: MessagesEventBusError | undefined
private latestRev: string | undefined = undefined
snapshot: MessagesEventBusState | undefined
constructor(params: MessagesEventBusParams) {
this.id = nanoid(3)
this.agent = params.agent
this.__tempFromUserDid = params.__tempFromUserDid
this.subscribe = this.subscribe.bind(this)
this.getSnapshot = this.getSnapshot.bind(this)
this.init = this.init.bind(this)
this.suspend = this.suspend.bind(this)
this.resume = this.resume.bind(this)
this.setPollInterval = this.setPollInterval.bind(this)
this.trail = this.trail.bind(this)
this.trailConvo = this.trailConvo.bind(this)
}
private commit() {
this.snapshot = undefined
this.subscribers.forEach(subscriber => subscriber())
}
private subscribers: (() => void)[] = []
subscribe(subscriber: () => void) {
if (this.subscribers.length === 0) this.init()
this.subscribers.push(subscriber)
return () => {
this.subscribers = this.subscribers.filter(s => s !== subscriber)
if (this.subscribers.length === 0) this.suspend()
}
}
getSnapshot(): MessagesEventBusState {
if (!this.snapshot) this.snapshot = this.generateSnapshot()
// logger.debug(`${LOGGER_CONTEXT}: snapshotted`, {}, logger.DebugContext.convo)
return this.snapshot
}
private generateSnapshot(): MessagesEventBusState {
switch (this.status) {
case MessagesEventBusStatus.Initializing: {
return {
status: MessagesEventBusStatus.Initializing,
rev: undefined,
error: undefined,
setPollInterval: this.setPollInterval,
trail: this.trail,
trailConvo: this.trailConvo,
}
}
case MessagesEventBusStatus.Ready: {
return {
status: this.status,
rev: this.latestRev!,
error: undefined,
setPollInterval: this.setPollInterval,
trail: this.trail,
trailConvo: this.trailConvo,
}
}
case MessagesEventBusStatus.Suspended: {
return {
status: this.status,
rev: this.latestRev,
error: undefined,
setPollInterval: this.setPollInterval,
trail: this.trail,
trailConvo: this.trailConvo,
}
}
case MessagesEventBusStatus.Error: {
return {
status: MessagesEventBusStatus.Error,
rev: this.latestRev,
error: this.error || {
code: MessagesEventBusErrorCode.Unknown,
retry: () => {
this.init()
},
},
setPollInterval: this.setPollInterval,
trail: this.trail,
trailConvo: this.trailConvo,
}
}
default: {
return {
status: MessagesEventBusStatus.Uninitialized,
rev: undefined,
error: undefined,
setPollInterval: this.setPollInterval,
trail: this.trail,
trailConvo: this.trailConvo,
}
}
}
}
dispatch(action: MessagesEventBusDispatch) {
const prevStatus = this.status
switch (this.status) {
case MessagesEventBusStatus.Uninitialized: {
switch (action.event) {
case MessagesEventBusDispatchEvent.Init: {
this.status = MessagesEventBusStatus.Initializing
this.setup()
break
}
}
break
}
case MessagesEventBusStatus.Initializing: {
switch (action.event) {
case MessagesEventBusDispatchEvent.Ready: {
this.status = MessagesEventBusStatus.Ready
this.setPollInterval(ACTIVE_POLL_INTERVAL)
break
}
case MessagesEventBusDispatchEvent.Background: {
this.status = MessagesEventBusStatus.Backgrounded
this.setPollInterval(BACKGROUND_POLL_INTERVAL)
break
}
case MessagesEventBusDispatchEvent.Suspend: {
this.status = MessagesEventBusStatus.Suspended
break
}
case MessagesEventBusDispatchEvent.Error: {
this.status = MessagesEventBusStatus.Error
this.error = action.payload
break
}
}
break
}
case MessagesEventBusStatus.Ready: {
switch (action.event) {
case MessagesEventBusDispatchEvent.Background: {
this.status = MessagesEventBusStatus.Backgrounded
this.setPollInterval(BACKGROUND_POLL_INTERVAL)
break
}
case MessagesEventBusDispatchEvent.Suspend: {
this.status = MessagesEventBusStatus.Suspended
this.stopPoll()
break
}
case MessagesEventBusDispatchEvent.Error: {
this.status = MessagesEventBusStatus.Error
this.error = action.payload
this.stopPoll()
break
}
}
break
}
case MessagesEventBusStatus.Backgrounded: {
switch (action.event) {
case MessagesEventBusDispatchEvent.Resume: {
this.status = MessagesEventBusStatus.Ready
this.setPollInterval(ACTIVE_POLL_INTERVAL)
break
}
case MessagesEventBusDispatchEvent.Suspend: {
this.status = MessagesEventBusStatus.Suspended
this.stopPoll()
break
}
case MessagesEventBusDispatchEvent.Error: {
this.status = MessagesEventBusStatus.Error
this.error = action.payload
this.stopPoll()
break
}
}
break
}
case MessagesEventBusStatus.Suspended: {
switch (action.event) {
case MessagesEventBusDispatchEvent.Resume: {
this.status = MessagesEventBusStatus.Ready
this.setPollInterval(ACTIVE_POLL_INTERVAL)
break
}
case MessagesEventBusDispatchEvent.Background: {
this.status = MessagesEventBusStatus.Backgrounded
this.setPollInterval(BACKGROUND_POLL_INTERVAL)
break
}
case MessagesEventBusDispatchEvent.Error: {
this.status = MessagesEventBusStatus.Error
this.error = action.payload
this.stopPoll()
break
}
}
break
}
case MessagesEventBusStatus.Error: {
switch (action.event) {
case MessagesEventBusDispatchEvent.Resume:
case MessagesEventBusDispatchEvent.Init: {
this.status = MessagesEventBusStatus.Initializing
this.error = undefined
this.latestRev = undefined
this.setup()
break
}
}
break
}
default:
break
}
logger.debug(
`${LOGGER_CONTEXT}: dispatch '${action.event}'`,
{
id: this.id,
prev: prevStatus,
next: this.status,
},
logger.DebugContext.convo,
)
this.commit()
}
private async setup() {
logger.debug(`${LOGGER_CONTEXT}: setup`, {}, logger.DebugContext.convo)
try {
await this.initializeLatestRev()
this.dispatch({event: MessagesEventBusDispatchEvent.Ready})
} catch (e: any) {
logger.error(e, {
context: `${LOGGER_CONTEXT}: setup failed`,
})
this.dispatch({
event: MessagesEventBusDispatchEvent.Error,
payload: {
exception: e,
code: MessagesEventBusErrorCode.InitFailed,
retry: () => {
this.init()
},
},
})
}
}
init() {
logger.debug(`${LOGGER_CONTEXT}: init`, {}, logger.DebugContext.convo)
this.dispatch({event: MessagesEventBusDispatchEvent.Init})
}
background() {
logger.debug(`${LOGGER_CONTEXT}: background`, {}, logger.DebugContext.convo)
this.dispatch({event: MessagesEventBusDispatchEvent.Background})
}
suspend() {
logger.debug(`${LOGGER_CONTEXT}: suspend`, {}, logger.DebugContext.convo)
this.dispatch({event: MessagesEventBusDispatchEvent.Suspend})
}
resume() {
logger.debug(`${LOGGER_CONTEXT}: resume`, {}, logger.DebugContext.convo)
this.dispatch({event: MessagesEventBusDispatchEvent.Resume})
}
setPollInterval(interval: number) {
this.pollInterval = interval
this.resetPoll()
}
trail(handler: (events: ChatBskyConvoGetLog.OutputSchema['logs']) => void) {
this.emitter.on('events', handler)
return () => {
this.emitter.off('events', handler)
}
}
trailConvo(
convoId: string,
handler: (events: ChatBskyConvoGetLog.OutputSchema['logs']) => void,
) {
const handle = (events: ChatBskyConvoGetLog.OutputSchema['logs']) => {
const convoEvents = events.filter(ev => {
if (typeof ev.convoId === 'string' && ev.convoId === convoId) {
return ev.convoId === convoId
}
return false
})
if (convoEvents.length > 0) {
handler(convoEvents)
}
}
this.emitter.on('events', handle)
return () => {
this.emitter.off('events', handle)
}
}
private async initializeLatestRev() {
logger.debug(
`${LOGGER_CONTEXT}: initialize latest rev`,
{},
logger.DebugContext.convo,
)
const response = await this.agent.api.chat.bsky.convo.listConvos(
{
limit: 1,
},
{
headers: {
Authorization: this.__tempFromUserDid,
},
},
)
const {convos} = response.data
for (const convo of convos) {
if (convo.rev > (this.latestRev = this.latestRev || convo.rev)) {
this.latestRev = convo.rev
}
}
}
/*
* Polling
*/
private isPolling = false
private pollIntervalRef: NodeJS.Timeout | undefined
private resetPoll() {
this.stopPoll()
this.startPoll()
}
private startPoll() {
if (!this.isPolling) this.poll()
this.pollIntervalRef = setInterval(() => {
if (this.isPolling) return
this.poll()
}, this.pollInterval)
}
private stopPoll() {
if (this.pollIntervalRef) clearInterval(this.pollIntervalRef)
}
private async poll() {
if (this.isPolling) return
this.isPolling = true
logger.debug(`${LOGGER_CONTEXT}: poll`, {}, logger.DebugContext.convo)
try {
const response = await this.agent.api.chat.bsky.convo.getLog(
{
cursor: this.latestRev,
},
{
headers: {
Authorization: this.__tempFromUserDid,
},
},
)
const {logs: events} = response.data
let needsEmit = false
let batch: ChatBskyConvoGetLog.OutputSchema['logs'] = []
for (const ev of events) {
/*
* If there's a rev, we should handle it. If there's not a rev, we don't
* know what it is.
*/
if (typeof ev.rev === 'string') {
/*
* We only care about new events
*/
if (ev.rev > (this.latestRev = this.latestRev || ev.rev)) {
/*
* Update rev regardless of if it's a ev type we care about or not
*/
this.latestRev = ev.rev
needsEmit = true
batch.push(ev)
}
}
}
if (needsEmit) {
try {
this.emitter.emit('events', batch)
} catch (e: any) {
logger.error(e, {
context: `${LOGGER_CONTEXT}: process latest events`,
})
}
}
} catch (e: any) {
logger.error(e, {context: `${LOGGER_CONTEXT}: poll events failed`})
this.dispatch({
event: MessagesEventBusDispatchEvent.Error,
payload: {
exception: e,
code: MessagesEventBusErrorCode.PollFailed,
retry: () => {
this.init()
},
},
})
} finally {
this.isPolling = false
}
}
}
+67
View File
@@ -0,0 +1,67 @@
import React from 'react'
import {AppState} from 'react-native'
import {BskyAgent} from '@atproto-labs/api'
import {isWeb} from '#/platform/detection'
import {MessagesEventBus} from '#/state/messages/events/agent'
import {MessagesEventBusState} from '#/state/messages/events/types'
import {useAgent} from '#/state/session'
import {useDmServiceUrlStorage} from '#/screens/Messages/Temp/useDmServiceUrlStorage'
import {IS_DEV} from '#/env'
const MessagesEventBusContext =
React.createContext<MessagesEventBusState | null>(null)
export function useMessagesEventBus() {
const ctx = React.useContext(MessagesEventBusContext)
if (!ctx) {
throw new Error('useChat must be used within a ChatProvider')
}
return ctx
}
export function MessagesEventBusProvider({
children,
}: {
children: React.ReactNode
}) {
const {serviceUrl} = useDmServiceUrlStorage()
const {getAgent} = useAgent()
const [bus] = React.useState(
() =>
new MessagesEventBus({
agent: new BskyAgent({
service: serviceUrl,
}),
__tempFromUserDid: getAgent().session?.did!,
}),
)
const service = React.useSyncExternalStore(bus.subscribe, bus.getSnapshot)
if (isWeb && IS_DEV) {
// @ts-ignore
window.messagesEventBus = service
}
React.useEffect(() => {
const handleAppStateChange = (nextAppState: string) => {
if (nextAppState === 'active') {
bus.resume()
} else {
bus.background()
}
}
const sub = AppState.addEventListener('change', handleAppStateChange)
return () => {
sub.remove()
}
}, [bus])
return (
<MessagesEventBusContext.Provider value={service}>
{children}
</MessagesEventBusContext.Provider>
)
}
+111
View File
@@ -0,0 +1,111 @@
import {BskyAgent, ChatBskyConvoGetLog} from '@atproto-labs/api'
export type MessagesEventBusParams = {
agent: BskyAgent
__tempFromUserDid: string
}
export enum MessagesEventBusStatus {
Uninitialized = 'uninitialized',
Initializing = 'initializing',
Ready = 'ready',
Error = 'error',
Backgrounded = 'backgrounded',
Suspended = 'suspended',
}
export enum MessagesEventBusDispatchEvent {
Init = 'init',
Ready = 'ready',
Error = 'error',
Background = 'background',
Suspend = 'suspend',
Resume = 'resume',
}
export enum MessagesEventBusErrorCode {
Unknown = 'unknown',
InitFailed = 'initFailed',
PollFailed = 'pollFailed',
}
export type MessagesEventBusError = {
code: MessagesEventBusErrorCode
exception?: Error
retry: () => void
}
export type MessagesEventBusDispatch =
| {
event: MessagesEventBusDispatchEvent.Init
}
| {
event: MessagesEventBusDispatchEvent.Ready
}
| {
event: MessagesEventBusDispatchEvent.Background
}
| {
event: MessagesEventBusDispatchEvent.Suspend
}
| {
event: MessagesEventBusDispatchEvent.Resume
}
| {
event: MessagesEventBusDispatchEvent.Error
payload: MessagesEventBusError
}
export type TrailHandler = (
events: ChatBskyConvoGetLog.OutputSchema['logs'],
) => void
export type MessagesEventBusState =
| {
status: MessagesEventBusStatus.Uninitialized
rev: undefined
error: undefined
setPollInterval: (interval: number) => void
trail: (handler: TrailHandler) => () => void
trailConvo: (convoId: string, handler: TrailHandler) => () => void
}
| {
status: MessagesEventBusStatus.Initializing
rev: undefined
error: undefined
setPollInterval: (interval: number) => void
trail: (handler: TrailHandler) => () => void
trailConvo: (convoId: string, handler: TrailHandler) => () => void
}
| {
status: MessagesEventBusStatus.Ready
rev: string
error: undefined
setPollInterval: (interval: number) => void
trail: (handler: TrailHandler) => () => void
trailConvo: (convoId: string, handler: TrailHandler) => () => void
}
| {
status: MessagesEventBusStatus.Backgrounded
rev: string | undefined
error: undefined
setPollInterval: (interval: number) => void
trail: (handler: TrailHandler) => () => void
trailConvo: (convoId: string, handler: TrailHandler) => () => void
}
| {
status: MessagesEventBusStatus.Suspended
rev: string | undefined
error: undefined
setPollInterval: (interval: number) => void
trail: (handler: TrailHandler) => () => void
trailConvo: (convoId: string, handler: TrailHandler) => () => void
}
| {
status: MessagesEventBusStatus.Error
rev: string | undefined
error: MessagesEventBusError
setPollInterval: (interval: number) => void
trail: (handler: TrailHandler) => () => void
trailConvo: (convoId: string, handler: TrailHandler) => () => void
}
+40 -29
View File
@@ -1,31 +1,30 @@
import React from 'react'
import React, {useContext, useState, useSyncExternalStore} from 'react'
import {AppState} from 'react-native'
import {BskyAgent} from '@atproto-labs/api'
import {useFocusEffect, useIsFocused} from '@react-navigation/native'
import {Convo, ConvoParams} from '#/state/messages/convo'
import {Convo, ConvoParams, ConvoState} from '#/state/messages/convo'
import {useAgent} from '#/state/session'
import {useDmServiceUrlStorage} from '#/screens/Messages/Temp/useDmServiceUrlStorage'
const ChatContext = React.createContext<{
service: Convo
state: Convo['state']
}>({
// @ts-ignore
service: null,
// @ts-ignore
state: null,
})
const ChatContext = React.createContext<ConvoState | null>(null)
export function useChat() {
return React.useContext(ChatContext)
const ctx = useContext(ChatContext)
if (!ctx) {
throw new Error('useChat must be used within a ChatProvider')
}
return ctx
}
export function ChatProvider({
children,
convoId,
}: Pick<ConvoParams, 'convoId'> & {children: React.ReactNode}) {
const isScreenFocused = useIsFocused()
const {serviceUrl} = useDmServiceUrlStorage()
const {getAgent} = useAgent()
const [service] = React.useState(
const [convo] = useState(
() =>
new Convo({
convoId,
@@ -35,23 +34,35 @@ export function ChatProvider({
__tempFromUserDid: getAgent().session?.did!,
}),
)
const [state, setState] = React.useState(service.state)
const service = useSyncExternalStore(convo.subscribe, convo.getSnapshot)
React.useEffect(() => {
service.initialize()
}, [service])
useFocusEffect(
React.useCallback(() => {
convo.resume()
React.useEffect(() => {
const update = () => setState(service.state)
service.on('update', update)
return () => {
service.destroy()
}
}, [service])
return (
<ChatContext.Provider value={{state, service}}>
{children}
</ChatContext.Provider>
return () => {
convo.background()
}
}, [convo]),
)
React.useEffect(() => {
const handleAppStateChange = (nextAppState: string) => {
if (isScreenFocused) {
if (nextAppState === 'active') {
convo.resume()
} else {
convo.background()
}
}
}
const sub = AppState.addEventListener('change', handleAppStateChange)
return () => {
sub.remove()
}
}, [convo, isScreenFocused])
return <ChatContext.Provider value={service}>{children}</ChatContext.Provider>
}
+1
View File
@@ -47,6 +47,7 @@ export interface EditImageModal {
export interface CropImageModal {
name: 'crop-image'
uri: string
dimensions?: {width: number; height: number}
onSelect: (img?: RNImage) => void
}
+5 -1
View File
@@ -24,7 +24,11 @@ export function useActorAutocompleteQuery(
const moderationOpts = useModerationOpts()
const {getAgent} = useAgent()
prefix = prefix.toLowerCase()
prefix = prefix.toLowerCase().trim()
if (prefix.endsWith('.')) {
// Going from "foo" to "foo." should not clear matches.
prefix = prefix.slice(0, -1)
}
return useQuery<AppBskyActorDefs.ProfileViewBasic[]>({
staleTime: STALE.MINUTES.ONE,
@@ -1,6 +1,7 @@
import {BskyAgent, ChatBskyConvoGetConvoForMembers} from '@atproto-labs/api'
import {useMutation, useQueryClient} from '@tanstack/react-query'
import {logger} from '#/logger'
import {useDmServiceUrlStorage} from '#/screens/Messages/Temp/useDmServiceUrlStorage'
import {RQKEY as CONVO_KEY} from './conversation'
import {useHeaders} from './temp-headers'
@@ -30,6 +31,9 @@ export function useGetConvoForMembers({
queryClient.setQueryData(CONVO_KEY(data.convo.id), data.convo)
onSuccess?.(data)
},
onError,
onError: error => {
logger.error(error)
onError?.(error)
},
})
}
@@ -0,0 +1,68 @@
import {
BskyAgent,
ChatBskyConvoLeaveConvo,
ChatBskyConvoListConvos,
} from '@atproto-labs/api'
import {useMutation, useQueryClient} from '@tanstack/react-query'
import {logger} from '#/logger'
import {useDmServiceUrlStorage} from '#/screens/Messages/Temp/useDmServiceUrlStorage'
import {RQKEY as CONVO_LIST_KEY} from './list-converations'
import {useHeaders} from './temp-headers'
export function useLeaveConvo(
convoId: string,
{
onSuccess,
onError,
}: {
onSuccess?: (data: ChatBskyConvoLeaveConvo.OutputSchema) => void
onError?: (error: Error) => void
},
) {
const queryClient = useQueryClient()
const headers = useHeaders()
const {serviceUrl} = useDmServiceUrlStorage()
return useMutation({
mutationFn: async () => {
const agent = new BskyAgent({service: serviceUrl})
const {data} = await agent.api.chat.bsky.convo.leaveConvo(
{convoId},
{headers, encoding: 'application/json'},
)
return data
},
onMutate: () => {
queryClient.setQueryData(
CONVO_LIST_KEY,
(old?: {
pageParams: Array<string | undefined>
pages: Array<ChatBskyConvoListConvos.OutputSchema>
}) => {
console.log('old', old)
if (!old) return old
return {
...old,
pages: old.pages.map(page => {
return {
...page,
convos: page.convos.filter(convo => convo.id !== convoId),
}
}),
}
},
)
},
onSuccess: data => {
queryClient.invalidateQueries({queryKey: CONVO_LIST_KEY})
onSuccess?.(data)
},
onError: error => {
logger.error(error)
queryClient.invalidateQueries({queryKey: CONVO_LIST_KEY})
onError?.(error)
},
})
}
@@ -7,7 +7,7 @@ import {useHeaders} from './temp-headers'
export const RQKEY = ['convo-list']
type RQPageParam = string | undefined
export function useListConvos() {
export function useListConvos({refetchInterval}: {refetchInterval: number}) {
const headers = useHeaders()
const {serviceUrl} = useDmServiceUrlStorage()
@@ -24,5 +24,6 @@ export function useListConvos() {
},
initialPageParam: undefined as RQPageParam,
getNextPageParam: lastPage => lastPage.cursor,
refetchInterval,
})
}
@@ -0,0 +1,84 @@
import {
BskyAgent,
ChatBskyConvoMuteConvo,
ChatBskyConvoUnmuteConvo,
} from '@atproto-labs/api'
import {useMutation, useQueryClient} from '@tanstack/react-query'
import {logger} from '#/logger'
import {useDmServiceUrlStorage} from '#/screens/Messages/Temp/useDmServiceUrlStorage'
import {RQKEY as CONVO_KEY} from './conversation'
import {RQKEY as CONVO_LIST_KEY} from './list-converations'
import {useHeaders} from './temp-headers'
export function useMuteConvo(
convoId: string,
{
onSuccess,
onError,
}: {
onSuccess?: (data: ChatBskyConvoMuteConvo.OutputSchema) => void
onError?: (error: Error) => void
},
) {
const queryClient = useQueryClient()
const headers = useHeaders()
const {serviceUrl} = useDmServiceUrlStorage()
return useMutation({
mutationFn: async () => {
const agent = new BskyAgent({service: serviceUrl})
const {data} = await agent.api.chat.bsky.convo.muteConvo(
{convoId},
{headers, encoding: 'application/json'},
)
return data
},
onSuccess: data => {
queryClient.invalidateQueries({queryKey: CONVO_LIST_KEY})
queryClient.invalidateQueries({queryKey: CONVO_KEY(convoId)})
onSuccess?.(data)
},
onError: error => {
logger.error(error)
onError?.(error)
},
})
}
export function useUnmuteConvo(
convoId: string,
{
onSuccess,
onError,
}: {
onSuccess?: (data: ChatBskyConvoUnmuteConvo.OutputSchema) => void
onError?: (error: Error) => void
},
) {
const queryClient = useQueryClient()
const headers = useHeaders()
const {serviceUrl} = useDmServiceUrlStorage()
return useMutation({
mutationFn: async () => {
const agent = new BskyAgent({service: serviceUrl})
const {data} = await agent.api.chat.bsky.convo.unmuteConvo(
{convoId},
{headers, encoding: 'application/json'},
)
return data
},
onSuccess: data => {
queryClient.invalidateQueries({queryKey: CONVO_LIST_KEY})
queryClient.invalidateQueries({queryKey: CONVO_KEY(convoId)})
onSuccess?.(data)
},
onError: error => {
logger.error(error)
onError?.(error)
},
})
}
+10
View File
@@ -70,10 +70,12 @@ export interface FeedPostSliceItem {
post: AppBskyFeedDefs.PostView
record: AppBskyFeedPost.Record
reason?: AppBskyFeedDefs.ReasonRepost | ReasonFeedSource
feedContext: string | undefined
moderation: ModerationDecision
}
export interface FeedPostSlice {
_isFeedPostSlice: boolean
_reactKey: string
rootUri: string
isThread: boolean
@@ -276,6 +278,7 @@ export function usePostFeedQuery(
return {
_reactKey: slice._reactKey,
_isFeedPostSlice: true,
rootUri: slice.rootItem.post.uri,
isThread:
slice.items.length > 1 &&
@@ -300,6 +303,7 @@ export function usePostFeedQuery(
i === 0 && slice.source
? slice.source
: item.reason,
feedContext: item.feedContext,
moderation: moderations[i],
}
}
@@ -507,3 +511,9 @@ export function resetProfilePostsQueries(
})
}, timeout)
}
export function isFeedPostSlice(v: any): v is FeedPostSlice {
return (
v && typeof v === 'object' && '_isFeedPostSlice' in v && v._isFeedPostSlice
)
}
+209 -227
View File
@@ -1,5 +1,5 @@
import React from 'react'
import {AtpPersistSessionHandler, BskyAgent} from '@atproto/api'
import {AtpSessionData, AtpSessionEvent, BskyAgent} from '@atproto/api'
import {track} from '#/lib/analytics/analytics'
import {networkRetry} from '#/lib/async/retry'
@@ -35,8 +35,6 @@ const PUBLIC_BSKY_AGENT = new BskyAgent({service: PUBLIC_BSKY_SERVICE})
configureModerationForGuest()
const StateContext = React.createContext<SessionStateContext>({
isInitialLoad: true,
isSwitchingAccounts: false,
accounts: [],
currentAccount: undefined,
hasSession: false,
@@ -47,9 +45,7 @@ const ApiContext = React.createContext<SessionApiContext>({
login: async () => {},
logout: async () => {},
initSession: async () => {},
resumeSession: async () => {},
removeAccount: () => {},
selectAccount: async () => {},
updateCurrentAccount: () => {},
clearCurrentAccount: () => {},
})
@@ -60,33 +56,26 @@ function __getAgent() {
return __globalAgent
}
type AgentState = {
readonly agent: BskyAgent
readonly did: string | undefined
}
type State = {
accounts: SessionStateContext['accounts']
currentAccountDid: string | undefined
currentAgentState: AgentState
needsPersist: boolean
}
export function Provider({children}: React.PropsWithChildren<{}>) {
const [isInitialLoad, setIsInitialLoad] = React.useState(true)
const [isSwitchingAccounts, setIsSwitchingAccounts] = React.useState(false)
const [state, setState] = React.useState<State>({
const [state, setState] = React.useState<State>(() => ({
accounts: persisted.get('session').accounts,
currentAccountDid: undefined, // assume logged out to start
needsPersist: false,
})
const upsertAccount = React.useCallback(
(account: SessionAccount, expired = false) => {
setState(s => {
return {
accounts: [account, ...s.accounts.filter(a => a.did !== account.did)],
currentAccountDid: expired ? undefined : account.did,
needsPersist: true,
}
})
currentAgentState: {
agent: PUBLIC_BSKY_AGENT,
did: undefined, // assume logged out to start
},
[setState],
)
needsPersist: false,
}))
const clearCurrentAccount = React.useCallback(() => {
logger.warn(`session: clear current account`)
@@ -94,80 +83,112 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
configureModerationForGuest()
setState(s => ({
accounts: s.accounts,
currentAccountDid: undefined,
currentAgentState: {
agent: PUBLIC_BSKY_AGENT,
did: undefined,
},
needsPersist: true,
}))
}, [setState])
const createPersistSessionHandler = React.useCallback(
const onAgentSessionChange = React.useCallback(
(
agent: BskyAgent,
account: SessionAccount,
persistSessionCallback: (props: {
expired: boolean
refreshedAccount: SessionAccount
}) => void,
{
networkErrorCallback,
}: {
networkErrorCallback?: () => void
} = {},
): AtpPersistSessionHandler => {
return function persistSession(event, session) {
const expired = event === 'expired' || event === 'create-failed'
event: AtpSessionEvent,
session: AtpSessionData | undefined,
) => {
const expired = event === 'expired' || event === 'create-failed'
if (event === 'network-error') {
logger.warn(
`session: persistSessionHandler received network-error event`,
)
networkErrorCallback?.()
return
}
if (event === 'network-error') {
logger.warn(
`session: persistSessionHandler received network-error event`,
)
logger.warn(`session: clear current account`)
__globalAgent = PUBLIC_BSKY_AGENT
configureModerationForGuest()
setState(s => ({
accounts: s.accounts,
currentAgentState: {
agent: PUBLIC_BSKY_AGENT,
did: undefined,
},
needsPersist: true,
}))
return
}
// TODO: use agentToSessionAccount for this too.
const refreshedAccount: SessionAccount = {
service: account.service,
did: session?.did || account.did,
handle: session?.handle || account.handle,
email: session?.email || account.email,
emailConfirmed: session?.emailConfirmed || account.emailConfirmed,
emailAuthFactor: session?.emailAuthFactor || account.emailAuthFactor,
deactivated: isSessionDeactivated(session?.accessJwt),
pdsUrl: agent.pdsUrl?.toString(),
/*
* Tokens are undefined if the session expires, or if creation fails for
* any reason e.g. tokens are invalid, network error, etc.
*/
refreshJwt: session?.refreshJwt,
accessJwt: session?.accessJwt,
}
logger.debug(`session: persistSession`, {
event,
deactivated: refreshedAccount.deactivated,
})
if (expired) {
logger.warn(`session: expired`)
emitSessionDropped()
}
// TODO: use agentToSessionAccount for this too.
const refreshedAccount: SessionAccount = {
service: account.service,
did: session?.did ?? account.did,
handle: session?.handle ?? account.handle,
email: session?.email ?? account.email,
emailConfirmed: session?.emailConfirmed ?? account.emailConfirmed,
emailAuthFactor: session?.emailAuthFactor ?? account.emailAuthFactor,
deactivated: isSessionDeactivated(session?.accessJwt),
pdsUrl: agent.pdsUrl?.toString(),
/*
* If the session expired, or it was successfully created/updated, we want
* to update/persist the data.
*
* If the session creation failed, it could be a network error, or it could
* be more serious like an invalid token(s). We can't differentiate, so in
* order to allow the user to get a fresh token (if they need it), we need
* to persist this data and wipe their tokens, effectively logging them
* out.
* Tokens are undefined if the session expires, or if creation fails for
* any reason e.g. tokens are invalid, network error, etc.
*/
persistSessionCallback({
expired,
refreshedAccount,
})
refreshJwt: session?.refreshJwt,
accessJwt: session?.accessJwt,
}
logger.debug(`session: persistSession`, {
event,
deactivated: refreshedAccount.deactivated,
})
if (expired) {
logger.warn(`session: expired`)
emitSessionDropped()
__globalAgent = PUBLIC_BSKY_AGENT
configureModerationForGuest()
setState(s => ({
accounts: s.accounts,
currentAgentState: {
agent: PUBLIC_BSKY_AGENT,
did: undefined,
},
needsPersist: true,
}))
}
/*
* If the session expired, or it was successfully created/updated, we want
* to update/persist the data.
*
* If the session creation failed, it could be a network error, or it could
* be more serious like an invalid token(s). We can't differentiate, so in
* order to allow the user to get a fresh token (if they need it), we need
* to persist this data and wipe their tokens, effectively logging them
* out.
*/
setState(s => {
const existingAccount = s.accounts.find(
a => a.did === refreshedAccount.did,
)
if (
!expired &&
existingAccount &&
refreshedAccount &&
JSON.stringify(existingAccount) === JSON.stringify(refreshedAccount)
) {
// Fast path without a state update.
return s
}
return {
accounts: [
refreshedAccount,
...s.accounts.filter(a => a.did !== refreshedAccount.did),
],
currentAgentState: s.currentAgentState,
needsPersist: true,
}
})
},
[],
)
@@ -197,26 +218,28 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
},
)
agent.setPersistSessionHandler(
createPersistSessionHandler(
agent,
account,
({expired, refreshedAccount}) => {
upsertAccount(refreshedAccount, expired)
},
{networkErrorCallback: clearCurrentAccount},
),
)
agent.setPersistSessionHandler((event, session) => {
onAgentSessionChange(agent, account, event, session)
})
__globalAgent = agent
await fetchingGates
upsertAccount(account)
setState(s => {
return {
accounts: [account, ...s.accounts.filter(a => a.did !== account.did)],
currentAgentState: {
did: account.did,
agent: agent,
},
needsPersist: true,
}
})
logger.debug(`session: created account`, {}, logger.DebugContext.session)
track('Create Account')
logEvent('account:create:success', {})
},
[upsertAccount, clearCurrentAccount, createPersistSessionHandler],
[onAgentSessionChange],
)
const login = React.useCallback<SessionApiContext['login']>(
@@ -229,35 +252,39 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
authFactorToken,
})
agent.setPersistSessionHandler(
createPersistSessionHandler(
agent,
account,
({expired, refreshedAccount}) => {
upsertAccount(refreshedAccount, expired)
},
{networkErrorCallback: clearCurrentAccount},
),
)
agent.setPersistSessionHandler((event, session) => {
onAgentSessionChange(agent, account, event, session)
})
__globalAgent = agent
// @ts-ignore
if (IS_DEV && isWeb) window.agent = agent
await fetchingGates
upsertAccount(account)
setState(s => {
return {
accounts: [account, ...s.accounts.filter(a => a.did !== account.did)],
currentAgentState: {
did: account.did,
agent: agent,
},
needsPersist: true,
}
})
logger.debug(`session: logged in`, {}, logger.DebugContext.session)
track('Sign In', {resumedSession: false})
logEvent('account:loggedIn', {logContext, withPassword: true})
},
[upsertAccount, clearCurrentAccount, createPersistSessionHandler],
[onAgentSessionChange],
)
const logout = React.useCallback<SessionApiContext['logout']>(
async logContext => {
logger.debug(`session: logout`)
clearCurrentAccount()
logger.warn(`session: clear current account`)
__globalAgent = PUBLIC_BSKY_AGENT
configureModerationForGuest()
setState(s => {
return {
accounts: s.accounts.map(a => ({
@@ -265,13 +292,16 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
refreshJwt: undefined,
accessJwt: undefined,
})),
currentAccountDid: s.currentAccountDid,
currentAgentState: {
did: undefined,
agent: PUBLIC_BSKY_AGENT,
},
needsPersist: true,
}
})
logEvent('account:loggedOut', {logContext})
},
[clearCurrentAccount, setState],
[setState],
)
const initSession = React.useCallback<SessionApiContext['initSession']>(
@@ -286,16 +316,9 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
agent.pdsUrl = agent.api.xrpc.uri = new URL(account.pdsUrl)
}
agent.setPersistSessionHandler(
createPersistSessionHandler(
agent,
account,
({expired, refreshedAccount}) => {
upsertAccount(refreshedAccount, expired)
},
{networkErrorCallback: clearCurrentAccount},
),
)
agent.setPersistSessionHandler((event, session) => {
onAgentSessionChange(agent, account, event, session)
})
// @ts-ignore
if (IS_DEV && isWeb) window.agent = agent
@@ -305,8 +328,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
isSessionDeactivated(account.accessJwt) || account.deactivated
const prevSession = {
accessJwt: account.accessJwt || '',
refreshJwt: account.refreshJwt || '',
accessJwt: account.accessJwt ?? '',
refreshJwt: account.refreshJwt ?? '',
did: account.did,
handle: account.handle,
}
@@ -314,23 +337,22 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
if (isSessionExpired(account)) {
logger.debug(`session: attempting to resume using previous session`)
try {
const freshAccount = await resumeSessionWithFreshAccount()
__globalAgent = agent
await fetchingGates
upsertAccount(freshAccount)
} catch (e) {
/*
* Note: `agent.persistSession` is also called when this fails, and
* we handle that failure via `createPersistSessionHandler`
*/
logger.info(`session: resumeSessionWithFreshAccount failed`, {
message: e,
})
__globalAgent = PUBLIC_BSKY_AGENT
// TODO: Should this update currentAccountDid?
}
const freshAccount = await resumeSessionWithFreshAccount()
__globalAgent = agent
await fetchingGates
setState(s => {
return {
accounts: [
freshAccount,
...s.accounts.filter(a => a.did !== freshAccount.did),
],
currentAgentState: {
did: freshAccount.did,
agent: agent,
},
needsPersist: true,
}
})
} else {
logger.debug(`session: attempting to reuse previous session`)
@@ -338,7 +360,19 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
__globalAgent = agent
await fetchingGates
upsertAccount(account)
setState(s => {
return {
accounts: [
account,
...s.accounts.filter(a => a.did !== account.did),
],
currentAgentState: {
did: account.did,
agent: agent,
},
needsPersist: true,
}
})
if (accountOrSessionDeactivated) {
// don't attempt to resume
@@ -349,26 +383,6 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
// Intentionally not awaited to unblock the UI:
resumeSessionWithFreshAccount()
.then(freshAccount => {
if (JSON.stringify(account) !== JSON.stringify(freshAccount)) {
logger.info(
`session: reuse of previous session returned a fresh account, upserting`,
)
upsertAccount(freshAccount)
}
})
.catch(e => {
/*
* Note: `agent.persistSession` is also called when this fails, and
* we handle that failure via `createPersistSessionHandler`
*/
logger.info(`session: resumeSessionWithFreshAccount failed`, {
message: e,
})
__globalAgent = PUBLIC_BSKY_AGENT
// TODO: Should this update currentAccountDid?
})
}
async function resumeSessionWithFreshAccount(): Promise<SessionAccount> {
@@ -386,22 +400,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
return sessionAccount
}
},
[upsertAccount, clearCurrentAccount, createPersistSessionHandler],
)
const resumeSession = React.useCallback<SessionApiContext['resumeSession']>(
async account => {
try {
if (account) {
await initSession(account)
}
} catch (e) {
logger.error(`session: resumeSession failed`, {message: e})
} finally {
setIsInitialLoad(false)
}
},
[initSession],
[onAgentSessionChange],
)
const removeAccount = React.useCallback<SessionApiContext['removeAccount']>(
@@ -409,7 +408,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
setState(s => {
return {
accounts: s.accounts.filter(a => a.did !== account.did),
currentAccountDid: s.currentAccountDid,
currentAgentState: s.currentAgentState,
needsPersist: true,
}
})
@@ -423,23 +422,19 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
account => {
setState(s => {
const currentAccount = s.accounts.find(
a => a.did === s.currentAccountDid,
a => a.did === s.currentAgentState.did,
)
// ignore, should never happen
if (!currentAccount) return s
const updatedAccount = {
...currentAccount,
handle: account.handle || currentAccount.handle,
email: account.email || currentAccount.email,
handle: account.handle ?? currentAccount.handle,
email: account.email ?? currentAccount.email,
emailConfirmed:
account.emailConfirmed !== undefined
? account.emailConfirmed
: currentAccount.emailConfirmed,
account.emailConfirmed ?? currentAccount.emailConfirmed,
emailAuthFactor:
account.emailAuthFactor !== undefined
? account.emailAuthFactor
: currentAccount.emailAuthFactor,
account.emailAuthFactor ?? currentAccount.emailAuthFactor,
}
return {
@@ -447,7 +442,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
updatedAccount,
...s.accounts.filter(a => a.did !== currentAccount.did),
],
currentAccountDid: s.currentAccountDid,
currentAgentState: s.currentAgentState,
needsPersist: true,
}
})
@@ -455,30 +450,13 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
[setState],
)
const selectAccount = React.useCallback<SessionApiContext['selectAccount']>(
async (account, logContext) => {
setIsSwitchingAccounts(true)
try {
await initSession(account)
setIsSwitchingAccounts(false)
logEvent('account:loggedIn', {logContext, withPassword: false})
} catch (e) {
// reset this in case of error
setIsSwitchingAccounts(false)
// but other listeners need a throw
throw e
}
},
[initSession],
)
React.useEffect(() => {
if (state.needsPersist) {
state.needsPersist = false
persisted.write('session', {
accounts: state.accounts,
currentAccount: state.accounts.find(
a => a.did === state.currentAccountDid,
a => a.did === state.currentAgentState.did,
),
})
}
@@ -489,16 +467,21 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
const persistedSession = persisted.get('session')
logger.debug(`session: persisted onUpdate`, {})
setState(s => ({
accounts: persistedSession.accounts,
currentAgentState: s.currentAgentState,
needsPersist: false, // Synced from another tab. Don't persist to avoid cycles.
}))
const selectedAccount = persistedSession.accounts.find(
a => a.did === persistedSession.currentAccount?.did,
)
if (selectedAccount && selectedAccount.refreshJwt) {
if (selectedAccount.did !== state.currentAccountDid) {
if (selectedAccount.did !== state.currentAgentState.did) {
logger.debug(`session: persisted onUpdate, switching accounts`, {
from: {
did: state.currentAccountDid,
did: state.currentAgentState.did,
},
to: {
did: selectedAccount.did,
@@ -516,8 +499,9 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
*/
// @ts-ignore we checked for `refreshJwt` above
__globalAgent.session = selectedAccount
// TODO: This needs a setState.
}
} else if (!selectedAccount && state.currentAccountDid) {
} else if (!selectedAccount && state.currentAgentState.did) {
logger.debug(
`session: persisted onUpdate, logging out`,
{},
@@ -530,28 +514,30 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
* handled by `persistSession` (which nukes this accounts tokens only),
* or by a `logout` call which nukes all accounts tokens)
*/
clearCurrentAccount()
logger.warn(`session: clear current account`)
__globalAgent = PUBLIC_BSKY_AGENT
configureModerationForGuest()
setState(s => ({
accounts: s.accounts,
currentAgentState: {
did: undefined,
agent: PUBLIC_BSKY_AGENT,
},
needsPersist: false, // Synced from another tab. Don't persist to avoid cycles.
}))
}
setState(() => ({
accounts: persistedSession.accounts,
currentAccountDid: selectedAccount?.did,
needsPersist: false, // Synced from another tab. Don't persist to avoid cycles.
}))
})
}, [state, setState, clearCurrentAccount, initSession])
}, [state, setState, initSession])
const stateContext = React.useMemo(
() => ({
accounts: state.accounts,
currentAccount: state.accounts.find(
a => a.did === state.currentAccountDid,
a => a.did === state.currentAgentState.did,
),
isInitialLoad,
isSwitchingAccounts,
hasSession: !!state.currentAccountDid,
hasSession: !!state.currentAgentState.did,
}),
[state, isInitialLoad, isSwitchingAccounts],
[state],
)
const api = React.useMemo(
@@ -560,9 +546,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
login,
logout,
initSession,
resumeSession,
removeAccount,
selectAccount,
updateCurrentAccount,
clearCurrentAccount,
}),
@@ -571,9 +555,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
login,
logout,
initSession,
resumeSession,
removeAccount,
selectAccount,
updateCurrentAccount,
clearCurrentAccount,
],
-7
View File
@@ -6,8 +6,6 @@ export type SessionAccount = PersistedAccount
export type SessionStateContext = {
accounts: SessionAccount[]
currentAccount: SessionAccount | undefined
isInitialLoad: boolean
isSwitchingAccounts: boolean
hasSession: boolean
}
export type SessionApiContext = {
@@ -46,12 +44,7 @@ export type SessionApiContext = {
*/
clearCurrentAccount: () => void
initSession: (account: SessionAccount) => Promise<void>
resumeSession: (account?: SessionAccount) => Promise<void>
removeAccount: (account: SessionAccount) => void
selectAccount: (
account: SessionAccount,
logContext: LogEvents['account:loggedIn']['logContext'],
) => Promise<void>
updateCurrentAccount: (
account: Partial<
Pick<
+59 -15
View File
@@ -18,6 +18,10 @@ import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {observer} from 'mobx-react-lite'
import {
createGIFDescription,
parseAltFromGIFDescription,
} from '#/lib/gif-alt-text'
import {LikelyType} from '#/lib/link-meta/link-meta'
import {logEvent} from '#/lib/statsig/statsig'
import {logger} from '#/logger'
@@ -59,6 +63,7 @@ import * as Toast from '../util/Toast'
import {UserAvatar} from '../util/UserAvatar'
import {CharProgress} from './char-progress/CharProgress'
import {ExternalEmbed} from './ExternalEmbed'
import {GifAltText} from './GifAltText'
import {LabelsBtn} from './labels/LabelsBtn'
import {Gallery} from './photos/Gallery'
import {OpenCameraBtn} from './photos/OpenCameraBtn'
@@ -210,11 +215,25 @@ export const ComposePost = observer(function ComposePost({
[gallery, track],
)
const isAltTextRequiredAndMissing = useMemo(() => {
if (!requireAltTextEnabled) return false
if (gallery.needsAltText) return true
if (extGif) {
if (!extLink?.meta?.description) return true
const parsedAlt = parseAltFromGIFDescription(extLink.meta.description)
if (!parsedAlt.isPreferred) return true
}
return false
}, [gallery.needsAltText, extLink, extGif, requireAltTextEnabled])
const onPressPublish = async () => {
if (isProcessing || graphemeLength > MAX_GRAPHEME_LENGTH) {
return
}
if (requireAltTextEnabled && gallery.needsAltText) {
if (isAltTextRequiredAndMissing) {
return
}
@@ -297,10 +316,8 @@ export const ComposePost = observer(function ComposePost({
}
const canPost = useMemo(
() =>
graphemeLength <= MAX_GRAPHEME_LENGTH &&
(!requireAltTextEnabled || !gallery.needsAltText),
[graphemeLength, requireAltTextEnabled, gallery.needsAltText],
() => graphemeLength <= MAX_GRAPHEME_LENGTH && !isAltTextRequiredAndMissing,
[graphemeLength, isAltTextRequiredAndMissing],
)
const selectTextInputPlaceholder = replyTo
? _(msg`Write your reply`)
@@ -327,7 +344,7 @@ export const ComposePost = observer(function ComposePost({
image: gif.media_formats.preview.url,
likelyType: LikelyType.HTML,
title: gif.content_description,
description: `ALT: ${gif.content_description}`,
description: createGIFDescription(gif.content_description),
},
})
setExtGif(gif)
@@ -335,6 +352,26 @@ export const ComposePost = observer(function ComposePost({
[setExtLink],
)
const handleChangeGifAltText = useCallback(
(altText: string) => {
setExtLink(ext =>
ext && ext.meta
? {
...ext,
meta: {
...ext.meta,
description: createGIFDescription(
ext.meta.title ?? '',
altText,
),
},
}
: ext,
)
},
[setExtLink],
)
return (
<KeyboardAvoidingView
testID="composePostView"
@@ -412,7 +449,7 @@ export const ComposePost = observer(function ComposePost({
</>
)}
</View>
{requireAltTextEnabled && gallery.needsAltText && (
{isAltTextRequiredAndMissing && (
<View style={[styles.reminderLine, pal.viewLight]}>
<View style={styles.errorIcon}>
<FontAwesomeIcon
@@ -474,14 +511,21 @@ export const ComposePost = observer(function ComposePost({
<Gallery gallery={gallery} />
{gallery.isEmpty && extLink && (
<ExternalEmbed
link={extLink}
gif={extGif}
onRemove={() => {
setExtLink(undefined)
setExtGif(undefined)
}}
/>
<View style={a.relative}>
<ExternalEmbed
link={extLink}
gif={extGif}
onRemove={() => {
setExtLink(undefined)
setExtGif(undefined)
}}
/>
<GifAltText
link={extLink}
gif={extGif}
onSubmit={handleChangeGifAltText}
/>
</View>
)}
{quote ? (
<View style={[s.mt5, isWeb && s.mb10]}>
+7 -2
View File
@@ -46,7 +46,12 @@ export const ExternalEmbed = ({
: undefined
return (
<View style={[a.mb_xl, a.overflow_hidden, t.atoms.border_contrast_medium]}>
<View
style={[
!gif && a.mb_xl,
a.overflow_hidden,
t.atoms.border_contrast_medium,
]}>
{link.isLoading ? (
<Container style={loadingStyle}>
<Loader size="xl" />
@@ -62,7 +67,7 @@ export const ExternalEmbed = ({
</Container>
) : linkInfo ? (
<View style={{pointerEvents: !gif ? 'none' : 'auto'}}>
<ExternalLinkEmbed link={linkInfo} />
<ExternalLinkEmbed link={linkInfo} hideAlt />
</View>
) : null}
<TouchableOpacity
+185
View File
@@ -0,0 +1,185 @@
import React, {useCallback, useState} from 'react'
import {TouchableOpacity, View} from 'react-native'
import {AppBskyEmbedExternal} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {ExternalEmbedDraft} from '#/lib/api'
import {HITSLOP_10, MAX_ALT_TEXT} from '#/lib/constants'
import {parseAltFromGIFDescription} from '#/lib/gif-alt-text'
import {
EmbedPlayerParams,
parseEmbedPlayerFromUrl,
} from '#/lib/strings/embed-player'
import {enforceLen} from '#/lib/strings/helpers'
import {isAndroid} from '#/platform/detection'
import {Gif} from '#/state/queries/tenor'
import {atoms as a, native, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import * as TextField from '#/components/forms/TextField'
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
import {PlusSmall_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
import {Text} from '#/components/Typography'
import {GifEmbed} from '../util/post-embeds/GifEmbed'
import {AltTextReminder} from './photos/Gallery'
export function GifAltText({
link: linkProp,
gif,
onSubmit,
}: {
link: ExternalEmbedDraft
gif?: Gif
onSubmit: (alt: string) => void
}) {
const control = Dialog.useDialogControl()
const {_} = useLingui()
const t = useTheme()
const {link, params} = React.useMemo(() => {
return {
link: {
title: linkProp.meta?.title ?? linkProp.uri,
uri: linkProp.uri,
description: linkProp.meta?.description ?? '',
thumb: linkProp.localThumb?.path,
},
params: parseEmbedPlayerFromUrl(linkProp.uri),
}
}, [linkProp])
const onPressSubmit = useCallback(
(alt: string) => {
control.close(() => {
onSubmit(alt)
})
},
[onSubmit, control],
)
if (!gif || !params) return null
const parsedAlt = parseAltFromGIFDescription(link.description)
return (
<>
<TouchableOpacity
testID="altTextButton"
accessibilityRole="button"
accessibilityLabel={_(msg`Add alt text`)}
accessibilityHint=""
hitSlop={HITSLOP_10}
onPress={control.open}
style={[
a.absolute,
{top: 20, left: 12},
{borderRadius: 6},
a.pl_xs,
a.pr_sm,
a.py_2xs,
a.flex_row,
a.gap_xs,
a.align_center,
{backgroundColor: 'rgba(0, 0, 0, 0.75)'},
]}>
{parsedAlt.isPreferred ? (
<Check size="xs" fill={t.palette.white} style={a.ml_xs} />
) : (
<Plus size="sm" fill={t.palette.white} />
)}
<Text
style={[a.font_bold, {color: t.palette.white}]}
accessible={false}>
<Trans>ALT</Trans>
</Text>
</TouchableOpacity>
<AltTextReminder />
<Dialog.Outer
control={control}
nativeOptions={isAndroid ? {sheet: {snapPoints: ['100%']}} : {}}>
<Dialog.Handle />
<AltTextInner
onSubmit={onPressSubmit}
link={link}
params={params}
initialValue={parsedAlt.isPreferred ? parsedAlt.alt : ''}
key={link.uri}
/>
</Dialog.Outer>
</>
)
}
function AltTextInner({
onSubmit,
link,
params,
initialValue: initalValue,
}: {
onSubmit: (text: string) => void
link: AppBskyEmbedExternal.ViewExternal
params: EmbedPlayerParams
initialValue: string
}) {
const {_} = useLingui()
const [altText, setAltText] = useState(initalValue)
const control = Dialog.useDialogContext()
const onPressSubmit = useCallback(() => {
onSubmit(altText)
}, [onSubmit, altText])
return (
<Dialog.ScrollableInner label={_(msg`Add alt text`)}>
<View style={a.flex_col_reverse}>
<View style={[a.mt_md, a.gap_md]}>
<View>
<TextField.LabelText>
<Trans>Descriptive alt text</Trans>
</TextField.LabelText>
<TextField.Root>
<Dialog.Input
label={_(msg`Alt text`)}
placeholder={link.title}
onChangeText={text =>
setAltText(enforceLen(text, MAX_ALT_TEXT))
}
value={altText}
multiline
numberOfLines={3}
autoFocus
onKeyPress={({nativeEvent}) => {
if (nativeEvent.key === 'Escape') {
control.close()
}
}}
/>
</TextField.Root>
</View>
<Button
label={_(msg`Save`)}
size="medium"
color="primary"
variant="solid"
onPress={onPressSubmit}>
<ButtonText>
<Trans>Save</Trans>
</ButtonText>
</Button>
</View>
{/* below the text input to force tab order */}
<View>
<Text style={[a.text_2xl, a.font_bold, a.leading_tight, a.pb_sm]}>
<Trans>Add ALT text</Trans>
</Text>
<View style={[a.w_full, a.align_center, native({maxHeight: 200})]}>
<GifEmbed link={link} params={params} hideAlt />
</View>
</View>
</View>
<Dialog.Close />
</Dialog.ScrollableInner>
)
}

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