Compare commits

...

88 Commits

Author SHA1 Message Date
Eric Bailey 697146bb17 Update persisted schema for new source of truth, implement in existing session (#3543) 2024-04-25 10:35:59 -05:00
Eric Bailey d2d324e151 Add public service constant, use 2024-04-13 13:39:26 -05:00
Eric Bailey 6a929e292a Add readLastActiveAccount to use accounts[] as source of truth 2024-04-13 13:36:36 -05:00
dan 2164d32295 Fix oopsie (#3538) 2024-04-13 18:40:50 +01:00
Hailey 228d947a84 Fade in animation for card (#3521)
* fade in and out the card

one more fix

dont leave an invisible card behind

okay just about there

move styles

glitch

clear hide timeouts on card enter

about there

* Tweak timings

* Rewrite with explicit states

---------

Co-authored-by: Dan Abramov <dan.abramov@gmail.com>
2024-04-13 18:28:53 +01:00
Samuel Newman 1a9eeb760f [Embeds] Add ref_url (#3533)
* get outer location and pass in to the iframe

* enable post.html in dev

* only add ref_url if starts with http

* Revert "enable post.html in dev"

This reverts commit 6f224c6e2c.

---------

Co-authored-by: Dan Abramov <dan.abramov@gmail.com>
2024-04-13 13:22:19 +01:00
Samuel Newman 4c95ec2ec4 add ref_src (#3532) 2024-04-13 12:44:37 +01:00
dan 9fb20915e8 [Embed] Don't reuse DOM when changing embed (#3530)
* Don't reuse DOM when changing embed

* add skeleton loading state 💀

* autoselect text

---------

Co-authored-by: Samuel Newman <mozzius@protonmail.com>
2024-04-13 12:19:21 +01:00
dan 1390b1dc9e [Statsig] Send ref source (#3531)
* [Statsig] Send ref source

* Add is web check

* Fix types
2024-04-13 12:09:49 +01:00
dan c3f75330ed More obvious click area (#3528) 2024-04-13 11:46:16 +01:00
Samuel Newman acbadc610b add hideRecord prop (#3527) 2024-04-13 11:42:23 +01:00
Samuel Newman c1137c3984 [Embeds] Tweak homepage styles (#3526)
* tweak error styles

* reduce error horizontal padding
2024-04-13 11:18:50 +01:00
Hailey 826f6b043c Moderate content in embeds (#3525)
* move info to its own file

* Revert "move info to its own file"

This reverts commit 1d45a2f403.

* better way

* all cases

* pass labelInfo to ImageEmbed

* blur avatars

* add back as string

* one more as string

* external embed

* add back as string again
2024-04-13 11:18:18 +01:00
Eric Bailey f5bb348bf5 Profile hovers (#3518)
* Add hover card for mentions

* Reposted by

* Fix key

* Add to composer reply to
2024-04-13 06:13:53 +01:00
dan a845587e1f [Embeds] Show error for users with PWI flag on landing (#3524) 2024-04-13 05:50:09 +01:00
Samuel Newman 4c966e5d6d [Embeds] "Embed post" post dropdown option (#3513)
* add embed option to post dropdown menu

* put embed post button behind a gate

* increase line height in dialog

* add gate to gate name union

* hide embed button if PWI optout

* Ungate embed button

* Escape HTML, align implementations

* Make dialog conditionally rendered

* Memoize EmbedDialog

* Render dialog lazily

---------

Co-authored-by: Dan Abramov <dan.abramov@gmail.com>
2024-04-13 05:13:53 +01:00
Samuel Newman 4b3ec55732 [Embeds] Embed subdomain landing page (#3501)
* add build output to web build

* simplify post-build step by copying everything at once

* make script that converts placeholder -> iframe

* dynamically resize iframe based on inner content

Requires the iframe content to `postMessage` its height back up to the parent

* add lang to embed

* svg explicit height -> viewBox

* add build output to web build

* simplify post-build step by copying everything at once

* attempt to fix go embed issue

* rm changes to bskyweb

* remove another bskyweb change

* embed landing page

* Drop xl breakpoint, too far down

* Remove pointer enter behavior

* Avoid button width jump

* Escape HTML

---------

Co-authored-by: Dan Abramov <dan.abramov@gmail.com>
2024-04-13 03:58:40 +01:00
Samuel Newman 8e29b1f633 [Embeds] Embed for single post (#3450)
* add bskyembed vite app

* create build script (temp until embedr is ready)

* add build output to web build

* simplify post-build step by copying everything at once

* add simple post viewer

* add butterfly logo

* add vite plugin legacy

* proper error screen

* add image embed

* add url embed

* record embed + embedwithmedia

* add list+feed embeds

* add labeller embed (just to be safe)

* fix curatelist and modlist being the wrong way around

* Add PWI opt-out

* add favicon

* improve wording of PWI

* remove padding I used for screenshots

* add disabled state to embed

* improve PWI styles by adding an icon

* remove unused prop

* rm open proxy

* [Embeds] Add CTA and add general polish - input needed! (#3454)

* add CTA, colourful icons, and bigger logo

* make hover effect smaller + add to cta

* more responsive + preserve whitespace

* add trailing newsline to deploy script

* add repost indicator

* Make butterfly link to content

* More consistent error text wording

---------

Co-authored-by: Dan Abramov <dan.abramov@gmail.com>
2024-04-13 02:07:39 +01:00
Hailey 93731e6d6b cache fix for search post results (#3520) 2024-04-12 17:31:55 -07:00
Hailey 7d01ff90d3 wait a sec before showing card (#3516) 2024-04-12 16:51:07 -07:00
Kisaragi Hiu cefa0a55e8 android: fix navigation bar always being bright even in dark mode (#3464)
Right now both light mode and dark mode get light navigation bar, and
looks jarring in the dark modes.

This commit applies a more appropriate color before the UI thread
runs (in app config), as well as applying the current theme background
after the UI shell is mounted.

This should fix #3332.

Co-authored-by: Hailey <me@haileyok.com>
2024-04-12 15:40:22 -07:00
Hailey c3821fdc31 Remove vertical scrollbars from views on native (#3429)
* remove vertical scrollbars

* add to a few missing lists

* gate this change

* use `hide_vertical_scroll_indicators`

* fix gate lint

* fix bool
2024-04-12 15:22:09 -07:00
Eric Bailey 6218eb0eea Zhuzh sign in dialog (#3512) 2024-04-12 15:19:58 -07:00
Eric Bailey 1f61109cfa Profile card hover preview (#3508)
* feat: initial user card hover

* feat: flesh it out some more

* fix: initialize middlewares once

* chore: remove floating-ui react-native

* chore: clean up

* Update moderation apis, fix lint

* Refactor profile hover card to alf

* Clean up

* Debounce, fix positioning when loading

* Fix going away

* Close on all link presses

* Tweak styles

* Disable on mobile web

* cleanup some of the changes pt. 1

* cleanup some of the changes pt. 2

* cleanup some of the changes pt. 3

* cleanup some of the changes pt. 4

* Re-revert files

* Fix handle presentation

* Don't follow yourself, silly

* Collapsed notifications group

* ProfileCard

* Tree view replies

* Suggested follows

* Fix hover-back-on-card edge case

* Moar

---------

Co-authored-by: Mary <git@mary.my.id>
Co-authored-by: Hailey <me@haileyok.com>
2024-04-12 17:01:32 -05:00
Hailey f91aa37c6b Enable updates for production behind receive_updates gate (#3496)
* add gate type

* gate the updates

* enable updates in `production`

* web placeholder for `useOTAUpdates()`

* update comment
2024-04-12 14:51:53 -07:00
surfdude29 1f587ea4b6 Remove obsolete strings from en message catalog (#3462) 2024-04-12 14:49:36 -07:00
imbstt aa1d4d4e26 Update German translations (#3466)
* Change occurrences of "likt" to "liked"

* Improve existing German translations

* Translate new strings for German translation

* Apply suggestions from code review

Co-authored-by: Felix Siebeneicker <felix.siebeneicker@outlook.de>

---------

Co-authored-by: Felix Siebeneicker <felix.siebeneicker@outlook.de>
2024-04-12 14:48:08 -07:00
Gildásio Filho f6dc216110 Update pt-BR localization to latest version (#3472)
* Update messages.po

* Update messages.po
2024-04-12 14:47:16 -07:00
Ivan Beà 5aed7db208 Update Catalan messages.po (#3476)
* Update messages.po

new lines translated

* Update messages.po

change {nom} by {name}

* Update messages.po

update changes by @jordimas
2024-04-12 14:46:39 -07:00
Frudrax Cheng a306fbfca3 Update zh-TW Localization (#3478)
* Update messages.po

* Fix typo

* Update messages.po

* Remove superseded strings
2024-04-12 14:46:15 -07:00
Minseo Lee 7b02b5d716 Update Korean localization (#3491)
* Update messages.po

* Update messages.po
2024-04-12 14:45:13 -07:00
Paul Frazee 4fab3c42f9 Remove report post option from PWI (#3510) 2024-04-12 14:43:55 -07:00
Paul Frazee ec5c4929c1 PWI improvements (#3489)
* Enable home and feeds on the PWI

* Add global SigninDialog to drive useRequireAuth()

* Tweak desktop styles

* Make the logo in leftnav PWI a clickable home link

* Add label

* Improve dialog on web

* Fix query key

* Go to home after signout from settings screen

* Filter out feeds from the discover listing for logged out users which are known to break without auth

* Update profile header follow/subscribe to give signin prompt

* Show profile feeds tabs on pwi

* Add language selector to account creation footer and pwi left nav desktop

---------

Co-authored-by: dan <dan.abramov@gmail.com>
2024-04-12 14:13:13 -07:00
Hailey 44039c68d6 Store QP authors in the DID cache (#3509)
* store qp author in did cache

* organize

* this seems nicer

* move outside of jsx
2024-04-12 12:53:48 -07:00
dan 835f2e6548 Fix stale Notifications after push (#3507) 2024-04-12 11:33:34 -07:00
Paul Frazee 14208eef11 Fix: dont let notifications count go behind the icon (#3505) 2024-04-12 10:21:55 -07:00
Hailey eb2fd53340 QT Jump Pt. 2 - Remove code duplication (#3506)
* remove code duplication

* now it's safe to remove shimmer
2024-04-12 18:00:44 +01:00
dan 7047755c50 Fix optimistic like/repost (#3503) 2024-04-12 16:39:59 +01:00
Samuel Newman f3951f2718 remove build-embed from Dockerfile (#3502) 2024-04-12 16:14:20 +01:00
Paul Frazee ed2c8b720e Dont apply the content-language filter if it will remove all content (#3492)
* Dont apply the content-language filter if it will remove all content

* Improve code
2024-04-12 15:53:11 +01:00
Samuel Newman ad97d4350c [Embeds] Create vite project and add to build pipeline (#3448)
* add bskyembed vite app

* create build script (temp until embedr is ready)
2024-04-12 15:52:26 +01:00
Hailey 24bd3d6986 add likeCount etal. to embedViewRecordToPostView (#3500)
* fix qt jumps

Revert "don't show loading placeholder if we don't need it"

This reverts commit 406f801f217b2733fdd82732c0af74186fc47464.

don't show loading placeholder if we don't need it

add `likeCount` etal. to `embedViewRecordToPostView`

* lint

* Revert the shimmer change

---------

Co-authored-by: Dan Abramov <dan.abramov@gmail.com>
2024-04-12 15:49:09 +01:00
dan bedb0c3fbd Use getSuggestions endpoint behind the gate (#3499)
* Move suggested follows out of the component

* Add new suggestions implementation

* Put new endpoint behind the gate

* Make bottom less weird
2024-04-12 13:02:15 +01:00
Hailey e3e8f10538 Added new_profile_scroll_component to Gate type (#3487)
* added to the types

* alphabetical pls

---------

Co-authored-by: dan <dan.abramov@gmail.com>
2024-04-11 23:59:13 +01:00
dan 491116ca66 Fix useGate lint rule (#3486) 2024-04-11 23:59:02 +01:00
Hailey 4e51772003 Make bio area scrollable on iOS (#2931)
* fix dampen logic

prevent ghost presses

handle refreshes, animations, and clamps

handle most cases for cancelling the scroll animation

handle animations

save point

simplify

remove unnecessary context

readme

apply offset on pan

find the RCTScrollView

send props, add native gesture recognizer

get the react tag

wrap the profile in context

create module

* fix swiping to go back

* remove debug

* use `findNodeHandle`

* create an expo module view

* port most of it to expo modules

* finish most of expomodules impl

* experiments

* remove refresh ability for now

* remove rn module

* changes

* cleanup a few issues

allow swipe back gesture

clean up types

always run animation if the final offset is < 0

separate logic

update patch readme

get the `RCTRefreshControl` working nicely

* gate new header

* organize
2024-04-11 15:20:38 -07:00
Hailey 740cd029d7 Improve Android haptic, offer toggle for haptics in the app (#3482)
* improve android haptics, offer toggle for haptics

* update haptics.ts

* default to false

* simplify to `playHaptic`

* just leave them as `feedInfo`

* use a hook for `playHaptic`

* missed one of them
2024-04-11 15:20:26 -07:00
Samuel Newman 9007810cdb Search - only enable queries once tab is active (#3471)
* only enable queries once tab is active

* remove hasBeenTrue hook

* make enabled optional
2024-04-10 23:32:31 +01:00
dan 310d865440 [Statsig] Mark Testflight as staging tier (#3470) 2024-04-10 20:08:46 +01:00
dan 427f3a848d [Statsig] Typecheck gates (#3467)
* Typecheck gates

* Lint against untyped useGate()

* Alphabetic
2024-04-10 19:36:37 +01:00
Hailey bf974b147e Bump to 1.77 (#3468) 2024-04-10 08:50:44 -07:00
Samuel Newman 353a963920 Search - extra tabs (#3408)
* add extra tab to search and translate tab names

* add feature gate

* flatten pager children

* Revert "flatten pager children"

This reverts commit 0050d42558.

* have pager children as array

* move gate to custom hook

* bundle titles and pages together

* remove comment

* Fix a crash

* Use Views as children

---------

Co-authored-by: dan <dan.abramov@gmail.com>
2024-04-10 16:02:13 +01:00
Hailey b5f5777939 notify slack on production builds (#3461)
* notify slack on production builds

* ugh
2024-04-09 17:25:59 -07:00
Hailey 26f5e02afa notify slack on production builds (#3460) 2024-04-09 17:00:14 -07:00
Paul Frazee ee87f2cadd 1.76 release preparations (#3459)
* Run intl:extract

* Update dev-env to 0.3.4

* Test fixes
2024-04-09 16:27:39 -07:00
Frudrax Cheng fbcd4ddabc Update zh-CN translation (#3433)
* Update messages.po

* Update messages.po by reviewer suggestions

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

* Update messages.po by reviewer suggestions

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

* Update messages.po by reviewer suggestions

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

* Update messages.po by reviewer suggestions

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

* Update messages.po by reviewer suggestions

* Remove superseded strings

* Update messages.po

* Update messages.po

* Update messages.po

* Update messages.po

* Update messages.po

* Update messages.po

---------

Co-authored-by: Leonid <kasumi@bangdream.moe>
2024-04-09 15:13:00 -07:00
Gabriella edab3d2db6 Italian Localization (#3388)
* Italian Localization

New strings translated for v.1.75

* Update messages.po

Changed two expressions following the revision of @marcomaroni

* Update messages.po

Additional changes to text applied following the suggestion of external translators.

* Update messages.po

Deleted extra stop and corrected a gramatical error

* Update messages.po

Added a correction on string 414 which had a grammatical error.

* Update messages.po

String with Labeler updated.

* Update messages.po

Additional changes made to wordings in traduced strings
2024-04-09 15:12:28 -07:00
dan d89b6eb7fd [Statsig] Send prev route name (#3456) 2024-04-09 15:09:53 -07:00
dan c300d4cab6 [Statsig] Instrument feed display (#3455)
* [Statsig] Instrument feed display

* Back out leftover change
2024-04-09 15:09:42 -07:00
Eric Bailey c96bc92042 Small logic cleanups (#3449)
* Small logic cleanups

* Small logic cleanups (#3451)

* remove a few things

* oops

* stop swallowing the error

* queue callbacks

* oops

* log error if caught

* no need to be nullable

* move isClosing=true up

* reset `isClosing` and `closeCallbacks` on close completion and open

* run queued callbacks on `open` if there are any pending

* rm unnecessary ref and check

* ensure order of calls is always correct

* call `snapToIndex()` on open

* add tester to storybook

---------

Co-authored-by: Hailey <me@haileyok.com>
2024-04-09 15:08:02 -07:00
Samuel Newman a49a5a351d Use ALF for the embed consent modal (#3336) 2024-04-09 00:58:18 +01:00
Eric Bailey 2bc20b1752 Onboarding tweaks (#3447)
* Remove feed

* Follow bsky.app
2024-04-08 14:32:00 -05:00
dan 6d3f9397f4 [Experiment] Ignore the persisted tab (#3442) 2024-04-08 18:39:32 +01:00
dan f03390e4b2 [Experiment] Show "Follow Back" label (#3439) 2024-04-08 18:38:59 +01:00
dan 887fedabea [Statsig] Track like/follow metadata (#3435)
* Track becoming mutuals

* Track poster/liker status

* Track post and followee clout

* Track follower and liker clout

* Extract utility
2024-04-08 18:38:51 +01:00
dan 8188f61e7d [Experiment] Turn off interval polling for Discover (#3431)
* Include feed URL in feed events

* [Experiment] Turn off polling interval for Discover
2024-04-08 17:21:44 +01:00
dan 1150007d37 Auto-expand follow suggestions on web (#3432) 2024-04-08 17:20:58 +01:00
dan ed5647d434 [Experiment] Disable min shell on foregrounding (#3430) 2024-04-08 17:20:41 +01:00
Hailey 731d94b973 Implement back button fix for all places in the app (#3434) 2024-04-08 09:09:40 -07:00
Hailey a1ac84687f Keep each deployment job in a separate concurrency group (#3446) 2024-04-08 08:54:50 -07:00
Hailey 00076ad1ff Bump async-storage to comply with App Store guidelines (#3436) 2024-04-08 08:24:59 -07:00
Hailey a6babaceaf adjust app.config.js to prevent development manifest error (#3413) 2024-04-05 13:29:54 -07:00
Hailey cd29dba761 fix dialog back button for android (#3428)
* fix types

* remove unused async

* add try/catch
2024-04-05 12:52:53 -07:00
Hailey c80dcc565e only run labelers for core team (#3426)
* use `pull_request_target` instead of `pull_request`

* adjust

* only run for core team
2024-04-05 08:58:28 -07:00
Hailey 443dfb5c3e prevent deployment race conditions (#3416) 2024-04-05 08:27:22 -07:00
dan 46c112edfd Enforce that text is wrapped in <Text>, remaining cases (#3421)
* Toggle.Button -> Toggle.ButtonWithText

* Simplify Prompt.Cancel/Action

* Move lines down for better diff

* Remove ButtonWithText

* Simplify types

* Enforce Button/ButtonText nesting

* Add suggested wrapper in linter error

* Check <Trans> ancestry too

* Also check literals

* Rm ts-ignore
2024-04-05 15:09:35 +01:00
dan 49266c355e Remove special cases for some buttons with text (#3412)
* Toggle.Button -> Toggle.ButtonWithText

* Simplify Prompt.Cancel/Action

* Move lines down for better diff

* Remove ButtonWithText

* Simplify types
2024-04-05 14:57:53 +01:00
Hailey 9b087b721d Lint and check types before building (#3424) 2024-04-04 23:15:59 -07:00
Hailey 40ccb574b8 properly import linear gradient (#3423) 2024-04-05 06:03:14 +01:00
Hailey 9bcb0a7a81 migrate to expo-linear-gradient (#3420)
* replace packages

* rm useless alias from webpack config

* fix accidental double commit

* replace all usages
2024-04-04 21:22:13 -07:00
Hailey 4e59914d2a migrate to 'expo-haptics' (#3418)
* migrate to 'expo-haptics'

* run yarn install twice if necessary
2024-04-04 21:19:38 -07:00
Hailey cddbbc4bd4 migrate to expo-application (#3417)
* migrate to `expo-application`

* replace additional usage
2024-04-04 21:19:28 -07:00
Hailey 0433f8ad68 migrate to expo-clipboard (#3419)
* replace package

* replace usages
2024-04-04 21:19:15 -07:00
Hailey 101d1589bf Bump some Expo package, remove unused packages to prepare for migration (#3415)
* bump some package versions

* remove completely unused packages

* remove blurview

* update yarn.lock
2024-04-04 18:30:32 -07:00
Samuel Newman 93b606e55a Use CSS keyframe animation for spinner on web (#3411)
* Use css keyframe animation for spinner on web

* duplicate css to index.html
2024-04-04 23:18:06 +01:00
dan 575f390d7b Fix false positive DEV check (#3410) 2024-04-04 22:54:18 +01:00
Hailey 2e048706e9 fix comments on prs (#3406)
* fix comments on prs

* remove labels

* remove test
2024-04-04 13:52:38 -07:00
dan 3915bb4316 Enforce Text suffix for Text-rendering components (#3407)
* Rm unused

* Add Text suffix to Title/Description

* Add Text suffix to text components

* Add Text suffix to props

* Validate Text components returns
2024-04-04 21:34:55 +01:00
255 changed files with 30201 additions and 20449 deletions
+7 -27
View File
@@ -1,5 +1,3 @@
const bskyEslint = require('./eslint')
module.exports = {
root: true,
extends: [
@@ -25,33 +23,15 @@ module.exports = {
'bsky-internal/avoid-unwrapped-text': [
'error',
{
impliedTextComponents: [
'Button', // TODO: Not always safe.
'ButtonText',
'DateField.Label',
'Description',
'H1',
'H2',
'H3',
'H4',
'H5',
'H6',
'InlineLink',
'Label',
'P',
'Prompt.Title',
'Prompt.Description',
'Prompt.Cancel', // TODO: Not always safe.
'Prompt.Action', // TODO: Not always safe.
'TextField.Label',
'TextField.Suffix',
'Title',
'Toggle.Label',
'ToggleButton.Button', // TODO: Not always safe.
],
impliedTextProps: ['FormContainer title'],
impliedTextComponents: ['H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'P'],
impliedTextProps: [],
suggestedTextWrappers: {
Button: 'ButtonText',
'ToggleButton.Button': 'ToggleButton.ButtonText',
},
},
],
'bsky-internal/use-typed-gates': 'error',
'simple-import-sort/imports': [
'warn',
{
+28 -11
View File
@@ -62,38 +62,55 @@ jobs:
- name: 🏗️ EAS Build
run: yarn use-build-number-with-bump eas build -p android --profile ${{ inputs.profile || 'testflight-android' }} --local --output build.aab --non-interactive
- name: 🚀 Deploy
if: ${{ inputs.profile == 'production' }}
run: eas submit -p android --non-interactive --path build.aab
- name: ✍️ Rename bundle
- name: ✍️ Rename Testflight bundle
if: ${{ inputs.profile != 'production' }}
run: mv build.aab build.apk
- name: ⏰ Get a timestamp
id: timestamp
if: ${{ inputs.profile != 'production' }}
uses: nanzm/get-time-action@master
with:
format: 'MM-DD-HH-mm-ss'
- name: 🚀 Upload Artifact
id: upload-artifact
- name: 🚀 Upload Production Artifact
id: upload-artifact-production
if: ${{ inputs.profile == 'production' }}
uses: actions/upload-artifact@v4
with:
retention-days: 30
compression-level: 6
name: build-${{ steps.timestamp.outputs.time }}.aab
path: build.aab
- name: 🚀 Upload Testflight Artifact
id: upload-artifact-testflight
if: ${{ inputs.profile != 'production' }}
uses: actions/upload-artifact@v4
with:
retention-days: 30
compression-level: 0
compression-level: 6
name: build-${{ steps.timestamp.outputs.time }}.apk
path: build.apk
- name: 🔔 Notify Slack
- name: 🔔 Notify Slack of Production Build
if: ${{ inputs.profile == 'production' }}
uses: slackapi/slack-github-action@v1.25.0
with:
payload: |
{
"text": "Android build is ready for submission. This is a production build! Download the artifact here: ${{ steps.upload-artifact-production.outputs.artifact-url }}"
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK
- name: 🔔 Notify Slack of Testflight Build
if: ${{ inputs.profile != 'production' }}
uses: slackapi/slack-github-action@v1.25.0
with:
payload: |
{
"text": "Android build is ready for testing. Download the artifact here: ${{ steps.upload-artifact.outputs.artifact-url }}"
"text": "Android build is ready for testing. Download the artifact here: ${{ steps.upload-artifact-testflight.outputs.artifact-url }}"
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
+39 -9
View File
@@ -22,6 +22,9 @@ jobs:
bundleDeploy:
name: Bundle and Deploy EAS Update
runs-on: ubuntu-latest
concurrency:
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}-deploy
cancel-in-progress: true
outputs:
fingerprint-is-different: ${{ steps.fingerprint-debug.outputs.fingerprint-is-different }}
@@ -44,7 +47,18 @@ jobs:
- name: ⬇️ Checkout
uses: actions/checkout@v4
with:
fetch-depth: 100
fetch-depth: 0
- name: ⬇️ Get last successful deployment commit from the cache
id: get-base-commit
uses: actions/cache@v4
with:
path: last-successful-commit-hash.txt
key: last-successful-deployment-commit-${{ github.ref_name }}
- name: Add the last successful deployment commit to the output
id: last-successful-commit
run: echo base-commit=$(cat last-successful-commit-hash.txt) >> "$GITHUB_OUTPUT"
- name: ⬇️ Fetch commits from base branch
if: ${{ github.ref != 'refs/heads/main' }}
@@ -57,12 +71,12 @@ jobs:
if [ -z "${{ inputs.channel == 'production' }}" ]; then
echo base-commit=$(git show-ref -s ${{ inputs.runtimeVersion }}) >> "$GITHUB_OUTPUT"
else
echo base-commit=$(git log -n 1 --skip 1 main --pretty=format:'%H') >> "$GITHUB_OUTPUT"
echo base-commit=${{ steps.last-successful-commit.base-commit }} >> "$GITHUB_OUTPUT"
fi
- name: ✓ Make sure we found a base commit
run: |
if [ -z "${{ steps.base-commit.outputs.base-commit }}" ]; then
if [ -z "${{ steps.base-commit.outputs.base-commit }}" && ${{ inputs.channel == 'production' }} ]; then
echo "Could not find a base commit for this release. Exiting."
exit 1
fi
@@ -82,7 +96,6 @@ jobs:
uses: expo/expo-github-action/fingerprint@main
with:
previous-git-commit: ${{ steps.base-commit.outputs.base-commit }}
args:
- name: 👀 Debug fingerprint
id: fingerprint-debug
@@ -90,13 +103,26 @@ jobs:
echo "previousGitCommit=${{ steps.fingerprint.outputs.previous-git-commit }} currentGitCommit=${{ steps.fingerprint.outputs.current-git-commit }}"
echo "isPreviousFingerprintEmpty=${{ steps.fingerprint.outputs.previous-fingerprint == '' }}"
fingerprintDiff="${{ steps.fingerprint.outputs.fingerprint-diff }}"
fingerprintDiff='$(echo "${{ steps.fingerprint.outputs.fingerprint-diff }}")'
if [[ $fingerprintDiff =~ "bareRncliAutolinking" || $fingerprintDiff =~ "expoAutolinkingAndroid" || $fingerprintDiff =~ "expoAutolinkingIos" ]]; then
echo fingerprint-is-different="true" >> "$GITHUB_OUTPUT"
else
echo fingerprint-is-different="false" >> "$GITHUB_OUTPUT"
fi
- name: Lint check
run: yarn lint
- name: Prettier check
run: yarn prettier --check .
- name: Check & compile i18n
run: yarn intl:build
- name: Type check
run: yarn typecheck
- name: 🔨 Setup EAS
uses: expo/expo-github-action@v8
if: ${{ steps.fingerprint-debug.outputs.fingerprint-is-different == 'false'}}
@@ -113,10 +139,6 @@ jobs:
if: ${{ steps.fingerprint-debug.outputs.fingerprint-is-different == 'false'}}
uses: dcarbone/install-jq-action@v2
- name: 🔤 Compile Translations
if: ${{ steps.fingerprint-debug.outputs.fingerprint-is-different == 'false'}}
run: yarn intl:build
- name: ✏️ Write environment variables
if: ${{ steps.fingerprint-debug.outputs.fingerprint-is-different == 'false'}}
run: |
@@ -136,11 +158,16 @@ jobs:
RUNTIME_VERSION: ${{ inputs.runtimeVersion }}
CHANNEL_NAME: ${{ inputs.channel || 'testflight' }}
- name: Save successful deployment commit hash
run: echo ${{ steps.fingerprint.outputs.current-git-commit }} > last-successful-commit-hash.txt
# GitHub actions are horrible so let's just copy paste this in
buildIfNecessaryIOS:
name: Build and Submit iOS
runs-on: macos-14
concurrency:
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}-build-ios
cancel-in-progress: false
needs: [bundleDeploy]
# Gotta check if its NOT '[]' because any md5 hash in the outputs is detected as a possible secret and won't be
# available here
@@ -206,6 +233,9 @@ jobs:
buildIfNecessaryAndroid:
name: Build and Submit Android
runs-on: ubuntu-latest
concurrency:
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}-build-android
cancel-in-progress: false
needs: [ bundleDeploy ]
# Gotta check if its NOT '[]' because any md5 hash in the outputs is detected as a possible secret and won't be
# available here
+26 -164
View File
@@ -13,12 +13,15 @@ concurrency:
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}
cancel-in-progress: true
permissions:
pull-requests: write
actions: write
contents: read
jobs:
webpack-analyzer:
runs-on: ubuntu-22.04
if: ${{ github.event_name == 'pull_request' }}
permissions:
pull-requests: write
if: ${{ github.event.pull_request.head.repo.full_name == github.repository }}
steps:
- name: ⬇️ Checkout
uses: actions/checkout@v4
@@ -31,9 +34,6 @@ jobs:
node-version-file: .nvmrc
cache: yarn
- name: ⚙️ Install Dependencies
run: yarn install
- name: Ensure tracking relevant branches and checkout base
run: |
git checkout ${{ github.head_ref }}
@@ -49,6 +49,7 @@ jobs:
git config --global user.email "github-actions[bot]@users.noreply.github.com"
git config --global user.name "github-actions[bot]"
git merge --no-edit ${{ github.head_ref }}
yarn install
- name: 🔦 Generate stats file for PR
run: |
@@ -71,6 +72,7 @@ jobs:
- name: 🔦 Generate stats file from base commit
if: ${{ !steps.get-base-stats.outputs.cache-hit }}
run: |
yarn install
yarn generate-webpack-stats-file
mv stats.json stats-base.json
@@ -82,62 +84,20 @@ jobs:
pr_path: '../stats-new.json'
excluded_assets: '(.+).chunk.js|(.+).js.map|(.+).json|(.+).png'
- name: 🔍 Find old comment if it exists
uses: peter-evans/find-comment@v2
if: ${{ github.event_name == 'pull_request' }}
id: old_comment
- name: 💬 Drop a comment
uses: marocchino/sticky-pull-request-comment@v2
with:
issue-number: ${{ github.event.pull_request.number }}
comment-author: 'github-actions[bot]'
body-includes: <!-- webpack-analyzer comment -->
- name: 💬 Add comment with diff
uses: actions/github-script@v6
if: ${{ steps.old_comment.outputs.comment-id == '' }}
with:
script: |
const body = `<!-- webpack-analyzer comment -->
header: bundle-diff
message: |
| Old size | New size | Diff |
|----------|----------|-----------------------|
| ${{ steps.get-diff.outputs.base_file_string }} | ${{ steps.get-diff.outputs.pr_file_string }} | ${{ steps.get-diff.outputs.diff_file_string }} (${{ steps.get-diff.outputs.percent }}%) |
`;
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: body,
});
- name: 💬 Update comment with fingerprint
if: ${{ steps.old_comment.outputs.comment-id != '' }}
uses: actions/github-script@v6
with:
script: |
const body = `<!-- webpack-analyzer comment -->
| Old size | New size | Diff |
|----------|----------|-----------------------|
| ${{ steps.get-diff.outputs.base_file_string }} | ${{ steps.get-diff.outputs.pr_file_string }} | ${{ steps.get-diff.outputs.diff_file_string }} (${{ steps.get-diff.outputs.percent }}%) |
`;
github.rest.issues.updateComment({
issue_number: context.issue.number,
comment_id: '${{ steps.old_comment.outputs.comment-id }}',
owner: context.repo.owner,
repo: context.repo.repo,
body: body,
});
---
test-suite-fingerprint:
runs-on: ubuntu-22.04
if: ${{ github.event_name == 'pull_request' || github.event_name == 'push' }}
# REQUIRED: limit concurrency when pushing main(default) branch to prevent conflict for this action to update its fingerprint database
if: ${{ github.event.pull_request.head.repo.full_name == github.repository || github.event_name == 'push' }}
concurrency: fingerprint-${{ github.event_name != 'pull_request' && 'main' || github.run_id }}
permissions:
# REQUIRED: Allow comments of PRs
pull-requests: write
# REQUIRED: Allow updating fingerprint in acton caches
actions: write
steps:
- name: ⬇️ Checkout
uses: actions/checkout@v4
@@ -172,126 +132,28 @@ jobs:
echo "previousGitCommit=${{ steps.fingerprint.outputs.previous-git-commit }} currentGitCommit=${{ steps.fingerprint.outputs.current-git-commit }}"
echo "isPreviousFingerprintEmpty=${{ steps.fingerprint.outputs.previous-fingerprint == '' }}"
- name: 🏷️ Labeling PR
uses: actions/github-script@v6
if: ${{ github.event_name == 'pull_request' && steps.fingerprint.outputs.fingerprint-diff == '[]' }}
with:
script: |
try {
await github.rest.issues.removeLabel({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
name: ['bot: fingerprint changed']
})
} catch (e) {
if (e.status != 404) {
throw e;
}
}
github.rest.issues.addLabels({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
labels: ['bot: fingerprint compatible']
})
- name: 🏷️ Labeling PR
uses: actions/github-script@v6
- name: 💬 Drop a comment
uses: marocchino/sticky-pull-request-comment@v2
if: ${{ github.event_name == 'pull_request' && steps.fingerprint.outputs.fingerprint-diff != '[]' }}
with:
script: |
try {
await github.rest.issues.removeLabel({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
name: ['bot: fingerprint compatible']
})
} catch (e) {
if (e.status != 404) {
throw e;
}
}
github.rest.issues.addLabels({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
labels: ['bot: fingerprint changed']
})
- name: 🔍 Find old comment if it exists
uses: peter-evans/find-comment@v2
if: ${{ github.event_name == 'pull_request' }}
id: old_comment
with:
issue-number: ${{ github.event.pull_request.number }}
comment-author: 'github-actions[bot]'
body-includes: <!-- pr-labeler comment -->
- name: 💬 Add comment with fingerprint
if: ${{ github.event_name == 'pull_request' && steps.fingerprint.outputs.fingerprint-diff != '[]' && steps.old_comment.outputs.comment-id == '' }}
uses: actions/github-script@v6
with:
script: |
const diff = JSON.stringify(${{ steps.fingerprint.outputs.fingerprint-diff}}, null, 2);
const body = `<!-- pr-labeler comment -->
header: fingerprint-diff
message: |
The Pull Request introduced fingerprint changes against the base commit: ${{ steps.fingerprint.outputs.previous-git-commit }}
<details><summary>Fingerprint diff</summary>
\`\`\`json
${diff}
\`\`\`
```json
${{ steps.fingerprint.outputs.fingerprint-diff }}
```
</details>
---
*Generated by [PR labeler](https://github.com/expo/expo/actions/workflows/pr-labeler.yml) 🤖*
`;
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: body,
});
- name: 💬 Update comment with fingerprint
if: ${{ github.event_name == 'pull_request' && steps.fingerprint.outputs.fingerprint-diff != '[]' && steps.old_comment.outputs.comment-id != '' }}
uses: actions/github-script@v6
- name: 💬 Delete comment
uses: marocchino/sticky-pull-request-comment@v2
if: ${{ github.event_name == 'pull_request' && steps.fingerprint.outputs.fingerprint-diff == '[]' }}
with:
script: |
const diff = JSON.stringify(${{ steps.fingerprint.outputs.fingerprint-diff}}, null, 2);
const body = `<!-- pr-labeler comment -->
The Pull Request introduced fingerprint changes against the base commit: ${{ steps.fingerprint.outputs.previous-git-commit }}
<details><summary>Fingerprint diff</summary>
header: fingerprint-diff
delete: true
\`\`\`json
${diff}
\`\`\`
</details>
---
*Generated by [PR labeler](https://github.com/expo/expo/actions/workflows/pr-labeler.yml) 🤖*
`;
github.rest.issues.updateComment({
issue_number: context.issue.number,
comment_id: '${{ steps.old_comment.outputs.comment-id }}',
owner: context.repo.owner,
repo: context.repo.repo,
body: body,
});
- name: 💬 Delete comment with fingerprint
if: ${{ github.event_name == 'pull_request' && steps.fingerprint.outputs.fingerprint-diff == '[]' && steps.old_comment.outputs.comment-id != '' }}
uses: actions/github-script@v6
with:
script: |
github.rest.issues.deleteComment({
issue_number: context.issue.number,
comment_id: '${{ steps.old_comment.outputs.comment-id }}',
owner: context.repo.owner,
repo: context.repo.repo,
});
+2 -2
View File
@@ -1,5 +1,6 @@
import {createServer as createHTTPServer} from 'node:http'
import {parse} from 'node:url'
import {createServer, TestPDS} from '../jest/test-pds'
async function main() {
@@ -14,8 +15,7 @@ async function main() {
await server?.close()
console.log('Starting new server')
const inviteRequired = url?.query && 'invite' in url.query
const phoneRequired = url?.query && 'phone' in url.query
server = await createServer({inviteRequired, phoneRequired})
server = await createServer({inviteRequired})
console.log('Listening at', server.pdsUrl)
if (url?.query) {
if ('users' in url.query) {
+13 -15
View File
@@ -1,8 +1,9 @@
/* eslint-env detox/detox */
import {describe, beforeAll, it} from '@jest/globals'
import {beforeAll, describe, it} from '@jest/globals'
import {expect} from 'detox'
import {openApp, loginAsAlice, createServer} from '../util'
import {createServer, loginAsAlice, openApp} from '../util'
describe('Home screen', () => {
beforeAll(async () => {
@@ -68,19 +69,16 @@ describe('Home screen', () => {
).not.toExist()
})
it('Can report posts', async () => {
const carlaPosts = by.id('feedItem-by-carla.test')
await element(by.id('postDropdownBtn').withAncestor(carlaPosts))
.atIndex(0)
.tap()
await element(by.text('Report post')).tap()
await expect(element(by.id('reportModal'))).toBeVisible()
await element(
by.id('reportReasonRadios-com.atproto.moderation.defs#reasonSpam'),
).tap()
await element(by.id('sendReportBtn')).tap()
await expect(element(by.id('reportModal'))).not.toBeVisible()
})
// TODO skipping because the test env PDS isnt setup correctly to handle the report -prf
// it('Can report posts', async () => {
// const carlaPosts = by.id('feedItem-by-carla.test')
// await element(by.id('postDropdownBtn').withAncestor(carlaPosts))
// .atIndex(0)
// .tap()
// await element(by.text('Report post')).tap()
// await element(by.id('com.atproto.moderation.defs#reasonSpam')).tap()
// await element(by.id('sendReportBtn')).tap()
// })
it('Can swipe between feeds', async () => {
await element(by.id('homeScreen')).swipe('left', 'fast', 0.75)
@@ -1,8 +1,9 @@
/* eslint-env detox/detox */
import {describe, beforeAll, it} from '@jest/globals'
import {beforeAll, describe, it} from '@jest/globals'
import {expect} from 'detox'
import {openApp, loginAsAlice, createServer} from '../util'
import {createServer, loginAsAlice, openApp} from '../util'
describe('invite-codes', () => {
let service: string
@@ -1,52 +0,0 @@
/* eslint-env detox/detox */
import {describe, beforeAll, it} from '@jest/globals'
import {expect} from 'detox'
import {openApp, loginAsAlice, createServer} from '../util'
describe('invite-codes', () => {
let service: string
let inviteCode = ''
beforeAll(async () => {
service = await createServer('?users&invite&phone')
await openApp({permissions: {notifications: 'YES'}})
})
it('I can fetch invite codes', async () => {
await loginAsAlice()
await element(by.id('e2eOpenInviteCodesModal')).tap()
await expect(element(by.id('inviteCodesModal'))).toBeVisible()
const attrs = await element(by.id('inviteCode-0-code')).getAttributes()
inviteCode = attrs.text
await element(by.id('closeBtn')).tap()
await element(by.id('e2eSignOut')).tap()
})
it('I can create a new account with the invite code', async () => {
await element(by.id('e2eOpenLoggedOutView')).tap()
await element(by.id('createAccountButton')).tap()
await device.takeScreenshot('1- opened create account screen')
await element(by.id('selectServiceButton')).tap()
await device.takeScreenshot('2- selected other server')
await element(by.id('customSelectBtn')).tap()
await element(by.id('customServerTextInput')).typeText(service)
await element(by.id('customServerTextInput')).tapReturnKey()
await element(by.id('doneBtn')).tap()
await device.takeScreenshot('3- input test server URL')
await element(by.id('inviteCodeInput')).typeText(inviteCode)
await element(by.id('emailInput')).typeText('example@test.com')
await element(by.id('passwordInput')).typeText('hunter2')
await device.takeScreenshot('4- entered account details')
await element(by.id('nextBtn')).tap()
await element(by.id('phoneInput')).typeText('2345551234')
await element(by.id('requestCodeBtn')).tap()
await device.takeScreenshot('5- requested code')
await element(by.id('codeInput')).typeText('000000')
await device.takeScreenshot('6- entered code')
await element(by.id('nextBtn')).tap()
await element(by.id('handleInput')).typeText('e2e-test')
await device.takeScreenshot('7- entered handle')
await element(by.id('nextBtn')).tap()
await expect(element(by.id('onboardingInterests'))).toBeVisible()
})
})
+26 -23
View File
@@ -1,8 +1,9 @@
/* eslint-env detox/detox */
import {describe, beforeAll, it} from '@jest/globals'
import {beforeAll, describe, it} from '@jest/globals'
import {expect} from 'detox'
import {openApp, loginAsAlice, createServer, sleep} from '../util'
import {createServer, loginAsAlice, openApp, sleep} from '../util'
describe('Profile screen', () => {
beforeAll(async () => {
@@ -124,16 +125,17 @@ describe('Profile screen', () => {
await expect(element(by.id('profileHeaderAlert'))).not.toExist()
})
it('Can report another user', async () => {
await element(by.id('profileHeaderDropdownBtn')).tap()
await element(by.text('Report Account')).tap()
await expect(element(by.id('reportModal'))).toBeVisible()
await element(
by.id('reportReasonRadios-com.atproto.moderation.defs#reasonSpam'),
).tap()
await element(by.id('sendReportBtn')).tap()
await expect(element(by.id('reportModal'))).not.toBeVisible()
})
// TODO skipping because the test env PDS isnt setup correctly to handle the report -prf
// it('Can report another user', async () => {
// await element(by.id('profileHeaderDropdownBtn')).tap()
// await element(by.text('Report Account')).tap()
// await expect(element(by.id('reportModal'))).toBeVisible()
// await element(
// by.id('reportReasonRadios-com.atproto.moderation.defs#reasonSpam'),
// ).tap()
// await element(by.id('sendReportBtn')).tap()
// await expect(element(by.id('reportModal'))).not.toBeVisible()
// })
it('Can like posts', async () => {
await element(by.id('postsFeed-flatlist')).swipe(
@@ -179,15 +181,16 @@ describe('Profile screen', () => {
).not.toExist()
})
it('Can report posts', async () => {
const posts = by.id('feedItem-by-bob.test')
await element(by.id('postDropdownBtn').withAncestor(posts)).atIndex(0).tap()
await element(by.text('Report post')).tap()
await expect(element(by.id('reportModal'))).toBeVisible()
await element(
by.id('reportReasonRadios-com.atproto.moderation.defs#reasonSpam'),
).tap()
await element(by.id('sendReportBtn')).tap()
await expect(element(by.id('reportModal'))).not.toBeVisible()
})
// TODO skipping because the test env PDS isnt setup correctly to handle the report -prf
// it('Can report posts', async () => {
// const posts = by.id('feedItem-by-bob.test')
// await element(by.id('postDropdownBtn').withAncestor(posts)).atIndex(0).tap()
// await element(by.text('Report post')).tap()
// await expect(element(by.id('reportModal'))).toBeVisible()
// await element(
// by.id('reportReasonRadios-com.atproto.moderation.defs#reasonSpam'),
// ).tap()
// await element(by.id('sendReportBtn')).tap()
// await expect(element(by.id('reportModal'))).not.toBeVisible()
// })
})
@@ -1,83 +0,0 @@
/* eslint-env detox/detox */
import {describe, beforeAll, it} from '@jest/globals'
import {expect} from 'detox'
import {openApp, createServer} from '../util'
describe('Create account', () => {
let service: string
beforeAll(async () => {
service = await createServer('?phone')
await openApp({permissions: {notifications: 'YES'}})
})
it('I can create a new account with text verification', async () => {
console.log('SERVICE IS', service)
await element(by.id('e2eOpenLoggedOutView')).tap()
await element(by.id('createAccountButton')).tap()
await device.takeScreenshot('1- opened create account screen')
await element(by.id('selectServiceButton')).tap()
await device.takeScreenshot('2- selected other server')
await element(by.id('customSelectBtn')).tap()
await element(by.id('customServerTextInput')).typeText(service)
await element(by.id('customServerTextInput')).tapReturnKey()
await element(by.id('doneBtn')).tap()
await device.takeScreenshot('3- input test server URL')
await element(by.id('emailInput')).typeText('text-verification@test.com')
await element(by.id('passwordInput')).typeText('hunter2')
await device.takeScreenshot('4- entered account details')
await element(by.id('nextBtn')).tap()
await element(by.id('handleInput')).typeText('text-verification-test')
await device.takeScreenshot('5- entered handle')
await element(by.id('nextBtn')).tap()
await element(by.id('phoneInput')).typeText('8042221111')
await element(by.id('requestCodeBtn')).tap()
await device.takeScreenshot('6- requested code')
await element(by.id('codeInput')).typeText('000000')
await device.takeScreenshot('7- entered code')
await element(by.id('nextBtn')).tap()
await element(by.id('nextBtn')).tap()
await expect(element(by.id('onboardingInterests'))).toBeVisible()
})
it('failed text verification correctly goes back to the code input screen', async () => {
await element(by.id('e2eSignOut')).tap()
await element(by.id('e2eOpenLoggedOutView')).tap()
await element(by.id('createAccountButton')).tap()
await device.takeScreenshot('1- opened create account screen')
await element(by.id('selectServiceButton')).tap()
await device.takeScreenshot('2- selected other server')
await element(by.id('customSelectBtn')).tap()
await element(by.id('customServerTextInput')).typeText(service)
await element(by.id('customServerTextInput')).tapReturnKey()
await element(by.id('doneBtn')).tap()
await device.takeScreenshot('3- input test server URL')
await element(by.id('emailInput')).typeText('text-verification2@test.com')
await element(by.id('passwordInput')).typeText('hunter2')
await device.takeScreenshot('4- entered account details')
await element(by.id('nextBtn')).tap()
await element(by.id('phoneInput')).typeText('8042221111')
await element(by.id('requestCodeBtn')).tap()
await device.takeScreenshot('5- requested code')
await element(by.id('codeInput')).typeText('111111')
await device.takeScreenshot('6- entered code')
await element(by.id('nextBtn')).tap()
await element(by.id('handleInput')).typeText('text-verification-test2')
await device.takeScreenshot('7- entered handle')
await element(by.id('nextBtn')).tap()
await expect(element(by.id('codeInput'))).toBeVisible()
await device.takeScreenshot('8- got error')
})
})
+27 -24
View File
@@ -1,8 +1,9 @@
/* eslint-env detox/detox */
import {describe, beforeAll, it} from '@jest/globals'
import {beforeAll, describe, it} from '@jest/globals'
import {expect} from 'detox'
import {openApp, loginAsAlice, createServer} from '../util'
import {createServer, loginAsAlice, openApp} from '../util'
describe('Thread screen', () => {
beforeAll(async () => {
@@ -102,27 +103,29 @@ describe('Thread screen', () => {
).not.toExist()
})
it('Can report the root post', async () => {
const post = by.id('postThreadItem-by-bob.test')
await element(by.id('postDropdownBtn').withAncestor(post)).atIndex(0).tap()
await element(by.text('Report post')).tap()
await expect(element(by.id('reportModal'))).toBeVisible()
await element(
by.id('reportReasonRadios-com.atproto.moderation.defs#reasonSpam'),
).tap()
await element(by.id('sendReportBtn')).tap()
await expect(element(by.id('reportModal'))).not.toBeVisible()
})
// TODO skipping because the test env PDS isnt setup correctly to handle the report -prf
// it('Can report the root post', async () => {
// const post = by.id('postThreadItem-by-bob.test')
// await element(by.id('postDropdownBtn').withAncestor(post)).atIndex(0).tap()
// await element(by.text('Report post')).tap()
// await expect(element(by.id('reportModal'))).toBeVisible()
// await element(
// by.id('reportReasonRadios-com.atproto.moderation.defs#reasonSpam'),
// ).tap()
// await element(by.id('sendReportBtn')).tap()
// await expect(element(by.id('reportModal'))).not.toBeVisible()
// })
it('Can report a reply post', async () => {
const post = by.id('postThreadItem-by-carla.test')
await element(by.id('postDropdownBtn').withAncestor(post)).atIndex(0).tap()
await element(by.text('Report post')).tap()
await expect(element(by.id('reportModal'))).toBeVisible()
await element(
by.id('reportReasonRadios-com.atproto.moderation.defs#reasonSpam'),
).tap()
await element(by.id('sendReportBtn')).tap()
await expect(element(by.id('reportModal'))).not.toBeVisible()
})
// TODO skipping because the test env PDS isnt setup correctly to handle the report -prf
// it('Can report a reply post', async () => {
// const post = by.id('postThreadItem-by-carla.test')
// await element(by.id('postDropdownBtn').withAncestor(post)).atIndex(0).tap()
// await element(by.text('Report post')).tap()
// await expect(element(by.id('reportModal'))).toBeVisible()
// await element(
// by.id('reportReasonRadios-com.atproto.moderation.defs#reasonSpam'),
// ).tap()
// await element(by.id('sendReportBtn')).tap()
// await expect(element(by.id('reportModal'))).not.toBeVisible()
// })
})
+22 -9
View File
@@ -42,8 +42,14 @@ module.exports = function (config) {
const IS_DEV = process.env.EXPO_PUBLIC_ENV === 'development'
const IS_TESTFLIGHT = process.env.EXPO_PUBLIC_ENV === 'testflight'
const IS_PRODUCTION = process.env.EXPO_PUBLIC_ENV === 'production'
const UPDATES_CHANNEL = IS_TESTFLIGHT ? 'testflight' : 'production'
const UPDATES_CHANNEL = IS_TESTFLIGHT
? 'testflight'
: IS_PRODUCTION
? 'production'
: undefined
const UPDATES_ENABLED = !!UPDATES_CHANNEL
return {
expo: {
@@ -89,6 +95,11 @@ module.exports = function (config) {
barStyle: 'light-content',
backgroundColor: '#00000000',
},
// Dark nav bar in light mode is better than light nav bar in dark mode
androidNavigationBar: {
barStyle: 'light-content',
backgroundColor: DARK_SPLASH_CONFIG_ANDROID.backgroundColor,
},
android: {
icon: './assets/icon.png',
adaptiveIcon: {
@@ -126,15 +137,17 @@ module.exports = function (config) {
},
updates: {
url: 'https://updates.bsky.app/manifest',
// TODO Eventually we want to enable this for all environments, but for now it will only be used for
// TestFlight builds
enabled: IS_TESTFLIGHT,
enabled: UPDATES_ENABLED,
fallbackToCacheTimeout: 30000,
codeSigningCertificate: './code-signing/certificate.pem',
codeSigningMetadata: {
keyid: 'main',
alg: 'rsa-v1_5-sha256',
},
codeSigningCertificate: UPDATES_ENABLED
? './code-signing/certificate.pem'
: undefined,
codeSigningMetadata: UPDATES_ENABLED
? {
keyid: 'main',
alg: 'rsa-v1_5-sha256',
}
: undefined,
checkAutomatically: 'NEVER',
channel: UPDATES_CHANNEL,
},
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M19.002 3a3 3 0 0 1 3 3v10a3 3 0 0 1-3 3H12.28l-4.762 2.858A1 1 0 0 1 6.002 21v-2h-1a3 3 0 0 1-3-3V6a3 3 0 0 1 3-3h14Z"/></svg>

After

Width:  |  Height:  |  Size: 221 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M2.002 6a3 3 0 0 1 3-3h14a3 3 0 0 1 3 3v10a3 3 0 0 1-3 3H12.28l-4.762 2.858A1 1 0 0 1 6.002 21v-2h-1a3 3 0 0 1-3-3V6Zm3-1a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h2a1 1 0 0 1 1 1v1.234l3.486-2.092a1 1 0 0 1 .514-.142h7a1 1 0 0 0 1-1V6a1 1 0 0 0-1-1h-14Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 386 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="M14.242 3.03a1 1 0 0 1 .728 1.213l-4 16a1 1 0 1 1-1.94-.485l4-16a1 1 0 0 1 1.213-.728ZM6.707 7.293a1 1 0 0 1 0 1.414L3.414 12l3.293 3.293a1 1 0 1 1-1.414 1.414l-4-4a1 1 0 0 1 0-1.414l4-4a1 1 0 0 1 1.414 0Zm10.586 0a1 1 0 0 1 1.414 0l4 4a1 1 0 0 1 0 1.414l-4 4a1 1 0 1 1-1.414-1.414L20.586 12l-3.293-3.293a1 1 0 0 1 0-1.414Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 466 B

@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none"><path fill="#000" fill-rule="evenodd" d="M10 4a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM5.5 6.5a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM16 11a1 1 0 0 1 1-1h5a1 1 0 1 1 0 2h-5a1 1 0 0 1-1-1ZM3.678 19h12.644c-.71-2.909-3.092-5-6.322-5s-5.613 2.091-6.322 5Zm-2.174.906C1.917 15.521 5.242 12 10 12c4.758 0 8.083 3.521 8.496 7.906A1 1 0 0 1 17.5 21h-15a1 1 0 0 1-.996-1.094Z" clip-rule="evenodd"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M10 4a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM5.5 6.5a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM16 11a1 1 0 0 1 1-1h5a1 1 0 1 1 0 2h-5a1 1 0 0 1-1-1ZM3.678 19h12.644c-.71-2.909-3.092-5-6.322-5s-5.613 2.091-6.322 5Zm-2.174.906C1.917 15.521 5.242 12 10 12c4.758 0 8.083 3.521 8.496 7.906A1 1 0 0 1 17.5 21h-15a1 1 0 0 1-.996-1.094Z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 466 B

After

Width:  |  Height:  |  Size: 463 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M17.957 2.293a1 1 0 1 0-1.414 1.414L17.836 5H6a3 3 0 0 0-3 3v3a1 1 0 1 0 2 0V8a1 1 0 0 1 1-1h11.836l-1.293 1.293a1 1 0 0 0 1.414 1.414l2.47-2.47a1.75 1.75 0 0 0 0-2.474l-2.47-2.47ZM20 12a1 1 0 0 1 1 1v3a3 3 0 0 1-3 3H6.164l1.293 1.293a1 1 0 1 1-1.414 1.414l-2.47-2.47a1.75 1.75 0 0 1 0-2.474l2.47-2.47a1 1 0 0 1 1.414 1.414L6.164 17H18a1 1 0 0 0 1-1v-3a1 1 0 0 1 1-1Z"/></svg>

After

Width:  |  Height:  |  Size: 470 B

+20
View File
@@ -0,0 +1,20 @@
{
"root": true,
"parser": "@typescript-eslint/parser",
"plugins": ["@typescript-eslint", "simple-import-sort"],
"extends": [
"eslint:recommended",
"preact",
"plugin:@typescript-eslint/recommended",
"plugin:@typescript-eslint/recommended-requiring-type-checking"
],
"rules": {
"simple-import-sort/imports": "warn",
"simple-import-sort/exports": "warn"
},
"parserOptions": {
"sourceType": "module",
"ecmaVersion": "latest",
"project": "./tsconfig.json"
}
}
+5
View File
@@ -0,0 +1,5 @@
node_modules
.DS_Store
dist
dist-ssr
*.local
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none"><path fill="#000" fill-rule="evenodd" d="M12 21a1 1 0 0 1-.707-.293l-6-6a1 1 0 1 1 1.414-1.414L11 17.586V4a1 1 0 1 1 2 0v13.586l4.293-4.293a1 1 0 0 1 1.414 1.414l-6 6A1 1 0 0 1 12 21Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 287 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="rgb(10,122,255)" d="M19.002 3a3 3 0 0 1 3 3v10a3 3 0 0 1-3 3H12.28l-4.762 2.858A1 1 0 0 1 6.002 21v-2h-1a3 3 0 0 1-3-3V6a3 3 0 0 1 3-3h14Z"/></svg>

After

Width:  |  Height:  |  Size: 232 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="rgb(66,87,108)" fill-rule="evenodd" d="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-1a1 1 0 0 1 1-1h1a1 1 0 0 1 1 1v5a1 1 0 1 1-2 0v-4a1 1 0 0 1-1-1Zm1-3a1 1 0 1 0 2 0 1 1 0 0 0-2 0Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 361 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#ec4899" d="M12.489 21.372c8.528-4.78 10.626-10.47 9.022-14.47-.779-1.941-2.414-3.333-4.342-3.763-1.697-.378-3.552.003-5.169 1.287-1.617-1.284-3.472-1.665-5.17-1.287-1.927.43-3.562 1.822-4.34 3.764-1.605 4 .493 9.69 9.021 14.47a1 1 0 0 0 .978 0Z"/></svg>

After

Width:  |  Height:  |  Size: 339 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 320 286"><path fill="rgb(10,122,255)" d="M69.364 19.146c36.687 27.806 76.147 84.186 90.636 114.439 14.489-30.253 53.948-86.633 90.636-114.439C277.107-.917 320-16.44 320 32.957c0 9.865-5.603 82.875-8.889 94.729-11.423 41.208-53.045 51.719-90.071 45.357 64.719 11.12 81.182 47.953 45.627 84.785-80 82.874-106.667-44.333-106.667-44.333s-26.667 127.207-106.667 44.333c-35.555-36.832-19.092-73.665 45.627-84.785-37.026 6.362-78.648-4.149-90.071-45.357C5.603 115.832 0 42.822 0 32.957 0-16.44 42.893-.917 69.364 19.147Z"/></svg>

After

Width:  |  Height:  |  Size: 588 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#20bc07" d="M17.957 2.293a1 1 0 1 0-1.414 1.414L17.836 5H6a3 3 0 0 0-3 3v3a1 1 0 1 0 2 0V8a1 1 0 0 1 1-1h11.836l-1.293 1.293a1 1 0 0 0 1.414 1.414l2.47-2.47a1.75 1.75 0 0 0 0-2.474l-2.47-2.47ZM20 12a1 1 0 0 1 1 1v3a3 3 0 0 1-3 3H6.164l1.293 1.293a1 1 0 1 1-1.414 1.414l-2.47-2.47a1.75 1.75 0 0 1 0-2.474l2.47-2.47a1 1 0 0 1 1.414 1.414L6.164 17H18a1 1 0 0 0 1-1v-3a1 1 0 0 1 1-1Z"/></svg>

After

Width:  |  Height:  |  Size: 473 B

+19
View File
@@ -0,0 +1,19 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Bluesky Embed</title>
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png">
<link rel="mask-icon" href="/static/safari-pinned-tab.svg" color="#1185fe">
<meta name="theme-color">
<meta name="application-name" content="Bluesky">
<meta name="generator" content="bskyweb">
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/screens/landing.tsx"></script>
</body>
</html>
+29
View File
@@ -0,0 +1,29 @@
{
"name": "bskyembed",
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"build-snippet": "tsc --project tsconfig.snippet.json",
"lint": "eslint --cache --ext .js,.jsx,.ts,.tsx src"
},
"dependencies": {
"@atproto/api": "^0.12.2",
"@preact/preset-vite": "^2.8.2",
"@vitejs/plugin-legacy": "^5.3.2",
"preact": "^10.4.8",
"terser": "^5.30.3"
},
"devDependencies": {
"autoprefixer": "^10.4.19",
"eslint": "^8.19.0",
"eslint-config-preact": "^1.3.0",
"eslint-plugin-simple-import-sort": "^12.0.0",
"postcss": "^8.4.38",
"tailwindcss": "^3.4.3",
"typescript": "^4.0.5",
"vite": "^5.2.8",
"vite-tsconfig-paths": "^4.3.2"
}
}
+19
View File
@@ -0,0 +1,19 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Bluesky Embed</title>
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png">
<link rel="mask-icon" href="/static/safari-pinned-tab.svg" color="#1185fe">
<meta name="theme-color">
<meta name="application-name" content="Bluesky">
<meta name="generator" content="bskyweb">
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/screens/post.tsx"></script>
</body>
</html>
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+100
View File
@@ -0,0 +1,100 @@
// eslint-disable-next-line @typescript-eslint/no-unused-vars
interface Window {
bluesky: {
scan: (element?: Pick<Element, 'querySelectorAll'>) => void
}
}
const EMBED_URL = 'https://embed.bsky.app'
window.bluesky = window.bluesky || {
scan,
}
/**
* Listen for messages from the Bluesky embed iframe and adjust the height of
* the iframe accordingly.
*/
window.addEventListener('message', event => {
if (event.origin !== EMBED_URL) {
return
}
const id = (event.data as {id: string}).id
if (!id) {
return
}
const embed = document.querySelector<HTMLIFrameElement>(
`[data-bluesky-id="${id}"]`,
)
if (!embed) {
return
}
const height = (event.data as {height: number}).height
if (height) {
embed.style.height = `${height}px`
}
})
/**
* Scan the document for all elements with the data-bluesky-aturi attribute,
* and initialize them as Bluesky embeds.
*
* @param element Only scan this specific element @default document @optional
* @returns
*/
function scan(node = document) {
const embeds = node.querySelectorAll('[data-bluesky-uri]')
for (let i = 0; i < embeds.length; i++) {
const id = String(Math.random()).slice(2)
const embed = embeds[i]
const aturi = embed.getAttribute('data-bluesky-uri')
if (!aturi) {
continue
}
const ref_url = location.origin + location.pathname
const searchParams = new URLSearchParams()
searchParams.set('id', id)
if (ref_url.startsWith('http')) {
searchParams.set('ref_url', encodeURIComponent(ref_url))
}
const iframe = document.createElement('iframe')
iframe.setAttribute('data-bluesky-id', id)
iframe.src = `${EMBED_URL}/embed/${aturi.slice(
'at://'.length,
)}?${searchParams.toString()}`
iframe.width = '100%'
iframe.style.border = 'none'
iframe.style.display = 'block'
iframe.style.flexGrow = '1'
iframe.frameBorder = '0'
iframe.scrolling = 'no'
const container = document.createElement('div')
container.style.maxWidth = '600px'
container.style.width = '100%'
container.style.marginTop = '10px'
container.style.marginBottom = '10px'
container.style.display = 'flex'
container.className = 'bluesky-embed'
container.appendChild(iframe)
embed.replaceWith(container)
}
}
if (['interactive', 'complete'].indexOf(document.readyState) !== -1) {
scan()
} else {
document.addEventListener('DOMContentLoaded', () => scan())
}
+55
View File
@@ -0,0 +1,55 @@
import {ComponentChildren, h} from 'preact'
import {useEffect, useRef} from 'preact/hooks'
import {Link} from './link'
export function Container({
children,
href,
}: {
children: ComponentChildren
href?: string
}) {
const ref = useRef<HTMLDivElement>(null)
const prevHeight = useRef(0)
useEffect(() => {
if (ref.current) {
const observer = new ResizeObserver(entries => {
const entry = entries[0]
if (!entry) return
let {height} = entry.contentRect
height += 2 // border top and bottom
if (height !== prevHeight.current) {
prevHeight.current = height
window.parent.postMessage(
{height, id: new URLSearchParams(window.location.search).get('id')},
'*',
)
}
})
observer.observe(ref.current)
return () => observer.disconnect()
}
}, [])
return (
<div
ref={ref}
className="w-full bg-white hover:bg-neutral-50 relative transition-colors max-w-[600px] min-w-[300px] flex border rounded-xl"
onClick={() => {
if (ref.current && href) {
// forwardRef requires preact/compat - let's keep it simple
// to keep the bundle size down
const anchor = ref.current.querySelector('a')
if (anchor) {
anchor.click()
}
}
}}>
{href && <Link href={href} />}
<div className="flex-1 px-4 pt-3 pb-2.5">{children}</div>
</div>
)
}
+351
View File
@@ -0,0 +1,351 @@
import {
AppBskyEmbedExternal,
AppBskyEmbedImages,
AppBskyEmbedRecord,
AppBskyEmbedRecordWithMedia,
AppBskyFeedDefs,
AppBskyFeedPost,
AppBskyGraphDefs,
AppBskyLabelerDefs,
} from '@atproto/api'
import {ComponentChildren, h} from 'preact'
import {useMemo} from 'preact/hooks'
import infoIcon from '../../assets/circleInfo_stroke2_corner0_rounded.svg'
import {CONTENT_LABELS, labelsToInfo} from '../labels'
import {getRkey} from '../utils'
import {Link} from './link'
export function Embed({
content,
labels,
hideRecord,
}: {
content: AppBskyFeedDefs.PostView['embed']
labels: AppBskyFeedDefs.PostView['labels']
hideRecord?: boolean
}) {
const labelInfo = useMemo(() => labelsToInfo(labels), [labels])
if (!content) return null
try {
// Case 1: Image
if (AppBskyEmbedImages.isView(content)) {
return <ImageEmbed content={content} labelInfo={labelInfo} />
}
// Case 2: External link
if (AppBskyEmbedExternal.isView(content)) {
return <ExternalEmbed content={content} labelInfo={labelInfo} />
}
// Case 3: Record (quote or linked post)
if (AppBskyEmbedRecord.isView(content)) {
if (hideRecord) {
return null
}
const record = content.record
// Case 3.1: Post
if (AppBskyEmbedRecord.isViewRecord(record)) {
const pwiOptOut = !!record.author.labels?.find(
label => label.val === '!no-unauthenticated',
)
if (pwiOptOut) {
return (
<Info>
The author of the quoted post has requested their posts not be
displayed on external sites.
</Info>
)
}
let text
if (AppBskyFeedPost.isRecord(record.value)) {
text = record.value.text
}
const isAuthorLabeled = record.author.labels?.some(label =>
CONTENT_LABELS.includes(label.val),
)
return (
<Link
href={`/profile/${record.author.did}/post/${getRkey(record)}`}
className="transition-colors hover:bg-neutral-100 border rounded-lg p-2 gap-1.5 w-full flex flex-col">
<div className="flex gap-1.5 items-center">
<div className="w-4 h-4 overflow-hidden rounded-full bg-neutral-300 shrink-0">
<img
src={record.author.avatar}
style={isAuthorLabeled ? {filter: 'blur(1.5px)'} : undefined}
/>
</div>
<p className="line-clamp-1 text-sm">
<span className="font-bold">{record.author.displayName}</span>
<span className="text-textLight ml-1">
@{record.author.handle}
</span>
</p>
</div>
{text && <p className="text-sm">{text}</p>}
{record.embeds?.map(embed => (
<Embed
key={embed.$type}
content={embed}
labels={record.labels}
hideRecord
/>
))}
</Link>
)
}
// Case 3.2: List
if (AppBskyGraphDefs.isListView(record)) {
return (
<GenericWithImage
image={record.avatar}
title={record.name}
href={`/profile/${record.creator.did}/lists/${getRkey(record)}`}
subtitle={
record.purpose === AppBskyGraphDefs.MODLIST
? `Moderation list by @${record.creator.handle}`
: `User list by @${record.creator.handle}`
}
description={record.description}
/>
)
}
// Case 3.3: Feed
if (AppBskyFeedDefs.isGeneratorView(record)) {
return (
<GenericWithImage
image={record.avatar}
title={record.displayName}
href={`/profile/${record.creator.did}/feed/${getRkey(record)}`}
subtitle={`Feed by @${record.creator.handle}`}
description={`Liked by ${record.likeCount ?? 0} users`}
/>
)
}
// Case 3.4: Labeler
if (AppBskyLabelerDefs.isLabelerView(record)) {
return (
<GenericWithImage
image={record.creator.avatar}
title={record.creator.displayName || record.creator.handle}
href={`/profile/${record.creator.did}`}
subtitle="Labeler"
description={`Liked by ${record.likeCount ?? 0} users`}
/>
)
}
// Case 3.5: Post not found
if (AppBskyEmbedRecord.isViewNotFound(record)) {
return <Info>Quoted post not found, it may have been deleted.</Info>
}
// Case 3.6: Post blocked
if (AppBskyEmbedRecord.isViewBlocked(record)) {
return <Info>The quoted post is blocked.</Info>
}
throw new Error('Unknown embed type')
}
// Case 4: Record with media
if (
AppBskyEmbedRecordWithMedia.isView(content) &&
AppBskyEmbedRecord.isViewRecord(content.record.record)
) {
return (
<div className="flex flex-col gap-2">
<Embed
content={content.media}
labels={labels}
hideRecord={hideRecord}
/>
<Embed
content={{
$type: 'app.bsky.embed.record#view',
record: content.record.record,
}}
labels={content.record.record.labels}
hideRecord={hideRecord}
/>
</div>
)
}
throw new Error('Unsupported embed type')
} catch (err) {
return (
<Info>{err instanceof Error ? err.message : 'An error occurred'}</Info>
)
}
}
function Info({children}: {children: ComponentChildren}) {
return (
<div className="w-full rounded-lg border py-2 px-2.5 flex-row flex gap-2 bg-neutral-50">
<img src={infoIcon as string} className="w-4 h-4 shrink-0 mt-0.5" />
<p className="text-sm text-textLight">{children}</p>
</div>
)
}
function ImageEmbed({
content,
labelInfo,
}: {
content: AppBskyEmbedImages.View
labelInfo?: string
}) {
if (labelInfo) {
return <Info>{labelInfo}</Info>
}
switch (content.images.length) {
case 1:
return (
<img
src={content.images[0].thumb}
alt={content.images[0].alt}
className="w-full rounded-lg overflow-hidden object-cover h-auto max-h-[1000px]"
/>
)
case 2:
return (
<div className="flex gap-1 rounded-lg overflow-hidden w-full aspect-[2/1]">
{content.images.map((image, i) => (
<img
key={i}
src={image.thumb}
alt={image.alt}
className="w-1/2 h-full object-cover rounded-sm"
/>
))}
</div>
)
case 3:
return (
<div className="flex gap-1 rounded-lg overflow-hidden w-full aspect-[2/1]">
<img
src={content.images[0].thumb}
alt={content.images[0].alt}
className="flex-[3] object-cover rounded-sm"
/>
<div className="flex flex-col gap-1 flex-[2]">
{content.images.slice(1).map((image, i) => (
<img
key={i}
src={image.thumb}
alt={image.alt}
className="w-full h-full object-cover rounded-sm"
/>
))}
</div>
</div>
)
case 4:
return (
<div className="grid grid-cols-2 gap-1 rounded-lg overflow-hidden">
{content.images.map((image, i) => (
<img
key={i}
src={image.thumb}
alt={image.alt}
className="aspect-square w-full object-cover rounded-sm"
/>
))}
</div>
)
default:
return null
}
}
function ExternalEmbed({
content,
labelInfo,
}: {
content: AppBskyEmbedExternal.View
labelInfo?: string
}) {
function toNiceDomain(url: string): string {
try {
const urlp = new URL(url)
return urlp.host ? urlp.host : url
} catch (e) {
return url
}
}
if (labelInfo) {
return <Info>{labelInfo}</Info>
}
return (
<Link
href={content.external.uri}
className="w-full rounded-lg overflow-hidden border flex flex-col items-stretch">
{content.external.thumb && (
<img
src={content.external.thumb}
className="aspect-[1.91/1] object-cover"
/>
)}
<div className="py-3 px-4">
<p className="text-sm text-textLight line-clamp-1">
{toNiceDomain(content.external.uri)}
</p>
<p className="font-semibold line-clamp-3">{content.external.title}</p>
<p className="text-sm text-textLight line-clamp-2 mt-0.5">
{content.external.description}
</p>
</div>
</Link>
)
}
function GenericWithImage({
title,
subtitle,
href,
image,
description,
}: {
title: string
subtitle: string
href: string
image?: string
description?: string
}) {
return (
<Link
href={href}
className="w-full rounded-lg border py-2 px-3 flex flex-col gap-2">
<div className="flex gap-2.5 items-center">
{image ? (
<img
src={image}
alt={title}
className="w-8 h-8 rounded-md bg-neutral-300 shrink-0"
/>
) : (
<div className="w-8 h-8 rounded-md bg-brand shrink-0" />
)}
<div className="flex-1">
<p className="font-bold text-sm">{title}</p>
<p className="text-textLight text-sm">{subtitle}</p>
</div>
</div>
{description && <p className="text-textLight text-sm">{description}</p>}
</Link>
)
}
+32
View File
@@ -0,0 +1,32 @@
import {h} from 'preact'
export function Link({
href,
className,
...props
}: {
href: string
className?: string
} & h.JSX.HTMLAttributes<HTMLAnchorElement>) {
const searchParam = new URLSearchParams(window.location.search)
const ref_url = searchParam.get('ref_url')
const newSearchParam = new URLSearchParams()
newSearchParam.set('ref_src', 'embed')
if (ref_url) {
newSearchParam.set('ref_url', ref_url)
}
return (
<a
href={`${
href.startsWith('http') ? href : `https://bsky.app${href}`
}?${newSearchParam.toString()}`}
target="_blank"
rel="noopener noreferrer nofollow"
onClick={evt => evt.stopPropagation()}
className={`cursor-pointer ${className || ''}`}
{...props}
/>
)
}
+160
View File
@@ -0,0 +1,160 @@
import {AppBskyFeedDefs, AppBskyFeedPost, RichText} from '@atproto/api'
import {h} from 'preact'
import replyIcon from '../../assets/bubble_filled_stroke2_corner2_rounded.svg'
import likeIcon from '../../assets/heart2_filled_stroke2_corner0_rounded.svg'
import logo from '../../assets/logo.svg'
import repostIcon from '../../assets/repost_stroke2_corner2_rounded.svg'
import {CONTENT_LABELS} from '../labels'
import {getRkey, niceDate} from '../utils'
import {Container} from './container'
import {Embed} from './embed'
import {Link} from './link'
interface Props {
thread: AppBskyFeedDefs.ThreadViewPost
}
export function Post({thread}: Props) {
const post = thread.post
const isAuthorLabeled = post.author.labels?.some(label =>
CONTENT_LABELS.includes(label.val),
)
let record: AppBskyFeedPost.Record | null = null
if (AppBskyFeedPost.isRecord(post.record)) {
record = post.record
}
const href = `/profile/${post.author.did}/post/${getRkey(post)}`
return (
<Container href={href}>
<div className="flex-1 flex-col flex gap-2" lang={record?.langs?.[0]}>
<div className="flex gap-2.5 items-center cursor-pointer">
<Link href={`/profile/${post.author.did}`} className="rounded-full">
<div className="w-10 h-10 overflow-hidden rounded-full bg-neutral-300 shrink-0">
<img
src={post.author.avatar}
style={isAuthorLabeled ? {filter: 'blur(2.5px)'} : undefined}
/>
</div>
</Link>
<div>
<Link
href={`/profile/${post.author.did}`}
className="font-bold text-[17px] leading-5 line-clamp-1 hover:underline underline-offset-2 decoration-2">
<p>{post.author.displayName}</p>
</Link>
<Link
href={`/profile/${post.author.did}`}
className="text-[15px] text-textLight hover:underline line-clamp-1">
<p>@{post.author.handle}</p>
</Link>
</div>
<div className="flex-1" />
<Link
href={href}
className="transition-transform hover:scale-110 shrink-0 self-start">
<img src={logo as string} className="h-8" />
</Link>
</div>
<PostContent record={record} />
<Embed content={post.embed} labels={post.labels} />
<Link href={href}>
<time
datetime={new Date(post.indexedAt).toISOString()}
className="text-textLight mt-1 text-sm hover:underline">
{niceDate(post.indexedAt)}
</time>
</Link>
<div className="border-t w-full pt-2.5 flex items-center gap-5 text-sm cursor-pointer">
{!!post.likeCount && (
<div className="flex items-center gap-2 cursor-pointer">
<img src={likeIcon as string} className="w-5 h-5" />
<p className="font-bold text-neutral-500 mb-px">
{post.likeCount}
</p>
</div>
)}
{!!post.repostCount && (
<div className="flex items-center gap-2 cursor-pointer">
<img src={repostIcon as string} className="w-5 h-5" />
<p className="font-bold text-neutral-500 mb-px">
{post.repostCount}
</p>
</div>
)}
<div className="flex items-center gap-2 cursor-pointer">
<img src={replyIcon as string} className="w-5 h-5" />
<p className="font-bold text-neutral-500 mb-px">Reply</p>
</div>
<div className="flex-1" />
<p className="cursor-pointer text-brand font-bold hover:underline hidden min-[450px]:inline">
{post.replyCount
? `Read ${post.replyCount} ${
post.replyCount > 1 ? 'replies' : 'reply'
} on Bluesky`
: `View on Bluesky`}
</p>
<p className="cursor-pointer text-brand font-bold hover:underline min-[450px]:hidden">
<span className="hidden min-[380px]:inline">View on </span>Bluesky
</p>
</div>
</div>
</Container>
)
}
function PostContent({record}: {record: AppBskyFeedPost.Record | null}) {
if (!record) return null
const rt = new RichText({
text: record.text,
facets: record.facets,
})
const richText = []
let counter = 0
for (const segment of rt.segments()) {
if (segment.isLink() && segment.link) {
richText.push(
<Link
key={counter}
href={segment.link.uri}
className="text-blue-400 hover:underline">
{segment.text}
</Link>,
)
} else if (segment.isMention() && segment.mention) {
richText.push(
<Link
key={counter}
href={`/profile/${segment.mention.did}`}
className="text-blue-500 hover:underline">
{segment.text}
</Link>,
)
} else if (segment.isTag() && segment.tag) {
richText.push(
<Link
key={counter}
href={`/tag/${segment.tag.tag}`}
className="text-blue-500 hover:underline">
{segment.text}
</Link>,
)
} else {
richText.push(segment.text)
}
counter++
}
return (
<p className="min-[300px]:text-lg leading-6 break-word break-words whitespace-pre-wrap">
{richText}
</p>
)
}
+7
View File
@@ -0,0 +1,7 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
.break-word {
word-break: break-word;
}
+21
View File
@@ -0,0 +1,21 @@
import {AppBskyFeedDefs} from '@atproto/api'
export const CONTENT_LABELS = ['porn', 'sexual', 'nudity', 'graphic-media']
export function labelsToInfo(
labels?: AppBskyFeedDefs.PostView['labels'],
): string | undefined {
const label = labels?.find(label => CONTENT_LABELS.includes(label.val))
switch (label?.val) {
case 'porn':
case 'sexual':
return 'Adult Content'
case 'nudity':
return 'Non-sexual Nudity'
case 'graphic-media':
return 'Graphic Media'
default:
return undefined
}
}
+294
View File
@@ -0,0 +1,294 @@
import '../index.css'
import {AppBskyFeedDefs, AppBskyFeedPost, AtUri, BskyAgent} from '@atproto/api'
import {h, render} from 'preact'
import {useEffect, useMemo, useRef, useState} from 'preact/hooks'
import arrowBottom from '../../assets/arrowBottom_stroke2_corner0_rounded.svg'
import logo from '../../assets/logo.svg'
import {Container} from '../components/container'
import {Link} from '../components/link'
import {Post} from '../components/post'
import {niceDate} from '../utils'
const DEFAULT_POST = 'https://bsky.app/profile/emilyliu.me/post/3jzn6g7ixgq2y'
const DEFAULT_URI =
'at://did:plc:vjug55kidv6sye7ykr5faxxn/app.bsky.feed.post/3jzn6g7ixgq2y'
export const EMBED_SERVICE = 'https://embed.bsky.app'
export const EMBED_SCRIPT = `${EMBED_SERVICE}/static/embed.js`
const root = document.getElementById('app')
if (!root) throw new Error('No root element')
const agent = new BskyAgent({
service: 'https://public.api.bsky.app',
})
render(<LandingPage />, root)
function LandingPage() {
const [uri, setUri] = useState('')
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const [thread, setThread] = useState<AppBskyFeedDefs.ThreadViewPost | null>(
null,
)
useEffect(() => {
void (async () => {
setError(null)
setThread(null)
setLoading(true)
try {
let atUri = DEFAULT_URI
if (uri) {
if (uri.startsWith('at://')) {
atUri = uri
} else {
try {
const urlp = new URL(uri)
if (!urlp.hostname.endsWith('bsky.app')) {
throw new Error('Invalid hostname')
}
const split = urlp.pathname.slice(1).split('/')
if (split.length < 4) {
throw new Error('Invalid pathname')
}
const [profile, didOrHandle, type, rkey] = split
if (profile !== 'profile' || type !== 'post') {
throw new Error('Invalid profile or type')
}
let did = didOrHandle
if (!didOrHandle.startsWith('did:')) {
const resolution = await agent.resolveHandle({
handle: didOrHandle,
})
if (!resolution.data.did) {
throw new Error('No DID found')
}
did = resolution.data.did
}
atUri = `at://${did}/app.bsky.feed.post/${rkey}`
} catch (err) {
console.log(err)
throw new Error('Invalid Bluesky URL')
}
}
}
const {data} = await agent.getPostThread({
uri: atUri,
depth: 0,
parentHeight: 0,
})
if (!AppBskyFeedDefs.isThreadViewPost(data.thread)) {
throw new Error('Post not found')
}
const pwiOptOut = !!data.thread.post.author.labels?.find(
label => label.val === '!no-unauthenticated',
)
if (pwiOptOut) {
throw new Error(
'The author of this post has requested their posts not be displayed on external sites.',
)
}
setThread(data.thread)
} catch (err) {
console.error(err)
setError(err instanceof Error ? err.message : 'Invalid Bluesky URL')
} finally {
setLoading(false)
}
})()
}, [uri])
return (
<main className="w-full min-h-screen flex flex-col items-center gap-8 py-14 px-4 md:pt-32">
<Link
href="https://bsky.social/about"
className="transition-transform hover:scale-110">
<img src={logo as string} className="h-10" />
</Link>
<h1 className="text-4xl font-bold text-center">Embed a Bluesky Post</h1>
<input
type="text"
value={uri}
onInput={e => setUri(e.currentTarget.value)}
className="border rounded-lg py-3 w-full max-w-[600px] px-4"
placeholder={DEFAULT_POST}
/>
<img src={arrowBottom as string} className="w-6" />
{loading ? (
<Skeleton />
) : (
<div className="w-full max-w-[600px] gap-8 flex flex-col">
{!error && thread && uri && <Snippet thread={thread} />}
{!error && thread && <Post thread={thread} key={thread.post.uri} />}
{error && (
<div className="w-full border border-red-500 bg-red-50 px-4 py-3 rounded-lg">
<p className="text-red-500 text-center">{error}</p>
</div>
)}
</div>
)}
</main>
)
}
function Skeleton() {
return (
<Container>
<div className="flex-1 flex-col flex gap-2 pb-8">
<div className="flex gap-2.5 items-center">
<div className="w-10 h-10 overflow-hidden rounded-full bg-neutral-100 shrink-0 animate-pulse" />
<div className="flex-1">
<div className="bg-neutral-100 animate-pulse w-64 h-4 rounded" />
<div className="bg-neutral-100 animate-pulse w-32 h-3 mt-1 rounded" />
</div>
</div>
<div className="w-full h-4 mt-2 bg-neutral-100 rounded animate-pulse" />
<div className="w-5/6 h-4 bg-neutral-100 rounded animate-pulse" />
<div className="w-3/4 h-4 bg-neutral-100 rounded animate-pulse" />
</div>
</Container>
)
}
function Snippet({thread}: {thread: AppBskyFeedDefs.ThreadViewPost}) {
const ref = useRef<HTMLInputElement>(null)
const [copied, setCopied] = useState(false)
// reset copied state after 2 seconds
useEffect(() => {
if (copied) {
const timeout = setTimeout(() => {
setCopied(false)
}, 2000)
return () => clearTimeout(timeout)
}
}, [copied])
const snippet = useMemo(() => {
const record = thread.post.record
if (!AppBskyFeedPost.isRecord(record)) {
return ''
}
const lang = record.langs && record.langs.length > 0 ? record.langs[0] : ''
const profileHref = toShareUrl(
['/profile', thread.post.author.did].join('/'),
)
const urip = new AtUri(thread.post.uri)
const href = toShareUrl(
['/profile', thread.post.author.did, 'post', urip.rkey].join('/'),
)
// x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x
// DO NOT ADD ANY NEW INTERPOLATIONS BELOW WITHOUT ESCAPING THEM!
// Also, keep this code synced with the app code in Embed.tsx.
// x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x
return `<blockquote class="bluesky-embed" data-bluesky-uri="${escapeHtml(
thread.post.uri,
)}" data-bluesky-cid="${escapeHtml(thread.post.cid)}"><p lang="${escapeHtml(
lang,
)}">${escapeHtml(record.text)}${
record.embed
? `<br><br><a href="${escapeHtml(href)}">[image or embed]</a>`
: ''
}</p>&mdash; ${escapeHtml(
thread.post.author.displayName || thread.post.author.handle,
)} (<a href="${escapeHtml(profileHref)}">@${escapeHtml(
thread.post.author.handle,
)}</a>) <a href="${escapeHtml(href)}">${escapeHtml(
niceDate(thread.post.indexedAt),
)}</a></blockquote><script async src="${EMBED_SCRIPT}" charset="utf-8"></script>`
}, [thread])
return (
<div className="flex gap-2 w-full">
<input
ref={ref}
type="text"
value={snippet}
className="border rounded-lg py-3 w-full px-4"
readOnly
autoFocus
onFocus={() => {
ref.current?.select()
}}
/>
<button
className="rounded-lg bg-brand text-white color-white py-3 px-4 whitespace-nowrap min-w-28"
onClick={() => {
ref.current?.focus()
ref.current?.select()
void navigator.clipboard.writeText(snippet)
setCopied(true)
}}>
{copied ? 'Copied!' : 'Copy code'}
</button>
</div>
)
}
function toShareUrl(path: string) {
return `https://bsky.app${path}?ref_src=embed`
}
/**
* Based on a snippet of code from React, which itself was based on the escape-html library.
* Copyright (c) Meta Platforms, Inc. and affiliates
* Copyright (c) 2012-2013 TJ Holowaychuk
* Copyright (c) 2015 Andreas Lubbe
* Copyright (c) 2015 Tiancheng "Timothy" Gu
* Licensed as MIT.
*/
const matchHtmlRegExp = /["'&<>]/
function escapeHtml(string: string) {
const str = String(string)
const match = matchHtmlRegExp.exec(str)
if (!match) {
return str
}
let escape
let html = ''
let index
let lastIndex = 0
for (index = match.index; index < str.length; index++) {
switch (str.charCodeAt(index)) {
case 34: // "
escape = '&quot;'
break
case 38: // &
escape = '&amp;'
break
case 39: // '
escape = '&#x27;'
break
case 60: // <
escape = '&lt;'
break
case 62: // >
escape = '&gt;'
break
default:
continue
}
if (lastIndex !== index) {
html += str.slice(lastIndex, index)
}
lastIndex = index + 1
html += escape
}
return lastIndex !== index ? html + str.slice(lastIndex, index) : html
}
+88
View File
@@ -0,0 +1,88 @@
import '../index.css'
import {AppBskyFeedDefs, BskyAgent} from '@atproto/api'
import {h, render} from 'preact'
import logo from '../../assets/logo.svg'
import {Container} from '../components/container'
import {Link} from '../components/link'
import {Post} from '../components/post'
import {getRkey} from '../utils'
const root = document.getElementById('app')
if (!root) throw new Error('No root element')
const agent = new BskyAgent({
service: 'https://public.api.bsky.app',
})
const uri = `at://${window.location.pathname.slice('/embed/'.length)}`
console.log(uri)
if (!uri) {
throw new Error('No uri in path')
}
agent
.getPostThread({
uri,
depth: 0,
parentHeight: 0,
})
.then(({data}) => {
if (!AppBskyFeedDefs.isThreadViewPost(data.thread)) {
throw new Error('Expected a ThreadViewPost')
}
const pwiOptOut = !!data.thread.post.author.labels?.find(
label => label.val === '!no-unauthenticated',
)
if (pwiOptOut) {
render(<PwiOptOut thread={data.thread} />, root)
} else {
render(<Post thread={data.thread} />, root)
}
})
.catch(err => {
console.error(err)
render(<ErrorMessage />, root)
})
function PwiOptOut({thread}: {thread: AppBskyFeedDefs.ThreadViewPost}) {
const href = `/profile/${thread.post.author.did}/post/${getRkey(thread.post)}`
return (
<Container href={href}>
<Link
href={href}
className="transition-transform hover:scale-110 absolute top-4 right-4">
<img src={logo as string} className="h-6" />
</Link>
<div className="w-full py-12 gap-4 flex flex-col items-center">
<p className="max-w-80 text-center w-full text-textLight">
The author of this post has requested their posts not be displayed on
external sites.
</p>
<Link
href={href}
className="max-w-80 rounded-lg bg-brand text-white color-white text-center py-1 px-4 w-full mx-auto">
View on Bluesky
</Link>
</div>
</Container>
)
}
function ErrorMessage() {
return (
<Container href="https://bsky.app/">
<Link
href="https://bsky.app/"
className="transition-transform hover:scale-110 absolute top-4 right-4">
<img src={logo as string} className="h-6" />
</Link>
<p className="my-16 text-center w-full text-textLight">
Post not found, it may have been deleted.
</p>
</Container>
)
}
+18
View File
@@ -0,0 +1,18 @@
import {AtUri} from '@atproto/api'
export function niceDate(date: number | string | Date) {
const d = new Date(date)
return `${d.toLocaleDateString('en-us', {
year: 'numeric',
month: 'short',
day: 'numeric',
})} at ${d.toLocaleTimeString(undefined, {
hour: 'numeric',
minute: '2-digit',
})}`
}
export function getRkey({uri}: {uri: string}): string {
const at = new AtUri(uri)
return at.rkey
}
+13
View File
@@ -0,0 +1,13 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {
colors: {
brand: 'rgb(10,122,255)',
textLight: 'rgb(66,87,108)',
},
},
},
plugins: [],
}
+24
View File
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES5",
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"types": ["vite/client"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": false,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react",
"jsxFactory": "h",
"jsxFragmentFactory": "Fragment",
"downlevelIteration": true
},
"include": ["src"]
}
+10
View File
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"target": "ES5",
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"strict": true,
"outDir": "dist"
},
"include": ["snippet"],
}
+27
View File
@@ -0,0 +1,27 @@
import {resolve} from 'node:path'
import preact from '@preact/preset-vite'
import legacy from '@vitejs/plugin-legacy'
import type {UserConfig} from 'vite'
import paths from 'vite-tsconfig-paths'
const config: UserConfig = {
plugins: [
preact(),
paths(),
legacy({
targets: ['defaults', 'not IE 11'],
}),
],
build: {
assetsDir: 'static',
rollupOptions: {
input: {
index: resolve(__dirname, 'index.html'),
post: resolve(__dirname, 'post.html'),
},
},
},
}
export default config
+4194
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -9,6 +9,10 @@ static/js/*.js
static/js/*.map
static/js/*.js.LICENSE.txt
templates/scripts.html
templates/*-embed.html
static/embed/*.html
static/embed/assets/*.js
static/embed/assets/*.css
# Don't ignore this file
!.gitignore
+26
View File
@@ -220,6 +220,32 @@
.nativeDropdown-item:focus {
outline: none;
}
/* Spinner component */
@keyframes rotate {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.rotate-500ms {
position: absolute;
inset:0;
animation: rotate 500ms linear infinite;
}
@keyframes avatarHoverFadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes avatarHoverFadeOut {
from { opacity: 1; }
to { opacity: 0; }
}
</style>
</style>
{% include "scripts.html" %}
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png">
+1 -2
View File
@@ -83,11 +83,10 @@ To run the build with Go, use staging credentials, your own, or any other accoun
```
cd social-app
yarn && yarn build-web
cp ./web-build/static/js/*.* bskyweb/static/js/
cd bskyweb/
go mod tidy
go build -v -tags timetzdata -o bskyweb ./cmd/bskyweb
./bskyweb serve --pds-host=https://staging.bsky.dev --handle=<HANDLE> --password=<PASSWORD>
./bskyweb serve --appview-host=https://public.api.bsky.app
```
On build success, access the application at [http://localhost:8100/](http://localhost:8100/). Subsequent changes require re-running the above steps in order to be reflected.
+403 -1
View File
@@ -199,7 +199,7 @@ describe('avoid-unwrapped-text', () => {
{
code: `
<View prop={
<View propText={
<Trans><Text>foo</Text></Trans>
}>
<Bar />
@@ -246,6 +246,205 @@ describe('avoid-unwrapped-text', () => {
</Foo>
`,
},
{
code: `
function Stuff() {
return <Text>foo</Text>
}
`,
},
{
code: `
function Stuff({ foo }) {
return <View>{foo}</View>
}
`,
},
{
code: `
function MyText() {
return <Text>foo</Text>
}
`,
},
{
code: `
function MyText({ foo }) {
if (foo) {
return <Text>foo</Text>
}
return <Text>foo</Text>
}
`,
},
{
code: `
<View>
<Text>{'foo'}</Text>
</View>
`,
},
{
code: `
<View>
<Text>{foo + 'foo'}</Text>
</View>
`,
},
{
code: `
<View>
<Text><Trans>{'foo'}</Trans></Text>
</View>
`,
},
{
code: `
<View>
{foo['bar'] && <Bar />}
</View>
`,
},
{
code: `
<View>
{(foo === 'bar') && <Bar />}
</View>
`,
},
{
code: `
<View>
{(foo !== 'bar') && <Bar />}
</View>
`,
},
{
code: `
<View>
<Text>{\`foo\`}</Text>
</View>
`,
},
{
code: `
<View>
<Text><Trans>{\`foo\`}</Trans></Text>
</View>
`,
},
{
code: `
<View>
<Text>{_(msg\`foo\`)}</Text>
</View>
`,
},
{
code: `
<View>
<Text><Trans>{_(msg\`foo\`)}</Trans></Text>
</View>
`,
},
{
code: `
<Foo>
<View prop={stuff('foo')}>
<Bar />
</View>
</Foo>
`,
},
{
code: `
<Foo>
<View onClick={() => stuff('foo')}>
<Bar />
</View>
</Foo>
`,
},
{
code: `
<View>
{renderItem('foo')}
</View>
`,
},
{
code: `
<View>
{foo === 'foo' && <Bar />}
</View>
`,
},
{
code: `
<View>
{foo['foo'] && <Bar />}
</View>
`,
},
{
code: `
<View>
{check('foo') && <Bar />}
</View>
`,
},
{
code: `
<View>
{foo.bar && <Bar />}
</View>
`,
},
{
code: `
<Text>
<Trans>{renderItem('foo')}</Trans>
</Text>
`,
},
{
code: `
<View>
{null}
</View>
`,
},
{
code: `
<Text>
<Trans>{null}</Trans>
</Text>
`,
},
],
invalid: [
@@ -390,6 +589,209 @@ describe('avoid-unwrapped-text', () => {
`,
errors: 1,
},
{
code: `
function MyText() {
return <Foo />
}
`,
errors: 1,
},
{
code: `
function MyText({ foo }) {
return <Foo>{foo}</Foo>
}
`,
errors: 1,
},
{
code: `
function MyText({ foo }) {
if (foo) {
return <Foo>{foo}</Foo>
}
return <Text>foo</Text>
}
`,
errors: 1,
},
{
code: `
<View>
{'foo'}
</View>
`,
errors: 1,
},
{
code: `
<View>
{foo && 'foo'}
</View>
`,
errors: 1,
},
{
code: `
<View>
<Trans>{'foo'}</Trans>
</View>
`,
errors: 1,
},
{
code: `
<View>
{foo && <Trans>{'foo'}</Trans>}
</View>
`,
errors: 1,
},
{
code: `
<View>
{10}
</View>
`,
errors: 1,
},
{
code: `
<View>
<Trans>{10}</Trans>
</View>
`,
errors: 1,
},
{
code: `
<View>
<Trans>{foo + 10}</Trans>
</View>
`,
errors: 1,
},
{
code: `
<View>
{\`foo\`}
</View>
`,
errors: 1,
},
{
code: `
<View>
<Trans>{\`foo\`}</Trans>
</View>
`,
errors: 1,
},
{
code: `
<View>
<Trans>{foo + \`foo\`}</Trans>
</View>
`,
errors: 1,
},
{
code: `
<View>
{_(msg\`foo\`)}
</View>
`,
errors: 1,
},
{
code: `
<View>
{foo + _(msg\`foo\`)}
</View>
`,
errors: 1,
},
{
code: `
<View>
<Trans>{_(msg\`foo\`)}</Trans>
</View>
`,
errors: 1,
},
{
code: `
<View>
<Trans>{foo + _(msg\`foo\`)}</Trans>
</View>
`,
errors: 1,
},
{
code: `
<View>
<Trans>foo</Trans>
</View>
`,
errors: 1,
},
{
code: `
<View>
<Trans><Trans>foo</Trans></Trans>
</View>
`,
errors: 1,
},
{
code: `
<View>
<Trans>{foo}</Trans>
</View>
`,
errors: 1,
},
{
code: `
<View>
<Trans>{'foo'}</Trans>
</View>
`,
errors: 1,
},
{
code: `
<View prop={
<Trans><Text>foo</Text></Trans>
}>
<Bar />
</View>
`,
errors: 1,
},
],
}
+226 -6
View File
@@ -33,8 +33,14 @@ exports.create = function create(context) {
const options = context.options[0] || {}
const impliedTextProps = options.impliedTextProps ?? []
const impliedTextComponents = options.impliedTextComponents ?? []
const suggestedTextWrappers = options.suggestedTextWrappers ?? {}
const textProps = [...impliedTextProps]
const textComponents = ['Text', ...impliedTextComponents]
function isTextComponent(tagName) {
return textComponents.includes(tagName) || tagName.endsWith('Text')
}
return {
JSXText(node) {
if (typeof node.value !== 'string' || hasOnlyLineBreak(node.value)) {
@@ -44,18 +50,18 @@ exports.create = function create(context) {
while (parent) {
if (parent.type === 'JSXElement') {
const tagName = getTagName(parent)
if (textComponents.includes(tagName) || tagName.endsWith('Text')) {
if (isTextComponent(tagName)) {
// We're good.
return
}
if (tagName === 'Trans') {
// Skip over it and check above.
// Exit and rely on the traversal for <Trans> JSXElement (code below).
// TODO: Maybe validate that it's present.
parent = parent.parent
continue
return
}
let message = 'Wrap this string in <Text>.'
if (tagName !== 'View') {
const suggestedWrapper = suggestedTextWrappers[tagName]
let message = `Wrap this string in <${suggestedWrapper ?? 'Text'}>.`
if (tagName !== 'View' && !suggestedWrapper) {
message +=
' If <' +
tagName +
@@ -107,5 +113,219 @@ exports.create = function create(context) {
continue
}
},
Literal(node) {
if (typeof node.value !== 'string' && typeof node.value !== 'number') {
return
}
let parent = node.parent
while (parent) {
if (parent.type === 'JSXElement') {
const tagName = getTagName(parent)
if (isTextComponent(tagName)) {
// We're good.
return
}
if (tagName === 'Trans') {
// Exit and rely on the traversal for <Trans> JSXElement (code below).
// TODO: Maybe validate that it's present.
return
}
const suggestedWrapper = suggestedTextWrappers[tagName]
let message = `Wrap this string in <${suggestedWrapper ?? 'Text'}>.`
if (tagName !== 'View' && !suggestedWrapper) {
message +=
' If <' +
tagName +
'> is guaranteed to render <Text>, ' +
'rename it to <' +
tagName +
'Text> or add it to impliedTextComponents.'
}
context.report({
node,
message,
})
return
}
if (parent.type === 'BinaryExpression' && parent.operator === '+') {
parent = parent.parent
continue
}
if (
parent.type === 'JSXExpressionContainer' ||
parent.type === 'LogicalExpression'
) {
parent = parent.parent
continue
}
// Be conservative for other types.
return
}
},
TemplateLiteral(node) {
let parent = node.parent
while (parent) {
if (parent.type === 'JSXElement') {
const tagName = getTagName(parent)
if (isTextComponent(tagName)) {
// We're good.
return
}
if (tagName === 'Trans') {
// Exit and rely on the traversal for <Trans> JSXElement (code below).
// TODO: Maybe validate that it's present.
return
}
const suggestedWrapper = suggestedTextWrappers[tagName]
let message = `Wrap this string in <${suggestedWrapper ?? 'Text'}>.`
if (tagName !== 'View' && !suggestedWrapper) {
message +=
' If <' +
tagName +
'> is guaranteed to render <Text>, ' +
'rename it to <' +
tagName +
'Text> or add it to impliedTextComponents.'
}
context.report({
node,
message,
})
return
}
if (
parent.type === 'CallExpression' &&
parent.callee.type === 'Identifier' &&
parent.callee.name === '_'
) {
// This is a user-facing string, keep going up.
parent = parent.parent
continue
}
if (parent.type === 'BinaryExpression' && parent.operator === '+') {
parent = parent.parent
continue
}
if (
parent.type === 'JSXExpressionContainer' ||
parent.type === 'LogicalExpression' ||
parent.type === 'TaggedTemplateExpression'
) {
parent = parent.parent
continue
}
// Be conservative for other types.
return
}
},
JSXElement(node) {
if (getTagName(node) !== 'Trans') {
return
}
let parent = node.parent
while (parent) {
if (parent.type === 'JSXElement') {
const tagName = getTagName(parent)
if (isTextComponent(tagName)) {
// We're good.
return
}
if (tagName === 'Trans') {
// Exit and rely on the traversal for this JSXElement.
// TODO: Should nested <Trans> even be allowed?
return
}
const suggestedWrapper = suggestedTextWrappers[tagName]
let message = `Wrap this <Trans> in <${suggestedWrapper ?? 'Text'}>.`
if (tagName !== 'View' && !suggestedWrapper) {
message +=
' If <' +
tagName +
'> is guaranteed to render <Text>, ' +
'rename it to <' +
tagName +
'Text> or add it to impliedTextComponents.'
}
context.report({
node,
message,
})
return
}
if (
parent.type === 'JSXAttribute' &&
parent.name.type === 'JSXIdentifier' &&
parent.parent.type === 'JSXOpeningElement' &&
parent.parent.parent.type === 'JSXElement'
) {
const tagName = getTagName(parent.parent.parent)
const propName = parent.name.name
if (
textProps.includes(tagName + ' ' + propName) ||
propName === 'text' ||
propName.endsWith('Text')
) {
// We're good.
return
}
const message =
'Wrap this <Trans> in <Text>.' +
' If `' +
propName +
'` is guaranteed to be wrapped in <Text>, ' +
'rename it to `' +
propName +
'Text' +
'` or add it to impliedTextProps.'
context.report({
node,
message,
})
return
}
parent = parent.parent
continue
}
},
ReturnStatement(node) {
let fnScope = context.getScope()
while (fnScope && fnScope.type !== 'function') {
fnScope = fnScope.upper
}
if (!fnScope) {
return
}
const fn = fnScope.block
if (!fn.id || fn.id.type !== 'Identifier' || !fn.id.name) {
return
}
if (!/^[A-Z]\w*Text$/.test(fn.id.name)) {
return
}
if (!node.argument || node.argument.type !== 'JSXElement') {
return
}
const openingEl = node.argument.openingElement
if (openingEl.name.type !== 'JSXIdentifier') {
return
}
const returnedComponentName = openingEl.name.name
if (!isTextComponent(returnedComponentName)) {
context.report({
node,
message:
'Components ending with *Text must return <Text> or <SomeText>.',
})
}
},
}
}
+1
View File
@@ -3,5 +3,6 @@
module.exports = {
rules: {
'avoid-unwrapped-text': require('./avoid-unwrapped-text'),
'use-typed-gates': require('./use-typed-gates'),
},
}
+30
View File
@@ -0,0 +1,30 @@
'use strict'
exports.create = function create(context) {
return {
ImportSpecifier(node) {
if (
!node.local ||
node.local.type !== 'Identifier' ||
node.local.name !== 'useGate'
) {
return
}
if (
node.parent.type !== 'ImportDeclaration' ||
!node.parent.source ||
node.parent.source.type !== 'Literal'
) {
return
}
const source = node.parent.source.value
if (source.startsWith('statsig') || source.startsWith('@statsig')) {
context.report({
node,
message:
"Use useGate() from '#/lib/statsig/statsig' instead of the one on npm.",
})
}
},
}
}
+59 -96
View File
@@ -1,8 +1,8 @@
import {AtUri, BskyAgent} from '@atproto/api'
import {TestBsky, TestNetwork} from '@atproto/dev-env'
import fs from 'fs'
import net from 'net'
import path from 'path'
import fs from 'fs'
import {TestNetwork, TestPds} from '@atproto/dev-env'
import {AtUri, BskyAgent} from '@atproto/api'
export interface TestUser {
email: string
@@ -55,12 +55,8 @@ class StringIdGenerator {
const ids = new StringIdGenerator()
export async function createServer(
{
inviteRequired,
phoneRequired,
}: {inviteRequired: boolean; phoneRequired: boolean} = {
{inviteRequired}: {inviteRequired: boolean} = {
inviteRequired: false,
phoneRequired: false,
},
): Promise<TestPDS> {
const port = 3000
@@ -69,23 +65,11 @@ export async function createServer(
const pdsUrl = `http://localhost:${port}`
const id = ids.next()
const phoneParams = phoneRequired
? {
phoneVerificationRequired: true,
phoneVerificationProvider: 'twilio',
twilioAccountSid: 'ACXXXXXXX',
twilioAuthToken: 'AUTH',
twilioServiceSid: 'VAXXXXXXXX',
}
: {}
const testNet = await TestNetwork.create({
pds: {
port,
hostname: 'localhost',
dbPostgresSchema: `pds_${id}`,
inviteRequired,
...phoneParams,
},
bsky: {
dbPostgresSchema: `bsky_${id}`,
@@ -94,36 +78,33 @@ export async function createServer(
},
plc: {port: port2},
})
mockTwilio(testNet.pds)
// add the test mod authority
if (!phoneRequired) {
const agent = new BskyAgent({service: pdsUrl})
const res = await agent.api.com.atproto.server.createAccount({
email: 'mod-authority@test.com',
handle: 'mod-authority.test',
password: 'hunter2',
})
agent.api.setHeader('Authorization', `Bearer ${res.data.accessJwt}`)
await agent.api.app.bsky.actor.profile.create(
{repo: res.data.did},
{
displayName: 'Dev-env Moderation',
description: `The pretend version of mod.bsky.app`,
},
)
const agent = new BskyAgent({service: pdsUrl})
const res = await agent.api.com.atproto.server.createAccount({
email: 'mod-authority@test.com',
handle: 'mod-authority.test',
password: 'hunter2',
})
agent.api.setHeader('Authorization', `Bearer ${res.data.accessJwt}`)
await agent.api.app.bsky.actor.profile.create(
{repo: res.data.did},
{
displayName: 'Dev-env Moderation',
description: `The pretend version of mod.bsky.app`,
},
)
await agent.api.app.bsky.labeler.service.create(
{repo: res.data.did, rkey: 'self'},
{
policies: {
labelValues: ['!hide', '!warn'],
labelValueDefinitions: [],
},
createdAt: new Date().toISOString(),
await agent.api.app.bsky.labeler.service.create(
{repo: res.data.did, rkey: 'self'},
{
policies: {
labelValues: ['!hide', '!warn'],
labelValueDefinitions: [],
},
)
}
createdAt: new Date().toISOString(),
},
)
const pic = fs.readFileSync(
path.join(__dirname, '..', 'assets', 'default-avatar.png'),
@@ -181,7 +162,7 @@ class Mocker {
const inviteRes = await agent.api.com.atproto.server.createInviteCode(
{useCount: 1},
{
headers: this.pds.adminAuthHeaders('admin'),
headers: this.pds.adminAuthHeaders(),
encoding: 'application/json',
},
)
@@ -192,8 +173,6 @@ class Mocker {
email,
handle: name + '.test',
password: 'hunter2',
verificationPhone: '1234567890',
verificationCode: '000000',
})
await agent.upsertProfile(async () => {
const blob = await agent.uploadBlob(this.pic, {
@@ -358,7 +337,7 @@ class Mocker {
await agent.api.com.atproto.server.createInviteCode(
{useCount: 1, forAccount},
{
headers: this.pds.adminAuthHeaders('admin'),
headers: this.pds.adminAuthHeaders(),
encoding: 'application/json',
},
)
@@ -373,18 +352,11 @@ class Mocker {
if (!ctx) {
throw new Error('Invalid appview')
}
const labelSrvc = ctx.services.label(ctx.db.getPrimary())
await labelSrvc.createLabels([
{
// @ts-ignore
src: ctx.cfg.labelerDid,
uri: did,
cid: '',
val: label,
neg: false,
cts: new Date().toISOString(),
},
])
await createLabel(this.bsky, {
uri: did,
cid: '',
val: label,
})
}
async labelProfile(label: string, user: string) {
@@ -403,18 +375,11 @@ class Mocker {
if (!ctx) {
throw new Error('Invalid appview')
}
const labelSrvc = ctx.services.label(ctx.db.getPrimary())
await labelSrvc.createLabels([
{
// @ts-ignore
src: ctx.cfg.labelerDid,
uri: profile.uri,
cid: profile.cid,
val: label,
neg: false,
cts: new Date().toISOString(),
},
])
await createLabel(this.bsky, {
uri: profile.uri,
cid: profile.cid,
val: label,
})
}
async labelPost(label: string, {uri, cid}: {uri: string; cid: string}) {
@@ -422,18 +387,11 @@ class Mocker {
if (!ctx) {
throw new Error('Invalid appview')
}
const labelSrvc = ctx.services.label(ctx.db.getPrimary())
await labelSrvc.createLabels([
{
// @ts-ignore
src: ctx.cfg.labelerDid,
uri,
cid,
val: label,
neg: false,
cts: new Date().toISOString(),
},
])
await createLabel(this.bsky, {
uri,
cid,
val: label,
})
}
async createMuteList(user: string, name: string): Promise<string> {
@@ -484,14 +442,19 @@ async function getPort(start = 3000) {
throw new Error('Unable to find an available port')
}
export const mockTwilio = (pds: TestPds) => {
if (!pds.ctx.phoneVerifier) return
pds.ctx.phoneVerifier.sendCode = async (_number: string) => {
// do nothing
}
pds.ctx.phoneVerifier.verifyCode = async (_number: string, code: string) => {
return code === '000000'
}
const createLabel = async (
bsky: TestBsky,
opts: {uri: string; cid: string; val: string},
) => {
await bsky.db.db
.insertInto('label')
.values({
uri: opts.uri,
cid: opts.cid,
val: opts.val,
cts: new Date().toISOString(),
neg: false,
src: 'did:example:labeler',
})
.execute()
}
@@ -0,0 +1,6 @@
{
"platforms": ["ios"],
"ios": {
"modules": ["ExpoScrollForwarderModule"]
}
}
+1
View File
@@ -0,0 +1 @@
export {ExpoScrollForwarderView} from './src/ExpoScrollForwarderView'
@@ -0,0 +1,21 @@
Pod::Spec.new do |s|
s.name = 'ExpoScrollForwarder'
s.version = '1.0.0'
s.summary = 'Forward scroll gesture from UIView to UIScrollView'
s.description = 'Forward scroll gesture from UIView to UIScrollView'
s.author = 'bluesky-social'
s.homepage = 'https://github.com/bluesky-social/social-app'
s.platforms = { :ios => '13.4', :tvos => '13.4' }
s.source = { git: '' }
s.static_framework = true
s.dependency 'ExpoModulesCore'
# Swift/Objective-C compatibility
s.pod_target_xcconfig = {
'DEFINES_MODULE' => 'YES',
'SWIFT_COMPILATION_MODE' => 'wholemodule'
}
s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}"
end
@@ -0,0 +1,13 @@
import ExpoModulesCore
public class ExpoScrollForwarderModule: Module {
public func definition() -> ModuleDefinition {
Name("ExpoScrollForwarder")
View(ExpoScrollForwarderView.self) {
Prop("scrollViewTag") { (view: ExpoScrollForwarderView, prop: Int) in
view.scrollViewTag = prop
}
}
}
}
@@ -0,0 +1,215 @@
import ExpoModulesCore
// This view will be used as a native component. Make sure to inherit from `ExpoView`
// to apply the proper styling (e.g. border radius and shadows).
class ExpoScrollForwarderView: ExpoView, UIGestureRecognizerDelegate {
var scrollViewTag: Int? {
didSet {
self.tryFindScrollView()
}
}
private var rctScrollView: RCTScrollView?
private var rctRefreshCtrl: RCTRefreshControl?
private var cancelGestureRecognizers: [UIGestureRecognizer]?
private var animTimer: Timer?
private var initialOffset: CGFloat = 0.0
private var didImpact: Bool = false
required init(appContext: AppContext? = nil) {
super.init(appContext: appContext)
let pg = UIPanGestureRecognizer(target: self, action: #selector(callOnPan(_:)))
pg.delegate = self
self.addGestureRecognizer(pg)
let tg = UITapGestureRecognizer(target: self, action: #selector(callOnPress(_:)))
tg.isEnabled = false
tg.delegate = self
let lpg = UILongPressGestureRecognizer(target: self, action: #selector(callOnPress(_:)))
lpg.minimumPressDuration = 0.01
lpg.isEnabled = false
lpg.delegate = self
self.cancelGestureRecognizers = [lpg, tg]
}
// We don't want to recognize the scroll pan gesture and the swipe back gesture together
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
if gestureRecognizer is UIPanGestureRecognizer, otherGestureRecognizer is UIPanGestureRecognizer {
return false
}
return true
}
// We only want the "scroll" gesture to happen whenever the pan is vertical, otherwise it will
// interfere with the native swipe back gesture.
override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
guard let gestureRecognizer = gestureRecognizer as? UIPanGestureRecognizer else {
return true
}
let velocity = gestureRecognizer.velocity(in: self)
return abs(velocity.y) > abs(velocity.x)
}
// This will be used to cancel the scroll animation whenever we tap inside of the header. We don't need another
// recognizer for this one.
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
self.stopTimer()
}
// This will be used to cancel the animation whenever we press inside of the scroll view. We don't want to change
// the scroll view gesture's delegate, so we add an additional recognizer to detect this.
@IBAction func callOnPress(_ sender: UITapGestureRecognizer) -> Void {
self.stopTimer()
}
@IBAction func callOnPan(_ sender: UIPanGestureRecognizer) -> Void {
guard let rctsv = self.rctScrollView, let sv = rctsv.scrollView else {
return
}
let translation = sender.translation(in: self).y
if sender.state == .began {
if sv.contentOffset.y < 0 {
sv.contentOffset.y = 0
}
self.initialOffset = sv.contentOffset.y
}
if sender.state == .changed {
sv.contentOffset.y = self.dampenOffset(-translation + self.initialOffset)
if sv.contentOffset.y <= -130, !didImpact {
let generator = UIImpactFeedbackGenerator(style: .light)
generator.impactOccurred()
self.didImpact = true
}
}
if sender.state == .ended {
let velocity = sender.velocity(in: self).y
self.didImpact = false
if sv.contentOffset.y <= -130 {
self.rctRefreshCtrl?.forwarderBeginRefreshing()
return
}
// A check for a velocity under 250 prevents animations from occurring when they wouldn't in a normal
// scroll view
if abs(velocity) < 250, sv.contentOffset.y >= 0 {
return
}
self.startDecayAnimation(translation, velocity)
}
}
func startDecayAnimation(_ translation: CGFloat, _ velocity: CGFloat) {
guard let sv = self.rctScrollView?.scrollView else {
return
}
var velocity = velocity
self.enableCancelGestureRecognizers()
if velocity > 0 {
velocity = min(velocity, 5000)
} else {
velocity = max(velocity, -5000)
}
var animTranslation = -translation
self.animTimer = Timer.scheduledTimer(withTimeInterval: 1.0 / 120, repeats: true) { timer in
velocity *= 0.9875
animTranslation = (-velocity / 120) + animTranslation
let nextOffset = self.dampenOffset(animTranslation + self.initialOffset)
if nextOffset <= 0 {
if self.initialOffset <= 1 {
self.scrollToOffset(0)
} else {
sv.contentOffset.y = 0
}
self.stopTimer()
return
} else {
sv.contentOffset.y = nextOffset
}
if abs(velocity) < 5 {
self.stopTimer()
}
}
}
func dampenOffset(_ offset: CGFloat) -> CGFloat {
if offset < 0 {
return offset - (offset * 0.55)
}
return offset
}
func tryFindScrollView() {
guard let scrollViewTag = scrollViewTag else {
return
}
// Before we switch to a different scrollview, we always want to remove the cancel gesture recognizer.
// Otherwise we might end up with duplicates when we switch back to that scrollview.
self.removeCancelGestureRecognizers()
self.rctScrollView = self.appContext?
.findView(withTag: scrollViewTag, ofType: RCTScrollView.self)
self.rctRefreshCtrl = self.rctScrollView?.scrollView.refreshControl as? RCTRefreshControl
self.addCancelGestureRecognizers()
}
func addCancelGestureRecognizers() {
self.cancelGestureRecognizers?.forEach { r in
self.rctScrollView?.scrollView?.addGestureRecognizer(r)
}
}
func removeCancelGestureRecognizers() {
self.cancelGestureRecognizers?.forEach { r in
self.rctScrollView?.scrollView?.removeGestureRecognizer(r)
}
}
func enableCancelGestureRecognizers() {
self.cancelGestureRecognizers?.forEach { r in
r.isEnabled = true
}
}
func disableCancelGestureRecognizers() {
self.cancelGestureRecognizers?.forEach { r in
r.isEnabled = false
}
}
func scrollToOffset(_ offset: Int, animated: Bool = true) -> Void {
self.rctScrollView?.scroll(toOffset: CGPoint(x: 0, y: offset), animated: animated)
}
func stopTimer() -> Void {
self.disableCancelGestureRecognizers()
self.animTimer?.invalidate()
self.animTimer = nil
}
}
@@ -0,0 +1,6 @@
import React from 'react'
export interface ExpoScrollForwarderViewProps {
scrollViewTag: number | null
children: React.ReactNode
}
@@ -0,0 +1,13 @@
import {requireNativeViewManager} from 'expo-modules-core'
import * as React from 'react'
import {ExpoScrollForwarderViewProps} from './ExpoScrollForwarder.types'
const NativeView: React.ComponentType<ExpoScrollForwarderViewProps> =
requireNativeViewManager('ExpoScrollForwarder')
export function ExpoScrollForwarderView({
children,
...rest
}: ExpoScrollForwarderViewProps) {
return <NativeView {...rest}>{children}</NativeView>
}
@@ -0,0 +1,7 @@
import React from 'react'
import {ExpoScrollForwarderViewProps} from './ExpoScrollForwarder.types'
export function ExpoScrollForwarderView({
children,
}: React.PropsWithChildren<ExpoScrollForwarderViewProps>) {
return children
}
+26 -30
View File
@@ -1,6 +1,6 @@
{
"name": "bsky.app",
"version": "1.76.0",
"version": "1.77.0",
"private": true,
"engines": {
"node": ">=18"
@@ -20,6 +20,7 @@
"build-ios": "yarn use-build-number-with-bump eas build -p ios",
"build-android": "yarn use-build-number-with-bump eas build -p android",
"build": "yarn use-build-number-with-bump eas build",
"build-embed": "cd bskyembed && yarn build && cd .. && node ./scripts/post-embed-build.js",
"start": "expo start --dev-client",
"start:prod": "expo start --dev-client --no-dev --minify",
"clean-cache": "rm -rf node_modules/.cache/babel-loader/*",
@@ -35,7 +36,7 @@
"e2e:run": "NODE_ENV=test detox test --configuration ios.sim.debug --take-screenshots all",
"perf:test": "NODE_ENV=test maestro test",
"perf:test:run": "NODE_ENV=test maestro test __e2e__/maestro/scroll.yaml",
"perf:test:measure": "NODE_ENV=test flashlight test --bundleId xyz.blueskyweb.app --testCommand 'yarn perf:test' --duration 150000 --resultsFilePath .perf/results.json",
"perf:test:measure": "NODE_ENV=test flashlight test --bundleId xyz.blueskyweb.app --testCommand \"yarn perf:test\" --duration 150000 --resultsFilePath .perf/results.json",
"perf:test:results": "NODE_ENV=test flashlight report .perf/results.json",
"perf:measure": "NODE_ENV=test flashlight measure",
"intl:build": "yarn intl:extract && yarn intl:compile",
@@ -56,6 +57,8 @@
"@emoji-mart/react": "^1.1.1",
"@expo/html-elements": "^0.4.2",
"@expo/webpack-config": "^19.0.0",
"@floating-ui/dom": "^1.6.3",
"@floating-ui/react-dom": "^2.0.8",
"@fortawesome/fontawesome-svg-core": "^6.1.1",
"@fortawesome/free-regular-svg-icons": "^6.1.1",
"@fortawesome/free-solid-svg-icons": "^6.1.1",
@@ -64,10 +67,7 @@
"@mattermost/react-native-paste-input": "^0.6.4",
"@miblanchard/react-native-slider": "^2.3.1",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@react-native-async-storage/async-storage": "1.21.0",
"@react-native-camera-roll/camera-roll": "^5.2.2",
"@react-native-clipboard/clipboard": "^1.10.0",
"@react-native-community/blur": "^4.3.0",
"@react-native-async-storage/async-storage": "1.23.1",
"@react-native-masked-view/masked-view": "0.3.0",
"@react-native-menu/menu": "^0.8.0",
"@react-native-picker/picker": "2.6.1",
@@ -108,27 +108,31 @@
"email-validator": "^2.0.4",
"emoji-mart": "^5.5.2",
"eventemitter3": "^5.0.1",
"expo": "^50.0.0-preview.10",
"expo-application": "~5.8.2",
"expo-build-properties": "^0.11.0",
"expo-camera": "~14.0.1",
"expo-constants": "~15.4.3",
"expo-dev-client": "~3.3.5",
"expo-device": "~5.9.2",
"expo-image": "~1.10.3",
"expo": "^50.0.8",
"expo-application": "^5.8.3",
"expo-build-properties": "^0.11.1",
"expo-camera": "~14.0.4",
"expo-clipboard": "^5.0.1",
"expo-constants": "~15.4.5",
"expo-dev-client": "~3.3.8",
"expo-device": "~5.9.3",
"expo-haptics": "^12.8.1",
"expo-image": "~1.10.6",
"expo-image-manipulator": "^11.8.0",
"expo-image-picker": "~14.7.1",
"expo-linear-gradient": "^12.7.2",
"expo-linking": "^6.2.2",
"expo-localization": "~14.8.2",
"expo-localization": "~14.8.3",
"expo-media-library": "~15.9.1",
"expo-notifications": "~0.27.3",
"expo-navigation-bar": "~2.8.1",
"expo-notifications": "~0.27.6",
"expo-sharing": "^11.10.0",
"expo-splash-screen": "~0.26.2",
"expo-splash-screen": "~0.26.4",
"expo-status-bar": "~1.11.1",
"expo-system-ui": "~2.9.3",
"expo-task-manager": "~11.7.0",
"expo-updates": "~0.24.7",
"expo-web-browser": "~12.8.1",
"expo-task-manager": "~11.7.2",
"expo-updates": "~0.24.10",
"expo-web-browser": "~12.8.2",
"fast-text-encoding": "^1.0.6",
"history": "^5.3.0",
"js-sha256": "^0.9.0",
@@ -143,7 +147,6 @@
"lodash.samplesize": "^4.2.0",
"lodash.set": "^4.3.2",
"lodash.shuffle": "^4.2.0",
"lru_map": "^0.4.1",
"mobx": "^6.6.1",
"mobx-react-lite": "^3.4.0",
"mobx-utils": "^6.0.6",
@@ -154,20 +157,16 @@
"psl": "^1.9.0",
"react": "18.2.0",
"react-avatar-editor": "^13.0.0",
"react-circular-progressbar": "^2.1.0",
"react-dom": "^18.2.0",
"react-keyed-flatten-children": "^3.0.0",
"react-native": "0.73.2",
"react-native-appstate-hook": "^1.0.6",
"react-native-date-picker": "^4.4.0",
"react-native-drawer-layout": "^4.0.0-alpha.3",
"react-native-fs": "^2.20.0",
"react-native-gesture-handler": "~2.14.0",
"react-native-get-random-values": "~1.8.0",
"react-native-haptic-feedback": "^1.14.0",
"react-native-get-random-values": "~1.11.0",
"react-native-image-crop-picker": "^0.38.1",
"react-native-ios-context-menu": "^1.15.3",
"react-native-linear-gradient": "^2.6.2",
"react-native-pager-view": "6.2.3",
"react-native-picker-select": "^8.1.0",
"react-native-progress": "bluesky-social/react-native-progress",
@@ -179,9 +178,7 @@
"react-native-uitextview": "^1.1.6",
"react-native-url-polyfill": "^1.3.0",
"react-native-uuid": "^2.0.1",
"react-native-version-number": "^0.3.6",
"react-native-web": "~0.19.6",
"react-native-web-linear-gradient": "^1.1.2",
"react-native-web-webview": "^1.0.2",
"react-native-webview": "13.6.4",
"react-responsive": "^9.0.2",
@@ -190,12 +187,11 @@
"statsig-react-native-expo": "^4.6.1",
"tippy.js": "^6.3.7",
"tlds": "^1.234.0",
"use-deep-compare": "^1.1.0",
"zeego": "^1.6.2",
"zod": "^3.20.2"
},
"devDependencies": {
"@atproto/dev-env": "^0.2.28",
"@atproto/dev-env": "^0.3.4",
"@babel/core": "^7.23.2",
"@babel/preset-env": "^7.20.0",
"@babel/runtime": "^7.20.0",
+11
View File
@@ -0,0 +1,11 @@
# Expo Haptics Patch
Whenever we migrated to Expo Haptics, there was a difference between how the previous and new libraries handled the
Android implementation of an iOS "light" haptic. The previous library used the `Vibration` API solely, which does not
have any configuration for intensity of vibration. The `Vibration` API has also been deprecated since SDK 26. See:
https://github.com/mkuczera/react-native-haptic-feedback/blob/master/android/src/main/java/com/mkuczera/vibrateFactory/VibrateWithDuration.java
Expo Haptics is using `VibrationManager` API on SDK >= 31. See: https://github.com/expo/expo/blob/main/packages/expo-haptics/android/src/main/java/expo/modules/haptics/HapticsModule.kt#L19
The timing and intensity of their haptic configurations though differs greatly from the original implementation. This
patch uses the new `VibrationManager` API to create the same vibration that would have been seen in the deprecated
`Vibration` API.
+13
View File
@@ -0,0 +1,13 @@
diff --git a/node_modules/expo-haptics/android/src/main/java/expo/modules/haptics/HapticsModule.kt b/node_modules/expo-haptics/android/src/main/java/expo/modules/haptics/HapticsModule.kt
index 26c52af..b949a4c 100644
--- a/node_modules/expo-haptics/android/src/main/java/expo/modules/haptics/HapticsModule.kt
+++ b/node_modules/expo-haptics/android/src/main/java/expo/modules/haptics/HapticsModule.kt
@@ -42,7 +42,7 @@ class HapticsModule : Module() {
private fun vibrate(type: HapticsVibrationType) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
- vibrator.vibrate(VibrationEffect.createWaveform(type.timings, type.amplitudes, -1))
+ vibrator.vibrate(VibrationEffect.createWaveform(type.oldSDKPattern, intArrayOf(0, 100), -1))
} else {
@Suppress("DEPRECATION")
vibrator.vibrate(type.oldSDKPattern, -1)
+54 -4
View File
@@ -1,11 +1,22 @@
diff --git a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.h b/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.h
index e9b330f..1ecdf0a 100644
--- a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.h
+++ b/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.h
@@ -16,4 +16,6 @@
@property (nonatomic, copy) RCTDirectEventBlock onRefresh;
@property (nonatomic, weak) UIScrollView *scrollView;
+- (void)forwarderBeginRefreshing;
+
@end
diff --git a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.m b/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.m
index b09e653..d290dab 100644
index b09e653..4c32b31 100644
--- a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.m
+++ b/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.m
@@ -198,6 +198,14 @@ - (void)refreshControlValueChanged
@@ -198,9 +198,53 @@ - (void)refreshControlValueChanged
[self setCurrentRefreshingState:super.refreshing];
_refreshingProgrammatically = NO;
+ if (@available(iOS 17.4, *)) {
+ if (_currentRefreshingState) {
+ UIImpactFeedbackGenerator *feedbackGenerator = [[UIImpactFeedbackGenerator alloc] initWithStyle:UIImpactFeedbackStyleLight];
@@ -16,4 +27,43 @@ index b09e653..d290dab 100644
+
if (_onRefresh) {
_onRefresh(nil);
}
}
}
+/*
+ This method is used by Bluesky's ExpoScrollForwarder. This allows other React Native
+ libraries to perform a refresh of a scrollview and access the refresh control's onRefresh
+ function.
+ */
+- (void)forwarderBeginRefreshing
+{
+ _refreshingProgrammatically = NO;
+
+ [self sizeToFit];
+
+ if (!self.scrollView) {
+ return;
+ }
+
+ UIScrollView *scrollView = (UIScrollView *)self.scrollView;
+
+ [UIView animateWithDuration:0.3
+ delay:0
+ options:UIViewAnimationOptionBeginFromCurrentState
+ animations:^(void) {
+ // Whenever we call this method, the scrollview will always be at a position of
+ // -130 or less. Scrolling back to -65 simulates the default behavior of RCTRefreshControl
+ [scrollView setContentOffset:CGPointMake(0, -65)];
+ }
+ completion:^(__unused BOOL finished) {
+ [super beginRefreshing];
+ [self setCurrentRefreshingState:super.refreshing];
+
+ if (self->_onRefresh) {
+ self->_onRefresh(nil);
+ }
+ }
+ ];
+}
+
@end
+10 -2
View File
@@ -1,5 +1,13 @@
# RefreshControl Patch
# ***This second part of this patch is load bearing, do not remove.***
## RefreshControl Patch - iOS 17.4 Haptic Regression
Patching `RCTRefreshControl.mm` temporarily to play an impact haptic on refresh when using iOS 17.4 or higher. Since
17.4, there has been a regression somewhere causing haptics to not play on iOS on refresh. Should monitor for an update
in the RN repo: https://github.com/facebook/react-native/issues/43388
in the RN repo: https://github.com/facebook/react-native/issues/43388
## RefreshControl Path - ScrollForwarder
Patching `RCTRefreshControl.m` and `RCTRefreshControl.h` to add a new `forwarderBeginRefreshing` method to the class.
This method is used by `ExpoScrollForwarder` to initiate a refresh of the underlying `UIScrollView` from inside that
module.
+49
View File
@@ -0,0 +1,49 @@
// const path = require('node:path')
// const fs = require('node:fs')
// const projectRoot = path.join(__dirname, '..')
// // copy embed assets to web-build
// const embedAssetSource = path.join(
// projectRoot,
// 'bskyembed',
// 'dist',
// 'static',
// 'embed',
// 'assets',
// )
// const embedAssetDest = path.join(
// projectRoot,
// 'web-build',
// 'static',
// 'embed',
// 'assets',
// )
// fs.cpSync(embedAssetSource, embedAssetDest, {recursive: true})
// // copy entrypoint(s) to web-build
// // additional entrypoints will need more work, but this'll do for now
// const embedHtmlSource = path.join(
// projectRoot,
// 'bskyembed',
// 'dist',
// 'index.html',
// )
// const embedHtmlDest = path.join(
// projectRoot,
// 'web-build',
// 'static',
// 'embed',
// 'post.html',
// )
// fs.copyFileSync(embedHtmlSource, embedHtmlDest)
// console.log(`Copied embed assets to web-build`)
console.log('post-embed-build.js - waiting for embedr!')
+2 -4
View File
@@ -16,10 +16,9 @@ import {useQueryClient} from '@tanstack/react-query'
import {Provider as StatsigProvider} from '#/lib/statsig/statsig'
import {init as initPersistedState} from '#/state/persisted'
import * as persisted from '#/state/persisted'
import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs'
import {readLastActiveAccount} from '#/state/session/util/readLastActiveAccount'
import {useIntentHandler} from 'lib/hooks/useIntentHandler'
import {useOTAUpdates} from 'lib/hooks/useOTAUpdates'
import {useNotificationsListener} from 'lib/notifications/notifications'
import {QueryProvider} from 'lib/react-query'
import {s} from 'lib/styles'
@@ -58,7 +57,6 @@ function InnerApp() {
const {_} = useLingui()
useIntentHandler()
useOTAUpdates()
// init
useEffect(() => {
@@ -66,7 +64,7 @@ function InnerApp() {
Toast.show(_(msg`Sorry! Your session expired. Please log in again.`))
})
const account = persisted.get('session').currentAccount
const account = readLastActiveAccount()
resumeSession(account)
}, [resumeSession, _])
+2 -2
View File
@@ -7,8 +7,8 @@ import {SafeAreaProvider} from 'react-native-safe-area-context'
import {Provider as StatsigProvider} from '#/lib/statsig/statsig'
import {init as initPersistedState} from '#/state/persisted'
import * as persisted from '#/state/persisted'
import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs'
import {readLastActiveAccount} from '#/state/session/util/readLastActiveAccount'
import {useIntentHandler} from 'lib/hooks/useIntentHandler'
import {QueryProvider} from 'lib/react-query'
import {ThemeProvider} from 'lib/ThemeContext'
@@ -42,7 +42,7 @@ function InnerApp() {
// init
useEffect(() => {
const account = persisted.get('session').currentAccount
const account = readLastActiveAccount()
resumeSession(account)
}, [resumeSession])
+11 -14
View File
@@ -193,7 +193,7 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
<Stack.Screen
name="ProfileFeed"
getComponent={() => ProfileFeedScreen}
options={{title: title(msg`Feed`), requireAuth: true}}
options={{title: title(msg`Feed`)}}
/>
<Stack.Screen
name="ProfileFeedLikedBy"
@@ -331,11 +331,7 @@ function HomeTabNavigator() {
animationDuration: 250,
contentStyle: pal.view,
}}>
<HomeTab.Screen
name="Home"
getComponent={() => HomeScreen}
options={{requireAuth: true}}
/>
<HomeTab.Screen name="Home" getComponent={() => HomeScreen} />
{commonScreens(HomeTab)}
</HomeTab.Navigator>
)
@@ -371,11 +367,7 @@ function FeedsTabNavigator() {
animationDuration: 250,
contentStyle: pal.view,
}}>
<FeedsTab.Screen
name="Feeds"
getComponent={() => FeedsScreen}
options={{requireAuth: true}}
/>
<FeedsTab.Screen name="Feeds" getComponent={() => FeedsScreen} />
{commonScreens(FeedsTab as typeof HomeTab)}
</FeedsTab.Navigator>
)
@@ -451,7 +443,7 @@ const FlatNavigator = () => {
<Flat.Screen
name="Home"
getComponent={() => HomeScreen}
options={{title: title(msg`Home`), requireAuth: true}}
options={{title: title(msg`Home`)}}
/>
<Flat.Screen
name="Search"
@@ -461,7 +453,7 @@ const FlatNavigator = () => {
<Flat.Screen
name="Feeds"
getComponent={() => FeedsScreen}
options={{title: title(msg`Feeds`), requireAuth: true}}
options={{title: title(msg`Feeds`)}}
/>
<Flat.Screen
name="Notifications"
@@ -539,8 +531,10 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
const theme = useColorSchemeStyle(DefaultTheme, DarkTheme)
const {currentAccount} = useSession()
const {openModal} = useModalControls()
const prevLoggedRouteName = React.useRef<string | undefined>(undefined)
function onReady() {
prevLoggedRouteName.current = getCurrentRouteName()
initAnalytics(currentAccount)
if (currentAccount && shouldRequestEmailConfirmation(currentAccount)) {
@@ -555,7 +549,10 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
linking={LINKING}
theme={theme}
onStateChange={() => {
logEvent('router:navigate', {})
logEvent('router:navigate', {
from: prevLoggedRouteName.current,
})
prevLoggedRouteName.current = getCurrentRouteName()
}}
onReady={() => {
attachRouteToLogEvents(getCurrentRouteName)
+67
View File
@@ -0,0 +1,67 @@
import React from 'react'
import {View} from 'react-native'
import RNPickerSelect, {PickerSelectProps} from 'react-native-picker-select'
import {sanitizeAppLanguageSetting} from '#/locale/helpers'
import {APP_LANGUAGES} from '#/locale/languages'
import {useLanguagePrefs, useLanguagePrefsApi} from '#/state/preferences'
import {atoms as a, useTheme} from '#/alf'
import {ChevronBottom_Stroke2_Corner0_Rounded as ChevronDown} from '#/components/icons/Chevron'
export function AppLanguageDropdown() {
const t = useTheme()
const langPrefs = useLanguagePrefs()
const setLangPrefs = useLanguagePrefsApi()
const sanitizedLang = sanitizeAppLanguageSetting(langPrefs.appLanguage)
const onChangeAppLanguage = React.useCallback(
(value: Parameters<PickerSelectProps['onValueChange']>[0]) => {
if (!value) return
if (sanitizedLang !== value) {
setLangPrefs.setAppLanguage(sanitizeAppLanguageSetting(value))
}
},
[sanitizedLang, setLangPrefs],
)
return (
<View style={a.relative}>
<RNPickerSelect
placeholder={{}}
value={sanitizedLang}
onValueChange={onChangeAppLanguage}
items={APP_LANGUAGES.filter(l => Boolean(l.code2)).map(l => ({
label: l.name,
value: l.code2,
key: l.code2,
}))}
useNativeAndroidPickerStyle={false}
style={{
inputAndroid: {
color: t.atoms.text_contrast_medium.color,
fontSize: 16,
paddingRight: 12 + 4,
},
inputIOS: {
color: t.atoms.text.color,
fontSize: 16,
paddingRight: 12 + 4,
},
}}
/>
<View
style={[
a.absolute,
a.inset_0,
{left: 'auto'},
{pointerEvents: 'none'},
a.align_center,
a.justify_center,
]}>
<ChevronDown fill={t.atoms.text.color} size="xs" />
</View>
</View>
)
}
@@ -0,0 +1,62 @@
import React from 'react'
import {View} from 'react-native'
import {sanitizeAppLanguageSetting} from '#/locale/helpers'
import {APP_LANGUAGES} from '#/locale/languages'
import {useLanguagePrefs, useLanguagePrefsApi} from '#/state/preferences'
import {atoms as a, useTheme} from '#/alf'
import {ChevronBottom_Stroke2_Corner0_Rounded as ChevronDown} from '#/components/icons/Chevron'
import {Text} from '#/components/Typography'
export function AppLanguageDropdown() {
const t = useTheme()
const langPrefs = useLanguagePrefs()
const setLangPrefs = useLanguagePrefsApi()
const sanitizedLang = sanitizeAppLanguageSetting(langPrefs.appLanguage)
const onChangeAppLanguage = React.useCallback(
(ev: React.ChangeEvent<HTMLSelectElement>) => {
const value = ev.target.value
if (!value) return
if (sanitizedLang !== value) {
setLangPrefs.setAppLanguage(sanitizeAppLanguageSetting(value))
}
},
[sanitizedLang, setLangPrefs],
)
return (
<View style={[a.flex_row, a.gap_sm, a.align_center, a.flex_shrink]}>
<Text aria-hidden={true} style={t.atoms.text_contrast_medium}>
{APP_LANGUAGES.find(l => l.code2 === sanitizedLang)?.name}
</Text>
<ChevronDown fill={t.atoms.text.color} size="xs" style={a.flex_shrink} />
<select
value={sanitizedLang}
onChange={onChangeAppLanguage}
style={{
cursor: 'pointer',
MozAppearance: 'none',
WebkitAppearance: 'none',
appearance: 'none',
position: 'absolute',
inset: 0,
width: '100%',
color: 'transparent',
background: 'transparent',
border: 0,
padding: 0,
}}>
{APP_LANGUAGES.filter(l => Boolean(l.code2)).map(l => (
<option key={l.code2} value={l.code2}>
{l.name}
</option>
))}
</select>
</View>
)
}
+8 -15
View File
@@ -11,8 +11,7 @@ import {
View,
ViewStyle,
} from 'react-native'
import LinearGradient from 'react-native-linear-gradient'
import {Trans} from '@lingui/macro'
import {LinearGradient} from 'expo-linear-gradient'
import {android, atoms as a, flatten, tokens, useTheme} from '#/alf'
import {Props as SVGIconProps} from '#/components/icons/common'
@@ -59,6 +58,10 @@ export type ButtonState = {
export type ButtonContext = VariantProps & ButtonState
type NonTextElements =
| React.ReactElement
| Iterable<React.ReactElement | null | undefined | boolean>
export type ButtonProps = Pick<
PressableProps,
'disabled' | 'onPress' | 'testID'
@@ -68,11 +71,9 @@ export type ButtonProps = Pick<
testID?: string
label: string
style?: StyleProp<ViewStyle>
children:
| React.ReactNode
| string
| ((context: ButtonContext) => React.ReactNode | string)
children: NonTextElements | ((context: ButtonContext) => NonTextElements)
}
export type ButtonTextProps = TextProps & VariantProps & {disabled?: boolean}
const Context = React.createContext<VariantProps & ButtonState>({
@@ -404,15 +405,7 @@ export function Button({
</View>
)}
<Context.Provider value={context}>
{/* @ts-ignore */}
{typeof children === 'string' || children?.type === Trans ? (
/* @ts-ignore */
<ButtonText>{children}</ButtonText>
) : typeof children === 'function' ? (
children(context)
) : (
children
)}
{typeof children === 'function' ? children(context) : children}
</Context.Provider>
</Pressable>
)
+34 -20
View File
@@ -83,7 +83,7 @@ export function Outer({
const sheetOptions = nativeOptions?.sheet || {}
const hasSnapPoints = !!sheetOptions.snapPoints
const insets = useSafeAreaInsets()
const closeCallback = React.useRef<() => void>()
const closeCallbacks = React.useRef<(() => void)[]>([])
const {setDialogIsOpen} = useDialogStateControlContext()
/*
@@ -96,22 +96,51 @@ export function Outer({
*/
const isOpen = openIndex > -1
const callQueuedCallbacks = React.useCallback(() => {
for (const cb of closeCallbacks.current) {
try {
cb()
} catch (e: any) {
logger.error('Error running close callback', e)
}
}
closeCallbacks.current = []
}, [])
const open = React.useCallback<DialogControlProps['open']>(
({index} = {}) => {
// Run any leftover callbacks that might have been queued up before calling `.open()`
callQueuedCallbacks()
setDialogIsOpen(control.id, true)
// can be set to any index of `snapPoints`, but `0` is the first i.e. "open"
setOpenIndex(index || 0)
sheet.current?.snapToIndex(index || 0)
},
[setOpenIndex, setDialogIsOpen, control.id],
[setDialogIsOpen, control.id, callQueuedCallbacks],
)
// This is the function that we call when we want to dismiss the dialog.
const close = React.useCallback<DialogControlProps['close']>(cb => {
if (cb && typeof cb === 'function') {
closeCallback.current = cb
if (typeof cb === 'function') {
closeCallbacks.current.push(cb)
}
sheet.current?.close()
}, [])
// This is the actual thing we are doing once we "confirm" the dialog. We want the dialog's close animation to
// happen before we run this. It is passed to the `BottomSheet` component.
const onCloseAnimationComplete = React.useCallback(() => {
// This removes the dialog from our list of stored dialogs. Not super necessary on iOS, but on Android this
// tells us that we need to toggle the accessibility overlay setting
setDialogIsOpen(control.id, false)
setOpenIndex(-1)
callQueuedCallbacks()
onClose?.()
}, [callQueuedCallbacks, control.id, onClose, setDialogIsOpen])
useImperativeHandle(
control.ref,
() => ({
@@ -121,21 +150,6 @@ export function Outer({
[open, close],
)
const onCloseInner = React.useCallback(() => {
try {
closeCallback.current?.()
} catch (e: any) {
logger.error(`Dialog closeCallback failed`, {
message: e.message,
})
} finally {
closeCallback.current = undefined
}
setDialogIsOpen(control.id, false)
onClose?.()
setOpenIndex(-1)
}, [control.id, onClose, setDialogIsOpen])
const context = React.useMemo(() => ({close}), [close])
return (
@@ -163,7 +177,7 @@ export function Outer({
backdropComponent={Backdrop}
handleIndicatorStyle={{backgroundColor: t.palette.primary_500}}
handleStyle={{display: 'none'}}
onClose={onCloseInner}>
onClose={onCloseAnimationComplete}>
<Context.Provider value={context}>
<View
style={[
+56 -36
View File
@@ -1,20 +1,24 @@
import React, {useImperativeHandle} from 'react'
import {View, TouchableWithoutFeedback} from 'react-native'
import {FocusScope} from '@tamagui/focus-scope'
import Animated, {FadeInDown, FadeIn} from 'react-native-reanimated'
import {TouchableWithoutFeedback, View} from 'react-native'
import Animated, {FadeIn, FadeInDown} from 'react-native-reanimated'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {FocusScope} from '@tamagui/focus-scope'
import {useTheme, atoms as a, useBreakpoints, web, flatten} from '#/alf'
import {logger} from '#/logger'
import {useDialogStateControlContext} from '#/state/dialogs'
import {atoms as a, flatten, useBreakpoints, useTheme, web} from '#/alf'
import {Button, ButtonIcon} from '#/components/Button'
import {Context} from '#/components/Dialog/context'
import {
DialogControlProps,
DialogInnerProps,
DialogOuterProps,
} from '#/components/Dialog/types'
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
import {Portal} from '#/components/Portal'
import {DialogOuterProps, DialogInnerProps} from '#/components/Dialog/types'
import {Context} from '#/components/Dialog/context'
import {Button, ButtonIcon} from '#/components/Button'
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
import {useDialogStateControlContext} from '#/state/dialogs'
export {useDialogControl, useDialogContext} from '#/components/Dialog/context'
export {useDialogContext, useDialogControl} from '#/components/Dialog/context'
export * from '#/components/Dialog/types'
export {Input} from '#/components/forms/TextField'
@@ -29,22 +33,40 @@ export function Outer({
const t = useTheme()
const {gtMobile} = useBreakpoints()
const [isOpen, setIsOpen] = React.useState(false)
const [isVisible, setIsVisible] = React.useState(true)
const {setDialogIsOpen} = useDialogStateControlContext()
const open = React.useCallback(() => {
setIsOpen(true)
setDialogIsOpen(control.id, true)
setIsOpen(true)
}, [setIsOpen, setDialogIsOpen, control.id])
const close = React.useCallback(async () => {
setIsVisible(false)
await new Promise(resolve => setTimeout(resolve, 150))
setIsOpen(false)
setIsVisible(true)
setDialogIsOpen(control.id, false)
onClose?.()
}, [onClose, setIsOpen, setDialogIsOpen, control.id])
const close = React.useCallback<DialogControlProps['close']>(
cb => {
setDialogIsOpen(control.id, false)
setIsOpen(false)
try {
if (cb && typeof cb === 'function') {
// This timeout ensures that the callback runs at the same time as it would on native. I.e.
// console.log('Step 1') -> close(() => console.log('Step 3')) -> console.log('Step 2')
// This should always output 'Step 1', 'Step 2', 'Step 3', but without the timeout it would output
// 'Step 1', 'Step 3', 'Step 2'.
setTimeout(cb)
}
} catch (e: any) {
logger.error(`Dialog closeCallback failed`, {
message: e.message,
})
}
onClose?.()
},
[control.id, onClose, setDialogIsOpen],
)
const handleBackgroundPress = React.useCallback(async () => {
close()
}, [close])
useImperativeHandle(
control.ref,
@@ -52,7 +74,7 @@ export function Outer({
open,
close,
}),
[open, close],
[close, open],
)
React.useEffect(() => {
@@ -65,7 +87,7 @@ export function Outer({
document.addEventListener('keydown', handler)
return () => document.removeEventListener('keydown', handler)
}, [isOpen, close])
}, [close, isOpen])
const context = React.useMemo(
() => ({
@@ -82,7 +104,7 @@ export function Outer({
<TouchableWithoutFeedback
accessibilityHint={undefined}
accessibilityLabel={_(msg`Close active dialog`)}
onPress={close}>
onPress={handleBackgroundPress}>
<View
style={[
web(a.fixed),
@@ -92,17 +114,15 @@ export function Outer({
gtMobile ? a.p_lg : a.p_md,
{overflowY: 'auto'},
]}>
{isVisible && (
<Animated.View
entering={FadeIn.duration(150)}
// exiting={FadeOut.duration(150)}
style={[
web(a.fixed),
a.inset_0,
{opacity: 0.8, backgroundColor: t.palette.black},
]}
/>
)}
<Animated.View
entering={FadeIn.duration(150)}
// exiting={FadeOut.duration(150)}
style={[
web(a.fixed),
a.inset_0,
{opacity: 0.8, backgroundColor: t.palette.black},
]}
/>
<View
style={[
@@ -114,7 +134,7 @@ export function Outer({
minHeight: web('calc(90vh - 36px)') || undefined,
},
]}>
{isVisible ? children : null}
{children}
</View>
</View>
</TouchableWithoutFeedback>
+1 -1
View File
@@ -1,5 +1,5 @@
import React from 'react'
import LinearGradient from 'react-native-linear-gradient'
import {LinearGradient} from 'expo-linear-gradient'
import {atoms as a, tokens} from '#/alf'
+1 -1
View File
@@ -250,7 +250,7 @@ export type InlineLinkProps = React.PropsWithChildren<
BaseLinkProps & TextStyleProp & Pick<TextProps, 'selectable'>
>
export function InlineLink({
export function InlineLinkText({
children,
to,
action = 'push',
+4 -2
View File
@@ -6,7 +6,7 @@ import {useLingui} from '@lingui/react'
import {cleanError} from 'lib/strings/errors'
import {CenteredView} from 'view/com/util/Views'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {Button} from '#/components/Button'
import {Button, ButtonText} from '#/components/Button'
import {Error} from '#/components/Error'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
@@ -87,7 +87,9 @@ function ListFooterMaybeError({
a.py_sm,
]}
onPress={onRetry}>
<Trans>Retry</Trans>
<ButtonText>
<Trans>Retry</Trans>
</ButtonText>
</Button>
</View>
</View>
+2 -2
View File
@@ -1,13 +1,13 @@
import React from 'react'
import Animated, {
Easing,
useSharedValue,
useAnimatedStyle,
useSharedValue,
withRepeat,
withTiming,
} from 'react-native-reanimated'
import {atoms as a, useTheme, flatten} from '#/alf'
import {atoms as a, flatten, useTheme} from '#/alf'
import {Props, useCommonSVGProps} from '#/components/icons/common'
import {Loader_Stroke2_Corner0_Rounded as Icon} from '#/components/icons/Loader'
+34
View File
@@ -0,0 +1,34 @@
import React from 'react'
import {View} from 'react-native'
import {atoms as a, flatten, useTheme} from '#/alf'
import {Props, useCommonSVGProps} from '#/components/icons/common'
import {Loader_Stroke2_Corner0_Rounded as Icon} from '#/components/icons/Loader'
export function Loader(props: Props) {
const t = useTheme()
const common = useCommonSVGProps(props)
return (
<View
style={[
a.relative,
a.justify_center,
a.align_center,
{width: common.size, height: common.size},
]}>
{/* css rotation animation - /bskyweb/templates/base.html */}
<div className="rotate-500ms">
<Icon
{...props}
style={[
a.absolute,
a.inset_0,
t.atoms.text_contrast_high,
flatten(props.style),
]}
/>
</div>
</View>
)
}
@@ -0,0 +1,5 @@
import {ProfileHoverCardProps} from './types'
export function ProfileHoverCard({children}: ProfileHoverCardProps) {
return children
}
@@ -0,0 +1,346 @@
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 {useLingui} from '@lingui/react'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {makeProfileLink} from '#/lib/routes/links'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
import {pluralize} from '#/lib/strings/helpers'
import {useModerationOpts} from '#/state/queries/preferences'
import {usePrefetchProfileQuery, useProfileQuery} from '#/state/queries/profile'
import {useSession} from '#/state/session'
import {useProfileShadow} from 'state/cache/profile-shadow'
import {formatCount} from '#/view/com/util/numeric/format'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {ProfileHeaderHandle} from '#/screens/Profile/Header/Handle'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {useFollowMethods} from '#/components/hooks/useFollowMethods'
import {useRichText} from '#/components/hooks/useRichText'
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
import {InlineLinkText, Link} from '#/components/Link'
import {Loader} from '#/components/Loader'
import {Portal} from '#/components/Portal'
import {RichText} from '#/components/RichText'
import {Text} from '#/components/Typography'
import {ProfileHoverCardProps} from './types'
const floatingMiddlewares = [
offset(4),
flip({padding: 16}),
shift({padding: 16}),
size({
padding: 16,
apply({availableWidth, availableHeight, elements}) {
Object.assign(elements.floating.style, {
maxWidth: `${availableWidth}px`,
maxHeight: `${availableHeight}px`,
})
},
}),
]
const isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0
export function ProfileHoverCard(props: ProfileHoverCardProps) {
return isTouchDevice ? props.children : <ProfileHoverCardInner {...props} />
}
type State = 'hidden' | 'might-show' | 'showing' | 'might-hide' | 'hiding'
const SHOW_DELAY = 350
const SHOW_DURATION = 300
const HIDE_DELAY = 200
const HIDE_DURATION = 200
export function ProfileHoverCardInner(props: ProfileHoverCardProps) {
const [state, setState] = React.useState<State>('hidden')
const {refs, floatingStyles} = useFloating({
middleware: floatingMiddlewares,
})
const animationStyle = {
animation:
state === 'hiding'
? `avatarHoverFadeOut ${HIDE_DURATION}ms both`
: `avatarHoverFadeIn ${SHOW_DURATION}ms both`,
}
const prefetchProfileQuery = usePrefetchProfileQuery()
const prefetchedProfile = React.useRef(false)
const prefetchIfNeeded = React.useCallback(async () => {
if (!prefetchedProfile.current) {
prefetchedProfile.current = true
prefetchProfileQuery(props.did)
}
}, [prefetchProfileQuery, props.did])
const isVisible =
state === 'showing' || state === 'might-hide' || state === 'hiding'
// We need at most one timeout at a time (to transition to the next state).
const nextTimeout = React.useRef<NodeJS.Timeout | null>(null)
const transitionToState = React.useCallback((nextState: State) => {
if (nextTimeout.current) {
clearTimeout(nextTimeout.current)
nextTimeout.current = null
}
setState(nextState)
}, [])
const onReadyToShow = useNonReactiveCallback(() => {
if (state === 'might-show') {
transitionToState('showing')
}
})
const onReadyToHide = useNonReactiveCallback(() => {
if (state === 'might-hide') {
transitionToState('hiding')
nextTimeout.current = setTimeout(onHidingAnimationEnd, HIDE_DURATION)
}
})
const onHidingAnimationEnd = useNonReactiveCallback(() => {
if (state === 'hiding') {
transitionToState('hidden')
}
})
const onReceiveHover = useNonReactiveCallback(() => {
prefetchIfNeeded()
if (state === 'hidden') {
transitionToState('might-show')
nextTimeout.current = setTimeout(onReadyToShow, SHOW_DELAY)
} else if (state === 'might-show') {
// Do nothing
} else if (state === 'showing') {
// Do nothing
} else if (state === 'might-hide') {
transitionToState('showing')
} else if (state === 'hiding') {
transitionToState('showing')
}
})
const onLoseHover = useNonReactiveCallback(() => {
if (state === 'hidden') {
// Do nothing
} else if (state === 'might-show') {
transitionToState('hidden')
} else if (state === 'showing') {
transitionToState('might-hide')
nextTimeout.current = setTimeout(onReadyToHide, HIDE_DELAY)
} else if (state === 'might-hide') {
// Do nothing
} else if (state === 'hiding') {
// Do nothing
}
})
const onPointerEnterTarget = React.useCallback(() => {
onReceiveHover()
}, [onReceiveHover])
const onPointerLeaveTarget = React.useCallback(() => {
onLoseHover()
}, [onLoseHover])
const onPointerEnterCard = React.useCallback(() => {
onReceiveHover()
}, [onReceiveHover])
const onPointerLeaveCard = React.useCallback(() => {
onLoseHover()
}, [onLoseHover])
const onDismiss = React.useCallback(() => {
transitionToState('hidden')
}, [transitionToState])
return (
<div
ref={refs.setReference}
onPointerEnter={onPointerEnterTarget}
onPointerLeave={onPointerLeaveTarget}
onMouseUp={onDismiss}
style={{
display: props.inline ? 'inline' : 'block',
}}>
{props.children}
{isVisible && (
<Portal>
<div style={animationStyle}>
<div
ref={refs.setFloating}
style={floatingStyles}
onPointerEnter={onPointerEnterCard}
onPointerLeave={onPointerLeaveCard}>
<Card did={props.did} hide={onDismiss} />
</div>
</div>
</Portal>
)}
</div>
)
}
let Card = ({did, hide}: {did: string; hide: () => void}): React.ReactNode => {
const t = useTheme()
const profile = useProfileQuery({did})
const moderationOpts = useModerationOpts()
const data = profile.data
return (
<View
style={[
a.p_lg,
a.border,
a.rounded_md,
a.overflow_hidden,
t.atoms.bg,
t.atoms.border_contrast_low,
t.atoms.shadow_lg,
{
width: 300,
},
]}>
{data && moderationOpts ? (
<Inner profile={data} moderationOpts={moderationOpts} hide={hide} />
) : (
<View style={[a.justify_center]}>
<Loader size="xl" />
</View>
)}
</View>
)
}
Card = React.memo(Card)
function Inner({
profile,
moderationOpts,
hide,
}: {
profile: AppBskyActorDefs.ProfileViewDetailed
moderationOpts: ModerationOpts
hide: () => void
}) {
const t = useTheme()
const {_} = useLingui()
const {currentAccount} = useSession()
const moderation = React.useMemo(
() => moderateProfile(profile, moderationOpts),
[profile, moderationOpts],
)
const [descriptionRT] = useRichText(profile.description ?? '')
const profileShadow = useProfileShadow(profile)
const {follow, unfollow} = useFollowMethods({
profile: profileShadow,
logContext: 'ProfileHoverCard',
})
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 profileURL = makeProfileLink({
did: profile.did,
handle: profile.handle,
})
const isMe = React.useMemo(
() => currentAccount?.did === profile.did,
[currentAccount, profile],
)
return (
<View>
<View style={[a.flex_row, a.justify_between, a.align_start]}>
<Link to={profileURL} label={_(msg`View profile`)} onPress={hide}>
<UserAvatar
size={64}
avatar={profile.avatar}
moderation={moderation.ui('avatar')}
/>
</Link>
{!isMe && (
<Button
size="small"
color={profileShadow.viewer?.following ? 'secondary' : 'primary'}
variant="solid"
label={
profileShadow.viewer?.following ? _('Following') : _('Follow')
}
style={[a.rounded_full]}
onPress={profileShadow.viewer?.following ? unfollow : follow}>
<ButtonIcon
position="left"
icon={profileShadow.viewer?.following ? Check : Plus}
/>
<ButtonText>
{profileShadow.viewer?.following ? _('Following') : _('Follow')}
</ButtonText>
</Button>
)}
</View>
<Link to={profileURL} label={_(msg`View profile`)} onPress={hide}>
<View style={[a.pb_sm, a.flex_1]}>
<Text style={[a.pt_md, a.pb_xs, a.text_lg, a.font_bold]}>
{sanitizeDisplayName(
profile.displayName || sanitizeHandle(profile.handle),
moderation.ui('displayName'),
)}
</Text>
<ProfileHeaderHandle profile={profileShadow} />
</View>
</Link>
{!blockHide && (
<>
<View style={[a.flex_row, a.flex_wrap, a.gap_md, a.pt_xs]}>
<InlineLinkText
to={makeProfileLink(profile, 'followers')}
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>
</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>
</InlineLinkText>
</View>
{profile.description?.trim() && !moderation.ui('profileView').blur ? (
<View style={[a.pt_md]}>
<RichText
numberOfLines={8}
value={descriptionRT}
onLinkPress={hide}
/>
</View>
) : undefined}
</>
)}
</View>
)
}
+7
View File
@@ -0,0 +1,7 @@
import React from 'react'
export type ProfileHoverCardProps = {
children: React.ReactElement
did: string
inline?: boolean
}
+27 -18
View File
@@ -51,7 +51,7 @@ export function Outer({
)
}
export function Title({children}: React.PropsWithChildren<{}>) {
export function TitleText({children}: React.PropsWithChildren<{}>) {
const {titleId} = React.useContext(Context)
return (
<Text nativeID={titleId} style={[a.text_2xl, a.font_bold, a.pb_sm]}>
@@ -60,7 +60,7 @@ export function Title({children}: React.PropsWithChildren<{}>) {
)
}
export function Description({children}: React.PropsWithChildren<{}>) {
export function DescriptionText({children}: React.PropsWithChildren<{}>) {
const t = useTheme()
const {descriptionId} = React.useContext(Context)
return (
@@ -91,15 +91,13 @@ export function Actions({children}: React.PropsWithChildren<{}>) {
}
export function Cancel({
children,
cta,
}: React.PropsWithChildren<{
}: {
/**
* Optional i18n string, used in lieu of `children` for simple buttons. If
* undefined (and `children` is undefined), it will default to "Cancel".
* Optional i18n string. If undefined, it will default to "Cancel".
*/
cta?: string
}>) {
}) {
const {_} = useLingui()
const {gtMobile} = useBreakpoints()
const {close} = Dialog.useDialogContext()
@@ -114,33 +112,37 @@ export function Cancel({
size={gtMobile ? 'small' : 'medium'}
label={cta || _(msg`Cancel`)}
onPress={onPress}>
{children ? children : <ButtonText>{cta || _(msg`Cancel`)}</ButtonText>}
<ButtonText>{cta || _(msg`Cancel`)}</ButtonText>
</Button>
)
}
export function Action({
children,
onPress,
color = 'primary',
cta,
testID,
}: React.PropsWithChildren<{
}: {
/**
* Callback to run when the action is pressed. The method is called _after_
* the dialog closes.
*
* Note: The dialog will close automatically when the action is pressed, you
* should NOT close the dialog as a side effect of this method.
*/
onPress: () => void
color?: ButtonColor
/**
* Optional i18n string, used in lieu of `children` for simple buttons. If
* undefined (and `children` is undefined), it will default to "Confirm".
* Optional i18n string. If undefined, it will default to "Confirm".
*/
cta?: string
testID?: string
}>) {
}) {
const {_} = useLingui()
const {gtMobile} = useBreakpoints()
const {close} = Dialog.useDialogContext()
const handleOnPress = React.useCallback(() => {
close()
onPress()
close(onPress)
}, [close, onPress])
return (
@@ -151,7 +153,7 @@ export function Action({
label={cta || _(msg`Confirm`)}
onPress={handleOnPress}
testID={testID}>
{children ? children : <ButtonText>{cta || _(msg`Confirm`)}</ButtonText>}
<ButtonText>{cta || _(msg`Confirm`)}</ButtonText>
</Button>
)
}
@@ -170,13 +172,20 @@ export function Basic({
description: string
cancelButtonCta?: string
confirmButtonCta?: string
/**
* Callback to run when the Confirm button is pressed. The method is called
* _after_ the dialog closes.
*
* Note: The dialog will close automatically when the action is pressed, you
* should NOT close the dialog as a side effect of this method.
*/
onConfirm: () => void
confirmButtonColor?: ButtonColor
}>) {
return (
<Outer control={control} testID="confirmModal">
<Title>{title}</Title>
<Description>{description}</Description>
<TitleText>{title}</TitleText>
<DescriptionText>{description}</DescriptionText>
<Actions>
<Action
cta={confirmButtonCta}
@@ -90,6 +90,7 @@ export function SelectReportOptionView({
return (
<Button
key={reportOption.reason}
testID={reportOption.reason}
label={_(msg`Create report for ${reportOption.title}`)}
onPress={() => props.onSelectReportOption(reportOption)}>
<ReportOptionButton
+13 -14
View File
@@ -1,25 +1,23 @@
import React from 'react'
import {View} from 'react-native'
import {AppBskyLabelerDefs} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {AppBskyLabelerDefs} from '@atproto/api'
import {getLabelingServiceTitle} from '#/lib/moderation'
import {ReportOption} from '#/lib/moderation/useReportOptions'
import {atoms as a, useTheme, native} from '#/alf'
import {Text} from '#/components/Typography'
import * as Dialog from '#/components/Dialog'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeft} from '#/components/icons/Chevron'
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
import * as Toggle from '#/components/forms/Toggle'
import {CharProgress} from '#/view/com/composer/char-progress/CharProgress'
import {Loader} from '#/components/Loader'
import * as Toast from '#/view/com/util/Toast'
import {ReportDialogProps} from './types'
import {getAgent} from '#/state/session'
import {CharProgress} from '#/view/com/composer/char-progress/CharProgress'
import * as Toast from '#/view/com/util/Toast'
import {atoms as a, native, useTheme} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import * as Toggle from '#/components/forms/Toggle'
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
import {ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeft} from '#/components/icons/Chevron'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
import {ReportDialogProps} from './types'
export function SubmitView({
params,
@@ -208,6 +206,7 @@ export function SubmitView({
))}
<Button
testID="sendReportBtn"
size="large"
variant="solid"
color="negative"
+19 -13
View File
@@ -7,7 +7,8 @@ import {toShortUrl} from '#/lib/strings/url-helpers'
import {isNative} from '#/platform/detection'
import {atoms as a, flatten, native, TextStyleProp, useTheme, web} from '#/alf'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import {InlineLink} from '#/components/Link'
import {InlineLinkText, LinkProps} from '#/components/Link'
import {ProfileHoverCard} from '#/components/ProfileHoverCard'
import {TagMenu, useTagMenuControl} from '#/components/TagMenu'
import {Text, TextProps} from '#/components/Typography'
@@ -22,6 +23,7 @@ export function RichText({
selectable,
enableTags = false,
authorHandle,
onLinkPress,
}: TextStyleProp &
Pick<TextProps, 'selectable'> & {
value: RichTextAPI | string
@@ -30,6 +32,7 @@ export function RichText({
disableLinks?: boolean
enableTags?: boolean
authorHandle?: string
onLinkPress?: LinkProps['onPress']
}) {
const richText = React.useMemo(
() =>
@@ -84,31 +87,34 @@ export function RichText({
!disableLinks
) {
els.push(
<InlineLink
selectable={selectable}
key={key}
to={`/profile/${mention.did}`}
style={[...styles, {pointerEvents: 'auto'}]}
// @ts-ignore TODO
dataSet={WORD_WRAP}>
{segment.text}
</InlineLink>,
<ProfileHoverCard key={key} inline did={mention.did}>
<InlineLinkText
selectable={selectable}
to={`/profile/${mention.did}`}
style={[...styles, {pointerEvents: 'auto'}]}
// @ts-ignore TODO
dataSet={WORD_WRAP}
onPress={onLinkPress}>
{segment.text}
</InlineLinkText>
</ProfileHoverCard>,
)
} else if (link && AppBskyRichtextFacet.validateLink(link).success) {
if (disableLinks) {
els.push(toShortUrl(segment.text))
} else {
els.push(
<InlineLink
<InlineLinkText
selectable={selectable}
key={key}
to={link.uri}
style={[...styles, {pointerEvents: 'auto'}]}
// @ts-ignore TODO
dataSet={WORD_WRAP}
shareOnLongPress>
shareOnLongPress
onPress={onLinkPress}>
{toShortUrl(segment.text)}
</InlineLink>,
</InlineLinkText>,
)
}
} else if (
+5 -2
View File
@@ -6,10 +6,12 @@ type Control = Dialog.DialogOuterProps['control']
type ControlsContext = {
mutedWordsDialogControl: Control
signinDialogControl: Control
}
const ControlsContext = React.createContext({
mutedWordsDialogControl: {} as Control,
signinDialogControl: {} as Control,
})
export function useGlobalDialogsControlContext() {
@@ -18,9 +20,10 @@ export function useGlobalDialogsControlContext() {
export function Provider({children}: React.PropsWithChildren<{}>) {
const mutedWordsDialogControl = Dialog.useDialogControl()
const signinDialogControl = Dialog.useDialogControl()
const ctx = React.useMemo<ControlsContext>(
() => ({mutedWordsDialogControl}),
[mutedWordsDialogControl],
() => ({mutedWordsDialogControl, signinDialogControl}),
[mutedWordsDialogControl, signinDialogControl],
)
return (
+195
View File
@@ -0,0 +1,195 @@
import React, {memo, useRef, useState} from 'react'
import {TextInput, View} from 'react-native'
import {AppBskyActorDefs, AppBskyFeedPost, AtUri} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {EMBED_SCRIPT} from '#/lib/constants'
import {niceDate} from '#/lib/strings/time'
import {toShareUrl} from '#/lib/strings/url-helpers'
import {atoms as a, useTheme} from '#/alf'
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 {CodeBrackets_Stroke2_Corner0_Rounded as CodeBrackets} from '#/components/icons/CodeBrackets'
import {Text} from '#/components/Typography'
import {Button, ButtonIcon, ButtonText} from '../Button'
type EmbedDialogProps = {
control: Dialog.DialogControlProps
postAuthor: AppBskyActorDefs.ProfileViewBasic
postCid: string
postUri: string
record: AppBskyFeedPost.Record
timestamp: string
}
let EmbedDialog = ({control, ...rest}: EmbedDialogProps): React.ReactNode => {
return (
<Dialog.Outer control={control}>
<Dialog.Handle />
<EmbedDialogInner {...rest} />
</Dialog.Outer>
)
}
EmbedDialog = memo(EmbedDialog)
export {EmbedDialog}
function EmbedDialogInner({
postAuthor,
postCid,
postUri,
record,
timestamp,
}: Omit<EmbedDialogProps, 'control'>) {
const t = useTheme()
const {_} = useLingui()
const ref = useRef<TextInput>(null)
const [copied, setCopied] = useState(false)
// reset copied state after 2 seconds
React.useEffect(() => {
if (copied) {
const timeout = setTimeout(() => {
setCopied(false)
}, 2000)
return () => clearTimeout(timeout)
}
}, [copied])
const snippet = React.useMemo(() => {
function toEmbedUrl(href: string) {
return toShareUrl(href) + '?ref_src=embed'
}
const lang = record.langs && record.langs.length > 0 ? record.langs[0] : ''
const profileHref = toEmbedUrl(['/profile', postAuthor.did].join('/'))
const urip = new AtUri(postUri)
const href = toEmbedUrl(
['/profile', postAuthor.did, 'post', urip.rkey].join('/'),
)
// x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x
// DO NOT ADD ANY NEW INTERPOLATIONS BELOW WITHOUT ESCAPING THEM!
// Also, keep this code synced with the bskyembed code in landing.tsx.
// x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x
return `<blockquote class="bluesky-embed" data-bluesky-uri="${escapeHtml(
postUri,
)}" data-bluesky-cid="${escapeHtml(postCid)}"><p lang="${escapeHtml(
lang,
)}">${escapeHtml(record.text)}${
record.embed
? `<br><br><a href="${escapeHtml(href)}">[image or embed]</a>`
: ''
}</p>&mdash; ${escapeHtml(
postAuthor.displayName || postAuthor.handle,
)} (<a href="${escapeHtml(profileHref)}">@${escapeHtml(
postAuthor.handle,
)}</a>) <a href="${escapeHtml(href)}">${escapeHtml(
niceDate(timestamp),
)}</a></blockquote><script async src="${EMBED_SCRIPT}" charset="utf-8"></script>`
}, [postUri, postCid, record, timestamp, postAuthor])
return (
<Dialog.Inner label="Embed post" style={[a.gap_md, {maxWidth: 500}]}>
<View style={[a.gap_sm, a.pb_lg]}>
<Text style={[a.text_2xl, a.font_bold]}>
<Trans>Embed post</Trans>
</Text>
<Text
style={[a.text_md, t.atoms.text_contrast_medium, a.leading_normal]}>
<Trans>
Embed this post in your website. Simply copy the following snippet
and paste it into the HTML code of your website.
</Trans>
</Text>
</View>
<View style={[a.flex_row, a.gap_sm]}>
<TextField.Root>
<TextField.Icon icon={CodeBrackets} />
<TextField.Input
label={_(msg`Embed HTML code`)}
editable={false}
selection={{start: 0, end: snippet.length}}
value={snippet}
style={{}}
/>
</TextField.Root>
<Button
label={_(msg`Copy code`)}
color="primary"
variant="solid"
size="medium"
onPress={() => {
ref.current?.focus()
ref.current?.setSelection(0, snippet.length)
navigator.clipboard.writeText(snippet)
setCopied(true)
}}>
{copied ? (
<>
<ButtonIcon icon={Check} />
<ButtonText>
<Trans>Copied!</Trans>
</ButtonText>
</>
) : (
<ButtonText>
<Trans>Copy code</Trans>
</ButtonText>
)}
</Button>
</View>
<Dialog.Close />
</Dialog.Inner>
)
}
/**
* Based on a snippet of code from React, which itself was based on the escape-html library.
* Copyright (c) Meta Platforms, Inc. and affiliates
* Copyright (c) 2012-2013 TJ Holowaychuk
* Copyright (c) 2015 Andreas Lubbe
* Copyright (c) 2015 Tiancheng "Timothy" Gu
* Licensed as MIT.
*/
const matchHtmlRegExp = /["'&<>]/
function escapeHtml(string: string) {
const str = String(string)
const match = matchHtmlRegExp.exec(str)
if (!match) {
return str
}
let escape
let html = ''
let index
let lastIndex = 0
for (index = match.index; index < str.length; index++) {
switch (str.charCodeAt(index)) {
case 34: // "
escape = '&quot;'
break
case 38: // &
escape = '&amp;'
break
case 39: // '
escape = '&#x27;'
break
case 60: // <
escape = '&lt;'
break
case 62: // >
escape = '&gt;'
break
default:
continue
}
if (lastIndex !== index) {
html += str.slice(lastIndex, index)
}
lastIndex = index + 1
html += escape
}
return lastIndex !== index ? html + str.slice(lastIndex, index) : html
}
+119
View File
@@ -0,0 +1,119 @@
import React, {useCallback} from 'react'
import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {
type EmbedPlayerSource,
embedPlayerSources,
externalEmbedLabels,
} from '#/lib/strings/embed-player'
import {useSetExternalEmbedPref} from '#/state/preferences'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import * as Dialog from '#/components/Dialog'
import {Button, ButtonText} from '../Button'
import {Text} from '../Typography'
export function EmbedConsentDialog({
control,
source,
onAccept,
}: {
control: Dialog.DialogControlProps
source: EmbedPlayerSource
onAccept: () => void
}) {
const {_} = useLingui()
const t = useTheme()
const setExternalEmbedPref = useSetExternalEmbedPref()
const {gtMobile} = useBreakpoints()
const onShowAllPress = useCallback(() => {
for (const key of embedPlayerSources) {
setExternalEmbedPref(key, 'show')
}
onAccept()
control.close()
}, [control, onAccept, setExternalEmbedPref])
const onShowPress = useCallback(() => {
setExternalEmbedPref(source, 'show')
onAccept()
control.close()
}, [control, onAccept, setExternalEmbedPref, source])
const onHidePress = useCallback(() => {
setExternalEmbedPref(source, 'hide')
control.close()
}, [control, setExternalEmbedPref, source])
return (
<Dialog.Outer control={control}>
<Dialog.Handle />
<Dialog.ScrollableInner
label={_(msg`External Media`)}
style={[gtMobile ? {width: 'auto', maxWidth: 400} : a.w_full]}>
<View style={a.gap_sm}>
<Text style={[a.text_2xl, a.font_bold]}>
<Trans>External Media</Trans>
</Text>
<View style={[a.mt_sm, a.mb_2xl, a.gap_lg]}>
<Text>
<Trans>
This content is hosted by {externalEmbedLabels[source]}. Do you
want to enable external media?
</Trans>
</Text>
<Text style={t.atoms.text_contrast_medium}>
<Trans>
External media may allow websites to collect information about
you and your device. No information is sent or requested until
you press the "play" button.
</Trans>
</Text>
</View>
</View>
<View style={a.gap_md}>
<Button
style={gtMobile && a.flex_1}
label={_(msg`Enable external media`)}
onPress={onShowAllPress}
onAccessibilityEscape={control.close}
color="primary"
size="medium"
variant="solid">
<ButtonText>
<Trans>Enable external media</Trans>
</ButtonText>
</Button>
<Button
style={gtMobile && a.flex_1}
label={_(msg`Enable this source only`)}
onPress={onShowPress}
onAccessibilityEscape={control.close}
color="secondary"
size="medium"
variant="solid">
<ButtonText>
<Trans>Enable {externalEmbedLabels[source]} only</Trans>
</ButtonText>
</Button>
<Button
label={_(msg`No thanks`)}
onAccessibilityEscape={control.close}
onPress={onHidePress}
color="secondary"
size="medium"
variant="ghost">
<ButtonText>
<Trans>No thanks</Trans>
</ButtonText>
</Button>
</View>
</Dialog.ScrollableInner>
</Dialog.Outer>
)
}
+20 -21
View File
@@ -1,37 +1,36 @@
import React from 'react'
import {Keyboard, View} from 'react-native'
import {AppBskyActorDefs, sanitizeMutedWordValue} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {AppBskyActorDefs, sanitizeMutedWordValue} from '@atproto/api'
import {
usePreferencesQuery,
useUpsertMutedWordsMutation,
useRemoveMutedWordMutation,
} from '#/state/queries/preferences'
import {logger} from '#/logger'
import {isNative} from '#/platform/detection'
import {
usePreferencesQuery,
useRemoveMutedWordMutation,
useUpsertMutedWordsMutation,
} from '#/state/queries/preferences'
import {
atoms as a,
useTheme,
native,
useBreakpoints,
useTheme,
ViewStyleProp,
web,
native,
} from '#/alf'
import {Text} from '#/components/Typography'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
import * as Dialog from '#/components/Dialog'
import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
import {Divider} from '#/components/Divider'
import * as Toggle from '#/components/forms/Toggle'
import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag'
import {PageText_Stroke2_Corner0_Rounded as PageText} from '#/components/icons/PageText'
import {Divider} from '#/components/Divider'
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
import {Loader} from '#/components/Loader'
import {logger} from '#/logger'
import * as Dialog from '#/components/Dialog'
import * as Toggle from '#/components/forms/Toggle'
import * as Prompt from '#/components/Prompt'
import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
import {Text} from '#/components/Typography'
export function MutedWordsDialog() {
const {mutedWordsDialogControl: control} = useGlobalDialogsControlContext()
@@ -130,9 +129,9 @@ function MutedWordsInner({}: {control: Dialog.DialogOuterProps['control']}) {
<TargetToggle>
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
<Toggle.Radio />
<Toggle.Label>
<Toggle.LabelText>
<Trans>Mute in text & tags</Trans>
</Toggle.Label>
</Toggle.LabelText>
</View>
<PageText size="sm" />
</TargetToggle>
@@ -145,9 +144,9 @@ function MutedWordsInner({}: {control: Dialog.DialogOuterProps['control']}) {
<TargetToggle>
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
<Toggle.Radio />
<Toggle.Label>
<Toggle.LabelText>
<Trans>Mute in tags only</Trans>
</Toggle.Label>
</Toggle.LabelText>
</View>
<Hashtag size="sm" />
</TargetToggle>
+110
View File
@@ -0,0 +1,110 @@
import React from 'react'
import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {isNative} from '#/platform/detection'
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
import {useCloseAllActiveElements} from '#/state/util'
import {Logo} from '#/view/icons/Logo'
import {Logotype} from '#/view/icons/Logotype'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
import {Text} from '#/components/Typography'
export function SigninDialog() {
const {signinDialogControl: control} = useGlobalDialogsControlContext()
return (
<Dialog.Outer control={control}>
<Dialog.Handle />
<SigninDialogInner control={control} />
</Dialog.Outer>
)
}
function SigninDialogInner({}: {control: Dialog.DialogOuterProps['control']}) {
const t = useTheme()
const {_} = useLingui()
const {gtMobile} = useBreakpoints()
const {requestSwitchToAccount} = useLoggedOutViewControls()
const closeAllActiveElements = useCloseAllActiveElements()
const showSignIn = React.useCallback(() => {
closeAllActiveElements()
requestSwitchToAccount({requestedAccount: 'none'})
}, [requestSwitchToAccount, closeAllActiveElements])
const showCreateAccount = React.useCallback(() => {
closeAllActiveElements()
requestSwitchToAccount({requestedAccount: 'new'})
}, [requestSwitchToAccount, closeAllActiveElements])
return (
<Dialog.ScrollableInner
label={_(msg`Sign into Bluesky or create a new account`)}
style={[gtMobile ? {width: 'auto', maxWidth: 420} : a.w_full]}>
<View style={[!isNative && a.p_2xl]}>
<View
style={[
a.flex_row,
a.align_center,
a.justify_center,
a.gap_sm,
a.pb_lg,
]}>
<Logo width={36} />
<View style={{paddingTop: 6}}>
<Logotype width={120} fill={t.atoms.text.color} />
</View>
</View>
<Text
style={[
a.text_lg,
a.text_center,
t.atoms.text,
a.pb_2xl,
a.leading_snug,
a.mx_auto,
{
maxWidth: 300,
},
]}>
<Trans>
Sign in or create your account to join the conversation!
</Trans>
</Text>
<View style={[a.flex_col, a.gap_md]}>
<Button
variant="solid"
color="primary"
size="large"
onPress={showCreateAccount}
label={_(msg`Create an account`)}>
<ButtonText>
<Trans>Create an account</Trans>
</ButtonText>
</Button>
<Button
variant="solid"
color="secondary"
size="large"
onPress={showSignIn}
label={_(msg`Sign in`)}>
<ButtonText>
<Trans>Sign in</Trans>
</ButtonText>
</Button>
</View>
{isNative && <View style={{height: 10}} />}
</View>
<Dialog.Close />
</Dialog.ScrollableInner>
)
}
@@ -8,7 +8,7 @@ import * as TextField from '#/components/forms/TextField'
import {DateFieldButton} from './index.shared'
export * as utils from '#/components/forms/DateField/utils'
export const Label = TextField.Label
export const LabelText = TextField.LabelText
export function DateField({
value,
+1 -1
View File
@@ -13,7 +13,7 @@ import * as TextField from '#/components/forms/TextField'
import {DateFieldButton} from './index.shared'
export * as utils from '#/components/forms/DateField/utils'
export const Label = TextField.Label
export const LabelText = TextField.LabelText
/**
* Date-only input. Accepts a date in the format YYYY-MM-DD, and reports date
+1 -1
View File
@@ -9,7 +9,7 @@ import * as TextField from '#/components/forms/TextField'
import {CalendarDays_Stroke2_Corner0_Rounded as CalendarDays} from '#/components/icons/CalendarDays'
export * as utils from '#/components/forms/DateField/utils'
export const Label = TextField.Label
export const LabelText = TextField.LabelText
const InputBase = React.forwardRef<HTMLInputElement, TextInputProps>(
({style, ...props}, ref) => {
+1
View File
@@ -41,6 +41,7 @@ export function HostingProvider({
onSelect={onSelectServiceUrl}
/>
<Button
testID="loginSelectServiceButton"
label={toNiceDomain(serviceUrl)}
accessibilityHint={_(msg`Press to change hosting provider`)}
variant="solid"
+2 -2
View File
@@ -225,7 +225,7 @@ export function createInput(Component: typeof TextInput) {
export const Input = createInput(TextInput)
export function Label({
export function LabelText({
nativeID,
children,
}: React.PropsWithChildren<{nativeID?: string}>) {
@@ -288,7 +288,7 @@ export function Icon({icon: Comp}: {icon: React.ComponentType<SVGIconProps>}) {
)
}
export function Suffix({
export function SuffixText({
children,
label,
accessibilityHint,
+5 -5
View File
@@ -3,16 +3,16 @@ import {Pressable, View, ViewStyle} from 'react-native'
import {HITSLOP_10} from 'lib/constants'
import {
useTheme,
atoms as a,
native,
flatten,
ViewStyleProp,
native,
TextStyleProp,
useTheme,
ViewStyleProp,
} from '#/alf'
import {Text} from '#/components/Typography'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import {CheckThick_Stroke2_Corner0_Rounded as Checkmark} from '#/components/icons/Check'
import {Text} from '#/components/Typography'
export type ItemState = {
name: string
@@ -234,7 +234,7 @@ export function Item({
)
}
export function Label({
export function LabelText({
children,
style,
}: React.PropsWithChildren<TextStyleProp>) {
+67 -57
View File
@@ -1,16 +1,15 @@
import React from 'react'
import {View, AccessibilityProps, TextStyle, ViewStyle} from 'react-native'
import {AccessibilityProps, TextStyle, View, ViewStyle} from 'react-native'
import {atoms as a, useTheme, native} from '#/alf'
import {atoms as a, native, useTheme} from '#/alf'
import * as Toggle from '#/components/forms/Toggle'
import {Text} from '#/components/Typography'
import * as Toggle from '#/components/forms/Toggle'
export type ItemProps = Omit<Toggle.ItemProps, 'style' | 'role' | 'children'> &
AccessibilityProps &
React.PropsWithChildren<{
type ItemProps = Omit<Toggle.ItemProps, 'style' | 'role' | 'children'> &
AccessibilityProps & {
children: React.ReactElement
testID?: string
}>
}
export type GroupProps = Omit<Toggle.GroupProps, 'style' | 'type'> & {
multiple?: boolean
@@ -47,49 +46,42 @@ function ButtonInner({children}: React.PropsWithChildren<{}>) {
const t = useTheme()
const state = Toggle.useItemContext()
const {baseStyles, hoverStyles, activeStyles, textStyles} =
React.useMemo(() => {
const base: ViewStyle[] = []
const hover: ViewStyle[] = []
const active: ViewStyle[] = []
const text: TextStyle[] = []
const {baseStyles, hoverStyles, activeStyles} = React.useMemo(() => {
const base: ViewStyle[] = []
const hover: ViewStyle[] = []
const active: ViewStyle[] = []
hover.push(
t.name === 'light' ? t.atoms.bg_contrast_100 : t.atoms.bg_contrast_25,
)
hover.push(
t.name === 'light' ? t.atoms.bg_contrast_100 : t.atoms.bg_contrast_25,
)
if (state.selected) {
active.push({
backgroundColor: t.palette.contrast_800,
})
text.push(t.atoms.text_inverted)
hover.push({
backgroundColor: t.palette.contrast_800,
})
if (state.disabled) {
active.push({
backgroundColor: t.palette.contrast_500,
})
}
}
if (state.selected) {
active.push({
backgroundColor: t.palette.contrast_800,
})
hover.push({
backgroundColor: t.palette.contrast_800,
})
if (state.disabled) {
base.push({
backgroundColor: t.palette.contrast_100,
})
text.push({
opacity: 0.5,
active.push({
backgroundColor: t.palette.contrast_500,
})
}
}
return {
baseStyles: base,
hoverStyles: hover,
activeStyles: active,
textStyles: text,
}
}, [t, state])
if (state.disabled) {
base.push({
backgroundColor: t.palette.contrast_100,
})
}
return {
baseStyles: base,
hoverStyles: hover,
activeStyles: active,
}
}, [t, state])
return (
<View
@@ -110,19 +102,37 @@ function ButtonInner({children}: React.PropsWithChildren<{}>) {
activeStyles,
(state.hovered || state.pressed) && hoverStyles,
]}>
{typeof children === 'string' ? (
<Text
style={[
a.text_center,
a.font_bold,
t.atoms.text_contrast_medium,
textStyles,
]}>
{children}
</Text>
) : (
children
)}
{children}
</View>
)
}
export function ButtonText({children}: {children: React.ReactNode}) {
const t = useTheme()
const state = Toggle.useItemContext()
const textStyles = React.useMemo(() => {
const text: TextStyle[] = []
if (state.selected) {
text.push(t.atoms.text_inverted)
}
if (state.disabled) {
text.push({
opacity: 0.5,
})
}
return text
}, [t, state])
return (
<Text
style={[
a.text_center,
a.font_bold,
t.atoms.text_contrast_medium,
textStyles,
]}>
{children}
</Text>
)
}
+60
View File
@@ -0,0 +1,60 @@
import React from 'react'
import {AppBskyActorDefs} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {LogEvents} from '#/lib/statsig/statsig'
import {logger} from '#/logger'
import {Shadow} from '#/state/cache/types'
import {useProfileFollowMutationQueue} from '#/state/queries/profile'
import {useRequireAuth} from '#/state/session'
import * as Toast from '#/view/com/util/Toast'
export function useFollowMethods({
profile,
logContext,
}: {
profile: Shadow<AppBskyActorDefs.ProfileViewBasic>
logContext: LogEvents['profile:follow']['logContext'] &
LogEvents['profile:unfollow']['logContext']
}) {
const {_} = useLingui()
const requireAuth = useRequireAuth()
const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(
profile,
logContext,
)
const follow = React.useCallback(() => {
requireAuth(async () => {
try {
await queueFollow()
} catch (e: any) {
logger.error(`useFollowMethods: failed to follow`, {message: String(e)})
if (e?.name !== 'AbortError') {
Toast.show(_(msg`An issue occurred, please try again.`))
}
}
})
}, [_, queueFollow, requireAuth])
const unfollow = React.useCallback(() => {
requireAuth(async () => {
try {
await queueUnfollow()
} catch (e: any) {
logger.error(`useFollowMethods: failed to unfollow`, {
message: String(e),
})
if (e?.name !== 'AbortError') {
Toast.show(_(msg`An issue occurred, please try again.`))
}
}
})
}, [_, queueUnfollow, requireAuth])
return {
follow,
unfollow,
}
}
+33
View File
@@ -0,0 +1,33 @@
import React from 'react'
import {RichText as RichTextAPI} from '@atproto/api'
import {getAgent} from '#/state/session'
export function useRichText(text: string): [RichTextAPI, boolean] {
const [prevText, setPrevText] = React.useState(text)
const [rawRT, setRawRT] = React.useState(() => new RichTextAPI({text}))
const [resolvedRT, setResolvedRT] = React.useState<RichTextAPI | null>(null)
if (text !== prevText) {
setPrevText(text)
setRawRT(new RichTextAPI({text}))
setResolvedRT(null)
// This will queue an immediate re-render
}
React.useEffect(() => {
let ignore = false
async function resolveRTFacets() {
// new each time
const resolvedRT = new RichTextAPI({text})
await resolvedRT.detectFacets(getAgent())
if (!ignore) {
setResolvedRT(resolvedRT)
}
}
resolveRTFacets()
return () => {
ignore = true
}
}, [text])
const isResolving = resolvedRT === null
return [resolvedRT ?? rawRT, isResolving]
}

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