Merge remote-tracking branch 'origin/main' into hailey/safelink
@@ -16,6 +16,9 @@ EXPO_PUBLIC_LOG_LEVEL=debug
|
|||||||
# Enable debug logs for specific logger instances
|
# Enable debug logs for specific logger instances
|
||||||
EXPO_PUBLIC_LOG_DEBUG=session
|
EXPO_PUBLIC_LOG_DEBUG=session
|
||||||
|
|
||||||
|
# Bluesky appview DID
|
||||||
|
EXPO_PUBLIC_BLUESKY_PROXY_DID=
|
||||||
|
|
||||||
# Chat service DID
|
# Chat service DID
|
||||||
EXPO_PUBLIC_CHAT_PROXY_DID=
|
EXPO_PUBLIC_CHAT_PROXY_DID=
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ module.exports = {
|
|||||||
'P',
|
'P',
|
||||||
'Admonition',
|
'Admonition',
|
||||||
'Admonition.Admonition',
|
'Admonition.Admonition',
|
||||||
|
'Toast.Action',
|
||||||
'AgeAssuranceAdmonition',
|
'AgeAssuranceAdmonition',
|
||||||
'Span',
|
'Span',
|
||||||
],
|
],
|
||||||
@@ -110,6 +111,8 @@ module.exports = {
|
|||||||
'bskyembed',
|
'bskyembed',
|
||||||
'src/locale/locales/_build/',
|
'src/locale/locales/_build/',
|
||||||
'src/locale/locales/**/*.js',
|
'src/locale/locales/**/*.js',
|
||||||
|
'*.e2e.ts',
|
||||||
|
'*.e2e.tsx',
|
||||||
],
|
],
|
||||||
settings: {
|
settings: {
|
||||||
componentWrapperFunctions: ['observer'],
|
componentWrapperFunctions: ['observer'],
|
||||||
|
|||||||
@@ -3,20 +3,26 @@ appId: xyz.blueskyweb.app
|
|||||||
- runScript:
|
- runScript:
|
||||||
file: ../setupServer.js
|
file: ../setupServer.js
|
||||||
env:
|
env:
|
||||||
SERVER_PATH: ?users
|
SERVER_PATH: ?users
|
||||||
- runFlow:
|
- runFlow:
|
||||||
file: ../setupApp.yml
|
file: ../setupApp.yml
|
||||||
- tapOn:
|
- tapOn:
|
||||||
id: "e2eSignInAlice"
|
id: "e2eSignInAlice"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
id: "viewHeaderHomeFeedPrefsBtn"
|
||||||
|
|
||||||
# Post an image with the porn label
|
# Post an image with the porn label
|
||||||
- assertVisible:
|
- assertVisible:
|
||||||
id: "composeFAB"
|
id: "composeFAB"
|
||||||
- tapOn:
|
- tapOn:
|
||||||
id: "composeFAB"
|
id: "composeFAB"
|
||||||
- inputText: "Post with an image"
|
- inputText: "Post with an image"
|
||||||
- tapOn:
|
- tapOn:
|
||||||
id: "openGalleryBtn"
|
id: "openMediaBtn"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
id: "selectedPhotosView"
|
||||||
- tapOn: "Content warnings"
|
- tapOn: "Content warnings"
|
||||||
- tapOn: "Porn"
|
- tapOn: "Porn"
|
||||||
- tapOn:
|
- tapOn:
|
||||||
|
|||||||
@@ -3,13 +3,17 @@ appId: xyz.blueskyweb.app
|
|||||||
- runScript:
|
- runScript:
|
||||||
file: ../setupServer.js
|
file: ../setupServer.js
|
||||||
env:
|
env:
|
||||||
SERVER_PATH: ?users
|
SERVER_PATH: ?users
|
||||||
- runFlow:
|
- runFlow:
|
||||||
file: ../setupApp.yml
|
file: ../setupApp.yml
|
||||||
- tapOn:
|
- tapOn:
|
||||||
id: "e2eSignInAlice"
|
id: "e2eSignInAlice"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
id: "viewHeaderHomeFeedPrefsBtn"
|
||||||
|
|
||||||
- assertVisible:
|
- assertVisible:
|
||||||
id: "composeFAB"
|
id: "composeFAB"
|
||||||
- tapOn:
|
- tapOn:
|
||||||
id: "composeFAB"
|
id: "composeFAB"
|
||||||
- inputText: "Post text only"
|
- inputText: "Post text only"
|
||||||
@@ -21,7 +25,10 @@ appId: xyz.blueskyweb.app
|
|||||||
id: "composeFAB"
|
id: "composeFAB"
|
||||||
- inputText: "Post with an image"
|
- inputText: "Post with an image"
|
||||||
- tapOn:
|
- tapOn:
|
||||||
id: "openGalleryBtn"
|
id: "openMediaBtn"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
id: "selectedPhotosView"
|
||||||
- tapOn:
|
- tapOn:
|
||||||
id: "composerPublishBtn"
|
id: "composerPublishBtn"
|
||||||
- assertVisible:
|
- assertVisible:
|
||||||
@@ -46,7 +53,10 @@ appId: xyz.blueskyweb.app
|
|||||||
id: "replyBtn"
|
id: "replyBtn"
|
||||||
- inputText: "Reply with an image"
|
- inputText: "Reply with an image"
|
||||||
- tapOn:
|
- tapOn:
|
||||||
id: "openGalleryBtn"
|
id: "openMediaBtn"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
id: "selectedPhotosView"
|
||||||
- tapOn:
|
- tapOn:
|
||||||
id: "composerPublishBtn"
|
id: "composerPublishBtn"
|
||||||
- assertVisible:
|
- assertVisible:
|
||||||
@@ -73,7 +83,10 @@ appId: xyz.blueskyweb.app
|
|||||||
id: "quoteBtn"
|
id: "quoteBtn"
|
||||||
- inputText: "QP with an image"
|
- inputText: "QP with an image"
|
||||||
- tapOn:
|
- tapOn:
|
||||||
id: "openGalleryBtn"
|
id: "openMediaBtn"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
id: "selectedPhotosView"
|
||||||
- tapOn:
|
- tapOn:
|
||||||
id: "composerPublishBtn"
|
id: "composerPublishBtn"
|
||||||
- assertVisible:
|
- assertVisible:
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ appId: xyz.blueskyweb.app
|
|||||||
text: "Not Now"
|
text: "Not Now"
|
||||||
optional: true
|
optional: true
|
||||||
- inputText: "e2e-test"
|
- inputText: "e2e-test"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
id: "handleAvailableCheck"
|
||||||
- tapOn:
|
- tapOn:
|
||||||
id: "nextBtn"
|
id: "nextBtn"
|
||||||
|
- assertVisible: "Give your profile a face"
|
||||||
|
|||||||
@@ -3,11 +3,14 @@ appId: xyz.blueskyweb.app
|
|||||||
- runScript:
|
- runScript:
|
||||||
file: ../setupServer.js
|
file: ../setupServer.js
|
||||||
env:
|
env:
|
||||||
SERVER_PATH: "?users&follows&posts"
|
SERVER_PATH: "?users&follows&posts"
|
||||||
- runFlow:
|
- runFlow:
|
||||||
file: ../setupApp.yml
|
file: ../setupApp.yml
|
||||||
- tapOn:
|
- tapOn:
|
||||||
id: "e2eSignInAlice"
|
id: "e2eSignInAlice"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
id: "viewHeaderHomeFeedPrefsBtn"
|
||||||
|
|
||||||
- tapOn:
|
- tapOn:
|
||||||
label: "Create a curate list"
|
label: "Create a curate list"
|
||||||
@@ -75,9 +78,8 @@ appId: xyz.blueskyweb.app
|
|||||||
- tapOn:
|
- tapOn:
|
||||||
id: "confirmBtn"
|
id: "confirmBtn"
|
||||||
|
|
||||||
- tapOn:
|
- assertVisible:
|
||||||
label: "Create a new curatelist"
|
id: "newUserListBtn"
|
||||||
id: "e2eGotoLists"
|
|
||||||
- tapOn:
|
- tapOn:
|
||||||
id: "newUserListBtn"
|
id: "newUserListBtn"
|
||||||
- assertVisible:
|
- assertVisible:
|
||||||
@@ -146,7 +148,7 @@ appId: xyz.blueskyweb.app
|
|||||||
|
|
||||||
- tapOn:
|
- tapOn:
|
||||||
id: "bottomBarSearchBtn"
|
id: "bottomBarSearchBtn"
|
||||||
- tapOn: "Search for posts, users, or feeds"
|
- tapOn: "Search for posts, users[,]? or feeds"
|
||||||
- inputText: "bob"
|
- inputText: "bob"
|
||||||
- tapOn:
|
- tapOn:
|
||||||
id: "searchAutoCompleteResult-bob.test"
|
id: "searchAutoCompleteResult-bob.test"
|
||||||
|
|||||||
@@ -3,11 +3,14 @@ appId: xyz.blueskyweb.app
|
|||||||
- runScript:
|
- runScript:
|
||||||
file: ../setupServer.js
|
file: ../setupServer.js
|
||||||
env:
|
env:
|
||||||
SERVER_PATH: "?users&follows&labels"
|
SERVER_PATH: "?users&follows&labels"
|
||||||
- runFlow:
|
- runFlow:
|
||||||
file: ../setupApp.yml
|
file: ../setupApp.yml
|
||||||
- tapOn:
|
- tapOn:
|
||||||
id: "e2eSignInAlice"
|
id: "e2eSignInAlice"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
id: "viewHeaderHomeFeedPrefsBtn"
|
||||||
|
|
||||||
# create a modlist
|
# create a modlist
|
||||||
- tapOn:
|
- tapOn:
|
||||||
@@ -28,20 +31,17 @@ appId: xyz.blueskyweb.app
|
|||||||
- assertVisible: "Muted Users"
|
- assertVisible: "Muted Users"
|
||||||
- assertVisible: "Shhh"
|
- assertVisible: "Shhh"
|
||||||
|
|
||||||
- tapOn:
|
# DOES NOT WORK - THE BUTTON IS NOT ACCESSIBLE
|
||||||
label: "Dropdown"
|
# IGNORING FOR NOW, FIX THE COMPONENTS IN THE NEXT RELEASE
|
||||||
point: "71%,9%"
|
# BECAUSE THIS IS A LEGIT A11Y PROBLEM -sfn
|
||||||
|
- tapOn: "Subscribe to this list"
|
||||||
- tapOn: "Mute accounts"
|
- tapOn: "Mute accounts"
|
||||||
- tapOn: "Mute list"
|
- tapOn: "Mute list"
|
||||||
- tapOn: "Unmute"
|
- tapOn: "Unmute"
|
||||||
|
|
||||||
- tapOn:
|
- tapOn: "Subscribe to this list"
|
||||||
label: "Dropdown"
|
|
||||||
point: "71%,9%"
|
|
||||||
|
|
||||||
- tapOn: "Block accounts"
|
- tapOn: "Block accounts"
|
||||||
- tapOn: "Block list"
|
- tapOn: "Block list"
|
||||||
- tapOn: "Unblock"
|
- tapOn: "Unblock"
|
||||||
|
|
||||||
# the rest of the behaviors are tested in curate-lists.yml
|
# the rest of the behaviors are tested in curate-lists.yml
|
||||||
|
|||||||
@@ -3,11 +3,15 @@ appId: xyz.blueskyweb.app
|
|||||||
- runScript:
|
- runScript:
|
||||||
file: ../setupServer.js
|
file: ../setupServer.js
|
||||||
env:
|
env:
|
||||||
SERVER_PATH: "?users"
|
SERVER_PATH: "?users"
|
||||||
- runFlow:
|
- runFlow:
|
||||||
file: ../setupApp.yml
|
file: ../setupApp.yml
|
||||||
- tapOn:
|
- tapOn:
|
||||||
id: "e2eSignInAlice"
|
id: "e2eSignInAlice"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
id: "viewHeaderHomeFeedPrefsBtn"
|
||||||
|
|
||||||
- tapOn:
|
- tapOn:
|
||||||
id: "e2eStartOnboarding"
|
id: "e2eStartOnboarding"
|
||||||
- tapOn: "Open avatar creator"
|
- tapOn: "Open avatar creator"
|
||||||
@@ -21,12 +25,12 @@ appId: xyz.blueskyweb.app
|
|||||||
- tapOn: "Select the atom emoji as your avatar"
|
- tapOn: "Select the atom emoji as your avatar"
|
||||||
- tapOn: "Done"
|
- tapOn: "Done"
|
||||||
- waitForAnimationToEnd
|
- waitForAnimationToEnd
|
||||||
- tapOn: "Continue to next step"
|
- tapOn:
|
||||||
|
id: "onboardingContinue"
|
||||||
- assertVisible: "What are your interests?"
|
- assertVisible: "What are your interests?"
|
||||||
- tapOn:
|
- tapOn:
|
||||||
label: "Tap on continue"
|
id: "onboardingContinue"
|
||||||
point: "50%,92%"
|
|
||||||
- assertVisible: "You're ready to go!"
|
- assertVisible: "You're ready to go!"
|
||||||
- tapOn:
|
- tapOn:
|
||||||
label: "Tap on Lets go"
|
id: "onboardingFinish"
|
||||||
point: "50%,92%"
|
- assertVisible: "Following"
|
||||||
|
|||||||
@@ -3,11 +3,15 @@ appId: xyz.blueskyweb.app
|
|||||||
- runScript:
|
- runScript:
|
||||||
file: ../setupServer.js
|
file: ../setupServer.js
|
||||||
env:
|
env:
|
||||||
SERVER_PATH: "?users"
|
SERVER_PATH: "?users"
|
||||||
- runFlow:
|
- runFlow:
|
||||||
file: ../setupApp.yml
|
file: ../setupApp.yml
|
||||||
- tapOn:
|
- tapOn:
|
||||||
id: "e2eSignInAlice"
|
id: "e2eSignInAlice"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
id: "viewHeaderHomeFeedPrefsBtn"
|
||||||
|
|
||||||
- tapOn:
|
- tapOn:
|
||||||
id: "e2eStartOnboarding"
|
id: "e2eStartOnboarding"
|
||||||
- tapOn: "Select an avatar"
|
- tapOn: "Select an avatar"
|
||||||
@@ -18,12 +22,12 @@ appId: xyz.blueskyweb.app
|
|||||||
- waitForAnimationToEnd
|
- waitForAnimationToEnd
|
||||||
- tapOn: "Done"
|
- tapOn: "Done"
|
||||||
- waitForAnimationToEnd
|
- waitForAnimationToEnd
|
||||||
- tapOn: "Continue to next step"
|
- tapOn:
|
||||||
|
id: "onboardingContinue"
|
||||||
- assertVisible: "What are your interests?"
|
- assertVisible: "What are your interests?"
|
||||||
- tapOn:
|
- tapOn:
|
||||||
label: "Tap on continue"
|
id: "onboardingContinue"
|
||||||
point: "50%,92%"
|
|
||||||
- assertVisible: "You're ready to go!"
|
- assertVisible: "You're ready to go!"
|
||||||
- tapOn:
|
- tapOn:
|
||||||
label: "Tap on Lets go"
|
id: "onboardingFinish"
|
||||||
point: "50%,92%"
|
- assertVisible: "Following"
|
||||||
|
|||||||
@@ -3,11 +3,14 @@ appId: xyz.blueskyweb.app
|
|||||||
- runScript:
|
- runScript:
|
||||||
file: ../setupServer.js
|
file: ../setupServer.js
|
||||||
env:
|
env:
|
||||||
SERVER_PATH: "?users&follows&posts"
|
SERVER_PATH: "?users&follows&posts"
|
||||||
- runFlow:
|
- runFlow:
|
||||||
file: ../setupApp.yml
|
file: ../setupApp.yml
|
||||||
- tapOn:
|
- tapOn:
|
||||||
id: "e2eSignInAlice"
|
id: "e2eSignInAlice"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
id: "viewHeaderHomeFeedPrefsBtn"
|
||||||
|
|
||||||
- tapOn:
|
- tapOn:
|
||||||
id: "postDropdownBtn"
|
id: "postDropdownBtn"
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ appId: xyz.blueskyweb.app
|
|||||||
- runScript:
|
- runScript:
|
||||||
file: ../setupServer.js
|
file: ../setupServer.js
|
||||||
env:
|
env:
|
||||||
SERVER_PATH: "?users&posts&feeds"
|
SERVER_PATH: "?users&posts&feeds"
|
||||||
- runFlow:
|
- runFlow:
|
||||||
file: ../setupApp.yml
|
file: ../setupApp.yml
|
||||||
- tapOn:
|
- tapOn:
|
||||||
@@ -12,10 +12,10 @@ appId: xyz.blueskyweb.app
|
|||||||
# Navigate to another user profile
|
# Navigate to another user profile
|
||||||
- extendedWaitUntil:
|
- extendedWaitUntil:
|
||||||
visible:
|
visible:
|
||||||
id: "bottomBarSearchBtn"
|
id: "bottomBarSearchBtn"
|
||||||
- tapOn:
|
- tapOn:
|
||||||
id: "bottomBarSearchBtn"
|
id: "bottomBarSearchBtn"
|
||||||
- tapOn: "Search for posts, users, or feeds"
|
- tapOn: "Search for posts, users[,]? or feeds"
|
||||||
- inputText: "b"
|
- inputText: "b"
|
||||||
- tapOn:
|
- tapOn:
|
||||||
id: "searchAutoCompleteResult-bob.test"
|
id: "searchAutoCompleteResult-bob.test"
|
||||||
@@ -38,4 +38,4 @@ appId: xyz.blueskyweb.app
|
|||||||
- tapOn:
|
- tapOn:
|
||||||
id: "profileHeaderDropdownBtn"
|
id: "profileHeaderDropdownBtn"
|
||||||
- tapOn: "Unmute Account"
|
- tapOn: "Unmute Account"
|
||||||
- assertNotVisible: "Account Muted"
|
- assertNotVisible: "Account Muted"
|
||||||
|
|||||||
@@ -3,20 +3,22 @@ appId: xyz.blueskyweb.app
|
|||||||
- runScript:
|
- runScript:
|
||||||
file: ../setupServer.js
|
file: ../setupServer.js
|
||||||
env:
|
env:
|
||||||
SERVER_PATH: "?users"
|
SERVER_PATH: "?users"
|
||||||
- runFlow:
|
- runFlow:
|
||||||
file: ../setupApp.yml
|
file: ../setupApp.yml
|
||||||
- tapOn:
|
- tapOn:
|
||||||
id: "e2eSignInAlice"
|
id: "e2eSignInAlice"
|
||||||
|
|
||||||
# Navigate to another user profile via autocomplete
|
# Navigate to another user profile via autocomplete
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
id: "bottomBarSearchBtn"
|
||||||
- tapOn:
|
- tapOn:
|
||||||
id: "bottomBarSearchBtn"
|
id: "bottomBarSearchBtn"
|
||||||
- assertVisible: "Search for posts, users, or feeds"
|
- assertVisible: "Search for posts, users[,]? or feeds"
|
||||||
- tapOn: "Search for posts, users, or feeds"
|
- tapOn: "Search for posts, users[,]? or feeds"
|
||||||
- inputText: "b"
|
- inputText: "b"
|
||||||
- tapOn:
|
- tapOn:
|
||||||
id: "searchAutoCompleteResult-bob.test"
|
id: "searchAutoCompleteResult-bob.test"
|
||||||
- assertVisible:
|
- assertVisible:
|
||||||
id: "profileView"
|
id: "profileView"
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,12 @@ appId: xyz.blueskyweb.app
|
|||||||
file: ../setupApp.yml
|
file: ../setupApp.yml
|
||||||
- tapOn:
|
- tapOn:
|
||||||
id: "e2eSignInAlice"
|
id: "e2eSignInAlice"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
id: "viewHeaderHomeFeedPrefsBtn"
|
||||||
|
|
||||||
- assertVisible:
|
- assertVisible:
|
||||||
id: "storybookBtn"
|
id: "storybookBtn"
|
||||||
- tapOn:
|
- tapOn:
|
||||||
id: "storybookBtn"
|
id: "storybookBtn"
|
||||||
- tapOn:
|
- tapOn:
|
||||||
|
|||||||
@@ -3,13 +3,16 @@ appId: xyz.blueskyweb.app
|
|||||||
- runScript:
|
- runScript:
|
||||||
file: ../setupServer.js
|
file: ../setupServer.js
|
||||||
env:
|
env:
|
||||||
SERVER_PATH: "?users&follows"
|
SERVER_PATH: "?users&follows"
|
||||||
- runFlow:
|
- runFlow:
|
||||||
file: ../setupApp.yml
|
file: ../setupApp.yml
|
||||||
|
|
||||||
# Login, create a thread, and log out
|
# Login, create a thread, and log out
|
||||||
- tapOn:
|
- tapOn:
|
||||||
id: "e2eSignInAlice"
|
id: "e2eSignInAlice"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
id: "viewHeaderHomeFeedPrefsBtn"
|
||||||
- assertVisible:
|
- assertVisible:
|
||||||
id: "composeFAB"
|
id: "composeFAB"
|
||||||
- tapOn:
|
- tapOn:
|
||||||
@@ -19,8 +22,13 @@ appId: xyz.blueskyweb.app
|
|||||||
id: "composerPublishBtn"
|
id: "composerPublishBtn"
|
||||||
|
|
||||||
# Login, reply to the thread, and log out
|
# Login, reply to the thread, and log out
|
||||||
|
- tapOn:
|
||||||
|
id: "e2eSignOut"
|
||||||
- tapOn:
|
- tapOn:
|
||||||
id: "e2eSignInBob"
|
id: "e2eSignInBob"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
id: "viewHeaderHomeFeedPrefsBtn"
|
||||||
- tapOn:
|
- tapOn:
|
||||||
id: "replyBtn"
|
id: "replyBtn"
|
||||||
- inputText: "Reply 1"
|
- inputText: "Reply 1"
|
||||||
@@ -28,8 +36,13 @@ appId: xyz.blueskyweb.app
|
|||||||
id: "composerPublishBtn"
|
id: "composerPublishBtn"
|
||||||
|
|
||||||
# Login, confirm notification exists, mute thread, and log out
|
# Login, confirm notification exists, mute thread, and log out
|
||||||
|
- tapOn:
|
||||||
|
id: "e2eSignOut"
|
||||||
- tapOn:
|
- tapOn:
|
||||||
id: "e2eSignInAlice"
|
id: "e2eSignInAlice"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
id: "viewHeaderHomeFeedPrefsBtn"
|
||||||
- tapOn:
|
- tapOn:
|
||||||
id: "bottomBarNotificationsBtn"
|
id: "bottomBarNotificationsBtn"
|
||||||
- assertVisible:
|
- assertVisible:
|
||||||
@@ -39,12 +52,17 @@ appId: xyz.blueskyweb.app
|
|||||||
- tapOn:
|
- tapOn:
|
||||||
id: "postDropdownBtn"
|
id: "postDropdownBtn"
|
||||||
childOf:
|
childOf:
|
||||||
id: "postThreadItem-by-bob.test"
|
id: "postThreadItem-by-bob.test"
|
||||||
- tapOn: "Mute thread"
|
- tapOn: "Mute thread"
|
||||||
|
|
||||||
# Login, reply to the thread twice, and log out
|
# Login, reply to the thread twice, and log out
|
||||||
|
- tapOn:
|
||||||
|
id: "e2eSignOut"
|
||||||
- tapOn:
|
- tapOn:
|
||||||
id: "e2eSignInBob"
|
id: "e2eSignInBob"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
id: "viewHeaderHomeFeedPrefsBtn"
|
||||||
- tapOn:
|
- tapOn:
|
||||||
id: "bottomBarProfileBtn"
|
id: "bottomBarProfileBtn"
|
||||||
- tapOn:
|
- tapOn:
|
||||||
@@ -60,24 +78,35 @@ appId: xyz.blueskyweb.app
|
|||||||
- tapOn:
|
- tapOn:
|
||||||
id: "composerPublishBtn"
|
id: "composerPublishBtn"
|
||||||
|
|
||||||
|
# Login, confirm notifications dont exist, unmute the thread, ~~confirm notifications exist~~
|
||||||
# Login, confirm notifications dont exist, unmute the thread, confirm notifications exist
|
# Mute thread behaviour no longer change old notifications after muting/unmuting a thread -sfn
|
||||||
|
- tapOn:
|
||||||
|
id: "e2eSignOut"
|
||||||
- tapOn:
|
- tapOn:
|
||||||
id: "e2eSignInAlice"
|
id: "e2eSignInAlice"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
id: "viewHeaderHomeFeedPrefsBtn"
|
||||||
- tapOn:
|
- tapOn:
|
||||||
id: "bottomBarNotificationsBtn"
|
id: "bottomBarNotificationsBtn"
|
||||||
- assertNotVisible:
|
- assertVisible: ".*Reply 1.*"
|
||||||
|
- assertNotVisible: ".*Reply 2.*"
|
||||||
|
- assertNotVisible: ".*Reply 3.*"
|
||||||
|
- assertVisible:
|
||||||
id: "feedItem-by-bob.test"
|
id: "feedItem-by-bob.test"
|
||||||
- tapOn:
|
- tapOn:
|
||||||
id: "bottomBarHomeBtn"
|
id: "feedItem-by-bob.test"
|
||||||
- tapOn:
|
- tapOn:
|
||||||
id: "postDropdownBtn"
|
id: "postDropdownBtn"
|
||||||
|
childOf:
|
||||||
|
id: "postThreadItem-by-bob.test"
|
||||||
- tapOn: "Unmute thread"
|
- tapOn: "Unmute thread"
|
||||||
- tapOn:
|
- tapOn:
|
||||||
id: "bottomBarNotificationsBtn"
|
id: "bottomBarNotificationsBtn"
|
||||||
- swipe:
|
- swipe:
|
||||||
from:
|
from:
|
||||||
id: "notifsFeed"
|
id: "notifsFeed"
|
||||||
direction: DOWN
|
direction: DOWN
|
||||||
- assertVisible:
|
- assertVisible: ".*Reply 1.*"
|
||||||
id: "feedItem-by-bob.test"
|
- assertNotVisible: ".*Reply 2.*"
|
||||||
|
- assertNotVisible: ".*Reply 3.*"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import {createServer as createHTTPServer} from 'node:http'
|
import {createServer as createHTTPServer} from 'node:http'
|
||||||
import {parse} from 'node:url'
|
import {parse} from 'node:url'
|
||||||
|
|
||||||
import {createServer, TestPDS} from '../jest/test-pds'
|
import {createServer, type TestPDS} from '../jest/test-pds'
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
let server: TestPDS
|
let server: TestPDS
|
||||||
@@ -181,10 +181,10 @@ async function main() {
|
|||||||
'warn-profile',
|
'warn-profile',
|
||||||
'warn-posts',
|
'warn-posts',
|
||||||
'muted-account',
|
'muted-account',
|
||||||
'muted-by-list-account',
|
'muted-by-list-acc',
|
||||||
'blocking-account',
|
'blocking-account',
|
||||||
'blockedby-account',
|
'blockedby-account',
|
||||||
'mutual-block-account',
|
'mutual-block-acc',
|
||||||
]) {
|
]) {
|
||||||
await server.mocker.createUser(user)
|
await server.mocker.createUser(user)
|
||||||
await server.mocker.follow('alice', user)
|
await server.mocker.follow('alice', user)
|
||||||
@@ -411,16 +411,16 @@ async function main() {
|
|||||||
await server.mocker.addToMuteList(
|
await server.mocker.addToMuteList(
|
||||||
'alice',
|
'alice',
|
||||||
list,
|
list,
|
||||||
server.mocker.users['muted-by-list-account'].did,
|
server.mocker.users['muted-by-list-acc'].did,
|
||||||
)
|
)
|
||||||
await server.mocker.createPost('muted-by-list-account', 'muted post')
|
await server.mocker.createPost('muted-by-list-acc', 'muted post')
|
||||||
await server.mocker.createQuotePost(
|
await server.mocker.createQuotePost(
|
||||||
'muted-by-list-account',
|
'muted-by-list-acc',
|
||||||
'account quote post',
|
'account quote post',
|
||||||
anchorPost,
|
anchorPost,
|
||||||
)
|
)
|
||||||
await server.mocker.createReply(
|
await server.mocker.createReply(
|
||||||
'muted-by-list-account',
|
'muted-by-list-acc',
|
||||||
'account reply',
|
'account reply',
|
||||||
anchorPost,
|
anchorPost,
|
||||||
)
|
)
|
||||||
@@ -470,16 +470,16 @@ async function main() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
await server.mocker.createPost(
|
await server.mocker.createPost(
|
||||||
'mutual-block-account',
|
'mutual-block-acc',
|
||||||
'mutual-block post',
|
'mutual-block post',
|
||||||
)
|
)
|
||||||
await server.mocker.createQuotePost(
|
await server.mocker.createQuotePost(
|
||||||
'mutual-block-account',
|
'mutual-block-acc',
|
||||||
'mutual-block quote post',
|
'mutual-block quote post',
|
||||||
anchorPost,
|
anchorPost,
|
||||||
)
|
)
|
||||||
await server.mocker.createReply(
|
await server.mocker.createReply(
|
||||||
'mutual-block-account',
|
'mutual-block-acc',
|
||||||
'mutual-block reply',
|
'mutual-block reply',
|
||||||
anchorPost,
|
anchorPost,
|
||||||
)
|
)
|
||||||
@@ -488,15 +488,15 @@ async function main() {
|
|||||||
repo: server.mocker.users.alice.did,
|
repo: server.mocker.users.alice.did,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
subject: server.mocker.users['mutual-block-account'].did,
|
subject: server.mocker.users['mutual-block-acc'].did,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
await server.mocker.users[
|
await server.mocker.users[
|
||||||
'mutual-block-account'
|
'mutual-block-acc'
|
||||||
].agent.app.bsky.graph.block.create(
|
].agent.app.bsky.graph.block.create(
|
||||||
{
|
{
|
||||||
repo: server.mocker.users['mutual-block-account'].did,
|
repo: server.mocker.users['mutual-block-acc'].did,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
subject: server.mocker.users.alice.did,
|
subject: server.mocker.users.alice.did,
|
||||||
@@ -509,7 +509,16 @@ async function main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
console.log('Ready')
|
console.log('Ready')
|
||||||
return res.writeHead(200).end(server.pdsUrl)
|
return res
|
||||||
|
.writeHead(200, {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
})
|
||||||
|
.end(
|
||||||
|
JSON.stringify({
|
||||||
|
pdsUrl: server.pdsUrl,
|
||||||
|
appviewDid: server.appviewDid,
|
||||||
|
}),
|
||||||
|
)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Error!', e)
|
console.error('Error!', e)
|
||||||
return res.writeHead(500).end()
|
return res.writeHead(500).end()
|
||||||
|
|||||||
@@ -11,3 +11,8 @@ appId: xyz.blueskyweb.app
|
|||||||
- swipe:
|
- swipe:
|
||||||
from: "Bluesky"
|
from: "Bluesky"
|
||||||
direction: DOWN
|
direction: DOWN
|
||||||
|
- tapOn:
|
||||||
|
id: e2eProxyHeaderInput
|
||||||
|
- inputText: ${output.result}
|
||||||
|
- pressKey: Enter
|
||||||
|
- hideKeyboard
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
// eslint-disable-next-line
|
// eslint-disable-next-line no-undef
|
||||||
http.post('http://localhost:1986/' + SERVER_PATH, {
|
var res = http.post('http://localhost:1986/' + SERVER_PATH, {
|
||||||
headers: {'Content-Type': 'text/plain'},
|
headers: {'Content-Type': 'text/plain'},
|
||||||
body: '',
|
body: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-undef
|
||||||
|
output.result = json(res.body).appviewDid
|
||||||
|
|||||||
@@ -303,6 +303,11 @@ module.exports = function (_config) {
|
|||||||
android: './assets/app-icons/android_icon_default_dark.png',
|
android: './assets/app-icons/android_icon_default_dark.png',
|
||||||
prerendered: true,
|
prerendered: true,
|
||||||
},
|
},
|
||||||
|
next: {
|
||||||
|
ios: './assets/app-icons/icon_default_next.png',
|
||||||
|
android: './assets/app-icons/icon_default_next.png',
|
||||||
|
prerendered: true,
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bluesky+ core set
|
* Bluesky+ core set
|
||||||
|
|||||||
|
After Width: | Height: | Size: 486 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 129 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 198 KiB |
|
After Width: | Height: | Size: 195 KiB |
|
After Width: | Height: | Size: 152 KiB |
|
After Width: | Height: | Size: 140 KiB |
|
After Width: | Height: | Size: 134 KiB |
|
After Width: | Height: | Size: 103 KiB |
@@ -6,24 +6,25 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
"build-snippet": "tsc --project tsconfig.snippet.json",
|
"build-snippet": "tsc --project tsconfig.snippet.json",
|
||||||
"lint": "eslint --cache --ext .js,.jsx,.ts,.tsx src"
|
"lint": "eslint --cache --ext .js,.jsx,.ts,.tsx src",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@atproto/api": "0.13.6",
|
"@atproto/api": "^0.15.25",
|
||||||
"@preact/preset-vite": "^2.8.2",
|
"preact": "^10.4.8"
|
||||||
"@vitejs/plugin-legacy": "^5.3.2",
|
|
||||||
"preact": "^10.4.8",
|
|
||||||
"terser": "^5.30.3"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@preact/preset-vite": "^2.10.2",
|
||||||
|
"@vitejs/plugin-legacy": "^7.0.0",
|
||||||
"autoprefixer": "^10.4.19",
|
"autoprefixer": "^10.4.19",
|
||||||
"eslint": "^8.19.0",
|
"eslint": "^8.19.0",
|
||||||
"eslint-config-preact": "^1.3.0",
|
"eslint-config-preact": "^1.3.0",
|
||||||
"eslint-plugin-simple-import-sort": "^12.0.0",
|
"eslint-plugin-simple-import-sort": "^12.0.0",
|
||||||
"postcss": "^8.4.38",
|
"postcss": "^8.4.38",
|
||||||
"tailwindcss": "^3.4.3",
|
"tailwindcss": "^3.4.3",
|
||||||
"typescript": "^5.5.4",
|
"terser": "^5.43.1",
|
||||||
"vite": "^5.2.8",
|
"typescript": "^5.8.3",
|
||||||
"vite-tsconfig-paths": "^4.3.2"
|
"vite": "^7.0.4",
|
||||||
|
"vite-tsconfig-paths": "^5.1.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export function Container({
|
|||||||
}
|
}
|
||||||
}}>
|
}}>
|
||||||
{href && <Link href={href} />}
|
{href && <Link href={href} />}
|
||||||
<div className="flex-1 px-4 pt-3 pb-2.5">{children}</div>
|
<div className="flex-1 px-4 pt-3 pb-2.5 max-w-full">{children}</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,8 +17,11 @@ import infoIcon from '../../assets/circleInfo_stroke2_corner0_rounded.svg'
|
|||||||
import playIcon from '../../assets/play_filled_corner2_rounded.svg'
|
import playIcon from '../../assets/play_filled_corner2_rounded.svg'
|
||||||
import starterPackIcon from '../../assets/starterPack.svg'
|
import starterPackIcon from '../../assets/starterPack.svg'
|
||||||
import {CONTENT_LABELS, labelsToInfo} from '../labels'
|
import {CONTENT_LABELS, labelsToInfo} from '../labels'
|
||||||
import {getRkey} from '../utils'
|
import * as bsky from '../types/bsky'
|
||||||
|
import {getRkey} from '../util/rkey'
|
||||||
|
import {getVerificationState} from '../util/verification-state'
|
||||||
import {Link} from './link'
|
import {Link} from './link'
|
||||||
|
import {VerificationCheck} from './verification-check'
|
||||||
|
|
||||||
export function Embed({
|
export function Embed({
|
||||||
content,
|
content,
|
||||||
@@ -75,23 +78,35 @@ export function Embed({
|
|||||||
CONTENT_LABELS.includes(label.val),
|
CONTENT_LABELS.includes(label.val),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const verification = getVerificationState({profile: record.author})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={`/profile/${record.author.did}/post/${getRkey(record)}`}
|
href={`/profile/${record.author.did}/post/${getRkey(record)}`}
|
||||||
className="transition-colors hover:bg-neutral-100 dark:hover:bg-slate-700 border dark:border-slate-600 rounded-xl p-2 gap-1.5 w-full flex flex-col">
|
className="transition-colors hover:bg-neutral-100 dark:hover:bg-slate-700 border dark:border-slate-600 rounded-xl p-2 gap-1.5 w-full flex flex-col">
|
||||||
<div className="flex gap-1.5 items-center">
|
<div className="flex gap-1.5 items-center">
|
||||||
<div className="w-4 h-4 overflow-hidden rounded-full bg-neutral-300 dark:bg-slate-700 shrink-0">
|
<div className="w-4 h-4 rounded-full bg-neutral-300 dark:bg-slate-700 shrink-0">
|
||||||
<img
|
<img
|
||||||
|
className="rounded-full"
|
||||||
src={record.author.avatar}
|
src={record.author.avatar}
|
||||||
style={isAuthorLabeled ? {filter: 'blur(1.5px)'} : undefined}
|
style={isAuthorLabeled ? {filter: 'blur(1.5px)'} : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="line-clamp-1 text-sm">
|
<div className="flex flex-1 items-center shrink min-w-0 min-h-0">
|
||||||
<span className="font-bold">{record.author.displayName}</span>
|
<p className="block text-sm shrink-0 font-bold max-w-[70%] line-clamp-1">
|
||||||
<span className="text-textLight dark:text-textDimmed ml-1">
|
{record.author.displayName?.trim() || record.author.handle}
|
||||||
|
</p>
|
||||||
|
{verification.isVerified && (
|
||||||
|
<VerificationCheck
|
||||||
|
className="ml-[3px] mt-px shrink-0 self-center"
|
||||||
|
verifier={verification.role === 'verifier'}
|
||||||
|
size={12}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<p className="block line-clamp-1 text-sm text-textLight dark:text-textDimmed shrink-[10] ml-1">
|
||||||
@{record.author.handle}
|
@{record.author.handle}
|
||||||
</span>
|
</p>
|
||||||
</p>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{text && <p className="text-sm">{text}</p>}
|
{text && <p className="text-sm">{text}</p>}
|
||||||
{record.embeds?.map(embed => (
|
{record.embeds?.map(embed => (
|
||||||
@@ -404,7 +419,12 @@ function StarterPackEmbed({
|
|||||||
}: {
|
}: {
|
||||||
content: AppBskyGraphDefs.StarterPackViewBasic
|
content: AppBskyGraphDefs.StarterPackViewBasic
|
||||||
}) {
|
}) {
|
||||||
if (!AppBskyGraphStarterpack.isRecord(content.record)) {
|
if (
|
||||||
|
!bsky.dangerousIsType<AppBskyGraphStarterpack.Record>(
|
||||||
|
content.record,
|
||||||
|
AppBskyGraphStarterpack.isRecord,
|
||||||
|
)
|
||||||
|
) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -443,7 +463,9 @@ function StarterPackEmbed({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// from #/lib/strings/starter-pack.ts
|
// from #/lib/strings/starter-pack.ts
|
||||||
function getStarterPackImage(starterPack: AppBskyGraphDefs.StarterPackView) {
|
function getStarterPackImage(
|
||||||
|
starterPack: AppBskyGraphDefs.StarterPackViewBasic,
|
||||||
|
) {
|
||||||
const rkey = getRkey({uri: starterPack.uri})
|
const rkey = getRkey({uri: starterPack.uri})
|
||||||
return `https://ogcard.cdn.bsky.app/start/${starterPack.creator.did}/${rkey}`
|
return `https://ogcard.cdn.bsky.app/start/${starterPack.creator.did}/${rkey}`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,10 +11,15 @@ import likeIcon from '../../assets/heart2_filled_stroke2_corner0_rounded.svg'
|
|||||||
import logo from '../../assets/logo.svg'
|
import logo from '../../assets/logo.svg'
|
||||||
import repostIcon from '../../assets/repost_stroke2_corner2_rounded.svg'
|
import repostIcon from '../../assets/repost_stroke2_corner2_rounded.svg'
|
||||||
import {CONTENT_LABELS} from '../labels'
|
import {CONTENT_LABELS} from '../labels'
|
||||||
import {getRkey, niceDate, prettyNumber} from '../utils'
|
import * as bsky from '../types/bsky'
|
||||||
|
import {niceDate} from '../util/nice-date'
|
||||||
|
import {prettyNumber} from '../util/pretty-number'
|
||||||
|
import {getRkey} from '../util/rkey'
|
||||||
|
import {getVerificationState} from '../util/verification-state'
|
||||||
import {Container} from './container'
|
import {Container} from './container'
|
||||||
import {Embed} from './embed'
|
import {Embed} from './embed'
|
||||||
import {Link} from './link'
|
import {Link} from './link'
|
||||||
|
import {VerificationCheck} from './verification-check'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
thread: AppBskyFeedDefs.ThreadViewPost
|
thread: AppBskyFeedDefs.ThreadViewPost
|
||||||
@@ -28,16 +33,25 @@ export function Post({thread}: Props) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
let record: AppBskyFeedPost.Record | null = null
|
let record: AppBskyFeedPost.Record | null = null
|
||||||
if (AppBskyFeedPost.isRecord(post.record)) {
|
if (
|
||||||
|
bsky.dangerousIsType<AppBskyFeedPost.Record>(
|
||||||
|
post.record,
|
||||||
|
AppBskyFeedPost.isRecord,
|
||||||
|
)
|
||||||
|
) {
|
||||||
record = post.record
|
record = post.record
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const verification = getVerificationState({profile: post.author})
|
||||||
|
|
||||||
const href = `/profile/${post.author.did}/post/${getRkey(post)}`
|
const href = `/profile/${post.author.did}/post/${getRkey(post)}`
|
||||||
return (
|
return (
|
||||||
<Container href={href}>
|
<Container href={href}>
|
||||||
<div className="flex-1 flex-col flex gap-2" lang={record?.langs?.[0]}>
|
<div className="flex-1 flex-col flex gap-2" lang={record?.langs?.[0]}>
|
||||||
<div className="flex gap-2.5 items-center cursor-pointer">
|
<div className="flex gap-2.5 items-center cursor-pointer w-full max-w-full">
|
||||||
<Link href={`/profile/${post.author.did}`} className="rounded-full">
|
<Link
|
||||||
|
href={`/profile/${post.author.did}`}
|
||||||
|
className="rounded-full shrink-0">
|
||||||
<div className="w-10 h-10 overflow-hidden rounded-full bg-neutral-300 dark:bg-slate-700 shrink-0">
|
<div className="w-10 h-10 overflow-hidden rounded-full bg-neutral-300 dark:bg-slate-700 shrink-0">
|
||||||
<img
|
<img
|
||||||
src={post.author.avatar}
|
src={post.author.avatar}
|
||||||
@@ -45,19 +59,27 @@ export function Post({thread}: Props) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
<div>
|
<div className="flex flex-1 flex-col min-w-0">
|
||||||
|
<div className="flex flex-1 items-center">
|
||||||
|
<Link
|
||||||
|
href={`/profile/${post.author.did}`}
|
||||||
|
className="block font-bold text-[17px] leading-5 line-clamp-1 hover:underline underline-offset-2 text-ellipsis decoration-2">
|
||||||
|
{post.author.displayName?.trim() || post.author.handle}
|
||||||
|
</Link>
|
||||||
|
{verification.isVerified && (
|
||||||
|
<VerificationCheck
|
||||||
|
className="pl-[3px] mt-px shrink-0"
|
||||||
|
verifier={verification.role === 'verifier'}
|
||||||
|
size={15}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<Link
|
<Link
|
||||||
href={`/profile/${post.author.did}`}
|
href={`/profile/${post.author.did}`}
|
||||||
className="font-bold text-[17px] leading-5 line-clamp-1 hover:underline underline-offset-2 decoration-2">
|
className="block text-[15px] text-textLight dark:text-textDimmed hover:underline line-clamp-1">
|
||||||
<p>{post.author.displayName}</p>
|
@{post.author.handle}
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href={`/profile/${post.author.did}`}
|
|
||||||
className="text-[15px] text-textLight dark:text-textDimmed hover:underline line-clamp-1">
|
|
||||||
<p>@{post.author.handle}</p>
|
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1" />
|
|
||||||
<Link
|
<Link
|
||||||
href={href}
|
href={href}
|
||||||
className="transition-transform hover:scale-110 shrink-0 self-start">
|
className="transition-transform hover:scale-110 shrink-0 self-start">
|
||||||
@@ -133,7 +155,7 @@ function PostContent({record}: {record: AppBskyFeedPost.Record | null}) {
|
|||||||
<Link
|
<Link
|
||||||
key={counter}
|
key={counter}
|
||||||
href={segment.link.uri}
|
href={segment.link.uri}
|
||||||
className="text-blue-400 hover:underline"
|
className="text-blue-500 hover:underline"
|
||||||
disableTracking={
|
disableTracking={
|
||||||
!segment.link.uri.startsWith('https://bsky.app') &&
|
!segment.link.uri.startsWith('https://bsky.app') &&
|
||||||
!segment.link.uri.startsWith('https://go.bsky.app')
|
!segment.link.uri.startsWith('https://go.bsky.app')
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import {h} from 'preact'
|
||||||
|
|
||||||
|
type IconProps = {
|
||||||
|
size: number
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VerificationCheck({
|
||||||
|
verifier,
|
||||||
|
...rest
|
||||||
|
}: {verifier: boolean} & IconProps) {
|
||||||
|
return verifier ? <VerifierCheck {...rest} /> : <VerifiedCheck {...rest} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VerifiedCheck({size, className}: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
className={className}>
|
||||||
|
<circle cx="12" cy="12" r="11.5" fill="hsl(211, 99%, 53%)" />
|
||||||
|
<path
|
||||||
|
fill="#fff"
|
||||||
|
fillRule="evenodd"
|
||||||
|
clipRule="evenodd"
|
||||||
|
d="M17.659 8.175a1.361 1.361 0 0 1 0 1.925l-6.224 6.223a1.361 1.361 0 0 1-1.925 0L6.4 13.212a1.361 1.361 0 0 1 1.925-1.925l2.149 2.148 5.26-5.26a1.361 1.361 0 0 1 1.925 0Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VerifierCheck({size, className}: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
className={className}>
|
||||||
|
<path
|
||||||
|
fill="hsl(211, 99%, 53%)"
|
||||||
|
fillRule="evenodd"
|
||||||
|
clipRule="evenodd"
|
||||||
|
d="M8.792 1.615a4.154 4.154 0 0 1 6.416 0 4.154 4.154 0 0 0 3.146 1.515 4.154 4.154 0 0 1 4 5.017 4.154 4.154 0 0 0 .777 3.404 4.154 4.154 0 0 1-1.427 6.255 4.153 4.153 0 0 0-2.177 2.73 4.154 4.154 0 0 1-5.781 2.784 4.154 4.154 0 0 0-3.492 0 4.154 4.154 0 0 1-5.78-2.784 4.154 4.154 0 0 0-2.178-2.73A4.154 4.154 0 0 1 .87 11.551a4.154 4.154 0 0 0 .776-3.404A4.154 4.154 0 0 1 5.646 3.13a4.154 4.154 0 0 0 3.146-1.515Z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="#fff"
|
||||||
|
fillRule="evenodd"
|
||||||
|
clipRule="evenodd"
|
||||||
|
d="M17.861 8.26a1.438 1.438 0 0 1 0 2.033l-6.571 6.571a1.437 1.437 0 0 1-2.033 0L5.97 13.58a1.438 1.438 0 0 1 2.033-2.033l2.27 2.269 5.554-5.555a1.437 1.437 0 0 1 2.033 0Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -14,9 +14,11 @@ import {
|
|||||||
import {Container} from '../components/container'
|
import {Container} from '../components/container'
|
||||||
import {Link} from '../components/link'
|
import {Link} from '../components/link'
|
||||||
import {Post} from '../components/post'
|
import {Post} from '../components/post'
|
||||||
import {niceDate} from '../utils'
|
import * as bsky from '../types/bsky'
|
||||||
|
import {niceDate} from '../util/nice-date'
|
||||||
|
|
||||||
const DEFAULT_POST = 'https://bsky.app/profile/emilyliu.me/post/3jzn6g7ixgq2y'
|
const DEFAULT_POST =
|
||||||
|
'https://bsky.app/profile/did:plc:vjug55kidv6sye7ykr5faxxn/post/3jzn6g7ixgq2y'
|
||||||
const DEFAULT_URI =
|
const DEFAULT_URI =
|
||||||
'at://did:plc:vjug55kidv6sye7ykr5faxxn/app.bsky.feed.post/3jzn6g7ixgq2y'
|
'at://did:plc:vjug55kidv6sye7ykr5faxxn/app.bsky.feed.post/3jzn6g7ixgq2y'
|
||||||
|
|
||||||
@@ -222,7 +224,12 @@ function Snippet({
|
|||||||
const snippet = useMemo(() => {
|
const snippet = useMemo(() => {
|
||||||
const record = thread.post.record
|
const record = thread.post.record
|
||||||
|
|
||||||
if (!AppBskyFeedPost.isRecord(record)) {
|
if (
|
||||||
|
!bsky.dangerousIsType<AppBskyFeedPost.Record>(
|
||||||
|
record,
|
||||||
|
AppBskyFeedPost.isRecord,
|
||||||
|
)
|
||||||
|
) {
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {applyTheme, initSystemColorMode} from '../color-mode'
|
|||||||
import {Container} from '../components/container'
|
import {Container} from '../components/container'
|
||||||
import {Link} from '../components/link'
|
import {Link} from '../components/link'
|
||||||
import {Post} from '../components/post'
|
import {Post} from '../components/post'
|
||||||
import {getRkey} from '../utils'
|
import {getRkey} from '../util/rkey'
|
||||||
|
|
||||||
const root = document.getElementById('app')
|
const root = document.getElementById('app')
|
||||||
if (!root) throw new Error('No root element')
|
if (!root) throw new Error('No root element')
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import {ValidationResult} from '@atproto/lexicon'
|
||||||
|
|
||||||
|
export * as profile from './profile'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fast type checking without full schema validation, for use with data we
|
||||||
|
* trust, or for non-critical path use cases. Why? Our SDK's `is*` identity
|
||||||
|
* utils do not assert the type of the entire object, only the `$type` string.
|
||||||
|
*
|
||||||
|
* For full validation of the object schema, use the `validate` export from
|
||||||
|
* this file.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* ```ts
|
||||||
|
* import * as bsky from '#/types/bsky'
|
||||||
|
*
|
||||||
|
* if (bsky.dangerousIsType<AppBskyFeedPost.Record>(item, AppBskyFeedPost.isRecord)) {
|
||||||
|
* // `item` has type `$Typed<AppBskyFeedPost.Record>` here
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function dangerousIsType<R extends {$type?: string}>(
|
||||||
|
record: unknown,
|
||||||
|
identity: <V>(v: V) => v is V & {$type: NonNullable<R['$type']>},
|
||||||
|
): record is R {
|
||||||
|
return identity(record)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fully validates the object schema, which has a performance cost.
|
||||||
|
*
|
||||||
|
* For faster checks with data we trust, like that from our app view, use the
|
||||||
|
* `dangerousIsType` export from this same file.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* ```ts
|
||||||
|
* import * as bsky from '#/types/bsky'
|
||||||
|
*
|
||||||
|
* if (bsky.validate(item, AppBskyFeedPost.validateRecord)) {
|
||||||
|
* // `item` has type `$Typed<AppBskyFeedPost.Record>` here
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function validate<R extends {$type?: string}>(
|
||||||
|
record: unknown,
|
||||||
|
validator: (v: unknown) => ValidationResult<R>,
|
||||||
|
): record is R {
|
||||||
|
return validator(record).success
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import {type AppBskyActorDefs, type ChatBskyActorDefs} from '@atproto/api'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Matches any profile view exported by our SDK
|
||||||
|
*/
|
||||||
|
export type AnyProfileView =
|
||||||
|
| AppBskyActorDefs.ProfileViewBasic
|
||||||
|
| AppBskyActorDefs.ProfileView
|
||||||
|
| AppBskyActorDefs.ProfileViewDetailed
|
||||||
|
| ChatBskyActorDefs.ProfileViewBasic
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
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',
|
||||||
|
})}`
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
/**
|
||||||
|
* This file is a copy of what exists in the social-app
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
AppBskyEmbedExternal,
|
||||||
|
AppBskyEmbedImages,
|
||||||
|
AppBskyEmbedRecord,
|
||||||
|
AppBskyEmbedRecordWithMedia,
|
||||||
|
AppBskyEmbedVideo,
|
||||||
|
AppBskyFeedDefs,
|
||||||
|
AppBskyGraphDefs,
|
||||||
|
AppBskyLabelerDefs,
|
||||||
|
} from '@atproto/api'
|
||||||
|
|
||||||
|
export type Embed =
|
||||||
|
| {
|
||||||
|
type: 'post'
|
||||||
|
view: AppBskyEmbedRecord.ViewRecord
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'post_not_found'
|
||||||
|
view: AppBskyEmbedRecord.ViewNotFound
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'post_blocked'
|
||||||
|
view: AppBskyEmbedRecord.ViewBlocked
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'post_detached'
|
||||||
|
view: AppBskyEmbedRecord.ViewDetached
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'feed'
|
||||||
|
view: AppBskyFeedDefs.GeneratorView
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'list'
|
||||||
|
view: AppBskyGraphDefs.ListView
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'labeler'
|
||||||
|
view: AppBskyLabelerDefs.LabelerView
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'starter_pack'
|
||||||
|
view: AppBskyGraphDefs.StarterPackViewBasic
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'images'
|
||||||
|
view: AppBskyEmbedImages.View
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'link'
|
||||||
|
view: AppBskyEmbedExternal.View
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'video'
|
||||||
|
view: AppBskyEmbedVideo.View
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'post_with_media'
|
||||||
|
view: Embed
|
||||||
|
media: Embed
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'unknown'
|
||||||
|
view: null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EmbedType<T extends Embed['type']> = Extract<Embed, {type: T}>
|
||||||
|
|
||||||
|
export function parseEmbedRecordView({record}: AppBskyEmbedRecord.View): Embed {
|
||||||
|
if (AppBskyEmbedRecord.isViewRecord(record)) {
|
||||||
|
return {
|
||||||
|
type: 'post',
|
||||||
|
view: record,
|
||||||
|
}
|
||||||
|
} else if (AppBskyEmbedRecord.isViewNotFound(record)) {
|
||||||
|
return {
|
||||||
|
type: 'post_not_found',
|
||||||
|
view: record,
|
||||||
|
}
|
||||||
|
} else if (AppBskyEmbedRecord.isViewBlocked(record)) {
|
||||||
|
return {
|
||||||
|
type: 'post_blocked',
|
||||||
|
view: record,
|
||||||
|
}
|
||||||
|
} else if (AppBskyEmbedRecord.isViewDetached(record)) {
|
||||||
|
return {
|
||||||
|
type: 'post_detached',
|
||||||
|
view: record,
|
||||||
|
}
|
||||||
|
} else if (AppBskyFeedDefs.isGeneratorView(record)) {
|
||||||
|
return {
|
||||||
|
type: 'feed',
|
||||||
|
view: record,
|
||||||
|
}
|
||||||
|
} else if (AppBskyGraphDefs.isListView(record)) {
|
||||||
|
return {
|
||||||
|
type: 'list',
|
||||||
|
view: record,
|
||||||
|
}
|
||||||
|
} else if (AppBskyLabelerDefs.isLabelerView(record)) {
|
||||||
|
return {
|
||||||
|
type: 'labeler',
|
||||||
|
view: record,
|
||||||
|
}
|
||||||
|
} else if (AppBskyGraphDefs.isStarterPackViewBasic(record)) {
|
||||||
|
return {
|
||||||
|
type: 'starter_pack',
|
||||||
|
view: record,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
type: 'unknown',
|
||||||
|
view: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseEmbed(embed: AppBskyFeedDefs.PostView['embed']): Embed {
|
||||||
|
if (AppBskyEmbedImages.isView(embed)) {
|
||||||
|
return {
|
||||||
|
type: 'images',
|
||||||
|
view: embed,
|
||||||
|
}
|
||||||
|
} else if (AppBskyEmbedExternal.isView(embed)) {
|
||||||
|
return {
|
||||||
|
type: 'link',
|
||||||
|
view: embed,
|
||||||
|
}
|
||||||
|
} else if (AppBskyEmbedVideo.isView(embed)) {
|
||||||
|
return {
|
||||||
|
type: 'video',
|
||||||
|
view: embed,
|
||||||
|
}
|
||||||
|
} else if (AppBskyEmbedRecord.isView(embed)) {
|
||||||
|
return parseEmbedRecordView(embed)
|
||||||
|
} else if (AppBskyEmbedRecordWithMedia.isView(embed)) {
|
||||||
|
return {
|
||||||
|
type: 'post_with_media',
|
||||||
|
view: parseEmbedRecordView(embed.record),
|
||||||
|
media: parseEmbed(embed.media),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
type: 'unknown',
|
||||||
|
view: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
const formatter = new Intl.NumberFormat('en-US', {
|
||||||
|
notation: 'compact',
|
||||||
|
maximumFractionDigits: 1,
|
||||||
|
roundingMode: 'trunc',
|
||||||
|
})
|
||||||
|
|
||||||
|
export function prettyNumber(number: number) {
|
||||||
|
return formatter.format(number)
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import {AtUri} from '@atproto/api'
|
||||||
|
|
||||||
|
export function getRkey({uri}: {uri: string}): string {
|
||||||
|
const at = new AtUri(uri)
|
||||||
|
return at.rkey
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import * as bsky from '../types/bsky'
|
||||||
|
|
||||||
|
export type VerificationState = {
|
||||||
|
role: 'default' | 'verifier'
|
||||||
|
isVerified: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getVerificationState({
|
||||||
|
profile,
|
||||||
|
}: {
|
||||||
|
profile?: bsky.profile.AnyProfileView
|
||||||
|
}): VerificationState {
|
||||||
|
if (!profile || !profile.verification) {
|
||||||
|
return {
|
||||||
|
role: 'default',
|
||||||
|
isVerified: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const {verifiedStatus, trustedVerifierStatus} = profile.verification
|
||||||
|
const isVerifiedUser = ['valid', 'invalid'].includes(verifiedStatus)
|
||||||
|
const isVerifierUser = ['valid', 'invalid'].includes(trustedVerifierStatus)
|
||||||
|
const isVerified =
|
||||||
|
(isVerifiedUser && verifiedStatus === 'valid') ||
|
||||||
|
(isVerifierUser && trustedVerifierStatus === 'valid')
|
||||||
|
|
||||||
|
return {
|
||||||
|
role: isVerifierUser ? 'verifier' : 'default',
|
||||||
|
isVerified,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatter = new Intl.NumberFormat('en-US', {
|
|
||||||
notation: 'compact',
|
|
||||||
maximumFractionDigits: 1,
|
|
||||||
roundingMode: 'trunc',
|
|
||||||
})
|
|
||||||
|
|
||||||
export function prettyNumber(number: number) {
|
|
||||||
return formatter.format(number)
|
|
||||||
}
|
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
"strict": true,
|
"strict": true,
|
||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"moduleResolution": "Node",
|
"moduleResolution": "Bundler",
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
@@ -19,5 +19,5 @@
|
|||||||
"jsxFragmentFactory": "Fragment",
|
"jsxFragmentFactory": "Fragment",
|
||||||
"downlevelIteration": true
|
"downlevelIteration": true
|
||||||
},
|
},
|
||||||
"include": ["src", "snippet", "vite.config.ts"]
|
"include": ["src", "snippet", "vite.config.ts", "*.config.cjs", ".eslintrc.cjs"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/url"
|
||||||
|
|
||||||
|
"github.com/flosch/pongo2/v6"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
pongo2.RegisterFilter("canonicalize_url", filterCanonicalizeURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func filterCanonicalizeURL(in *pongo2.Value, param *pongo2.Value) (*pongo2.Value, *pongo2.Error) {
|
||||||
|
urlStr := in.String()
|
||||||
|
|
||||||
|
parsedURL, err := url.Parse(urlStr)
|
||||||
|
if err != nil {
|
||||||
|
// If parsing fails, return the original URL
|
||||||
|
return in, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove query parameters and fragment
|
||||||
|
parsedURL.RawQuery = ""
|
||||||
|
parsedURL.Fragment = ""
|
||||||
|
|
||||||
|
// Return the cleaned URL
|
||||||
|
return pongo2.AsValue(parsedURL.String()), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/flosch/pongo2/v6"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCanonicalizeURLFilter(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "clean URL",
|
||||||
|
input: "https://bsky.app/profile/user",
|
||||||
|
expected: "https://bsky.app/profile/user",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "URL with query params",
|
||||||
|
input: "https://bsky.app/profile/user?utm_source=test",
|
||||||
|
expected: "https://bsky.app/profile/user",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "URL with multiple params",
|
||||||
|
input: "https://bsky.app/profile/user?utm_source=twitter&utm_campaign=test",
|
||||||
|
expected: "https://bsky.app/profile/user",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "URL with fragment",
|
||||||
|
input: "https://bsky.app/profile/user#section",
|
||||||
|
expected: "https://bsky.app/profile/user",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "URL with both params and fragment",
|
||||||
|
input: "https://bsky.app/profile/user?param=1#section",
|
||||||
|
expected: "https://bsky.app/profile/user",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "malformed URL",
|
||||||
|
input: "not-a-url",
|
||||||
|
expected: "not-a-url", // Should return original on error
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
inputValue := pongo2.AsValue(tt.input)
|
||||||
|
result, err := filterCanonicalizeURL(inputValue, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("filterCanonicalizeURL() error = %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.String() != tt.expected {
|
||||||
|
t.Errorf("filterCanonicalizeURL() = %v, want %v", result.String(), tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -606,10 +606,10 @@ type IPCCRequest struct {
|
|||||||
type IPCCResponse struct {
|
type IPCCResponse struct {
|
||||||
CC string `json:"countryCode"`
|
CC string `json:"countryCode"`
|
||||||
AgeRestrictedGeo bool `json:"isAgeRestrictedGeo,omitempty"`
|
AgeRestrictedGeo bool `json:"isAgeRestrictedGeo,omitempty"`
|
||||||
|
AgeBlockedGeo bool `json:"isAgeBlockedGeo,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// IP address data is powered by IPinfo
|
// This product includes GeoLite2 Data created by MaxMind, available from https://www.maxmind.com.
|
||||||
// https://ipinfo.io
|
|
||||||
func (srv *Server) WebIpCC(c echo.Context) error {
|
func (srv *Server) WebIpCC(c echo.Context) error {
|
||||||
realIP := c.RealIP()
|
realIP := c.RealIP()
|
||||||
addr, err := netip.ParseAddr(realIP)
|
addr, err := netip.ParseAddr(realIP)
|
||||||
|
|||||||
@@ -95,6 +95,7 @@
|
|||||||
<meta name="application-name" content="Bluesky">
|
<meta name="application-name" content="Bluesky">
|
||||||
<meta name="generator" content="bskyweb">
|
<meta name="generator" content="bskyweb">
|
||||||
<meta property="og:site_name" content="Bluesky Social" />
|
<meta property="og:site_name" content="Bluesky Social" />
|
||||||
|
<meta name="twitter:site" content="@bluesky" />
|
||||||
<link type="application/activity+json" href="" />
|
<link type="application/activity+json" href="" />
|
||||||
|
|
||||||
{% block html_head_extra -%}{%- endblock %}
|
{% block html_head_extra -%}{%- endblock %}
|
||||||
|
|||||||
@@ -12,11 +12,11 @@
|
|||||||
|
|
||||||
<meta property="og:url" content="https://bsky.app" />
|
<meta property="og:url" content="https://bsky.app" />
|
||||||
<meta name="twitter:url" content="https://bsky.app" />
|
<meta name="twitter:url" content="https://bsky.app" />
|
||||||
|
<link rel="canonical" href="https://bsky.app" />
|
||||||
|
|
||||||
<meta property="og:image" content="https://bsky.app/static/social-card-default-gradient.png" />
|
<meta property="og:image" content="https://bsky.app/static/social-card-default-gradient.png" />
|
||||||
<meta property="twitter:image" content="https://bsky.app/static/social-card-default-gradient.png" />
|
<meta property="twitter:image" content="https://bsky.app/static/social-card-default-gradient.png" />
|
||||||
|
|
||||||
<meta name="twitter:site" content="@bluesky" />
|
|
||||||
<meta name="twitter:card" content="summary_large_image" />
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
{%- endblock %}
|
{%- endblock %}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
<meta property="profile:username" content="{{ profileView.Handle }}">
|
<meta property="profile:username" content="{{ profileView.Handle }}">
|
||||||
{%- if requestURI %}
|
{%- if requestURI %}
|
||||||
<meta property="og:url" content="{{ requestURI }}">
|
<meta property="og:url" content="{{ requestURI }}">
|
||||||
|
<link rel="canonical" href="{{ requestURI|canonicalize_url }}" />
|
||||||
{% endif -%}
|
{% endif -%}
|
||||||
{%- if postView.Author.DisplayName %}
|
{%- if postView.Author.DisplayName %}
|
||||||
<meta property="og:title" content="{{ postView.Author.DisplayName }} (@{{ postView.Author.Handle }})">
|
<meta property="og:title" content="{{ postView.Author.DisplayName }} (@{{ postView.Author.Handle }})">
|
||||||
@@ -23,15 +24,18 @@
|
|||||||
{%- if postText %}
|
{%- if postText %}
|
||||||
<meta name="description" content="{{ postText }}">
|
<meta name="description" content="{{ postText }}">
|
||||||
<meta property="og:description" content="{{ postText }}">
|
<meta property="og:description" content="{{ postText }}">
|
||||||
|
<meta property="twitter:description" content="{{ postText }}">
|
||||||
{% endif -%}
|
{% endif -%}
|
||||||
{%- if imgThumbUrls %}
|
{%- if imgThumbUrls %}
|
||||||
{% for imgThumbUrl in imgThumbUrls %}
|
{% for imgThumbUrl in imgThumbUrls %}
|
||||||
<meta property="og:image" content="{{ imgThumbUrl }}">
|
<meta property="og:image" content="{{ imgThumbUrl }}">
|
||||||
|
<meta property="twitter:image" content="{{ imgThumbUrl }}">
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
<meta name="twitter:card" content="summary_large_image">
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
{%- elif postView.Author.Avatar %}
|
{%- elif postView.Author.Avatar %}
|
||||||
{# Don't use avatar image in cards; usually looks bad #}
|
|
||||||
<meta name="twitter:card" content="summary">
|
<meta name="twitter:card" content="summary">
|
||||||
|
<meta property="og:image" content="{{ postView.Author.Avatar }}">
|
||||||
|
<meta property="twitter:image" content="{{ postView.Author.Avatar }}">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<meta name="twitter:label1" content="Posted At">
|
<meta name="twitter:label1" content="Posted At">
|
||||||
<meta name="twitter:value1" content="{{ postView.IndexedAt }}">
|
<meta name="twitter:value1" content="{{ postView.IndexedAt }}">
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
<meta property="profile:username" content="{{ profileView.Handle }}">
|
<meta property="profile:username" content="{{ profileView.Handle }}">
|
||||||
{%- if requestURI %}
|
{%- if requestURI %}
|
||||||
<meta property="og:url" content="{{ requestURI }}">
|
<meta property="og:url" content="{{ requestURI }}">
|
||||||
|
<link rel="canonical" href="{{ requestURI|canonicalize_url }}" />
|
||||||
{% endif -%}
|
{% endif -%}
|
||||||
{%- if profileView.DisplayName %}
|
{%- if profileView.DisplayName %}
|
||||||
<meta property="og:title" content="{{ profileView.DisplayName }} (@{{ profileView.Handle }})">
|
<meta property="og:title" content="{{ profileView.DisplayName }} (@{{ profileView.Handle }})">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {AtUri, BskyAgent} from '@atproto/api'
|
import {AtUri, BskyAgent} from '@atproto/api'
|
||||||
import {TestBsky, TestNetwork} from '@atproto/dev-env'
|
import {type TestBsky, TestNetwork} from '@atproto/dev-env'
|
||||||
import fs from 'fs'
|
import fs from 'fs'
|
||||||
import net from 'net'
|
import net from 'net'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
@@ -13,6 +13,7 @@ export interface TestUser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface TestPDS {
|
export interface TestPDS {
|
||||||
|
appviewDid: string
|
||||||
pdsUrl: string
|
pdsUrl: string
|
||||||
mocker: Mocker
|
mocker: Mocker
|
||||||
close: () => Promise<void>
|
close: () => Promise<void>
|
||||||
@@ -112,6 +113,7 @@ export async function createServer(
|
|||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
appviewDid: testNet.bsky.serverDid,
|
||||||
pdsUrl,
|
pdsUrl,
|
||||||
mocker: new Mocker(testNet, pdsUrl, pic),
|
mocker: new Mocker(testNet, pdsUrl, pic),
|
||||||
async close() {
|
async close() {
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"prepare": "is-ci || husky install",
|
"prepare": "is-ci || husky install",
|
||||||
"postinstall": "patch-package && yarn intl:compile-if-needed",
|
"postinstall": "patch-package && yarn intl:compile-if-needed",
|
||||||
"prebuild": "expo prebuild --clean",
|
"prebuild": "EXPO_NO_GIT_STATUS=1 expo prebuild --clean",
|
||||||
"android": "expo run:android",
|
"android": "expo run:android",
|
||||||
"android:prod": "expo run:android --variant release",
|
"android:prod": "expo run:android --variant release",
|
||||||
"android:profile": "BSKY_PROFILE=1 expo run:android --variant release",
|
"android:profile": "BSKY_PROFILE=1 expo run:android --variant release",
|
||||||
@@ -92,7 +92,7 @@
|
|||||||
"@lingui/react": "^4.14.1",
|
"@lingui/react": "^4.14.1",
|
||||||
"@mattermost/react-native-paste-input": "mattermost/react-native-paste-input",
|
"@mattermost/react-native-paste-input": "mattermost/react-native-paste-input",
|
||||||
"@miblanchard/react-native-slider": "^2.6.0",
|
"@miblanchard/react-native-slider": "^2.6.0",
|
||||||
"@mozzius/expo-dynamic-app-icon": "1.5.0",
|
"@mozzius/expo-dynamic-app-icon": "^1.7.1",
|
||||||
"@react-native-async-storage/async-storage": "2.1.2",
|
"@react-native-async-storage/async-storage": "2.1.2",
|
||||||
"@react-native-menu/menu": "^1.2.3",
|
"@react-native-menu/menu": "^1.2.3",
|
||||||
"@react-native-picker/picker": "2.11.0",
|
"@react-native-picker/picker": "2.11.0",
|
||||||
@@ -146,7 +146,7 @@
|
|||||||
"expo-image": "^2.4.0",
|
"expo-image": "^2.4.0",
|
||||||
"expo-image-crop-tool": "^0.1.8",
|
"expo-image-crop-tool": "^0.1.8",
|
||||||
"expo-image-manipulator": "~13.1.7",
|
"expo-image-manipulator": "~13.1.7",
|
||||||
"expo-image-picker": "^17.0.2",
|
"expo-image-picker": "17.0.5",
|
||||||
"expo-intent-launcher": "^12.1.5",
|
"expo-intent-launcher": "^12.1.5",
|
||||||
"expo-linear-gradient": "~14.1.5",
|
"expo-linear-gradient": "~14.1.5",
|
||||||
"expo-linking": "~7.1.5",
|
"expo-linking": "~7.1.5",
|
||||||
@@ -224,7 +224,7 @@
|
|||||||
"zod": "^3.20.2"
|
"zod": "^3.20.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@atproto/dev-env": "^0.3.160",
|
"@atproto/dev-env": "^0.3.167",
|
||||||
"@babel/core": "^7.26.0",
|
"@babel/core": "^7.26.0",
|
||||||
"@babel/preset-env": "^7.26.0",
|
"@babel/preset-env": "^7.26.0",
|
||||||
"@babel/runtime": "^7.26.0",
|
"@babel/runtime": "^7.26.0",
|
||||||
|
|||||||
@@ -55,7 +55,6 @@ import {DebugModScreen} from '#/view/screens/DebugMod'
|
|||||||
import {FeedsScreen} from '#/view/screens/Feeds'
|
import {FeedsScreen} from '#/view/screens/Feeds'
|
||||||
import {HomeScreen} from '#/view/screens/Home'
|
import {HomeScreen} from '#/view/screens/Home'
|
||||||
import {ListsScreen} from '#/view/screens/Lists'
|
import {ListsScreen} from '#/view/screens/Lists'
|
||||||
import {LogScreen} from '#/view/screens/Log'
|
|
||||||
import {ModerationBlockedAccounts} from '#/view/screens/ModerationBlockedAccounts'
|
import {ModerationBlockedAccounts} from '#/view/screens/ModerationBlockedAccounts'
|
||||||
import {ModerationModlistsScreen} from '#/view/screens/ModerationModlists'
|
import {ModerationModlistsScreen} from '#/view/screens/ModerationModlists'
|
||||||
import {ModerationMutedAccounts} from '#/view/screens/ModerationMutedAccounts'
|
import {ModerationMutedAccounts} from '#/view/screens/ModerationMutedAccounts'
|
||||||
@@ -74,6 +73,7 @@ import {BottomBar} from '#/view/shell/bottom-bar/BottomBar'
|
|||||||
import {createNativeStackNavigatorWithAuth} from '#/view/shell/createNativeStackNavigatorWithAuth'
|
import {createNativeStackNavigatorWithAuth} from '#/view/shell/createNativeStackNavigatorWithAuth'
|
||||||
import {SharedPreferencesTesterScreen} from '#/screens/E2E/SharedPreferencesTesterScreen'
|
import {SharedPreferencesTesterScreen} from '#/screens/E2E/SharedPreferencesTesterScreen'
|
||||||
import HashtagScreen from '#/screens/Hashtag'
|
import HashtagScreen from '#/screens/Hashtag'
|
||||||
|
import {LogScreen} from '#/screens/Log'
|
||||||
import {MessagesScreen} from '#/screens/Messages/ChatList'
|
import {MessagesScreen} from '#/screens/Messages/ChatList'
|
||||||
import {MessagesConversationScreen} from '#/screens/Messages/Conversation'
|
import {MessagesConversationScreen} from '#/screens/Messages/Conversation'
|
||||||
import {MessagesInboxScreen} from '#/screens/Messages/Inbox'
|
import {MessagesInboxScreen} from '#/screens/Messages/Inbox'
|
||||||
|
|||||||
@@ -70,9 +70,21 @@ export const atoms = {
|
|||||||
overflow_visible: {
|
overflow_visible: {
|
||||||
overflow: 'visible',
|
overflow: 'visible',
|
||||||
},
|
},
|
||||||
|
overflow_x_visible: {
|
||||||
|
overflowX: 'visible',
|
||||||
|
},
|
||||||
|
overflow_y_visible: {
|
||||||
|
overflowY: 'visible',
|
||||||
|
},
|
||||||
overflow_hidden: {
|
overflow_hidden: {
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
},
|
},
|
||||||
|
overflow_x_hidden: {
|
||||||
|
overflowX: 'hidden',
|
||||||
|
},
|
||||||
|
overflow_y_hidden: {
|
||||||
|
overflowY: 'hidden',
|
||||||
|
},
|
||||||
/**
|
/**
|
||||||
* @platform web
|
* @platform web
|
||||||
*/
|
*/
|
||||||
@@ -363,6 +375,14 @@ export const atoms = {
|
|||||||
border_r_0: {
|
border_r_0: {
|
||||||
borderRightWidth: 0,
|
borderRightWidth: 0,
|
||||||
},
|
},
|
||||||
|
border_x_0: {
|
||||||
|
borderLeftWidth: 0,
|
||||||
|
borderRightWidth: 0,
|
||||||
|
},
|
||||||
|
border_y_0: {
|
||||||
|
borderTopWidth: 0,
|
||||||
|
borderBottomWidth: 0,
|
||||||
|
},
|
||||||
border: {
|
border: {
|
||||||
borderWidth: StyleSheet.hairlineWidth,
|
borderWidth: StyleSheet.hairlineWidth,
|
||||||
},
|
},
|
||||||
@@ -378,6 +398,14 @@ export const atoms = {
|
|||||||
border_r: {
|
border_r: {
|
||||||
borderRightWidth: StyleSheet.hairlineWidth,
|
borderRightWidth: StyleSheet.hairlineWidth,
|
||||||
},
|
},
|
||||||
|
border_x: {
|
||||||
|
borderLeftWidth: StyleSheet.hairlineWidth,
|
||||||
|
borderRightWidth: StyleSheet.hairlineWidth,
|
||||||
|
},
|
||||||
|
border_y: {
|
||||||
|
borderTopWidth: StyleSheet.hairlineWidth,
|
||||||
|
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||||
|
},
|
||||||
border_transparent: {
|
border_transparent: {
|
||||||
borderColor: 'transparent',
|
borderColor: 'transparent',
|
||||||
},
|
},
|
||||||
@@ -988,6 +1016,9 @@ export const atoms = {
|
|||||||
block: web({
|
block: web({
|
||||||
display: 'block',
|
display: 'block',
|
||||||
}),
|
}),
|
||||||
|
contents: web({
|
||||||
|
display: 'contents',
|
||||||
|
}),
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Transition
|
* Transition
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import {useEffect} from 'react'
|
||||||
|
import {ScrollView, View} from 'react-native'
|
||||||
|
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||||
|
import {msg, Trans} from '@lingui/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
|
import {logger} from '#/logger'
|
||||||
|
import {isWeb} from '#/platform/detection'
|
||||||
|
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
|
||||||
|
import {Full as Logo, Mark} from '#/components/icons/Logo'
|
||||||
|
import {SimpleInlineLinkText as InlineLinkText} from '#/components/Link'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
|
export function BlockedGeoOverlay() {
|
||||||
|
const t = useTheme()
|
||||||
|
const {_} = useLingui()
|
||||||
|
const {gtPhone} = useBreakpoints()
|
||||||
|
const insets = useSafeAreaInsets()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// just counting overall hits here
|
||||||
|
logger.metric(`blockedGeoOverlay:shown`, {})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const textStyles = [a.text_md, a.leading_normal]
|
||||||
|
const links = {
|
||||||
|
blog: {
|
||||||
|
to: `https://bsky.social/about/blog/08-22-2025-mississippi-hb1126`,
|
||||||
|
label: _(msg`Read our blog post`),
|
||||||
|
overridePresentation: false,
|
||||||
|
disableMismatchWarning: true,
|
||||||
|
style: textStyles,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const blocks = [
|
||||||
|
_(msg`Unfortunately, Bluesky is unavailable in Mississippi right now.`),
|
||||||
|
_(
|
||||||
|
msg`A new Mississippi law requires us to implement age verification for all users before they can access Bluesky. We think this law creates challenges that go beyond its child safety goals, and creates significant barriers that limit free speech and disproportionately harm smaller platforms and emerging technologies.`,
|
||||||
|
),
|
||||||
|
_(
|
||||||
|
msg`As a small team, we cannot justify building the expensive infrastructure this requirement demands while legal challenges to this law are pending.`,
|
||||||
|
),
|
||||||
|
_(
|
||||||
|
msg`For now, we have made the difficult decision to block access to Bluesky in the state of Mississippi.`,
|
||||||
|
),
|
||||||
|
<>
|
||||||
|
To learn more, read our{' '}
|
||||||
|
<InlineLinkText {...links.blog}>blog post</InlineLinkText>.
|
||||||
|
</>,
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
contentContainerStyle={[
|
||||||
|
a.px_2xl,
|
||||||
|
{
|
||||||
|
paddingTop: isWeb ? a.p_5xl.padding : insets.top + a.p_2xl.padding,
|
||||||
|
paddingBottom: 100,
|
||||||
|
},
|
||||||
|
]}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.mx_auto,
|
||||||
|
web({
|
||||||
|
maxWidth: 440,
|
||||||
|
paddingTop: gtPhone ? '8vh' : undefined,
|
||||||
|
}),
|
||||||
|
]}>
|
||||||
|
<View style={[a.align_start]}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.pl_md,
|
||||||
|
a.pr_lg,
|
||||||
|
a.py_sm,
|
||||||
|
a.rounded_full,
|
||||||
|
a.flex_row,
|
||||||
|
a.align_center,
|
||||||
|
a.gap_xs,
|
||||||
|
{
|
||||||
|
backgroundColor: t.palette.primary_25,
|
||||||
|
},
|
||||||
|
]}>
|
||||||
|
<Mark fill={t.palette.primary_600} width={14} />
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.font_bold,
|
||||||
|
{
|
||||||
|
color: t.palette.primary_600,
|
||||||
|
},
|
||||||
|
]}>
|
||||||
|
<Trans>Announcement</Trans>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={[a.gap_lg, {paddingTop: 32, paddingBottom: 48}]}>
|
||||||
|
{blocks.map((block, index) => (
|
||||||
|
<Text key={index} style={[textStyles]}>
|
||||||
|
{block}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Logo width={120} textFill={t.atoms.text.color} />
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -12,9 +12,13 @@ import {
|
|||||||
import {
|
import {
|
||||||
KeyboardAwareScrollView,
|
KeyboardAwareScrollView,
|
||||||
useKeyboardHandler,
|
useKeyboardHandler,
|
||||||
|
useReanimatedKeyboardAnimation,
|
||||||
} from 'react-native-keyboard-controller'
|
} from 'react-native-keyboard-controller'
|
||||||
import {runOnJS} from 'react-native-reanimated'
|
import Animated, {
|
||||||
import {type ReanimatedScrollEvent} from 'react-native-reanimated/lib/typescript/hook/commonTypes'
|
runOnJS,
|
||||||
|
type ScrollEvent,
|
||||||
|
useAnimatedStyle,
|
||||||
|
} from 'react-native-reanimated'
|
||||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||||
import {msg} from '@lingui/macro'
|
import {msg} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
@@ -26,7 +30,7 @@ import {isAndroid, isIOS} from '#/platform/detection'
|
|||||||
import {useA11y} from '#/state/a11y'
|
import {useA11y} from '#/state/a11y'
|
||||||
import {useDialogStateControlContext} from '#/state/dialogs'
|
import {useDialogStateControlContext} from '#/state/dialogs'
|
||||||
import {List, type ListMethods, type ListProps} from '#/view/com/util/List'
|
import {List, type ListMethods, type ListProps} from '#/view/com/util/List'
|
||||||
import {atoms as a, tokens, useTheme} from '#/alf'
|
import {atoms as a, ios, platform, tokens, useTheme} from '#/alf'
|
||||||
import {useThemeName} from '#/alf/util/useColorModeTheme'
|
import {useThemeName} from '#/alf/util/useColorModeTheme'
|
||||||
import {Context, useDialogContext} from '#/components/Dialog/context'
|
import {Context, useDialogContext} from '#/components/Dialog/context'
|
||||||
import {
|
import {
|
||||||
@@ -256,6 +260,7 @@ export const ScrollableInner = React.forwardRef<ScrollView, DialogInnerProps>(
|
|||||||
contentContainerStyle,
|
contentContainerStyle,
|
||||||
]}
|
]}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
showsVerticalScrollIndicator={isAndroid ? false : undefined}
|
||||||
{...props}
|
{...props}
|
||||||
bounces={nativeSnapPoint === BottomSheetSnapPoint.Full}
|
bounces={nativeSnapPoint === BottomSheetSnapPoint.Full}
|
||||||
bottomOffset={30}
|
bottomOffset={30}
|
||||||
@@ -275,12 +280,15 @@ export const InnerFlatList = React.forwardRef<
|
|||||||
ListProps<any> & {
|
ListProps<any> & {
|
||||||
webInnerStyle?: StyleProp<ViewStyle>
|
webInnerStyle?: StyleProp<ViewStyle>
|
||||||
webInnerContentContainerStyle?: StyleProp<ViewStyle>
|
webInnerContentContainerStyle?: StyleProp<ViewStyle>
|
||||||
|
footer?: React.ReactNode
|
||||||
}
|
}
|
||||||
>(function InnerFlatList({style, ...props}, ref) {
|
>(function InnerFlatList({footer, style, ...props}, ref) {
|
||||||
const insets = useSafeAreaInsets()
|
const insets = useSafeAreaInsets()
|
||||||
const {nativeSnapPoint, disableDrag, setDisableDrag} = useDialogContext()
|
const {nativeSnapPoint, disableDrag, setDisableDrag} = useDialogContext()
|
||||||
|
|
||||||
const onScroll = (e: ReanimatedScrollEvent) => {
|
useEnableKeyboardController(isIOS)
|
||||||
|
|
||||||
|
const onScroll = (e: ScrollEvent) => {
|
||||||
'worklet'
|
'worklet'
|
||||||
if (!isAndroid) {
|
if (!isAndroid) {
|
||||||
return
|
return
|
||||||
@@ -300,13 +308,54 @@ export const InnerFlatList = React.forwardRef<
|
|||||||
bounces={nativeSnapPoint === BottomSheetSnapPoint.Full}
|
bounces={nativeSnapPoint === BottomSheetSnapPoint.Full}
|
||||||
ListFooterComponent={<View style={{height: insets.bottom + 100}} />}
|
ListFooterComponent={<View style={{height: insets.bottom + 100}} />}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
showsVerticalScrollIndicator={isAndroid ? false : undefined}
|
||||||
{...props}
|
{...props}
|
||||||
style={[style]}
|
style={[a.h_full, style]}
|
||||||
/>
|
/>
|
||||||
|
{footer}
|
||||||
</ScrollProvider>
|
</ScrollProvider>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export function FlatListFooter({children}: {children: React.ReactNode}) {
|
||||||
|
const t = useTheme()
|
||||||
|
const {top, bottom} = useSafeAreaInsets()
|
||||||
|
const {height} = useReanimatedKeyboardAnimation()
|
||||||
|
|
||||||
|
const animatedStyle = useAnimatedStyle(() => {
|
||||||
|
if (!isIOS) return {}
|
||||||
|
return {
|
||||||
|
transform: [{translateY: Math.min(0, height.get() + bottom - 10)}],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
a.absolute,
|
||||||
|
a.bottom_0,
|
||||||
|
a.w_full,
|
||||||
|
a.z_10,
|
||||||
|
a.border_t,
|
||||||
|
t.atoms.bg,
|
||||||
|
t.atoms.border_contrast_low,
|
||||||
|
a.px_lg,
|
||||||
|
a.pt_md,
|
||||||
|
{
|
||||||
|
paddingBottom: platform({
|
||||||
|
ios: tokens.space.md + bottom,
|
||||||
|
android: tokens.space.md + bottom + top,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
// TODO: had to admit defeat here, but we should
|
||||||
|
// try and get this to work for Android as well -sfn
|
||||||
|
ios(animatedStyle),
|
||||||
|
]}>
|
||||||
|
{children}
|
||||||
|
</Animated.View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function Handle({difference = false}: {difference?: boolean}) {
|
export function Handle({difference = false}: {difference?: boolean}) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React, {useImperativeHandle} from 'react'
|
|||||||
import {
|
import {
|
||||||
FlatList,
|
FlatList,
|
||||||
type FlatListProps,
|
type FlatListProps,
|
||||||
|
type GestureResponderEvent,
|
||||||
type StyleProp,
|
type StyleProp,
|
||||||
TouchableWithoutFeedback,
|
TouchableWithoutFeedback,
|
||||||
View,
|
View,
|
||||||
@@ -32,6 +33,9 @@ export * from '#/components/Dialog/types'
|
|||||||
export * from '#/components/Dialog/utils'
|
export * from '#/components/Dialog/utils'
|
||||||
export {Input} from '#/components/forms/TextField'
|
export {Input} from '#/components/forms/TextField'
|
||||||
|
|
||||||
|
// 100 minus 10vh of paddingVertical
|
||||||
|
export const WEB_DIALOG_HEIGHT = '80vh'
|
||||||
|
|
||||||
const stopPropagation = (e: any) => e.stopPropagation()
|
const stopPropagation = (e: any) => e.stopPropagation()
|
||||||
const preventDefault = (e: any) => e.preventDefault()
|
const preventDefault = (e: any) => e.preventDefault()
|
||||||
|
|
||||||
@@ -75,9 +79,12 @@ export function Outer({
|
|||||||
[control.id, onClose, setDialogIsOpen],
|
[control.id, onClose, setDialogIsOpen],
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleBackgroundPress = React.useCallback(async () => {
|
const handleBackgroundPress = React.useCallback(
|
||||||
close()
|
async (e: GestureResponderEvent) => {
|
||||||
}, [close])
|
webOptions?.onBackgroundPress ? webOptions.onBackgroundPress(e) : close()
|
||||||
|
},
|
||||||
|
[webOptions, close],
|
||||||
|
)
|
||||||
|
|
||||||
useImperativeHandle(
|
useImperativeHandle(
|
||||||
control.ref,
|
control.ref,
|
||||||
@@ -193,7 +200,7 @@ export function Inner({
|
|||||||
onInteractOutside={preventDefault}
|
onInteractOutside={preventDefault}
|
||||||
onFocusOutside={preventDefault}
|
onFocusOutside={preventDefault}
|
||||||
onDismiss={close}
|
onDismiss={close}
|
||||||
style={{display: 'flex', flexDirection: 'column'}}>
|
style={{height: '100%', display: 'flex', flexDirection: 'column'}}>
|
||||||
{header}
|
{header}
|
||||||
<View style={[gtMobile ? a.p_2xl : a.p_xl, contentContainerStyle]}>
|
<View style={[gtMobile ? a.p_2xl : a.p_xl, contentContainerStyle]}>
|
||||||
{children}
|
{children}
|
||||||
@@ -211,9 +218,17 @@ export const InnerFlatList = React.forwardRef<
|
|||||||
FlatListProps<any> & {label: string} & {
|
FlatListProps<any> & {label: string} & {
|
||||||
webInnerStyle?: StyleProp<ViewStyle>
|
webInnerStyle?: StyleProp<ViewStyle>
|
||||||
webInnerContentContainerStyle?: StyleProp<ViewStyle>
|
webInnerContentContainerStyle?: StyleProp<ViewStyle>
|
||||||
|
footer?: React.ReactNode
|
||||||
}
|
}
|
||||||
>(function InnerFlatList(
|
>(function InnerFlatList(
|
||||||
{label, style, webInnerStyle, webInnerContentContainerStyle, ...props},
|
{
|
||||||
|
label,
|
||||||
|
style,
|
||||||
|
webInnerStyle,
|
||||||
|
webInnerContentContainerStyle,
|
||||||
|
footer,
|
||||||
|
...props
|
||||||
|
},
|
||||||
ref,
|
ref,
|
||||||
) {
|
) {
|
||||||
const {gtMobile} = useBreakpoints()
|
const {gtMobile} = useBreakpoints()
|
||||||
@@ -223,20 +238,41 @@ export const InnerFlatList = React.forwardRef<
|
|||||||
style={[
|
style={[
|
||||||
a.overflow_hidden,
|
a.overflow_hidden,
|
||||||
a.px_0,
|
a.px_0,
|
||||||
// 100 minus 10vh of paddingVertical
|
web({maxHeight: WEB_DIALOG_HEIGHT}),
|
||||||
web({maxHeight: '80vh'}),
|
|
||||||
webInnerStyle,
|
webInnerStyle,
|
||||||
]}
|
]}
|
||||||
contentContainerStyle={[a.px_0, webInnerContentContainerStyle]}>
|
contentContainerStyle={[a.h_full, a.px_0, webInnerContentContainerStyle]}>
|
||||||
<FlatList
|
<FlatList
|
||||||
ref={ref}
|
ref={ref}
|
||||||
style={[gtMobile ? a.px_2xl : a.px_xl, flatten(style)]}
|
style={[a.h_full, gtMobile ? a.px_2xl : a.px_xl, flatten(style)]}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
|
{footer}
|
||||||
</Inner>
|
</Inner>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export function FlatListFooter({children}: {children: React.ReactNode}) {
|
||||||
|
const t = useTheme()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.absolute,
|
||||||
|
a.bottom_0,
|
||||||
|
a.w_full,
|
||||||
|
a.z_10,
|
||||||
|
t.atoms.bg,
|
||||||
|
a.border_t,
|
||||||
|
t.atoms.border_contrast_low,
|
||||||
|
a.px_lg,
|
||||||
|
a.py_md,
|
||||||
|
]}>
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function Close() {
|
export function Close() {
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const {close} = React.useContext(Context)
|
const {close} = React.useContext(Context)
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
View,
|
View,
|
||||||
type ViewStyle,
|
type ViewStyle,
|
||||||
} from 'react-native'
|
} from 'react-native'
|
||||||
import type React from 'react'
|
|
||||||
|
|
||||||
import {atoms as a, useTheme} from '#/alf'
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
@@ -28,6 +27,8 @@ export function Header({
|
|||||||
<View
|
<View
|
||||||
onLayout={onLayout}
|
onLayout={onLayout}
|
||||||
style={[
|
style={[
|
||||||
|
a.sticky,
|
||||||
|
a.top_0,
|
||||||
a.relative,
|
a.relative,
|
||||||
a.w_full,
|
a.w_full,
|
||||||
a.py_sm,
|
a.py_sm,
|
||||||
@@ -61,7 +62,7 @@ export function HeaderText({
|
|||||||
style?: StyleProp<TextStyle>
|
style?: StyleProp<TextStyle>
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Text style={[a.text_lg, a.text_center, a.font_bold, style]}>
|
<Text style={[a.text_lg, a.text_center, a.font_heavy, style]}>
|
||||||
{children}
|
{children}
|
||||||
</Text>
|
</Text>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import React from 'react'
|
import {
|
||||||
import type {
|
type AccessibilityProps,
|
||||||
AccessibilityProps,
|
type GestureResponderEvent,
|
||||||
GestureResponderEvent,
|
type ScrollViewProps,
|
||||||
ScrollViewProps,
|
type StyleProp,
|
||||||
|
type ViewStyle,
|
||||||
} from 'react-native'
|
} from 'react-native'
|
||||||
import {ViewStyle} from 'react-native'
|
import type React from 'react'
|
||||||
import {StyleProp} from 'react-native'
|
|
||||||
|
|
||||||
import {ViewStyleProp} from '#/alf'
|
import {type ViewStyleProp} from '#/alf'
|
||||||
import {BottomSheetViewProps} from '../../../modules/bottom-sheet'
|
import {type BottomSheetViewProps} from '../../../modules/bottom-sheet'
|
||||||
import {BottomSheetSnapPoint} from '../../../modules/bottom-sheet/src/BottomSheet.types'
|
import {type BottomSheetSnapPoint} from '../../../modules/bottom-sheet/src/BottomSheet.types'
|
||||||
|
|
||||||
type A11yProps = Required<AccessibilityProps>
|
type A11yProps = Required<AccessibilityProps>
|
||||||
|
|
||||||
@@ -64,6 +64,7 @@ export type DialogOuterProps = {
|
|||||||
nativeOptions?: Omit<BottomSheetViewProps, 'children'>
|
nativeOptions?: Omit<BottomSheetViewProps, 'children'>
|
||||||
webOptions?: {
|
webOptions?: {
|
||||||
alignCenter?: boolean
|
alignCenter?: boolean
|
||||||
|
onBackgroundPress?: (e: GestureResponderEvent) => void
|
||||||
}
|
}
|
||||||
testID?: string
|
testID?: string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {View} from 'react-native'
|
import {ScrollView, View} from 'react-native'
|
||||||
import {ScrollView} from 'react-native-gesture-handler'
|
|
||||||
import {type AppBskyFeedDefs, AtUri} from '@atproto/api'
|
import {type AppBskyFeedDefs, AtUri} from '@atproto/api'
|
||||||
import {msg, Trans} from '@lingui/macro'
|
import {msg, Trans} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
@@ -9,6 +8,7 @@ import {useNavigation} from '@react-navigation/native'
|
|||||||
import {type NavigationProp} from '#/lib/routes/types'
|
import {type NavigationProp} from '#/lib/routes/types'
|
||||||
import {logEvent} from '#/lib/statsig/statsig'
|
import {logEvent} from '#/lib/statsig/statsig'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
|
import {isIOS} from '#/platform/detection'
|
||||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||||
import {useGetPopularFeedsQuery} from '#/state/queries/feed'
|
import {useGetPopularFeedsQuery} from '#/state/queries/feed'
|
||||||
import {type FeedDescriptor} from '#/state/queries/post-feed'
|
import {type FeedDescriptor} from '#/state/queries/post-feed'
|
||||||
@@ -25,17 +25,18 @@ import {
|
|||||||
type ViewStyleProp,
|
type ViewStyleProp,
|
||||||
web,
|
web,
|
||||||
} from '#/alf'
|
} from '#/alf'
|
||||||
import {Button, ButtonText} from '#/components/Button'
|
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||||
import * as FeedCard from '#/components/FeedCard'
|
import * as FeedCard from '#/components/FeedCard'
|
||||||
import {ArrowRight_Stroke2_Corner0_Rounded as Arrow} from '#/components/icons/Arrow'
|
import {ArrowRight_Stroke2_Corner0_Rounded as ArrowRight} from '#/components/icons/Arrow'
|
||||||
import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag'
|
import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag'
|
||||||
import {InlineLinkText} from '#/components/Link'
|
import {InlineLinkText, Link} from '#/components/Link'
|
||||||
import * as ProfileCard from '#/components/ProfileCard'
|
import * as ProfileCard from '#/components/ProfileCard'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
import type * as bsky from '#/types/bsky'
|
import type * as bsky from '#/types/bsky'
|
||||||
import {ProgressGuideList} from './ProgressGuide/List'
|
import {ProgressGuideList} from './ProgressGuide/List'
|
||||||
|
|
||||||
const MOBILE_CARD_WIDTH = 165
|
const MOBILE_CARD_WIDTH = 165
|
||||||
|
const FINAL_CARD_WIDTH = 120
|
||||||
|
|
||||||
function CardOuter({
|
function CardOuter({
|
||||||
children,
|
children,
|
||||||
@@ -46,11 +47,13 @@ function CardOuter({
|
|||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
|
a.flex_1,
|
||||||
a.w_full,
|
a.w_full,
|
||||||
a.p_md,
|
a.p_md,
|
||||||
a.rounded_lg,
|
a.rounded_lg,
|
||||||
a.border,
|
a.border,
|
||||||
t.atoms.bg,
|
t.atoms.bg,
|
||||||
|
t.atoms.shadow_sm,
|
||||||
t.atoms.border_contrast_low,
|
t.atoms.border_contrast_low,
|
||||||
!gtMobile && {
|
!gtMobile && {
|
||||||
width: MOBILE_CARD_WIDTH,
|
width: MOBILE_CARD_WIDTH,
|
||||||
@@ -63,11 +66,8 @@ function CardOuter({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function SuggestedFollowPlaceholder() {
|
export function SuggestedFollowPlaceholder() {
|
||||||
const t = useTheme()
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CardOuter
|
<CardOuter>
|
||||||
style={[a.gap_md, t.atoms.border_contrast_low, t.atoms.shadow_sm]}>
|
|
||||||
<ProfileCard.Outer>
|
<ProfileCard.Outer>
|
||||||
<View
|
<View
|
||||||
style={[a.flex_col, a.align_center, a.gap_sm, a.pb_sm, a.mb_auto]}>
|
style={[a.flex_col, a.align_center, a.gap_sm, a.pb_sm, a.mb_auto]}>
|
||||||
@@ -78,24 +78,15 @@ export function SuggestedFollowPlaceholder() {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Button
|
<ProfileCard.FollowButtonPlaceholder />
|
||||||
label=""
|
|
||||||
size="small"
|
|
||||||
variant="solid"
|
|
||||||
color="secondary"
|
|
||||||
disabled
|
|
||||||
style={[a.w_full, a.rounded_sm]}>
|
|
||||||
<ButtonText>Follow</ButtonText>
|
|
||||||
</Button>
|
|
||||||
</ProfileCard.Outer>
|
</ProfileCard.Outer>
|
||||||
</CardOuter>
|
</CardOuter>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SuggestedFeedsCardPlaceholder() {
|
export function SuggestedFeedsCardPlaceholder() {
|
||||||
const t = useTheme()
|
|
||||||
return (
|
return (
|
||||||
<CardOuter style={[a.gap_sm, t.atoms.border_contrast_low]}>
|
<CardOuter style={[a.gap_sm]}>
|
||||||
<FeedCard.Header>
|
<FeedCard.Header>
|
||||||
<FeedCard.AvatarPlaceholder />
|
<FeedCard.AvatarPlaceholder />
|
||||||
<FeedCard.TitleAndBylinePlaceholder creator />
|
<FeedCard.TitleAndBylinePlaceholder creator />
|
||||||
@@ -253,129 +244,133 @@ export function ProfileGrid({
|
|||||||
profiles: bsky.profile.AnyProfileView[]
|
profiles: bsky.profile.AnyProfileView[]
|
||||||
recId?: number
|
recId?: number
|
||||||
error: Error | null
|
error: Error | null
|
||||||
viewContext: 'profile' | 'feed'
|
viewContext: 'profile' | 'profileHeader' | 'feed'
|
||||||
}) {
|
}) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const moderationOpts = useModerationOpts()
|
const moderationOpts = useModerationOpts()
|
||||||
const {gtMobile} = useBreakpoints()
|
const {gtMobile} = useBreakpoints()
|
||||||
const isLoading = isSuggestionsLoading || !moderationOpts
|
|
||||||
const maxLength = gtMobile ? 3 : 6
|
|
||||||
|
|
||||||
const content = isLoading ? (
|
const isLoading = isSuggestionsLoading || !moderationOpts
|
||||||
Array(maxLength)
|
const isProfileHeaderContext = viewContext === 'profileHeader'
|
||||||
.fill(0)
|
const isFeedContext = viewContext === 'feed'
|
||||||
.map((_, i) => (
|
|
||||||
<View
|
const maxLength = gtMobile ? 3 : isProfileHeaderContext ? 12 : 6
|
||||||
key={i}
|
const minLength = gtMobile ? 3 : 4
|
||||||
style={[
|
|
||||||
gtMobile &&
|
const content = isLoading
|
||||||
web([
|
? Array(maxLength)
|
||||||
a.flex_0,
|
.fill(0)
|
||||||
a.flex_grow,
|
.map((_, i) => (
|
||||||
{width: `calc(30% - ${a.gap_md.gap / 2}px)`},
|
<View
|
||||||
]),
|
key={i}
|
||||||
]}>
|
style={[
|
||||||
<SuggestedFollowPlaceholder />
|
a.flex_1,
|
||||||
</View>
|
gtMobile &&
|
||||||
))
|
web([
|
||||||
) : error || !profiles.length ? null : (
|
a.flex_0,
|
||||||
<>
|
a.flex_grow,
|
||||||
{profiles.slice(0, maxLength).map((profile, index) => (
|
{width: `calc(30% - ${a.gap_md.gap / 2}px)`},
|
||||||
<ProfileCard.Link
|
]),
|
||||||
key={profile.did}
|
]}>
|
||||||
profile={profile}
|
<SuggestedFollowPlaceholder />
|
||||||
onPress={() => {
|
</View>
|
||||||
logEvent('suggestedUser:press', {
|
))
|
||||||
logContext:
|
: error || !profiles.length
|
||||||
viewContext === 'feed'
|
? null
|
||||||
|
: profiles.slice(0, maxLength).map((profile, index) => (
|
||||||
|
<ProfileCard.Link
|
||||||
|
key={profile.did}
|
||||||
|
profile={profile}
|
||||||
|
onPress={() => {
|
||||||
|
logEvent('suggestedUser:press', {
|
||||||
|
logContext: isFeedContext
|
||||||
? 'InterstitialDiscover'
|
? 'InterstitialDiscover'
|
||||||
: 'InterstitialProfile',
|
: 'InterstitialProfile',
|
||||||
recId,
|
recId,
|
||||||
position: index,
|
position: index,
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
style={[
|
style={[
|
||||||
a.flex_1,
|
a.flex_1,
|
||||||
gtMobile &&
|
gtMobile &&
|
||||||
web([
|
web([
|
||||||
a.flex_0,
|
a.flex_0,
|
||||||
a.flex_grow,
|
a.flex_grow,
|
||||||
{width: `calc(30% - ${a.gap_md.gap / 2}px)`},
|
{width: `calc(30% - ${a.gap_md.gap / 2}px)`},
|
||||||
]),
|
]),
|
||||||
]}>
|
]}>
|
||||||
{({hovered, pressed}) => (
|
{({hovered, pressed}) => (
|
||||||
<CardOuter
|
<CardOuter
|
||||||
style={[
|
style={[(hovered || pressed) && t.atoms.border_contrast_high]}>
|
||||||
a.flex_1,
|
<ProfileCard.Outer>
|
||||||
t.atoms.shadow_sm,
|
<View
|
||||||
(hovered || pressed) && t.atoms.border_contrast_high,
|
style={[
|
||||||
]}>
|
a.flex_col,
|
||||||
<ProfileCard.Outer>
|
a.align_center,
|
||||||
<View
|
a.gap_sm,
|
||||||
style={[
|
a.pb_sm,
|
||||||
a.flex_col,
|
a.mb_auto,
|
||||||
a.align_center,
|
]}>
|
||||||
a.gap_sm,
|
<ProfileCard.Avatar
|
||||||
a.pb_sm,
|
|
||||||
a.mb_auto,
|
|
||||||
]}>
|
|
||||||
<ProfileCard.Avatar
|
|
||||||
profile={profile}
|
|
||||||
moderationOpts={moderationOpts}
|
|
||||||
size={88}
|
|
||||||
/>
|
|
||||||
<View style={[a.flex_col, a.align_center, a.max_w_full]}>
|
|
||||||
<ProfileCard.Name
|
|
||||||
profile={profile}
|
profile={profile}
|
||||||
moderationOpts={moderationOpts}
|
moderationOpts={moderationOpts}
|
||||||
|
disabledPreview
|
||||||
|
size={88}
|
||||||
/>
|
/>
|
||||||
<ProfileCard.Description
|
<View style={[a.flex_col, a.align_center, a.max_w_full]}>
|
||||||
profile={profile}
|
<ProfileCard.Name
|
||||||
numberOfLines={2}
|
profile={profile}
|
||||||
style={[
|
moderationOpts={moderationOpts}
|
||||||
t.atoms.text_contrast_medium,
|
/>
|
||||||
a.text_center,
|
<ProfileCard.Description
|
||||||
a.text_xs,
|
profile={profile}
|
||||||
]}
|
numberOfLines={2}
|
||||||
/>
|
style={[
|
||||||
|
t.atoms.text_contrast_medium,
|
||||||
|
a.text_center,
|
||||||
|
a.text_xs,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
|
||||||
|
|
||||||
<ProfileCard.FollowButton
|
<ProfileCard.FollowButton
|
||||||
profile={profile}
|
profile={profile}
|
||||||
moderationOpts={moderationOpts}
|
moderationOpts={moderationOpts}
|
||||||
logContext="FeedInterstitial"
|
logContext="FeedInterstitial"
|
||||||
withIcon={false}
|
withIcon={false}
|
||||||
style={[a.rounded_sm]}
|
style={[a.rounded_sm]}
|
||||||
onFollow={() => {
|
onFollow={() => {
|
||||||
logEvent('suggestedUser:follow', {
|
logEvent('suggestedUser:follow', {
|
||||||
logContext:
|
logContext: isFeedContext
|
||||||
viewContext === 'feed'
|
|
||||||
? 'InterstitialDiscover'
|
? 'InterstitialDiscover'
|
||||||
: 'InterstitialProfile',
|
: 'InterstitialProfile',
|
||||||
location: 'Card',
|
location: 'Card',
|
||||||
recId,
|
recId,
|
||||||
position: index,
|
position: index,
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</ProfileCard.Outer>
|
</ProfileCard.Outer>
|
||||||
</CardOuter>
|
</CardOuter>
|
||||||
)}
|
)}
|
||||||
</ProfileCard.Link>
|
</ProfileCard.Link>
|
||||||
))}
|
))
|
||||||
</>
|
|
||||||
)
|
|
||||||
|
|
||||||
if (error || (!isLoading && profiles.length < 4)) {
|
if (error || (!isLoading && profiles.length < minLength)) {
|
||||||
logger.debug(`Not enough profiles to show suggested follows`)
|
logger.debug(`Not enough profiles to show suggested follows`)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
style={[a.border_t, t.atoms.border_contrast_low, t.atoms.bg_contrast_25]}>
|
style={[
|
||||||
|
!isProfileHeaderContext && a.border_t,
|
||||||
|
t.atoms.border_contrast_low,
|
||||||
|
t.atoms.bg_contrast_25,
|
||||||
|
]}
|
||||||
|
pointerEvents={isIOS ? 'auto' : 'box-none'}>
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
a.px_lg,
|
a.px_lg,
|
||||||
@@ -383,19 +378,22 @@ export function ProfileGrid({
|
|||||||
a.flex_row,
|
a.flex_row,
|
||||||
a.align_center,
|
a.align_center,
|
||||||
a.justify_between,
|
a.justify_between,
|
||||||
]}>
|
]}
|
||||||
|
pointerEvents={isIOS ? 'auto' : 'box-none'}>
|
||||||
<Text style={[a.text_sm, a.font_bold, t.atoms.text]}>
|
<Text style={[a.text_sm, a.font_bold, t.atoms.text]}>
|
||||||
{viewContext === 'profile' ? (
|
{isFeedContext ? (
|
||||||
<Trans>Similar accounts</Trans>
|
|
||||||
) : (
|
|
||||||
<Trans>Suggested for you</Trans>
|
<Trans>Suggested for you</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>Similar accounts</Trans>
|
||||||
)}
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
<InlineLinkText
|
{!isProfileHeaderContext && (
|
||||||
label={_(msg`See more suggested profiles on the Explore page`)}
|
<InlineLinkText
|
||||||
to="/search">
|
label={_(msg`See more suggested profiles on the Explore page`)}
|
||||||
<Trans>See more</Trans>
|
to="/search">
|
||||||
</InlineLinkText>
|
<Trans>See more</Trans>
|
||||||
|
</InlineLinkText>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{gtMobile ? (
|
{gtMobile ? (
|
||||||
@@ -406,19 +404,16 @@ export function ProfileGrid({
|
|||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<BlockDrawerGesture>
|
<BlockDrawerGesture>
|
||||||
<View>
|
<ScrollView
|
||||||
<ScrollView
|
horizontal
|
||||||
horizontal
|
showsHorizontalScrollIndicator={false}
|
||||||
showsHorizontalScrollIndicator={false}
|
contentContainerStyle={[a.p_lg, a.pt_md, a.flex_row, a.gap_md]}
|
||||||
snapToInterval={MOBILE_CARD_WIDTH + a.gap_md.gap}
|
snapToInterval={MOBILE_CARD_WIDTH + a.gap_md.gap}
|
||||||
decelerationRate="fast">
|
decelerationRate="fast">
|
||||||
<View style={[a.p_lg, a.pt_md, a.flex_row, a.gap_md]}>
|
{content}
|
||||||
{content}
|
|
||||||
|
|
||||||
<SeeMoreSuggestedProfilesCard />
|
{!isProfileHeaderContext && <SeeMoreSuggestedProfilesCard />}
|
||||||
</View>
|
</ScrollView>
|
||||||
</ScrollView>
|
|
||||||
</View>
|
|
||||||
</BlockDrawerGesture>
|
</BlockDrawerGesture>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
@@ -426,29 +421,30 @@ export function ProfileGrid({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function SeeMoreSuggestedProfilesCard() {
|
function SeeMoreSuggestedProfilesCard() {
|
||||||
const navigation = useNavigation<NavigationProp>()
|
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Link
|
||||||
|
to="/search"
|
||||||
|
color="primary"
|
||||||
label={_(msg`Browse more accounts on the Explore page`)}
|
label={_(msg`Browse more accounts on the Explore page`)}
|
||||||
style={[a.flex_col]}
|
style={[
|
||||||
onPress={() => {
|
a.flex_col,
|
||||||
navigation.navigate('SearchTab')
|
a.align_center,
|
||||||
}}>
|
a.justify_center,
|
||||||
<CardOuter style={[a.flex_1, t.atoms.shadow_sm]}>
|
a.gap_sm,
|
||||||
<View style={[a.flex_1, a.justify_center]}>
|
a.p_md,
|
||||||
<View style={[a.flex_col, a.align_center, a.gap_md]}>
|
a.rounded_lg,
|
||||||
<Text style={[a.leading_snug, a.text_center]}>
|
t.atoms.shadow_sm,
|
||||||
<Trans>See more accounts you might like</Trans>
|
{width: FINAL_CARD_WIDTH},
|
||||||
</Text>
|
]}>
|
||||||
|
<ButtonIcon icon={ArrowRight} size="lg" />
|
||||||
<Arrow size="xl" />
|
<ButtonText
|
||||||
</View>
|
style={[a.text_md, a.font_medium, a.leading_snug, a.text_center]}>
|
||||||
</View>
|
<Trans>See more</Trans>
|
||||||
</CardOuter>
|
</ButtonText>
|
||||||
</Button>
|
</Link>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -491,10 +487,7 @@ export function SuggestedFeeds() {
|
|||||||
}}>
|
}}>
|
||||||
{({hovered, pressed}) => (
|
{({hovered, pressed}) => (
|
||||||
<CardOuter
|
<CardOuter
|
||||||
style={[
|
style={[(hovered || pressed) && t.atoms.border_contrast_high]}>
|
||||||
a.flex_1,
|
|
||||||
(hovered || pressed) && t.atoms.border_contrast_high,
|
|
||||||
]}>
|
|
||||||
<FeedCard.Outer>
|
<FeedCard.Outer>
|
||||||
<FeedCard.Header>
|
<FeedCard.Header>
|
||||||
<FeedCard.Avatar src={feed.avatar} />
|
<FeedCard.Avatar src={feed.avatar} />
|
||||||
@@ -549,7 +542,7 @@ export function SuggestedFeeds() {
|
|||||||
style={[t.atoms.text_contrast_medium]}>
|
style={[t.atoms.text_contrast_medium]}>
|
||||||
<Trans>Browse more suggestions</Trans>
|
<Trans>Browse more suggestions</Trans>
|
||||||
</InlineLinkText>
|
</InlineLinkText>
|
||||||
<Arrow size="sm" fill={t.atoms.text_contrast_medium.color} />
|
<ArrowRight size="sm" fill={t.atoms.text_contrast_medium.color} />
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
@@ -568,7 +561,7 @@ export function SuggestedFeeds() {
|
|||||||
navigation.navigate('SearchTab')
|
navigation.navigate('SearchTab')
|
||||||
}}
|
}}
|
||||||
style={[a.flex_col]}>
|
style={[a.flex_col]}>
|
||||||
<CardOuter style={[a.flex_1]}>
|
<CardOuter>
|
||||||
<View style={[a.flex_1, a.justify_center]}>
|
<View style={[a.flex_1, a.justify_center]}>
|
||||||
<View style={[a.flex_row, a.px_lg]}>
|
<View style={[a.flex_row, a.px_lg]}>
|
||||||
<Text style={[a.pr_xl, a.flex_1, a.leading_snug]}>
|
<Text style={[a.pr_xl, a.flex_1, a.leading_snug]}>
|
||||||
@@ -577,7 +570,7 @@ export function SuggestedFeeds() {
|
|||||||
</Trans>
|
</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Arrow size="xl" />
|
<ArrowRight size="xl" />
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</CardOuter>
|
</CardOuter>
|
||||||
|
|||||||
@@ -0,0 +1,390 @@
|
|||||||
|
import {useEffect, useRef, useState} from 'react'
|
||||||
|
import {
|
||||||
|
type ScrollView,
|
||||||
|
type StyleProp,
|
||||||
|
View,
|
||||||
|
type ViewStyle,
|
||||||
|
} from 'react-native'
|
||||||
|
import {msg} from '@lingui/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
|
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
||||||
|
import {isWeb} from '#/platform/detection'
|
||||||
|
import {DraggableScrollView} from '#/view/com/pager/DraggableScrollView'
|
||||||
|
import {atoms as a, tokens, useTheme, web} from '#/alf'
|
||||||
|
import {transparentifyColor} from '#/alf/util/colorGeneration'
|
||||||
|
import {Button, ButtonIcon} from '#/components/Button'
|
||||||
|
import {
|
||||||
|
ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeft,
|
||||||
|
ArrowRight_Stroke2_Corner0_Rounded as ArrowRight,
|
||||||
|
} from '#/components/icons/Arrow'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tab component that automatically scrolls the selected tab into view - used for interests
|
||||||
|
* in the Find Follows dialog, Explore screen, etc.
|
||||||
|
*/
|
||||||
|
export function InterestTabs({
|
||||||
|
onSelectTab,
|
||||||
|
interests,
|
||||||
|
selectedInterest,
|
||||||
|
disabled,
|
||||||
|
interestsDisplayNames,
|
||||||
|
TabComponent = Tab,
|
||||||
|
contentContainerStyle,
|
||||||
|
gutterWidth = tokens.space.lg,
|
||||||
|
}: {
|
||||||
|
onSelectTab: (tab: string) => void
|
||||||
|
interests: string[]
|
||||||
|
selectedInterest: string
|
||||||
|
interestsDisplayNames: Record<string, string>
|
||||||
|
/** still allows changing tab, but removes the active state from the selected tab */
|
||||||
|
disabled?: boolean
|
||||||
|
TabComponent?: React.ComponentType<React.ComponentProps<typeof Tab>>
|
||||||
|
contentContainerStyle?: StyleProp<ViewStyle>
|
||||||
|
gutterWidth?: number
|
||||||
|
}) {
|
||||||
|
const t = useTheme()
|
||||||
|
const {_} = useLingui()
|
||||||
|
const listRef = useRef<ScrollView>(null)
|
||||||
|
const [totalWidth, setTotalWidth] = useState(0)
|
||||||
|
const [scrollX, setScrollX] = useState(0)
|
||||||
|
const [contentWidth, setContentWidth] = useState(0)
|
||||||
|
const pendingTabOffsets = useRef<{x: number; width: number}[]>([])
|
||||||
|
const [tabOffsets, setTabOffsets] = useState<{x: number; width: number}[]>([])
|
||||||
|
|
||||||
|
const onInitialLayout = useNonReactiveCallback(() => {
|
||||||
|
const index = interests.indexOf(selectedInterest)
|
||||||
|
scrollIntoViewIfNeeded(index)
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (tabOffsets) {
|
||||||
|
onInitialLayout()
|
||||||
|
}
|
||||||
|
}, [tabOffsets, onInitialLayout])
|
||||||
|
|
||||||
|
function scrollIntoViewIfNeeded(index: number) {
|
||||||
|
const btnLayout = tabOffsets[index]
|
||||||
|
if (!btnLayout) return
|
||||||
|
listRef.current?.scrollTo({
|
||||||
|
// centered
|
||||||
|
x: btnLayout.x - (totalWidth / 2 - btnLayout.width / 2),
|
||||||
|
animated: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSelectTab(index: number) {
|
||||||
|
const tab = interests[index]
|
||||||
|
onSelectTab(tab)
|
||||||
|
scrollIntoViewIfNeeded(index)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTabLayout(index: number, x: number, width: number) {
|
||||||
|
if (!tabOffsets.length) {
|
||||||
|
pendingTabOffsets.current[index] = {x, width}
|
||||||
|
if (pendingTabOffsets.current.length === interests.length) {
|
||||||
|
setTabOffsets(pendingTabOffsets.current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const canScrollLeft = scrollX > 0
|
||||||
|
const canScrollRight = scrollX < contentWidth - totalWidth
|
||||||
|
|
||||||
|
const cleanupRef = useRef<(() => void) | null>(null)
|
||||||
|
|
||||||
|
function scrollLeft() {
|
||||||
|
if (isContinuouslyScrollingRef.current) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (listRef.current && canScrollLeft) {
|
||||||
|
const newScrollX = Math.max(0, scrollX - 200)
|
||||||
|
listRef.current.scrollTo({x: newScrollX, animated: true})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollRight() {
|
||||||
|
if (isContinuouslyScrollingRef.current) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (listRef.current && canScrollRight) {
|
||||||
|
const maxScroll = contentWidth - totalWidth
|
||||||
|
const newScrollX = Math.min(maxScroll, scrollX + 200)
|
||||||
|
listRef.current.scrollTo({x: newScrollX, animated: true})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isContinuouslyScrollingRef = useRef(false)
|
||||||
|
|
||||||
|
function startContinuousScroll(direction: 'left' | 'right') {
|
||||||
|
// Clear any existing continuous scroll
|
||||||
|
if (cleanupRef.current) {
|
||||||
|
cleanupRef.current()
|
||||||
|
}
|
||||||
|
|
||||||
|
let holdTimeout: NodeJS.Timeout | null = null
|
||||||
|
let animationFrame: number | null = null
|
||||||
|
let isActive = true
|
||||||
|
isContinuouslyScrollingRef.current = false
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
isActive = false
|
||||||
|
if (holdTimeout) clearTimeout(holdTimeout)
|
||||||
|
if (animationFrame) cancelAnimationFrame(animationFrame)
|
||||||
|
cleanupRef.current = null
|
||||||
|
// Reset flag after a delay to prevent onPress from firing
|
||||||
|
setTimeout(() => {
|
||||||
|
isContinuouslyScrollingRef.current = false
|
||||||
|
}, 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanupRef.current = cleanup
|
||||||
|
|
||||||
|
// Start continuous scrolling after hold delay
|
||||||
|
holdTimeout = setTimeout(() => {
|
||||||
|
if (!isActive) return
|
||||||
|
|
||||||
|
isContinuouslyScrollingRef.current = true
|
||||||
|
let currentScrollPosition = scrollX
|
||||||
|
|
||||||
|
const scroll = () => {
|
||||||
|
if (!isActive || !listRef.current) return
|
||||||
|
|
||||||
|
const scrollAmount = 3
|
||||||
|
const maxScroll = contentWidth - totalWidth
|
||||||
|
|
||||||
|
let newScrollX: number
|
||||||
|
let canContinue = false
|
||||||
|
|
||||||
|
if (direction === 'left' && currentScrollPosition > 0) {
|
||||||
|
newScrollX = Math.max(0, currentScrollPosition - scrollAmount)
|
||||||
|
canContinue = newScrollX > 0
|
||||||
|
} else if (direction === 'right' && currentScrollPosition < maxScroll) {
|
||||||
|
newScrollX = Math.min(maxScroll, currentScrollPosition + scrollAmount)
|
||||||
|
canContinue = newScrollX < maxScroll
|
||||||
|
} else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
currentScrollPosition = newScrollX
|
||||||
|
listRef.current.scrollTo({x: newScrollX, animated: false})
|
||||||
|
|
||||||
|
if (canContinue && isActive) {
|
||||||
|
animationFrame = requestAnimationFrame(scroll)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scroll()
|
||||||
|
}, 500)
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopContinuousScroll() {
|
||||||
|
if (cleanupRef.current) {
|
||||||
|
cleanupRef.current()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (cleanupRef.current) {
|
||||||
|
cleanupRef.current()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[a.relative, a.flex_row]}>
|
||||||
|
<DraggableScrollView
|
||||||
|
ref={listRef}
|
||||||
|
contentContainerStyle={[
|
||||||
|
a.gap_sm,
|
||||||
|
{paddingHorizontal: gutterWidth},
|
||||||
|
contentContainerStyle,
|
||||||
|
]}
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
decelerationRate="fast"
|
||||||
|
snapToOffsets={
|
||||||
|
tabOffsets.length === interests.length
|
||||||
|
? tabOffsets.map(o => o.x - tokens.space.xl)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onLayout={evt => setTotalWidth(evt.nativeEvent.layout.width)}
|
||||||
|
onContentSizeChange={width => setContentWidth(width)}
|
||||||
|
onScroll={evt => {
|
||||||
|
const newScrollX = evt.nativeEvent.contentOffset.x
|
||||||
|
setScrollX(newScrollX)
|
||||||
|
}}
|
||||||
|
scrollEventThrottle={16}>
|
||||||
|
{interests.map((interest, i) => {
|
||||||
|
const active = interest === selectedInterest && !disabled
|
||||||
|
return (
|
||||||
|
<TabComponent
|
||||||
|
key={interest}
|
||||||
|
onSelectTab={handleSelectTab}
|
||||||
|
active={active}
|
||||||
|
index={i}
|
||||||
|
interest={interest}
|
||||||
|
interestsDisplayName={interestsDisplayNames[interest]}
|
||||||
|
onLayout={handleTabLayout}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</DraggableScrollView>
|
||||||
|
{isWeb && canScrollLeft && (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.absolute,
|
||||||
|
a.top_0,
|
||||||
|
a.left_0,
|
||||||
|
a.bottom_0,
|
||||||
|
a.justify_center,
|
||||||
|
{paddingLeft: gutterWidth},
|
||||||
|
a.pr_md,
|
||||||
|
a.z_10,
|
||||||
|
web({
|
||||||
|
background: `linear-gradient(to right, ${t.atoms.bg.backgroundColor} 0%, ${t.atoms.bg.backgroundColor} 70%, ${transparentifyColor(t.atoms.bg.backgroundColor, 0)} 100%)`,
|
||||||
|
}),
|
||||||
|
]}>
|
||||||
|
<Button
|
||||||
|
label={_(msg`Scroll left`)}
|
||||||
|
onPress={scrollLeft}
|
||||||
|
onPressIn={() => startContinuousScroll('left')}
|
||||||
|
onPressOut={stopContinuousScroll}
|
||||||
|
color="secondary"
|
||||||
|
size="small"
|
||||||
|
style={[
|
||||||
|
a.border,
|
||||||
|
t.atoms.border_contrast_low,
|
||||||
|
t.atoms.bg,
|
||||||
|
a.h_full,
|
||||||
|
{aspectRatio: 1},
|
||||||
|
a.rounded_full,
|
||||||
|
]}>
|
||||||
|
<ButtonIcon icon={ArrowLeft} />
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{isWeb && canScrollRight && (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.absolute,
|
||||||
|
a.top_0,
|
||||||
|
a.right_0,
|
||||||
|
a.bottom_0,
|
||||||
|
a.justify_center,
|
||||||
|
{paddingRight: gutterWidth},
|
||||||
|
a.pl_md,
|
||||||
|
a.z_10,
|
||||||
|
web({
|
||||||
|
background: `linear-gradient(to left, ${t.atoms.bg.backgroundColor} 0%, ${t.atoms.bg.backgroundColor} 70%, ${transparentifyColor(t.atoms.bg.backgroundColor, 0)} 100%)`,
|
||||||
|
}),
|
||||||
|
]}>
|
||||||
|
<Button
|
||||||
|
label={_(msg`Scroll right`)}
|
||||||
|
onPress={scrollRight}
|
||||||
|
onPressIn={() => startContinuousScroll('right')}
|
||||||
|
onPressOut={stopContinuousScroll}
|
||||||
|
color="secondary"
|
||||||
|
size="small"
|
||||||
|
style={[
|
||||||
|
a.border,
|
||||||
|
t.atoms.border_contrast_low,
|
||||||
|
t.atoms.bg,
|
||||||
|
a.h_full,
|
||||||
|
{aspectRatio: 1},
|
||||||
|
a.rounded_full,
|
||||||
|
]}>
|
||||||
|
<ButtonIcon icon={ArrowRight} />
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Tab({
|
||||||
|
onSelectTab,
|
||||||
|
interest,
|
||||||
|
active,
|
||||||
|
index,
|
||||||
|
interestsDisplayName,
|
||||||
|
onLayout,
|
||||||
|
}: {
|
||||||
|
onSelectTab: (index: number) => void
|
||||||
|
interest: string
|
||||||
|
active: boolean
|
||||||
|
index: number
|
||||||
|
interestsDisplayName: string
|
||||||
|
onLayout: (index: number, x: number, width: number) => void
|
||||||
|
}) {
|
||||||
|
const t = useTheme()
|
||||||
|
const {_} = useLingui()
|
||||||
|
const label = active
|
||||||
|
? _(
|
||||||
|
msg({
|
||||||
|
message: `"${interestsDisplayName}" category (active)`,
|
||||||
|
comment:
|
||||||
|
'Accessibility label for a category (e.g. Art, Video Games, Sports, etc.) that shows suggested accounts for the user to follow. The tab is currently selected.',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
: _(
|
||||||
|
msg({
|
||||||
|
message: `Select "${interestsDisplayName}" category`,
|
||||||
|
comment:
|
||||||
|
'Accessibility label for a category (e.g. Art, Video Games, Sports, etc.) that shows suggested accounts for the user to follow. The tab is not currently active and can be selected.',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
key={interest}
|
||||||
|
onLayout={e =>
|
||||||
|
onLayout(index, e.nativeEvent.layout.x, e.nativeEvent.layout.width)
|
||||||
|
}>
|
||||||
|
<Button
|
||||||
|
label={label}
|
||||||
|
onPress={() => onSelectTab(index)}
|
||||||
|
// disable focus ring, we handle it
|
||||||
|
style={web({outline: 'none'})}>
|
||||||
|
{({hovered, pressed, focused}) => (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.rounded_full,
|
||||||
|
a.px_lg,
|
||||||
|
a.py_sm,
|
||||||
|
a.border,
|
||||||
|
active || hovered || pressed
|
||||||
|
? [t.atoms.bg_contrast_25, t.atoms.border_contrast_medium]
|
||||||
|
: focused
|
||||||
|
? {
|
||||||
|
borderColor: t.palette.primary_300,
|
||||||
|
backgroundColor: t.palette.primary_25,
|
||||||
|
}
|
||||||
|
: [t.atoms.bg, t.atoms.border_contrast_low],
|
||||||
|
]}>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.font_medium,
|
||||||
|
active || hovered || pressed
|
||||||
|
? t.atoms.text
|
||||||
|
: t.atoms.text_contrast_medium,
|
||||||
|
]}>
|
||||||
|
{interestsDisplayName}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function boostInterests(boosts?: string[]) {
|
||||||
|
return (_a: string, _b: string) => {
|
||||||
|
const indexA = boosts?.indexOf(_a) ?? -1
|
||||||
|
const indexB = boosts?.indexOf(_b) ?? -1
|
||||||
|
const rankA = indexA === -1 ? Infinity : indexA
|
||||||
|
const rankB = indexB === -1 ? Infinity : indexB
|
||||||
|
return rankA - rankB
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@ export const BUTTON_VISUAL_ALIGNMENT_OFFSET = 3
|
|||||||
/**
|
/**
|
||||||
* Corresponds to the width of a small square or round button
|
* Corresponds to the width of a small square or round button
|
||||||
*/
|
*/
|
||||||
export const HEADER_SLOT_SIZE = 34
|
export const HEADER_SLOT_SIZE = 33
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* How far to shift the center column when in the tablet breakpoint
|
* How far to shift the center column when in the tablet breakpoint
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, {useMemo} from 'react'
|
import React, {useMemo} from 'react'
|
||||||
import {type GestureResponderEvent} from 'react-native'
|
import {type GestureResponderEvent, Linking} from 'react-native'
|
||||||
import {sanitizeUrl} from '@braintree/sanitize-url'
|
import {sanitizeUrl} from '@braintree/sanitize-url'
|
||||||
import {
|
import {
|
||||||
type LinkProps as RNLinkProps,
|
type LinkProps as RNLinkProps,
|
||||||
@@ -13,6 +13,7 @@ import {type AllNavigatorParams, type RouteParams} from '#/lib/routes/types'
|
|||||||
import {shareUrl} from '#/lib/sharing'
|
import {shareUrl} from '#/lib/sharing'
|
||||||
import {
|
import {
|
||||||
convertBskyAppUrlIfNeeded,
|
convertBskyAppUrlIfNeeded,
|
||||||
|
createProxiedUrl,
|
||||||
isBskyDownloadUrl,
|
isBskyDownloadUrl,
|
||||||
isExternalUrl,
|
isExternalUrl,
|
||||||
linkRequiresWarning,
|
linkRequiresWarning,
|
||||||
@@ -407,6 +408,91 @@ export function InlineLinkText({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A barebones version of `InlineLinkText`, for use outside a
|
||||||
|
* `react-navigation` context.
|
||||||
|
*/
|
||||||
|
export function SimpleInlineLinkText({
|
||||||
|
children,
|
||||||
|
to,
|
||||||
|
style,
|
||||||
|
download,
|
||||||
|
selectable,
|
||||||
|
label,
|
||||||
|
disableUnderline,
|
||||||
|
shouldProxy,
|
||||||
|
...rest
|
||||||
|
}: Omit<
|
||||||
|
InlineLinkProps,
|
||||||
|
| 'to'
|
||||||
|
| 'action'
|
||||||
|
| 'disableMismatchWarning'
|
||||||
|
| 'overridePresentation'
|
||||||
|
| 'onPress'
|
||||||
|
| 'onLongPress'
|
||||||
|
| 'shareOnLongPress'
|
||||||
|
> & {
|
||||||
|
to: string
|
||||||
|
}) {
|
||||||
|
const t = useTheme()
|
||||||
|
const {
|
||||||
|
state: hovered,
|
||||||
|
onIn: onHoverIn,
|
||||||
|
onOut: onHoverOut,
|
||||||
|
} = useInteractionState()
|
||||||
|
const flattenedStyle = flatten(style) || {}
|
||||||
|
const isExternal = isExternalUrl(to)
|
||||||
|
|
||||||
|
let href = to
|
||||||
|
if (shouldProxy) {
|
||||||
|
href = createProxiedUrl(href)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onPress = () => {
|
||||||
|
Linking.openURL(href)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Text
|
||||||
|
selectable={selectable}
|
||||||
|
accessibilityHint=""
|
||||||
|
accessibilityLabel={label}
|
||||||
|
{...rest}
|
||||||
|
style={[
|
||||||
|
{color: t.palette.primary_500},
|
||||||
|
hovered &&
|
||||||
|
!disableUnderline && {
|
||||||
|
...web({
|
||||||
|
outline: 0,
|
||||||
|
textDecorationLine: 'underline',
|
||||||
|
textDecorationColor:
|
||||||
|
flattenedStyle.color ?? t.palette.primary_500,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
flattenedStyle,
|
||||||
|
]}
|
||||||
|
role="link"
|
||||||
|
onPress={onPress}
|
||||||
|
onMouseEnter={onHoverIn}
|
||||||
|
onMouseLeave={onHoverOut}
|
||||||
|
accessibilityRole="link"
|
||||||
|
href={href}
|
||||||
|
{...web({
|
||||||
|
hrefAttrs: {
|
||||||
|
target: download ? undefined : isExternal ? 'blank' : undefined,
|
||||||
|
rel: isExternal ? 'noopener noreferrer' : undefined,
|
||||||
|
download,
|
||||||
|
},
|
||||||
|
dataSet: {
|
||||||
|
// default to no underline, apply this ourselves
|
||||||
|
noUnderline: '1',
|
||||||
|
},
|
||||||
|
})}>
|
||||||
|
{children}
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function WebOnlyInlineLinkText({
|
export function WebOnlyInlineLinkText({
|
||||||
children,
|
children,
|
||||||
to,
|
to,
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import {View, type ViewStyle} from 'react-native'
|
||||||
|
import {msg, Trans} from '@lingui/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
|
import {type Gate} from '#/lib/statsig/gates'
|
||||||
|
import {useGate} from '#/lib/statsig/statsig'
|
||||||
|
import {isWeb} from '#/platform/detection'
|
||||||
|
import {useSession} from '#/state/session'
|
||||||
|
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
|
||||||
|
import {Logo} from '#/view/icons/Logo'
|
||||||
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
|
import {Button, ButtonText} from '#/components/Button'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
|
interface LoggedOutCTAProps {
|
||||||
|
style?: ViewStyle
|
||||||
|
gateName: Gate
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LoggedOutCTA({style, gateName}: LoggedOutCTAProps) {
|
||||||
|
const {hasSession} = useSession()
|
||||||
|
const {requestSwitchToAccount} = useLoggedOutViewControls()
|
||||||
|
const gate = useGate()
|
||||||
|
const t = useTheme()
|
||||||
|
const {_} = useLingui()
|
||||||
|
|
||||||
|
// Only show for logged-out users on web
|
||||||
|
if (hasSession || !isWeb) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check gate at the last possible moment to avoid counting users as exposed when they won't see the element
|
||||||
|
if (!gate(gateName)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[a.pb_md, style]}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.flex_row,
|
||||||
|
a.align_center,
|
||||||
|
a.justify_between,
|
||||||
|
a.px_lg,
|
||||||
|
a.py_md,
|
||||||
|
a.rounded_md,
|
||||||
|
a.mb_xs,
|
||||||
|
t.atoms.bg_contrast_25,
|
||||||
|
]}>
|
||||||
|
<View style={[a.flex_row, a.align_center, a.flex_1, a.pr_md]}>
|
||||||
|
<Logo width={30} style={[a.mr_md]} />
|
||||||
|
<View style={[a.flex_1]}>
|
||||||
|
<Text style={[a.text_lg, a.font_bold, a.leading_snug]}>
|
||||||
|
<Trans>Join Bluesky</Trans>
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.text_md,
|
||||||
|
a.font_medium,
|
||||||
|
a.leading_snug,
|
||||||
|
t.atoms.text_contrast_medium,
|
||||||
|
]}>
|
||||||
|
<Trans>The open social network.</Trans>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<Button
|
||||||
|
onPress={() => {
|
||||||
|
requestSwitchToAccount({requestedAccount: 'new'})
|
||||||
|
}}
|
||||||
|
label={_(msg`Create account`)}
|
||||||
|
size="small"
|
||||||
|
variant="solid"
|
||||||
|
color="primary">
|
||||||
|
<ButtonText>
|
||||||
|
<Trans>Create account</Trans>
|
||||||
|
</ButtonText>
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import React from 'react'
|
import {type StyleProp, StyleSheet, View, type ViewStyle} from 'react-native'
|
||||||
import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
|
|
||||||
import {Image} from 'expo-image'
|
import {Image} from 'expo-image'
|
||||||
import {AppBskyFeedDefs} from '@atproto/api'
|
import {type AppBskyFeedDefs} from '@atproto/api'
|
||||||
import {Trans} from '@lingui/macro'
|
import {Trans} from '@lingui/macro'
|
||||||
|
|
||||||
import {isTenorGifUri} from '#/lib/strings/embed-player'
|
import {isTenorGifUri} from '#/lib/strings/embed-player'
|
||||||
@@ -92,12 +91,11 @@ export function ImageItem({
|
|||||||
<Image
|
<Image
|
||||||
key={thumbnail}
|
key={thumbnail}
|
||||||
source={{uri: thumbnail}}
|
source={{uri: thumbnail}}
|
||||||
|
alt={alt}
|
||||||
style={[a.flex_1, a.rounded_xs, t.atoms.bg_contrast_25]}
|
style={[a.flex_1, a.rounded_xs, t.atoms.bg_contrast_25]}
|
||||||
contentFit="cover"
|
contentFit="cover"
|
||||||
accessible={true}
|
accessible={true}
|
||||||
accessibilityIgnoresInvertColors
|
accessibilityIgnoresInvertColors
|
||||||
accessibilityHint={alt}
|
|
||||||
accessibilityLabel=""
|
|
||||||
/>
|
/>
|
||||||
<MediaInsetBorder style={[a.rounded_xs]} />
|
<MediaInsetBorder style={[a.rounded_xs]} />
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
type PolicyUpdateState,
|
type PolicyUpdateState,
|
||||||
usePolicyUpdateState,
|
usePolicyUpdateState,
|
||||||
} from '#/components/PolicyUpdateOverlay/usePolicyUpdateState'
|
} from '#/components/PolicyUpdateOverlay/usePolicyUpdateState'
|
||||||
|
import {ENV} from '#/env'
|
||||||
|
|
||||||
const Context = createContext<{
|
const Context = createContext<{
|
||||||
state: PolicyUpdateState
|
state: PolicyUpdateState
|
||||||
@@ -45,8 +46,7 @@ export function Provider({children}: {children?: ReactNode}) {
|
|||||||
const [isReadyToShowOverlay, setIsReadyToShowOverlay] = useState(false)
|
const [isReadyToShowOverlay, setIsReadyToShowOverlay] = useState(false)
|
||||||
const state = usePolicyUpdateState({
|
const state = usePolicyUpdateState({
|
||||||
// only enable the policy update overlay in non-test environments
|
// only enable the policy update overlay in non-test environments
|
||||||
enabled:
|
enabled: isReadyToShowOverlay && hasSession && ENV !== 'e2e',
|
||||||
isReadyToShowOverlay && hasSession && process.env.NODE_ENV !== 'test',
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const ctx = useMemo(
|
const ctx = useMemo(
|
||||||
|
|||||||
@@ -87,14 +87,18 @@ function MediaEmbed({
|
|||||||
switch (embed.type) {
|
switch (embed.type) {
|
||||||
case 'images': {
|
case 'images': {
|
||||||
return (
|
return (
|
||||||
<ContentHider modui={rest.moderation?.ui('contentMedia')}>
|
<ContentHider
|
||||||
|
modui={rest.moderation?.ui('contentMedia')}
|
||||||
|
activeStyle={[a.mt_sm]}>
|
||||||
<ImageEmbed embed={embed} {...rest} />
|
<ImageEmbed embed={embed} {...rest} />
|
||||||
</ContentHider>
|
</ContentHider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
case 'link': {
|
case 'link': {
|
||||||
return (
|
return (
|
||||||
<ContentHider modui={rest.moderation?.ui('contentMedia')}>
|
<ContentHider
|
||||||
|
modui={rest.moderation?.ui('contentMedia')}
|
||||||
|
activeStyle={[a.mt_sm]}>
|
||||||
<ExternalEmbed
|
<ExternalEmbed
|
||||||
link={embed.view.external}
|
link={embed.view.external}
|
||||||
onOpen={rest.onOpen}
|
onOpen={rest.onOpen}
|
||||||
@@ -105,7 +109,9 @@ function MediaEmbed({
|
|||||||
}
|
}
|
||||||
case 'video': {
|
case 'video': {
|
||||||
return (
|
return (
|
||||||
<ContentHider modui={rest.moderation?.ui('contentMedia')}>
|
<ContentHider
|
||||||
|
modui={rest.moderation?.ui('contentMedia')}
|
||||||
|
activeStyle={[a.mt_sm]}>
|
||||||
<VideoEmbed embed={embed.view} />
|
<VideoEmbed embed={embed.view} />
|
||||||
</ContentHider>
|
</ContentHider>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -266,7 +266,9 @@ let PostMenuItems = ({
|
|||||||
feedContext: postFeedContext,
|
feedContext: postFeedContext,
|
||||||
reqId: postReqId,
|
reqId: postReqId,
|
||||||
})
|
})
|
||||||
Toast.show(_(msg({message: 'Feedback sent!', context: 'toast'})))
|
Toast.show(
|
||||||
|
_(msg({message: 'Feedback sent to feed operator', context: 'toast'})),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const onPressShowLess = () => {
|
const onPressShowLess = () => {
|
||||||
@@ -282,7 +284,9 @@ let PostMenuItems = ({
|
|||||||
feedContext: postFeedContext,
|
feedContext: postFeedContext,
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
Toast.show(_(msg({message: 'Feedback sent!', context: 'toast'})))
|
Toast.show(
|
||||||
|
_(msg({message: 'Feedback sent to feed operator', context: 'toast'})),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -486,13 +490,16 @@ let PostMenuItems = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{isDiscoverDebugUser && (
|
{isDiscoverDebugUser && (
|
||||||
<Menu.Item
|
<>
|
||||||
testID="postDropdownReportMisclassificationBtn"
|
<Menu.Divider />
|
||||||
label={_(msg`Assign topic for algo`)}
|
<Menu.Item
|
||||||
onPress={onReportMisclassification}>
|
testID="postDropdownReportMisclassificationBtn"
|
||||||
<Menu.ItemText>{_(msg`Assign topic for algo`)}</Menu.ItemText>
|
label={_(msg`Assign topic for algo`)}
|
||||||
<Menu.ItemIcon icon={AtomIcon} position="right" />
|
onPress={onReportMisclassification}>
|
||||||
</Menu.Item>
|
<Menu.ItemText>{_(msg`Assign topic for algo`)}</Menu.ItemText>
|
||||||
|
<Menu.ItemIcon icon={AtomIcon} position="right" />
|
||||||
|
</Menu.Item>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{hasSession && (
|
{hasSession && (
|
||||||
|
|||||||
@@ -513,12 +513,19 @@ export function FollowButtonInner({
|
|||||||
comment: 'User is following this account, click to unfollow',
|
comment: 'User is following this account, click to unfollow',
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
const followLabel = _(
|
const followLabel = profile.viewer?.followedBy
|
||||||
msg({
|
? _(
|
||||||
message: 'Follow',
|
msg({
|
||||||
comment: 'User is not following this account, click to follow',
|
message: 'Follow back',
|
||||||
}),
|
comment: 'User is not following this account, click to follow back',
|
||||||
)
|
}),
|
||||||
|
)
|
||||||
|
: _(
|
||||||
|
msg({
|
||||||
|
message: 'Follow',
|
||||||
|
comment: 'User is not following this account, click to follow',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
if (!profile.viewer) return null
|
if (!profile.viewer) return null
|
||||||
if (
|
if (
|
||||||
@@ -561,6 +568,24 @@ export function FollowButtonInner({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function FollowButtonPlaceholder({style}: ViewStyleProp) {
|
||||||
|
const t = useTheme()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.rounded_sm,
|
||||||
|
t.atoms.bg_contrast_25,
|
||||||
|
a.w_full,
|
||||||
|
{
|
||||||
|
height: 33,
|
||||||
|
},
|
||||||
|
style,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function Labels({
|
export function Labels({
|
||||||
profile,
|
profile,
|
||||||
moderationOpts,
|
moderationOpts,
|
||||||
|
|||||||
@@ -1,17 +1,9 @@
|
|||||||
import {memo, useCallback, useEffect, useMemo, useRef, useState} from 'react'
|
import {memo, useCallback, useEffect, useMemo, useRef, useState} from 'react'
|
||||||
import {
|
import {TextInput, useWindowDimensions, View} from 'react-native'
|
||||||
ScrollView,
|
|
||||||
type StyleProp,
|
|
||||||
TextInput,
|
|
||||||
useWindowDimensions,
|
|
||||||
View,
|
|
||||||
type ViewStyle,
|
|
||||||
} from 'react-native'
|
|
||||||
import {type ModerationOpts} from '@atproto/api'
|
import {type ModerationOpts} from '@atproto/api'
|
||||||
import {msg, Trans} from '@lingui/macro'
|
import {msg, Trans} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
|
||||||
import {logEvent} from '#/lib/statsig/statsig'
|
import {logEvent} from '#/lib/statsig/statsig'
|
||||||
import {isWeb} from '#/platform/detection'
|
import {isWeb} from '#/platform/detection'
|
||||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||||
@@ -28,7 +20,6 @@ import {
|
|||||||
import {
|
import {
|
||||||
atoms as a,
|
atoms as a,
|
||||||
native,
|
native,
|
||||||
tokens,
|
|
||||||
useBreakpoints,
|
useBreakpoints,
|
||||||
useTheme,
|
useTheme,
|
||||||
type ViewStyleProp,
|
type ViewStyleProp,
|
||||||
@@ -40,6 +31,7 @@ import {useInteractionState} from '#/components/hooks/useInteractionState'
|
|||||||
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as SearchIcon} from '#/components/icons/MagnifyingGlass2'
|
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as SearchIcon} from '#/components/icons/MagnifyingGlass2'
|
||||||
import {PersonGroup_Stroke2_Corner2_Rounded as PersonGroupIcon} from '#/components/icons/Person'
|
import {PersonGroup_Stroke2_Corner2_Rounded as PersonGroupIcon} from '#/components/icons/Person'
|
||||||
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
|
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
|
||||||
|
import {boostInterests, InterestTabs} from '#/components/InterestTabs'
|
||||||
import * as ProfileCard from '#/components/ProfileCard'
|
import * as ProfileCard from '#/components/ProfileCard'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
import type * as bsky from '#/types/bsky'
|
import type * as bsky from '#/types/bsky'
|
||||||
@@ -337,12 +329,13 @@ let Header = ({
|
|||||||
}}
|
}}
|
||||||
onEscape={control.close}
|
onEscape={control.close}
|
||||||
/>
|
/>
|
||||||
<Tabs
|
<InterestTabs
|
||||||
onSelectTab={onSelectTab}
|
onSelectTab={onSelectTab}
|
||||||
interests={interests}
|
interests={interests}
|
||||||
selectedInterest={selectedInterest}
|
selectedInterest={selectedInterest}
|
||||||
hasSearchText={!!searchText}
|
disabled={!!searchText}
|
||||||
interestsDisplayNames={interestsDisplayNames}
|
interestsDisplayNames={interestsDisplayNames}
|
||||||
|
TabComponent={Tab}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -403,99 +396,6 @@ function HeaderTop({guide}: {guide: Follow10ProgressGuide}) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
let Tabs = ({
|
|
||||||
onSelectTab,
|
|
||||||
interests,
|
|
||||||
selectedInterest,
|
|
||||||
hasSearchText,
|
|
||||||
interestsDisplayNames,
|
|
||||||
TabComponent = Tab,
|
|
||||||
contentContainerStyle,
|
|
||||||
}: {
|
|
||||||
onSelectTab: (tab: string) => void
|
|
||||||
interests: string[]
|
|
||||||
selectedInterest: string
|
|
||||||
hasSearchText: boolean
|
|
||||||
interestsDisplayNames: Record<string, string>
|
|
||||||
TabComponent?: React.ComponentType<React.ComponentProps<typeof Tab>>
|
|
||||||
contentContainerStyle?: StyleProp<ViewStyle>
|
|
||||||
}): React.ReactNode => {
|
|
||||||
const listRef = useRef<ScrollView>(null)
|
|
||||||
const [totalWidth, setTotalWidth] = useState(0)
|
|
||||||
const pendingTabOffsets = useRef<{x: number; width: number}[]>([])
|
|
||||||
const [tabOffsets, setTabOffsets] = useState<{x: number; width: number}[]>([])
|
|
||||||
|
|
||||||
const onInitialLayout = useNonReactiveCallback(() => {
|
|
||||||
const index = interests.indexOf(selectedInterest)
|
|
||||||
scrollIntoViewIfNeeded(index)
|
|
||||||
})
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (tabOffsets) {
|
|
||||||
onInitialLayout()
|
|
||||||
}
|
|
||||||
}, [tabOffsets, onInitialLayout])
|
|
||||||
|
|
||||||
function scrollIntoViewIfNeeded(index: number) {
|
|
||||||
const btnLayout = tabOffsets[index]
|
|
||||||
if (!btnLayout) return
|
|
||||||
listRef.current?.scrollTo({
|
|
||||||
// centered
|
|
||||||
x: btnLayout.x - (totalWidth / 2 - btnLayout.width / 2),
|
|
||||||
animated: true,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSelectTab(index: number) {
|
|
||||||
const tab = interests[index]
|
|
||||||
onSelectTab(tab)
|
|
||||||
scrollIntoViewIfNeeded(index)
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleTabLayout(index: number, x: number, width: number) {
|
|
||||||
if (!tabOffsets.length) {
|
|
||||||
pendingTabOffsets.current[index] = {x, width}
|
|
||||||
if (pendingTabOffsets.current.length === interests.length) {
|
|
||||||
setTabOffsets(pendingTabOffsets.current)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ScrollView
|
|
||||||
ref={listRef}
|
|
||||||
horizontal
|
|
||||||
contentContainerStyle={[a.gap_sm, a.px_lg, contentContainerStyle]}
|
|
||||||
showsHorizontalScrollIndicator={false}
|
|
||||||
decelerationRate="fast"
|
|
||||||
snapToOffsets={
|
|
||||||
tabOffsets.length === interests.length
|
|
||||||
? tabOffsets.map(o => o.x - tokens.space.xl)
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
onLayout={evt => setTotalWidth(evt.nativeEvent.layout.width)}
|
|
||||||
scrollEventThrottle={200} // big throttle
|
|
||||||
>
|
|
||||||
{interests.map((interest, i) => {
|
|
||||||
const active = interest === selectedInterest && !hasSearchText
|
|
||||||
return (
|
|
||||||
<TabComponent
|
|
||||||
key={interest}
|
|
||||||
onSelectTab={handleSelectTab}
|
|
||||||
active={active}
|
|
||||||
index={i}
|
|
||||||
interest={interest}
|
|
||||||
interestsDisplayName={interestsDisplayNames[interest]}
|
|
||||||
onLayout={handleTabLayout}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</ScrollView>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Tabs = memo(Tabs)
|
|
||||||
export {Tabs}
|
|
||||||
|
|
||||||
let Tab = ({
|
let Tab = ({
|
||||||
onSelectTab,
|
onSelectTab,
|
||||||
interest,
|
interest,
|
||||||
@@ -513,24 +413,36 @@ let Tab = ({
|
|||||||
}): React.ReactNode => {
|
}): React.ReactNode => {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const activeText = active ? _(msg` (active)`) : ''
|
const label = active
|
||||||
|
? _(
|
||||||
|
msg({
|
||||||
|
message: `Search for "${interestsDisplayName}" (active)`,
|
||||||
|
comment:
|
||||||
|
'Accessibility label for a tab that searches for accounts in a category (e.g. Art, Video Games, Sports, etc.) that are suggested for the user to follow. The tab is currently selected.',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
: _(
|
||||||
|
msg({
|
||||||
|
message: `Search for "${interestsDisplayName}"`,
|
||||||
|
comment:
|
||||||
|
'Accessibility label for a tab that searches for accounts in a category (e.g. Art, Video Games, Sports, etc.) that are suggested for the user to follow. The tab is not currently active and can be selected.',
|
||||||
|
}),
|
||||||
|
)
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
key={interest}
|
key={interest}
|
||||||
onLayout={e =>
|
onLayout={e =>
|
||||||
onLayout(index, e.nativeEvent.layout.x, e.nativeEvent.layout.width)
|
onLayout(index, e.nativeEvent.layout.x, e.nativeEvent.layout.width)
|
||||||
}>
|
}>
|
||||||
<Button
|
<Button label={label} onPress={() => onSelectTab(index)}>
|
||||||
label={_(msg`Search for "${interestsDisplayName}"${activeText}`)}
|
{({hovered, pressed}) => (
|
||||||
onPress={() => onSelectTab(index)}>
|
|
||||||
{({hovered, pressed, focused}) => (
|
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
a.rounded_full,
|
a.rounded_full,
|
||||||
a.px_lg,
|
a.px_lg,
|
||||||
a.py_sm,
|
a.py_sm,
|
||||||
a.border,
|
a.border,
|
||||||
active || hovered || pressed || focused
|
active || hovered || pressed
|
||||||
? [
|
? [
|
||||||
t.atoms.bg_contrast_25,
|
t.atoms.bg_contrast_25,
|
||||||
{borderColor: t.atoms.bg_contrast_25.backgroundColor},
|
{borderColor: t.atoms.bg_contrast_25.backgroundColor},
|
||||||
@@ -540,7 +452,7 @@ let Tab = ({
|
|||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
a.font_medium,
|
a.font_medium,
|
||||||
active || hovered || pressed || focused
|
active || hovered || pressed
|
||||||
? t.atoms.text
|
? t.atoms.text
|
||||||
: t.atoms.text_contrast_medium,
|
: t.atoms.text_contrast_medium,
|
||||||
]}>
|
]}>
|
||||||
@@ -759,13 +671,3 @@ function Empty({message}: {message: string}) {
|
|||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function boostInterests(boosts?: string[]) {
|
|
||||||
return (_a: string, _b: string) => {
|
|
||||||
const indexA = boosts?.indexOf(_a) ?? -1
|
|
||||||
const indexB = boosts?.indexOf(_b) ?? -1
|
|
||||||
const rankA = indexA === -1 ? Infinity : indexA
|
|
||||||
const rankB = indexB === -1 ? Infinity : indexB
|
|
||||||
return rankA - rankB
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -180,7 +180,7 @@ function CreateAnother() {
|
|||||||
color="secondary"
|
color="secondary"
|
||||||
size="small"
|
size="small"
|
||||||
style={[a.self_center]}
|
style={[a.self_center]}
|
||||||
onPress={() => navigation.navigate('StarterPackWizard')}>
|
onPress={() => navigation.navigate('StarterPackWizard', {})}>
|
||||||
<ButtonText>
|
<ButtonText>
|
||||||
<Trans>Create another</Trans>
|
<Trans>Create another</Trans>
|
||||||
</ButtonText>
|
</ButtonText>
|
||||||
@@ -238,7 +238,7 @@ function Empty() {
|
|||||||
],
|
],
|
||||||
})
|
})
|
||||||
const navToWizard = useCallback(() => {
|
const navToWizard = useCallback(() => {
|
||||||
navigation.navigate('StarterPackWizard')
|
navigation.navigate('StarterPackWizard', {})
|
||||||
}, [navigation])
|
}, [navigation])
|
||||||
const wrappedNavToWizard = requireEmailVerification(navToWizard, {
|
const wrappedNavToWizard = requireEmailVerification(navToWizard, {
|
||||||
instructions: [
|
instructions: [
|
||||||
@@ -322,7 +322,7 @@ function Empty() {
|
|||||||
color="secondary"
|
color="secondary"
|
||||||
cta={_(msg`Let me choose`)}
|
cta={_(msg`Let me choose`)}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
navigation.navigate('StarterPackWizard')
|
navigation.navigate('StarterPackWizard', {})
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Prompt.Actions>
|
</Prompt.Actions>
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import {useLingui} from '@lingui/react'
|
|||||||
|
|
||||||
import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
|
import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
|
||||||
import {isWeb} from '#/platform/detection'
|
import {isWeb} from '#/platform/detection'
|
||||||
import {useSession} from '#/state/session'
|
|
||||||
import {type ListMethods} from '#/view/com/util/List'
|
import {type ListMethods} from '#/view/com/util/List'
|
||||||
import {
|
import {
|
||||||
type WizardAction,
|
type WizardAction,
|
||||||
@@ -48,7 +47,6 @@ export function WizardEditListDialog({
|
|||||||
}) {
|
}) {
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {currentAccount} = useSession()
|
|
||||||
const initialNumToRender = useInitialNumToRender()
|
const initialNumToRender = useInitialNumToRender()
|
||||||
|
|
||||||
const listRef = useRef<ListMethods>(null)
|
const listRef = useRef<ListMethods>(null)
|
||||||
@@ -56,10 +54,7 @@ export function WizardEditListDialog({
|
|||||||
const getData = () => {
|
const getData = () => {
|
||||||
if (state.currentStep === 'Feeds') return state.feeds
|
if (state.currentStep === 'Feeds') return state.feeds
|
||||||
|
|
||||||
return [
|
return [profile, ...state.profiles.filter(p => p.did !== profile.did)]
|
||||||
profile,
|
|
||||||
...state.profiles.filter(p => p.did !== currentAccount?.did),
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const renderItem = ({item}: ListRenderItemInfo<any>) =>
|
const renderItem = ({item}: ListRenderItemInfo<any>) =>
|
||||||
|
|||||||
@@ -131,10 +131,13 @@ export function WizardProfileCard({
|
|||||||
}) {
|
}) {
|
||||||
const {currentAccount} = useSession()
|
const {currentAccount} = useSession()
|
||||||
|
|
||||||
const isMe = profile.did === currentAccount?.did
|
// Determine the "main" profile for this starter pack - either targetDid or current account
|
||||||
const included = isMe || state.profiles.some(p => p.did === profile.did)
|
const targetProfileDid = state.targetDid || currentAccount?.did
|
||||||
|
const isTarget = profile.did === targetProfileDid
|
||||||
|
const included = isTarget || state.profiles.some(p => p.did === profile.did)
|
||||||
const disabled =
|
const disabled =
|
||||||
isMe || (!included && state.profiles.length >= STARTER_PACK_MAX_SIZE - 1)
|
isTarget ||
|
||||||
|
(!included && state.profiles.length >= STARTER_PACK_MAX_SIZE - 1)
|
||||||
const moderationUi = moderateProfile(profile, moderationOpts).ui('avatar')
|
const moderationUi = moderateProfile(profile, moderationOpts).ui('avatar')
|
||||||
const displayName = profile.displayName
|
const displayName = profile.displayName
|
||||||
? sanitizeDisplayName(profile.displayName)
|
? sanitizeDisplayName(profile.displayName)
|
||||||
@@ -144,7 +147,7 @@ export function WizardProfileCard({
|
|||||||
if (disabled) return
|
if (disabled) return
|
||||||
|
|
||||||
Keyboard.dismiss()
|
Keyboard.dismiss()
|
||||||
if (profile.did === currentAccount?.did) return
|
if (profile.did === targetProfileDid) return
|
||||||
|
|
||||||
if (!included) {
|
if (!included) {
|
||||||
dispatch({type: 'AddProfile', profile})
|
dispatch({type: 'AddProfile', profile})
|
||||||
|
|||||||
@@ -1,22 +1,20 @@
|
|||||||
import {createContext, useContext, useMemo} from 'react'
|
import {createContext, useContext, useMemo} from 'react'
|
||||||
import {View} from 'react-native'
|
import {type GestureResponderEvent, View} from 'react-native'
|
||||||
|
|
||||||
import {atoms as a, select, useAlf, useTheme} from '#/alf'
|
import {atoms as a, select, useAlf, useTheme} from '#/alf'
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
type ButtonProps,
|
||||||
|
type UninheritableButtonProps,
|
||||||
|
} from '#/components/Button'
|
||||||
|
import {CircleCheck_Stroke2_Corner0_Rounded as CircleCheck} from '#/components/icons/CircleCheck'
|
||||||
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
|
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
|
||||||
import {CircleInfo_Stroke2_Corner0_Rounded as ErrorIcon} from '#/components/icons/CircleInfo'
|
import {CircleInfo_Stroke2_Corner0_Rounded as ErrorIcon} from '#/components/icons/CircleInfo'
|
||||||
|
import {type Props as SVGIconProps} from '#/components/icons/common'
|
||||||
import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning'
|
import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning'
|
||||||
|
import {dismiss} from '#/components/Toast/sonner'
|
||||||
import {type ToastType} from '#/components/Toast/types'
|
import {type ToastType} from '#/components/Toast/types'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text as BaseText} from '#/components/Typography'
|
||||||
import {CircleCheck_Stroke2_Corner0_Rounded as CircleCheck} from '../icons/CircleCheck'
|
|
||||||
|
|
||||||
type ContextType = {
|
|
||||||
type: ToastType
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ToastComponentProps = {
|
|
||||||
type?: ToastType
|
|
||||||
content: React.ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ICONS = {
|
export const ICONS = {
|
||||||
default: CircleCheck,
|
default: CircleCheck,
|
||||||
@@ -26,81 +24,225 @@ export const ICONS = {
|
|||||||
info: CircleInfo,
|
info: CircleInfo,
|
||||||
}
|
}
|
||||||
|
|
||||||
const Context = createContext<ContextType>({
|
const ToastConfigContext = createContext<{
|
||||||
|
id: string
|
||||||
|
type: ToastType
|
||||||
|
}>({
|
||||||
|
id: '',
|
||||||
type: 'default',
|
type: 'default',
|
||||||
})
|
})
|
||||||
Context.displayName = 'ToastContext'
|
ToastConfigContext.displayName = 'ToastConfigContext'
|
||||||
|
|
||||||
export function Toast({type = 'default', content}: ToastComponentProps) {
|
export function ToastConfigProvider({
|
||||||
const {fonts} = useAlf()
|
children,
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
id: string
|
||||||
|
type: ToastType
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ToastConfigContext.Provider
|
||||||
|
value={useMemo(() => ({id, type}), [id, type])}>
|
||||||
|
{children}
|
||||||
|
</ToastConfigContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Outer({children}: {children: React.ReactNode}) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
|
const {type} = useContext(ToastConfigContext)
|
||||||
const styles = useToastStyles({type})
|
const styles = useToastStyles({type})
|
||||||
const Icon = ICONS[type]
|
|
||||||
/**
|
return (
|
||||||
* Vibes-based number, adjusts `top` of `View` that wraps the text to
|
<View
|
||||||
* compensate for different type sizes and keep the first line of text
|
style={[
|
||||||
* aligned with the icon. - esb
|
a.flex_1,
|
||||||
*/
|
a.p_lg,
|
||||||
|
a.rounded_md,
|
||||||
|
a.border,
|
||||||
|
a.flex_row,
|
||||||
|
a.gap_sm,
|
||||||
|
t.atoms.shadow_sm,
|
||||||
|
{
|
||||||
|
paddingVertical: 14, // 16 seems too big
|
||||||
|
backgroundColor: styles.backgroundColor,
|
||||||
|
borderColor: styles.borderColor,
|
||||||
|
},
|
||||||
|
]}>
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Icon({icon}: {icon?: React.ComponentType<SVGIconProps>}) {
|
||||||
|
const {type} = useContext(ToastConfigContext)
|
||||||
|
const styles = useToastStyles({type})
|
||||||
|
const IconComponent = icon || ICONS[type]
|
||||||
|
return <IconComponent size="md" fill={styles.iconColor} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Text({children}: {children: React.ReactNode}) {
|
||||||
|
const {type} = useContext(ToastConfigContext)
|
||||||
|
const {textColor} = useToastStyles({type})
|
||||||
|
const {fontScaleCompensation} = useToastFontScaleCompensation()
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.flex_1,
|
||||||
|
a.pr_lg,
|
||||||
|
{
|
||||||
|
top: fontScaleCompensation,
|
||||||
|
},
|
||||||
|
]}>
|
||||||
|
<BaseText
|
||||||
|
selectable={false}
|
||||||
|
style={[
|
||||||
|
a.text_md,
|
||||||
|
a.font_medium,
|
||||||
|
a.leading_snug,
|
||||||
|
a.pointer_events_none,
|
||||||
|
{
|
||||||
|
color: textColor,
|
||||||
|
},
|
||||||
|
]}>
|
||||||
|
{children}
|
||||||
|
</BaseText>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Action(
|
||||||
|
props: Omit<ButtonProps, UninheritableButtonProps | 'children'> & {
|
||||||
|
children: React.ReactNode
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const t = useTheme()
|
||||||
|
const {fontScaleCompensation} = useToastFontScaleCompensation()
|
||||||
|
const {type} = useContext(ToastConfigContext)
|
||||||
|
const {id} = useContext(ToastConfigContext)
|
||||||
|
const styles = useMemo(() => {
|
||||||
|
const base = {
|
||||||
|
base: {
|
||||||
|
textColor: t.palette.contrast_600,
|
||||||
|
backgroundColor: t.atoms.bg_contrast_25.backgroundColor,
|
||||||
|
},
|
||||||
|
interacted: {
|
||||||
|
textColor: t.atoms.text.color,
|
||||||
|
backgroundColor: t.atoms.bg_contrast_50.backgroundColor,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
default: base,
|
||||||
|
success: {
|
||||||
|
base: {
|
||||||
|
textColor: select(t.name, {
|
||||||
|
light: t.palette.primary_800,
|
||||||
|
dim: t.palette.primary_900,
|
||||||
|
dark: t.palette.primary_900,
|
||||||
|
}),
|
||||||
|
backgroundColor: t.palette.primary_25,
|
||||||
|
},
|
||||||
|
interacted: {
|
||||||
|
textColor: select(t.name, {
|
||||||
|
light: t.palette.primary_900,
|
||||||
|
dim: t.palette.primary_975,
|
||||||
|
dark: t.palette.primary_975,
|
||||||
|
}),
|
||||||
|
backgroundColor: t.palette.primary_50,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
base: {
|
||||||
|
textColor: select(t.name, {
|
||||||
|
light: t.palette.negative_700,
|
||||||
|
dim: t.palette.negative_900,
|
||||||
|
dark: t.palette.negative_900,
|
||||||
|
}),
|
||||||
|
backgroundColor: t.palette.negative_25,
|
||||||
|
},
|
||||||
|
interacted: {
|
||||||
|
textColor: select(t.name, {
|
||||||
|
light: t.palette.negative_900,
|
||||||
|
dim: t.palette.negative_975,
|
||||||
|
dark: t.palette.negative_975,
|
||||||
|
}),
|
||||||
|
backgroundColor: t.palette.negative_50,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
warning: base,
|
||||||
|
info: base,
|
||||||
|
}[type]
|
||||||
|
}, [t, type])
|
||||||
|
|
||||||
|
const onPress = (e: GestureResponderEvent) => {
|
||||||
|
console.log('Toast Action pressed, dismissing toast', id)
|
||||||
|
dismiss(id)
|
||||||
|
props.onPress?.(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{top: fontScaleCompensation}}>
|
||||||
|
<Button {...props} onPress={onPress}>
|
||||||
|
{s => {
|
||||||
|
const interacted = s.pressed || s.hovered || s.focused
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.absolute,
|
||||||
|
a.curve_continuous,
|
||||||
|
{
|
||||||
|
// tiny button styles
|
||||||
|
top: -5,
|
||||||
|
bottom: -5,
|
||||||
|
left: -9,
|
||||||
|
right: -9,
|
||||||
|
borderRadius: 6,
|
||||||
|
backgroundColor: interacted
|
||||||
|
? styles.interacted.backgroundColor
|
||||||
|
: styles.base.backgroundColor,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<BaseText
|
||||||
|
style={[
|
||||||
|
a.text_md,
|
||||||
|
a.font_medium,
|
||||||
|
a.leading_snug,
|
||||||
|
{
|
||||||
|
color: interacted
|
||||||
|
? styles.interacted.textColor
|
||||||
|
: styles.base.textColor,
|
||||||
|
},
|
||||||
|
]}>
|
||||||
|
{props.children}
|
||||||
|
</BaseText>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vibes-based number, provides t `top` value to wrap the text to compensate
|
||||||
|
* for different type sizes and keep the first line of text aligned with the
|
||||||
|
* icon. - esb
|
||||||
|
*/
|
||||||
|
function useToastFontScaleCompensation() {
|
||||||
|
const {fonts} = useAlf()
|
||||||
const fontScaleCompensation = useMemo(
|
const fontScaleCompensation = useMemo(
|
||||||
() => parseInt(fonts.scale) * -1 * 0.65,
|
() => parseInt(fonts.scale) * -1 * 0.65,
|
||||||
[fonts.scale],
|
[fonts.scale],
|
||||||
)
|
)
|
||||||
|
return useMemo(
|
||||||
return (
|
() => ({
|
||||||
<Context.Provider value={useMemo(() => ({type}), [type])}>
|
fontScaleCompensation,
|
||||||
<View
|
}),
|
||||||
style={[
|
[fontScaleCompensation],
|
||||||
a.flex_1,
|
|
||||||
a.py_lg,
|
|
||||||
a.pl_xl,
|
|
||||||
a.pr_2xl,
|
|
||||||
a.rounded_md,
|
|
||||||
a.border,
|
|
||||||
a.flex_row,
|
|
||||||
a.gap_sm,
|
|
||||||
t.atoms.shadow_sm,
|
|
||||||
{
|
|
||||||
backgroundColor: styles.backgroundColor,
|
|
||||||
borderColor: styles.borderColor,
|
|
||||||
},
|
|
||||||
]}>
|
|
||||||
<Icon size="md" fill={styles.iconColor} />
|
|
||||||
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
a.flex_1,
|
|
||||||
{
|
|
||||||
top: fontScaleCompensation,
|
|
||||||
},
|
|
||||||
]}>
|
|
||||||
{typeof content === 'string' ? (
|
|
||||||
<ToastText>{content}</ToastText>
|
|
||||||
) : (
|
|
||||||
content
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</Context.Provider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ToastText({children}: {children: React.ReactNode}) {
|
|
||||||
const {type} = useContext(Context)
|
|
||||||
const {textColor} = useToastStyles({type})
|
|
||||||
return (
|
|
||||||
<Text
|
|
||||||
selectable={false}
|
|
||||||
style={[
|
|
||||||
a.text_md,
|
|
||||||
a.font_medium,
|
|
||||||
a.leading_snug,
|
|
||||||
a.pointer_events_none,
|
|
||||||
{
|
|
||||||
color: textColor,
|
|
||||||
},
|
|
||||||
]}>
|
|
||||||
{children}
|
|
||||||
</Text>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,11 @@
|
|||||||
|
export const DURATION = 0
|
||||||
|
|
||||||
|
export const Action = () => null
|
||||||
|
export const Icon = () => null
|
||||||
|
export const Outer = () => null
|
||||||
|
export const Text = () => null
|
||||||
|
export const ToastConfigProvider = () => null
|
||||||
|
|
||||||
export function ToastOutlet() {
|
export function ToastOutlet() {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,21 @@
|
|||||||
|
import React from 'react'
|
||||||
import {View} from 'react-native'
|
import {View} from 'react-native'
|
||||||
|
import {nanoid} from 'nanoid/non-secure'
|
||||||
import {toast as sonner, Toaster} from 'sonner-native'
|
import {toast as sonner, Toaster} from 'sonner-native'
|
||||||
|
|
||||||
import {atoms as a} from '#/alf'
|
import {atoms as a} from '#/alf'
|
||||||
import {DURATION} from '#/components/Toast/const'
|
import {DURATION} from '#/components/Toast/const'
|
||||||
import {
|
import {
|
||||||
Toast as BaseToast,
|
Icon as ToastIcon,
|
||||||
type ToastComponentProps,
|
Outer as BaseOuter,
|
||||||
|
Text as ToastText,
|
||||||
|
ToastConfigProvider,
|
||||||
} from '#/components/Toast/Toast'
|
} from '#/components/Toast/Toast'
|
||||||
import {type BaseToastOptions} from '#/components/Toast/types'
|
import {type BaseToastOptions} from '#/components/Toast/types'
|
||||||
|
|
||||||
export {DURATION} from '#/components/Toast/const'
|
export {DURATION} from '#/components/Toast/const'
|
||||||
|
export {Action, Icon, Text, ToastConfigProvider} from '#/components/Toast/Toast'
|
||||||
|
export {type ToastType} from '#/components/Toast/types'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Toasts are rendered in a global outlet, which is placed at the top of the
|
* Toasts are rendered in a global outlet, which is placed at the top of the
|
||||||
@@ -19,13 +25,10 @@ export function ToastOutlet() {
|
|||||||
return <Toaster pauseWhenPageIsHidden gap={a.gap_sm.gap} />
|
return <Toaster pauseWhenPageIsHidden gap={a.gap_sm.gap} />
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export function Outer({children}: {children: React.ReactNode}) {
|
||||||
* The toast UI component
|
|
||||||
*/
|
|
||||||
export function Toast({type, content}: ToastComponentProps) {
|
|
||||||
return (
|
return (
|
||||||
<View style={[a.px_xl, a.w_full]}>
|
<View style={[a.px_xl, a.w_full]}>
|
||||||
<BaseToast content={content} type={type} />
|
<BaseOuter>{children}</BaseOuter>
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -40,10 +43,38 @@ export const api = sonner
|
|||||||
*/
|
*/
|
||||||
export function show(
|
export function show(
|
||||||
content: React.ReactNode,
|
content: React.ReactNode,
|
||||||
{type, ...options}: BaseToastOptions = {},
|
{type = 'default', ...options}: BaseToastOptions = {},
|
||||||
) {
|
) {
|
||||||
sonner.custom(<Toast content={content} type={type} />, {
|
const id = nanoid()
|
||||||
...options,
|
|
||||||
duration: options?.duration ?? DURATION,
|
if (typeof content === 'string') {
|
||||||
})
|
sonner.custom(
|
||||||
|
<ToastConfigProvider id={id} type={type}>
|
||||||
|
<Outer>
|
||||||
|
<ToastIcon />
|
||||||
|
<ToastText>{content}</ToastText>
|
||||||
|
</Outer>
|
||||||
|
</ToastConfigProvider>,
|
||||||
|
{
|
||||||
|
...options,
|
||||||
|
id,
|
||||||
|
duration: options?.duration ?? DURATION,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
} else if (React.isValidElement(content)) {
|
||||||
|
sonner.custom(
|
||||||
|
<ToastConfigProvider id={id} type={type}>
|
||||||
|
{content}
|
||||||
|
</ToastConfigProvider>,
|
||||||
|
{
|
||||||
|
...options,
|
||||||
|
id,
|
||||||
|
duration: options?.duration ?? DURATION,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
throw new Error(
|
||||||
|
`Toast can be a string or a React element, got ${typeof content}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,21 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import {nanoid} from 'nanoid/non-secure'
|
||||||
import {toast as sonner, Toaster} from 'sonner'
|
import {toast as sonner, Toaster} from 'sonner'
|
||||||
|
|
||||||
import {atoms as a} from '#/alf'
|
import {atoms as a} from '#/alf'
|
||||||
import {DURATION} from '#/components/Toast/const'
|
import {DURATION} from '#/components/Toast/const'
|
||||||
import {Toast} from '#/components/Toast/Toast'
|
import {
|
||||||
|
Icon as ToastIcon,
|
||||||
|
Outer as ToastOuter,
|
||||||
|
Text as ToastText,
|
||||||
|
ToastConfigProvider,
|
||||||
|
} from '#/components/Toast/Toast'
|
||||||
import {type BaseToastOptions} from '#/components/Toast/types'
|
import {type BaseToastOptions} from '#/components/Toast/types'
|
||||||
|
|
||||||
|
export {DURATION} from '#/components/Toast/const'
|
||||||
|
export * from '#/components/Toast/Toast'
|
||||||
|
export {type ToastType} from '#/components/Toast/types'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Toasts are rendered in a global outlet, which is placed at the top of the
|
* Toasts are rendered in a global outlet, which is placed at the top of the
|
||||||
* component tree.
|
* component tree.
|
||||||
@@ -30,11 +41,40 @@ export const api = sonner
|
|||||||
*/
|
*/
|
||||||
export function show(
|
export function show(
|
||||||
content: React.ReactNode,
|
content: React.ReactNode,
|
||||||
{type, ...options}: BaseToastOptions = {},
|
{type = 'default', ...options}: BaseToastOptions = {},
|
||||||
) {
|
) {
|
||||||
sonner(<Toast content={content} type={type} />, {
|
const id = nanoid()
|
||||||
unstyled: true, // required on web
|
|
||||||
...options,
|
if (typeof content === 'string') {
|
||||||
duration: options?.duration ?? DURATION,
|
sonner(
|
||||||
})
|
<ToastConfigProvider id={id} type={type}>
|
||||||
|
<ToastOuter>
|
||||||
|
<ToastIcon />
|
||||||
|
<ToastText>{content}</ToastText>
|
||||||
|
</ToastOuter>
|
||||||
|
</ToastConfigProvider>,
|
||||||
|
{
|
||||||
|
...options,
|
||||||
|
unstyled: true, // required on web
|
||||||
|
id,
|
||||||
|
duration: options?.duration ?? DURATION,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
} else if (React.isValidElement(content)) {
|
||||||
|
sonner(
|
||||||
|
<ToastConfigProvider id={id} type={type}>
|
||||||
|
{content}
|
||||||
|
</ToastConfigProvider>,
|
||||||
|
{
|
||||||
|
...options,
|
||||||
|
unstyled: true, // required on web
|
||||||
|
id,
|
||||||
|
duration: options?.duration ?? DURATION,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
throw new Error(
|
||||||
|
`Toast can be a string or a React element, got ${typeof content}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import {toast} from 'sonner-native'
|
||||||
|
|
||||||
|
export const dismiss = toast.dismiss
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import {toast} from 'sonner'
|
||||||
|
|
||||||
|
export const dismiss = toast.dismiss
|
||||||
@@ -4,21 +4,23 @@ import {msg, Trans} from '@lingui/macro'
|
|||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
import {cleanError} from '#/lib/strings/errors'
|
import {cleanError} from '#/lib/strings/errors'
|
||||||
import {getDateAgo} from '#/lib/strings/time'
|
import {getAge, getDateAgo} from '#/lib/strings/time'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {isIOS, isWeb} from '#/platform/detection'
|
import {isIOS, isWeb} from '#/platform/detection'
|
||||||
import {
|
import {
|
||||||
usePreferencesQuery,
|
usePreferencesQuery,
|
||||||
UsePreferencesQueryResponse,
|
type UsePreferencesQueryResponse,
|
||||||
usePreferencesSetBirthDateMutation,
|
usePreferencesSetBirthDateMutation,
|
||||||
} from '#/state/queries/preferences'
|
} from '#/state/queries/preferences'
|
||||||
import {ErrorMessage} from '#/view/com/util/error/ErrorMessage'
|
import {ErrorMessage} from '#/view/com/util/error/ErrorMessage'
|
||||||
import {atoms as a, useTheme} from '#/alf'
|
import {atoms as a, useTheme, web} from '#/alf'
|
||||||
|
import {Admonition} from '#/components/Admonition'
|
||||||
|
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||||
import * as Dialog from '#/components/Dialog'
|
import * as Dialog from '#/components/Dialog'
|
||||||
import {DateField} from '#/components/forms/DateField'
|
import {DateField} from '#/components/forms/DateField'
|
||||||
|
import {InlineLinkText} from '#/components/Link'
|
||||||
import {Loader} from '#/components/Loader'
|
import {Loader} from '#/components/Loader'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
import {Button, ButtonIcon, ButtonText} from '../Button'
|
|
||||||
|
|
||||||
export function BirthDateSettingsDialog({
|
export function BirthDateSettingsDialog({
|
||||||
control,
|
control,
|
||||||
@@ -32,31 +34,35 @@ export function BirthDateSettingsDialog({
|
|||||||
return (
|
return (
|
||||||
<Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}>
|
<Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}>
|
||||||
<Dialog.Handle />
|
<Dialog.Handle />
|
||||||
<Dialog.ScrollableInner label={_(msg`My Birthday`)}>
|
<Dialog.ScrollableInner
|
||||||
<View style={[a.gap_sm, a.pb_lg]}>
|
label={_(msg`My Birthday`)}
|
||||||
<Text style={[a.text_2xl, a.font_bold]}>
|
style={web({maxWidth: 400})}>
|
||||||
|
<View style={[a.gap_sm]}>
|
||||||
|
<Text style={[a.text_xl, a.font_bold]}>
|
||||||
<Trans>My Birthday</Trans>
|
<Trans>My Birthday</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={[a.text_md, t.atoms.text_contrast_medium]}>
|
<Text style={[a.leading_snug, t.atoms.text_contrast_medium]}>
|
||||||
<Trans>This information is not shared with other users.</Trans>
|
<Trans>
|
||||||
|
This information is private and not shared with other users.
|
||||||
|
</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<Loader size="xl" />
|
<Loader size="xl" />
|
||||||
) : error || !preferences ? (
|
) : error || !preferences ? (
|
||||||
<ErrorMessage
|
<ErrorMessage
|
||||||
message={
|
message={
|
||||||
error?.toString() ||
|
error?.toString() ||
|
||||||
_(
|
_(
|
||||||
msg`We were unable to load your birth date preferences. Please try again.`,
|
msg`We were unable to load your birth date preferences. Please try again.`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
style={[a.rounded_sm]}
|
style={[a.rounded_sm]}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<BirthdayInner control={control} preferences={preferences} />
|
<BirthdayInner control={control} preferences={preferences} />
|
||||||
)}
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
<Dialog.Close />
|
<Dialog.Close />
|
||||||
</Dialog.ScrollableInner>
|
</Dialog.ScrollableInner>
|
||||||
@@ -72,7 +78,9 @@ function BirthdayInner({
|
|||||||
preferences: UsePreferencesQueryResponse
|
preferences: UsePreferencesQueryResponse
|
||||||
}) {
|
}) {
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const [date, setDate] = React.useState(preferences.birthDate || new Date())
|
const [date, setDate] = React.useState(
|
||||||
|
preferences.birthDate || getDateAgo(18),
|
||||||
|
)
|
||||||
const {
|
const {
|
||||||
isPending,
|
isPending,
|
||||||
isError,
|
isError,
|
||||||
@@ -81,6 +89,10 @@ function BirthdayInner({
|
|||||||
} = usePreferencesSetBirthDateMutation()
|
} = usePreferencesSetBirthDateMutation()
|
||||||
const hasChanged = date !== preferences.birthDate
|
const hasChanged = date !== preferences.birthDate
|
||||||
|
|
||||||
|
const age = getAge(new Date(date))
|
||||||
|
const isUnder13 = age < 13
|
||||||
|
const isUnder18 = age >= 13 && age < 18
|
||||||
|
|
||||||
const onSave = React.useCallback(async () => {
|
const onSave = React.useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
// skip if date is the same
|
// skip if date is the same
|
||||||
@@ -102,10 +114,32 @@ function BirthdayInner({
|
|||||||
onChangeDate={newDate => setDate(new Date(newDate))}
|
onChangeDate={newDate => setDate(new Date(newDate))}
|
||||||
label={_(msg`Birthday`)}
|
label={_(msg`Birthday`)}
|
||||||
accessibilityHint={_(msg`Enter your birth date`)}
|
accessibilityHint={_(msg`Enter your birth date`)}
|
||||||
maximumDate={getDateAgo(13)}
|
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{isUnder18 && hasChanged && (
|
||||||
|
<Admonition type="info">
|
||||||
|
<Trans>
|
||||||
|
The birthdate you've entered means you are under 18 years old.
|
||||||
|
Certain content and features may be unavailable to you.
|
||||||
|
</Trans>
|
||||||
|
</Admonition>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isUnder13 && (
|
||||||
|
<Admonition type="error">
|
||||||
|
<Trans>
|
||||||
|
You must be at least 13 years old to use Bluesky. Read our{' '}
|
||||||
|
<InlineLinkText
|
||||||
|
to="https://bsky.social/about/support/tos"
|
||||||
|
label={_(msg`Terms of Service`)}>
|
||||||
|
Terms of Service
|
||||||
|
</InlineLinkText>{' '}
|
||||||
|
for more information.
|
||||||
|
</Trans>
|
||||||
|
</Admonition>
|
||||||
|
)}
|
||||||
|
|
||||||
{isError ? (
|
{isError ? (
|
||||||
<ErrorMessage message={cleanError(error)} style={[a.rounded_sm]} />
|
<ErrorMessage message={cleanError(error)} style={[a.rounded_sm]} />
|
||||||
) : undefined}
|
) : undefined}
|
||||||
@@ -116,7 +150,8 @@ function BirthdayInner({
|
|||||||
size="large"
|
size="large"
|
||||||
onPress={onSave}
|
onPress={onSave}
|
||||||
variant="solid"
|
variant="solid"
|
||||||
color="primary">
|
color="primary"
|
||||||
|
disabled={isUnder13}>
|
||||||
<ButtonText>
|
<ButtonText>
|
||||||
{hasChanged ? <Trans>Save</Trans> : <Trans>Done</Trans>}
|
{hasChanged ? <Trans>Save</Trans> : <Trans>Done</Trans>}
|
||||||
</ButtonText>
|
</ButtonText>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, {
|
import {
|
||||||
useCallback,
|
useCallback,
|
||||||
useImperativeHandle,
|
useImperativeHandle,
|
||||||
useMemo,
|
useMemo,
|
||||||
@@ -119,7 +119,7 @@ function GifList({
|
|||||||
[onSelectGif],
|
[onSelectGif],
|
||||||
)
|
)
|
||||||
|
|
||||||
const onEndReached = React.useCallback(() => {
|
const onEndReached = useCallback(() => {
|
||||||
if (isFetchingNextPage || !hasNextPage || error) return
|
if (isFetchingNextPage || !hasNextPage || error) return
|
||||||
fetchNextPage()
|
fetchNextPage()
|
||||||
}, [isFetchingNextPage, hasNextPage, error, fetchNextPage])
|
}, [isFetchingNextPage, hasNextPage, error, fetchNextPage])
|
||||||
@@ -172,7 +172,7 @@ function GifList({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<TextField.Root>
|
<TextField.Root style={[!gtMobile && isWeb && a.flex_1]}>
|
||||||
<TextField.Icon icon={Search} />
|
<TextField.Icon icon={Search} />
|
||||||
<TextField.Input
|
<TextField.Input
|
||||||
label={_(msg`Search GIFs`)}
|
label={_(msg`Search GIFs`)}
|
||||||
@@ -206,11 +206,9 @@ function GifList({
|
|||||||
renderItem={renderItem}
|
renderItem={renderItem}
|
||||||
numColumns={gtMobile ? 3 : 2}
|
numColumns={gtMobile ? 3 : 2}
|
||||||
columnWrapperStyle={[a.gap_sm]}
|
columnWrapperStyle={[a.gap_sm]}
|
||||||
contentContainerStyle={[
|
contentContainerStyle={[native([a.px_xl, {minHeight: height}])]}
|
||||||
native([a.px_xl, {minHeight: height}]),
|
webInnerStyle={[web({minHeight: '80vh'})]}
|
||||||
web(a.h_full_vh),
|
webInnerContentContainerStyle={[web(a.pb_0)]}
|
||||||
]}
|
|
||||||
style={[web(a.h_full_vh)]}
|
|
||||||
ListHeaderComponent={
|
ListHeaderComponent={
|
||||||
<>
|
<>
|
||||||
{listHeader}
|
{listHeader}
|
||||||
|
|||||||
@@ -0,0 +1,395 @@
|
|||||||
|
import {useCallback, useState} from 'react'
|
||||||
|
import {View} from 'react-native'
|
||||||
|
import {
|
||||||
|
type AppBskyGraphGetStarterPacksWithMembership,
|
||||||
|
AppBskyGraphStarterpack,
|
||||||
|
} from '@atproto/api'
|
||||||
|
import {msg, Plural, Trans} from '@lingui/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
import {useNavigation} from '@react-navigation/native'
|
||||||
|
import {useQueryClient} from '@tanstack/react-query'
|
||||||
|
|
||||||
|
import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification'
|
||||||
|
import {type NavigationProp} from '#/lib/routes/types'
|
||||||
|
import {isWeb} from '#/platform/detection'
|
||||||
|
import {
|
||||||
|
invalidateActorStarterPacksWithMembershipQuery,
|
||||||
|
useActorStarterPacksWithMembershipsQuery,
|
||||||
|
} from '#/state/queries/actor-starter-packs'
|
||||||
|
import {
|
||||||
|
useListMembershipAddMutation,
|
||||||
|
useListMembershipRemoveMutation,
|
||||||
|
} from '#/state/queries/list-memberships'
|
||||||
|
import * as Toast from '#/view/com/util/Toast'
|
||||||
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
|
import {AvatarStack} from '#/components/AvatarStack'
|
||||||
|
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||||
|
import * as Dialog from '#/components/Dialog'
|
||||||
|
import {Divider} from '#/components/Divider'
|
||||||
|
import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus'
|
||||||
|
import {StarterPack} from '#/components/icons/StarterPack'
|
||||||
|
import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
|
||||||
|
import {Loader} from '#/components/Loader'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
import * as bsky from '#/types/bsky'
|
||||||
|
|
||||||
|
type StarterPackWithMembership =
|
||||||
|
AppBskyGraphGetStarterPacksWithMembership.StarterPackWithMembership
|
||||||
|
|
||||||
|
export type StarterPackDialogProps = {
|
||||||
|
control: Dialog.DialogControlProps
|
||||||
|
targetDid: string
|
||||||
|
enabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StarterPackDialog({
|
||||||
|
control,
|
||||||
|
targetDid,
|
||||||
|
enabled,
|
||||||
|
}: StarterPackDialogProps) {
|
||||||
|
const {_} = useLingui()
|
||||||
|
const navigation = useNavigation<NavigationProp>()
|
||||||
|
const requireEmailVerification = useRequireEmailVerification()
|
||||||
|
|
||||||
|
const navToWizard = useCallback(() => {
|
||||||
|
control.close()
|
||||||
|
navigation.navigate('StarterPackWizard', {
|
||||||
|
fromDialog: true,
|
||||||
|
targetDid: targetDid,
|
||||||
|
onSuccess: () => {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!control.isOpen) {
|
||||||
|
control.open()
|
||||||
|
}
|
||||||
|
}, 0)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}, [navigation, control, targetDid])
|
||||||
|
|
||||||
|
const wrappedNavToWizard = requireEmailVerification(navToWizard, {
|
||||||
|
instructions: [
|
||||||
|
<Trans key="nav">
|
||||||
|
Before creating a starter pack, you must first verify your email.
|
||||||
|
</Trans>,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog.Outer control={control}>
|
||||||
|
<Dialog.Handle />
|
||||||
|
<StarterPackList
|
||||||
|
control={control}
|
||||||
|
onStartWizard={wrappedNavToWizard}
|
||||||
|
targetDid={targetDid}
|
||||||
|
enabled={enabled}
|
||||||
|
/>
|
||||||
|
</Dialog.Outer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Empty({onStartWizard}: {onStartWizard: () => void}) {
|
||||||
|
const {_} = useLingui()
|
||||||
|
const t = useTheme()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[a.gap_2xl, {paddingTop: isWeb ? 100 : 64}]}>
|
||||||
|
<View style={[a.gap_xs, a.align_center]}>
|
||||||
|
<StarterPack
|
||||||
|
width={48}
|
||||||
|
fill={t.atoms.border_contrast_medium.borderColor}
|
||||||
|
/>
|
||||||
|
<Text style={[a.text_center]}>
|
||||||
|
<Trans>You have no starter packs.</Trans>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={[a.align_center]}>
|
||||||
|
<Button
|
||||||
|
label={_(msg`Create starter pack`)}
|
||||||
|
color="secondary_inverted"
|
||||||
|
size="small"
|
||||||
|
onPress={onStartWizard}>
|
||||||
|
<ButtonText>
|
||||||
|
<Trans comment="Text on button to create a new starter pack">
|
||||||
|
Create
|
||||||
|
</Trans>
|
||||||
|
</ButtonText>
|
||||||
|
<ButtonIcon icon={PlusIcon} />
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function StarterPackList({
|
||||||
|
control,
|
||||||
|
onStartWizard,
|
||||||
|
targetDid,
|
||||||
|
enabled,
|
||||||
|
}: {
|
||||||
|
control: Dialog.DialogControlProps
|
||||||
|
onStartWizard: () => void
|
||||||
|
targetDid: string
|
||||||
|
enabled?: boolean
|
||||||
|
}) {
|
||||||
|
const {_} = useLingui()
|
||||||
|
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
refetch,
|
||||||
|
isError,
|
||||||
|
isLoading,
|
||||||
|
hasNextPage,
|
||||||
|
isFetchingNextPage,
|
||||||
|
fetchNextPage,
|
||||||
|
} = useActorStarterPacksWithMembershipsQuery({did: targetDid, enabled})
|
||||||
|
|
||||||
|
const membershipItems =
|
||||||
|
data?.pages.flatMap(page => page.starterPacksWithMembership) || []
|
||||||
|
|
||||||
|
const _onRefresh = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
await refetch()
|
||||||
|
} catch (err) {
|
||||||
|
// Error handling is optional since this is just a refresh
|
||||||
|
}
|
||||||
|
}, [refetch])
|
||||||
|
|
||||||
|
const _onEndReached = useCallback(async () => {
|
||||||
|
if (isFetchingNextPage || !hasNextPage || isError) return
|
||||||
|
try {
|
||||||
|
await fetchNextPage()
|
||||||
|
} catch (err) {
|
||||||
|
// Error handling is optional since this is just pagination
|
||||||
|
}
|
||||||
|
}, [isFetchingNextPage, hasNextPage, isError, fetchNextPage])
|
||||||
|
|
||||||
|
const renderItem = useCallback(
|
||||||
|
({item}: {item: StarterPackWithMembership}) => (
|
||||||
|
<StarterPackItem starterPackWithMembership={item} targetDid={targetDid} />
|
||||||
|
),
|
||||||
|
[targetDid],
|
||||||
|
)
|
||||||
|
|
||||||
|
const onClose = useCallback(() => {
|
||||||
|
control.close()
|
||||||
|
}, [control])
|
||||||
|
|
||||||
|
const listHeader = (
|
||||||
|
<>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
{justifyContent: 'space-between', flexDirection: 'row'},
|
||||||
|
isWeb ? a.mb_2xl : a.my_lg,
|
||||||
|
a.align_center,
|
||||||
|
]}>
|
||||||
|
<Text style={[a.text_lg, a.font_bold]}>
|
||||||
|
<Trans>Add to starter packs</Trans>
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
label={_(msg`Close`)}
|
||||||
|
onPress={onClose}
|
||||||
|
variant="ghost"
|
||||||
|
color="secondary"
|
||||||
|
size="small"
|
||||||
|
shape="round">
|
||||||
|
<ButtonIcon icon={XIcon} />
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
{membershipItems.length > 0 && (
|
||||||
|
<>
|
||||||
|
<View
|
||||||
|
style={[a.flex_row, a.justify_between, a.align_center, a.py_md]}>
|
||||||
|
<Text style={[a.text_md, a.font_bold]}>
|
||||||
|
<Trans>New starter pack</Trans>
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
label={_(msg`Create starter pack`)}
|
||||||
|
color="secondary_inverted"
|
||||||
|
size="small"
|
||||||
|
onPress={onStartWizard}>
|
||||||
|
<ButtonText>
|
||||||
|
<Trans comment="Text on button to create a new starter pack">
|
||||||
|
Create
|
||||||
|
</Trans>
|
||||||
|
</ButtonText>
|
||||||
|
<ButtonIcon icon={PlusIcon} />
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
<Divider />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog.InnerFlatList
|
||||||
|
data={isLoading ? [{}] : membershipItems}
|
||||||
|
renderItem={
|
||||||
|
isLoading
|
||||||
|
? () => (
|
||||||
|
<View style={[a.align_center, a.py_2xl]}>
|
||||||
|
<Loader size="xl" />
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
: renderItem
|
||||||
|
}
|
||||||
|
keyExtractor={
|
||||||
|
isLoading
|
||||||
|
? () => 'starter_pack_dialog_loader'
|
||||||
|
: (item: StarterPackWithMembership) => item.starterPack.uri
|
||||||
|
}
|
||||||
|
refreshing={false}
|
||||||
|
onRefresh={_onRefresh}
|
||||||
|
onEndReached={_onEndReached}
|
||||||
|
onEndReachedThreshold={0.1}
|
||||||
|
ListHeaderComponent={listHeader}
|
||||||
|
ListEmptyComponent={<Empty onStartWizard={onStartWizard} />}
|
||||||
|
style={isWeb ? [a.px_md, {minHeight: 500}] : [a.px_2xl, a.pt_lg]}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function StarterPackItem({
|
||||||
|
starterPackWithMembership,
|
||||||
|
targetDid,
|
||||||
|
}: {
|
||||||
|
starterPackWithMembership: StarterPackWithMembership
|
||||||
|
targetDid: string
|
||||||
|
}) {
|
||||||
|
const {_} = useLingui()
|
||||||
|
const t = useTheme()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
const starterPack = starterPackWithMembership.starterPack
|
||||||
|
const isInPack = !!starterPackWithMembership.listItem
|
||||||
|
|
||||||
|
const [isPendingRefresh, setIsPendingRefresh] = useState(false)
|
||||||
|
|
||||||
|
const {mutate: addMembership} = useListMembershipAddMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
Toast.show(_(msg`Added to starter pack`))
|
||||||
|
// Use a timeout to wait for the appview to update, matching the pattern
|
||||||
|
// in list-memberships.ts
|
||||||
|
setTimeout(() => {
|
||||||
|
invalidateActorStarterPacksWithMembershipQuery({
|
||||||
|
queryClient,
|
||||||
|
did: targetDid,
|
||||||
|
})
|
||||||
|
setIsPendingRefresh(false)
|
||||||
|
}, 1e3)
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
Toast.show(_(msg`Failed to add to starter pack`), 'xmark')
|
||||||
|
setIsPendingRefresh(false)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const {mutate: removeMembership} = useListMembershipRemoveMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
Toast.show(_(msg`Removed from starter pack`))
|
||||||
|
// Use a timeout to wait for the appview to update, matching the pattern
|
||||||
|
// in list-memberships.ts
|
||||||
|
setTimeout(() => {
|
||||||
|
invalidateActorStarterPacksWithMembershipQuery({
|
||||||
|
queryClient,
|
||||||
|
did: targetDid,
|
||||||
|
})
|
||||||
|
setIsPendingRefresh(false)
|
||||||
|
}, 1e3)
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
Toast.show(_(msg`Failed to remove from starter pack`), 'xmark')
|
||||||
|
setIsPendingRefresh(false)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleToggleMembership = () => {
|
||||||
|
if (!starterPack.list?.uri || isPendingRefresh) return
|
||||||
|
|
||||||
|
const listUri = starterPack.list.uri
|
||||||
|
|
||||||
|
setIsPendingRefresh(true)
|
||||||
|
|
||||||
|
if (!isInPack) {
|
||||||
|
addMembership({
|
||||||
|
listUri: listUri,
|
||||||
|
actorDid: targetDid,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
if (!starterPackWithMembership.listItem?.uri) {
|
||||||
|
console.error('Cannot remove: missing membership URI')
|
||||||
|
setIsPendingRefresh(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
removeMembership({
|
||||||
|
listUri: listUri,
|
||||||
|
actorDid: targetDid,
|
||||||
|
membershipUri: starterPackWithMembership.listItem.uri,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const {record} = starterPack
|
||||||
|
|
||||||
|
if (
|
||||||
|
!bsky.dangerousIsType<AppBskyGraphStarterpack.Record>(
|
||||||
|
record,
|
||||||
|
AppBskyGraphStarterpack.isRecord,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[a.flex_row, a.justify_between, a.align_center, a.py_md]}>
|
||||||
|
<View>
|
||||||
|
<Text emoji style={[a.text_md, a.font_bold]} numberOfLines={1}>
|
||||||
|
{record.name}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View style={[a.flex_row, a.align_center, a.mt_xs]}>
|
||||||
|
{starterPack.listItemsSample &&
|
||||||
|
starterPack.listItemsSample.length > 0 && (
|
||||||
|
<>
|
||||||
|
<AvatarStack
|
||||||
|
size={32}
|
||||||
|
profiles={starterPack.listItemsSample
|
||||||
|
?.slice(0, 4)
|
||||||
|
.map(p => p.subject)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{starterPack.list?.listItemCount &&
|
||||||
|
starterPack.list.listItemCount > 4 && (
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.text_sm,
|
||||||
|
t.atoms.text_contrast_medium,
|
||||||
|
a.ml_xs,
|
||||||
|
]}>
|
||||||
|
<Trans>
|
||||||
|
<Plural
|
||||||
|
value={starterPack.list.listItemCount - 4}
|
||||||
|
other="+# more"
|
||||||
|
/>
|
||||||
|
</Trans>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
label={isInPack ? _(msg`Remove`) : _(msg`Add`)}
|
||||||
|
color={isInPack ? 'secondary' : 'primary'}
|
||||||
|
size="tiny"
|
||||||
|
disabled={isPendingRefresh}
|
||||||
|
onPress={handleToggleMembership}>
|
||||||
|
<ButtonText>
|
||||||
|
{isInPack ? <Trans>Remove</Trans> : <Trans>Add</Trans>}
|
||||||
|
</ButtonText>
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
import React, {useCallback} from 'react'
|
import React, {useCallback} from 'react'
|
||||||
import {Keyboard, Pressable, View} from 'react-native'
|
import {Keyboard, View} from 'react-native'
|
||||||
import {ChatBskyConvoDefs, ModerationCause} from '@atproto/api'
|
import {type ChatBskyConvoDefs, type ModerationCause} from '@atproto/api'
|
||||||
import {msg, Trans} from '@lingui/macro'
|
import {msg, Trans} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
import {useNavigation} from '@react-navigation/native'
|
import {useNavigation} from '@react-navigation/native'
|
||||||
|
|
||||||
import {NavigationProp} from '#/lib/routes/types'
|
import {type NavigationProp} from '#/lib/routes/types'
|
||||||
import {Shadow} from '#/state/cache/types'
|
import {type Shadow} from '#/state/cache/types'
|
||||||
import {
|
import {
|
||||||
useConvoQuery,
|
useConvoQuery,
|
||||||
useMarkAsReadMutation,
|
useMarkAsReadMutation,
|
||||||
@@ -14,11 +14,15 @@ import {
|
|||||||
import {useMuteConvo} from '#/state/queries/messages/mute-conversation'
|
import {useMuteConvo} from '#/state/queries/messages/mute-conversation'
|
||||||
import {useProfileBlockMutationQueue} from '#/state/queries/profile'
|
import {useProfileBlockMutationQueue} from '#/state/queries/profile'
|
||||||
import * as Toast from '#/view/com/util/Toast'
|
import * as Toast from '#/view/com/util/Toast'
|
||||||
import {atoms as a, useTheme, ViewStyleProp} from '#/alf'
|
import {type ViewStyleProp} from '#/alf'
|
||||||
|
import {atoms as a} from '#/alf'
|
||||||
|
import {Button, ButtonIcon} from '#/components/Button'
|
||||||
import {BlockedByListDialog} from '#/components/dms/BlockedByListDialog'
|
import {BlockedByListDialog} from '#/components/dms/BlockedByListDialog'
|
||||||
import {LeaveConvoPrompt} from '#/components/dms/LeaveConvoPrompt'
|
import {LeaveConvoPrompt} from '#/components/dms/LeaveConvoPrompt'
|
||||||
import {ReportConversationPrompt} from '#/components/dms/ReportConversationPrompt'
|
import {ReportConversationPrompt} from '#/components/dms/ReportConversationPrompt'
|
||||||
|
import {ReportDialog} from '#/components/dms/ReportDialog'
|
||||||
import {ArrowBoxLeft_Stroke2_Corner0_Rounded as ArrowBoxLeft} from '#/components/icons/ArrowBoxLeft'
|
import {ArrowBoxLeft_Stroke2_Corner0_Rounded as ArrowBoxLeft} from '#/components/icons/ArrowBoxLeft'
|
||||||
|
import {Bubble_Stroke2_Corner2_Rounded as Bubble} from '#/components/icons/Bubble'
|
||||||
import {DotGrid_Stroke2_Corner0_Rounded as DotsHorizontal} from '#/components/icons/DotGrid'
|
import {DotGrid_Stroke2_Corner0_Rounded as DotsHorizontal} from '#/components/icons/DotGrid'
|
||||||
import {Flag_Stroke2_Corner0_Rounded as Flag} from '#/components/icons/Flag'
|
import {Flag_Stroke2_Corner0_Rounded as Flag} from '#/components/icons/Flag'
|
||||||
import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute'
|
import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute'
|
||||||
@@ -30,9 +34,7 @@ import {
|
|||||||
import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/icons/Speaker'
|
import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/icons/Speaker'
|
||||||
import * as Menu from '#/components/Menu'
|
import * as Menu from '#/components/Menu'
|
||||||
import * as Prompt from '#/components/Prompt'
|
import * as Prompt from '#/components/Prompt'
|
||||||
import * as bsky from '#/types/bsky'
|
import type * as bsky from '#/types/bsky'
|
||||||
import {Bubble_Stroke2_Corner2_Rounded as Bubble} from '../icons/Bubble'
|
|
||||||
import {ReportDialog} from './ReportDialog'
|
|
||||||
|
|
||||||
let ConvoMenu = ({
|
let ConvoMenu = ({
|
||||||
convo,
|
convo,
|
||||||
@@ -59,7 +61,6 @@ let ConvoMenu = ({
|
|||||||
style?: ViewStyleProp['style']
|
style?: ViewStyleProp['style']
|
||||||
}): React.ReactNode => {
|
}): React.ReactNode => {
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const t = useTheme()
|
|
||||||
|
|
||||||
const leaveConvoControl = Prompt.usePromptControl()
|
const leaveConvoControl = Prompt.usePromptControl()
|
||||||
const reportControl = Prompt.usePromptControl()
|
const reportControl = Prompt.usePromptControl()
|
||||||
@@ -73,22 +74,21 @@ let ConvoMenu = ({
|
|||||||
{!hideTrigger && (
|
{!hideTrigger && (
|
||||||
<View style={[style]}>
|
<View style={[style]}>
|
||||||
<Menu.Trigger label={_(msg`Chat settings`)}>
|
<Menu.Trigger label={_(msg`Chat settings`)}>
|
||||||
{({props, state}) => (
|
{({props}) => (
|
||||||
<Pressable
|
<Button
|
||||||
|
label={props.accessibilityLabel}
|
||||||
{...props}
|
{...props}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
Keyboard.dismiss()
|
Keyboard.dismiss()
|
||||||
props.onPress()
|
props.onPress()
|
||||||
}}
|
}}
|
||||||
style={[
|
size="small"
|
||||||
a.p_sm,
|
color="secondary"
|
||||||
a.rounded_full,
|
shape="round"
|
||||||
(state.hovered || state.pressed) && t.atoms.bg_contrast_25,
|
variant="ghost"
|
||||||
// make sure pfp is in the middle
|
style={[a.bg_transparent]}>
|
||||||
{marginLeft: -10},
|
<ButtonIcon icon={DotsHorizontal} size="md" />
|
||||||
]}>
|
</Button>
|
||||||
<DotsHorizontal size="md" style={t.atoms.text} />
|
|
||||||
</Pressable>
|
|
||||||
)}
|
)}
|
||||||
</Menu.Trigger>
|
</Menu.Trigger>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,48 +1,42 @@
|
|||||||
import React, {useCallback} from 'react'
|
import {useMemo} from 'react'
|
||||||
import {TouchableOpacity, View} from 'react-native'
|
import {View} from 'react-native'
|
||||||
import {
|
import {
|
||||||
type AppBskyActorDefs,
|
type AppBskyActorDefs,
|
||||||
type ModerationCause,
|
type ModerationCause,
|
||||||
type ModerationDecision,
|
type ModerationDecision,
|
||||||
} from '@atproto/api'
|
} from '@atproto/api'
|
||||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
|
||||||
import {msg} from '@lingui/macro'
|
import {msg} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
import {useNavigation} from '@react-navigation/native'
|
|
||||||
|
|
||||||
import {BACK_HITSLOP} from '#/lib/constants'
|
|
||||||
import {makeProfileLink} from '#/lib/routes/links'
|
import {makeProfileLink} from '#/lib/routes/links'
|
||||||
import {type NavigationProp} from '#/lib/routes/types'
|
|
||||||
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||||
import {isWeb} from '#/platform/detection'
|
import {isWeb} from '#/platform/detection'
|
||||||
import {type Shadow} from '#/state/cache/profile-shadow'
|
import {type Shadow} from '#/state/cache/profile-shadow'
|
||||||
import {isConvoActive, useConvo} from '#/state/messages/convo'
|
import {isConvoActive, useConvo} from '#/state/messages/convo'
|
||||||
import {type ConvoItem} from '#/state/messages/convo/types'
|
import {type ConvoItem} from '#/state/messages/convo/types'
|
||||||
import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
|
import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
|
||||||
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
|
import {atoms as a, useTheme, web} from '#/alf'
|
||||||
import {ConvoMenu} from '#/components/dms/ConvoMenu'
|
import {ConvoMenu} from '#/components/dms/ConvoMenu'
|
||||||
import {Bell2Off_Filled_Corner0_Rounded as BellStroke} from '#/components/icons/Bell2'
|
import {Bell2Off_Filled_Corner0_Rounded as BellStroke} from '#/components/icons/Bell2'
|
||||||
|
import * as Layout from '#/components/Layout'
|
||||||
import {Link} from '#/components/Link'
|
import {Link} from '#/components/Link'
|
||||||
import {PostAlerts} from '#/components/moderation/PostAlerts'
|
import {PostAlerts} from '#/components/moderation/PostAlerts'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
import {useSimpleVerificationState} from '#/components/verification'
|
import {useSimpleVerificationState} from '#/components/verification'
|
||||||
import {VerificationCheck} from '#/components/verification/VerificationCheck'
|
import {VerificationCheck} from '#/components/verification/VerificationCheck'
|
||||||
|
|
||||||
const PFP_SIZE = isWeb ? 40 : 34
|
const PFP_SIZE = isWeb ? 40 : Layout.HEADER_SLOT_SIZE
|
||||||
|
|
||||||
export let MessagesListHeader = ({
|
export function MessagesListHeader({
|
||||||
profile,
|
profile,
|
||||||
moderation,
|
moderation,
|
||||||
}: {
|
}: {
|
||||||
profile?: Shadow<AppBskyActorDefs.ProfileViewDetailed>
|
profile?: Shadow<AppBskyActorDefs.ProfileViewDetailed>
|
||||||
moderation?: ModerationDecision
|
moderation?: ModerationDecision
|
||||||
}): React.ReactNode => {
|
}) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {_} = useLingui()
|
|
||||||
const {gtTablet} = useBreakpoints()
|
|
||||||
const navigation = useNavigation<NavigationProp>()
|
|
||||||
|
|
||||||
const blockInfo = React.useMemo(() => {
|
const blockInfo = useMemo(() => {
|
||||||
if (!moderation) return
|
if (!moderation) return
|
||||||
const modui = moderation.ui('profileView')
|
const modui = moderation.ui('profileView')
|
||||||
const blocks = modui.alerts.filter(alert => alert.type === 'blocking')
|
const blocks = modui.alerts.filter(alert => alert.type === 'blocking')
|
||||||
@@ -54,87 +48,54 @@ export let MessagesListHeader = ({
|
|||||||
}
|
}
|
||||||
}, [moderation])
|
}, [moderation])
|
||||||
|
|
||||||
const onPressBack = useCallback(() => {
|
|
||||||
if (navigation.canGoBack()) {
|
|
||||||
navigation.goBack()
|
|
||||||
} else {
|
|
||||||
navigation.navigate('Messages', {})
|
|
||||||
}
|
|
||||||
}, [navigation])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<Layout.Header.Outer>
|
||||||
style={[
|
<View style={[a.w_full, a.flex_row, a.gap_xs, a.align_start]}>
|
||||||
t.atoms.bg,
|
<View style={[{minHeight: PFP_SIZE}, a.justify_center]}>
|
||||||
t.atoms.border_contrast_low,
|
<Layout.Header.BackButton />
|
||||||
a.border_b,
|
</View>
|
||||||
a.flex_row,
|
{profile && moderation && blockInfo ? (
|
||||||
a.align_start,
|
<HeaderReady
|
||||||
a.gap_sm,
|
profile={profile}
|
||||||
gtTablet ? a.pl_lg : a.pl_xl,
|
moderation={moderation}
|
||||||
a.pr_lg,
|
blockInfo={blockInfo}
|
||||||
a.py_sm,
|
/>
|
||||||
]}>
|
) : (
|
||||||
<TouchableOpacity
|
<>
|
||||||
testID="conversationHeaderBackBtn"
|
<View style={[a.flex_row, a.align_center, a.gap_md, a.flex_1]}>
|
||||||
onPress={onPressBack}
|
|
||||||
hitSlop={BACK_HITSLOP}
|
|
||||||
style={{width: 30, height: 30, marginTop: isWeb ? 6 : 4}}
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel={_(msg`Back`)}
|
|
||||||
accessibilityHint="">
|
|
||||||
<FontAwesomeIcon
|
|
||||||
size={18}
|
|
||||||
icon="angle-left"
|
|
||||||
style={{
|
|
||||||
marginTop: 6,
|
|
||||||
}}
|
|
||||||
color={t.atoms.text.color}
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
{profile && moderation && blockInfo ? (
|
|
||||||
<HeaderReady
|
|
||||||
profile={profile}
|
|
||||||
moderation={moderation}
|
|
||||||
blockInfo={blockInfo}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<View style={[a.flex_row, a.align_center, a.gap_md, a.flex_1]}>
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
{width: PFP_SIZE, height: PFP_SIZE},
|
|
||||||
a.rounded_full,
|
|
||||||
t.atoms.bg_contrast_25,
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<View style={a.gap_xs}>
|
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
{width: 120, height: 16},
|
{width: PFP_SIZE, height: PFP_SIZE},
|
||||||
a.rounded_xs,
|
a.rounded_full,
|
||||||
t.atoms.bg_contrast_25,
|
|
||||||
a.mt_xs,
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
{width: 175, height: 12},
|
|
||||||
a.rounded_xs,
|
|
||||||
t.atoms.bg_contrast_25,
|
t.atoms.bg_contrast_25,
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
<View style={a.gap_xs}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
{width: 120, height: 16},
|
||||||
|
a.rounded_xs,
|
||||||
|
t.atoms.bg_contrast_25,
|
||||||
|
a.mt_xs,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
{width: 175, height: 12},
|
||||||
|
a.rounded_xs,
|
||||||
|
t.atoms.bg_contrast_25,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={{width: 30}} />
|
<Layout.Header.Slot />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
</Layout.Header.Outer>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
MessagesListHeader = React.memo(MessagesListHeader)
|
|
||||||
|
|
||||||
function HeaderReady({
|
function HeaderReady({
|
||||||
profile,
|
profile,
|
||||||
@@ -181,15 +142,13 @@ function HeaderReady({
|
|||||||
label={_(msg`View ${displayName}'s profile`)}
|
label={_(msg`View ${displayName}'s profile`)}
|
||||||
style={[a.flex_row, a.align_start, a.gap_md, a.flex_1, a.pr_md]}
|
style={[a.flex_row, a.align_start, a.gap_md, a.flex_1, a.pr_md]}
|
||||||
to={makeProfileLink(profile)}>
|
to={makeProfileLink(profile)}>
|
||||||
<View style={[a.pt_2xs]}>
|
<PreviewableUserAvatar
|
||||||
<PreviewableUserAvatar
|
size={PFP_SIZE}
|
||||||
size={PFP_SIZE}
|
profile={profile}
|
||||||
profile={profile}
|
moderation={moderation.ui('avatar')}
|
||||||
moderation={moderation.ui('avatar')}
|
disableHoverCard={moderation.blocked}
|
||||||
disableHoverCard={moderation.blocked}
|
/>
|
||||||
/>
|
<View style={[a.flex_1]}>
|
||||||
</View>
|
|
||||||
<View style={a.flex_1}>
|
|
||||||
<View style={[a.flex_row, a.align_center]}>
|
<View style={[a.flex_row, a.align_center]}>
|
||||||
<Text
|
<Text
|
||||||
emoji
|
emoji
|
||||||
@@ -215,7 +174,7 @@ function HeaderReady({
|
|||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
t.atoms.text_contrast_medium,
|
t.atoms.text_contrast_medium,
|
||||||
a.text_sm,
|
a.text_xs,
|
||||||
web([a.leading_normal, {marginTop: -2}]),
|
web([a.leading_normal, {marginTop: -2}]),
|
||||||
]}
|
]}
|
||||||
numberOfLines={1}>
|
numberOfLines={1}>
|
||||||
@@ -235,15 +194,19 @@ function HeaderReady({
|
|||||||
</View>
|
</View>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{isConvoActive(convoState) && (
|
<View style={[{minHeight: PFP_SIZE}, a.justify_center]}>
|
||||||
<ConvoMenu
|
<Layout.Header.Slot>
|
||||||
convo={convoState.convo}
|
{isConvoActive(convoState) && (
|
||||||
profile={profile}
|
<ConvoMenu
|
||||||
currentScreen="conversation"
|
convo={convoState.convo}
|
||||||
blockInfo={blockInfo}
|
profile={profile}
|
||||||
latestReportableMessage={latestReportableMessage}
|
currentScreen="conversation"
|
||||||
/>
|
blockInfo={blockInfo}
|
||||||
)}
|
latestReportableMessage={latestReportableMessage}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Layout.Header.Slot>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View
|
<View
|
||||||
|
|||||||
@@ -48,9 +48,11 @@ const Context = createContext<{
|
|||||||
})
|
})
|
||||||
Context.displayName = 'TextFieldContext'
|
Context.displayName = 'TextFieldContext'
|
||||||
|
|
||||||
export type RootProps = React.PropsWithChildren<{isInvalid?: boolean}>
|
export type RootProps = React.PropsWithChildren<
|
||||||
|
{isInvalid?: boolean} & TextStyleProp
|
||||||
|
>
|
||||||
|
|
||||||
export function Root({children, isInvalid = false}: RootProps) {
|
export function Root({children, isInvalid = false, style}: RootProps) {
|
||||||
const inputRef = useRef<TextInput>(null)
|
const inputRef = useRef<TextInput>(null)
|
||||||
const {
|
const {
|
||||||
state: hovered,
|
state: hovered,
|
||||||
@@ -85,7 +87,14 @@ export function Root({children, isInvalid = false}: RootProps) {
|
|||||||
return (
|
return (
|
||||||
<Context.Provider value={context}>
|
<Context.Provider value={context}>
|
||||||
<View
|
<View
|
||||||
style={[a.flex_row, a.align_center, a.relative, a.w_full, a.px_md]}
|
style={[
|
||||||
|
a.flex_row,
|
||||||
|
a.align_center,
|
||||||
|
a.relative,
|
||||||
|
a.w_full,
|
||||||
|
a.px_md,
|
||||||
|
style,
|
||||||
|
]}
|
||||||
{...web({
|
{...web({
|
||||||
onClick: () => inputRef.current?.focus(),
|
onClick: () => inputRef.current?.focus(),
|
||||||
onMouseOver: onHoverIn,
|
onMouseOver: onHoverIn,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {Pressable, View, type ViewStyle} from 'react-native'
|
import {Pressable, type StyleProp, View, type ViewStyle} from 'react-native'
|
||||||
import Animated, {LinearTransition} from 'react-native-reanimated'
|
import Animated, {LinearTransition} from 'react-native-reanimated'
|
||||||
|
|
||||||
import {HITSLOP_10} from '#/lib/constants'
|
import {HITSLOP_10} from '#/lib/constants'
|
||||||
@@ -59,6 +59,7 @@ export type GroupProps = React.PropsWithChildren<{
|
|||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
onChange: (value: string[]) => void
|
onChange: (value: string[]) => void
|
||||||
label: string
|
label: string
|
||||||
|
style?: StyleProp<ViewStyle>
|
||||||
}>
|
}>
|
||||||
|
|
||||||
export type ItemProps = ViewStyleProp & {
|
export type ItemProps = ViewStyleProp & {
|
||||||
@@ -84,6 +85,7 @@ export function Group({
|
|||||||
type = 'checkbox',
|
type = 'checkbox',
|
||||||
maxSelections,
|
maxSelections,
|
||||||
label,
|
label,
|
||||||
|
style,
|
||||||
}: GroupProps) {
|
}: GroupProps) {
|
||||||
const groupRole = type === 'radio' ? 'radiogroup' : undefined
|
const groupRole = type === 'radio' ? 'radiogroup' : undefined
|
||||||
const values = type === 'radio' ? providedValues.slice(0, 1) : providedValues
|
const values = type === 'radio' ? providedValues.slice(0, 1) : providedValues
|
||||||
@@ -136,7 +138,7 @@ export function Group({
|
|||||||
return (
|
return (
|
||||||
<GroupContext.Provider value={context}>
|
<GroupContext.Provider value={context}>
|
||||||
<View
|
<View
|
||||||
style={[a.w_full]}
|
style={[a.w_full, style]}
|
||||||
role={groupRole}
|
role={groupRole}
|
||||||
{...(groupRole === 'radiogroup'
|
{...(groupRole === 'radiogroup'
|
||||||
? {
|
? {
|
||||||
|
|||||||
@@ -1,5 +1,42 @@
|
|||||||
|
import Svg, {Path} from 'react-native-svg'
|
||||||
|
|
||||||
|
import {type Props, useCommonSVGProps} from './common'
|
||||||
import {createSinglePathSVG} from './TEMPLATE'
|
import {createSinglePathSVG} from './TEMPLATE'
|
||||||
|
|
||||||
export const Mark = createSinglePathSVG({
|
export const Mark = createSinglePathSVG({
|
||||||
path: 'M6.335 4.212c2.293 1.76 4.76 5.327 5.665 7.241.906-1.914 3.372-5.482 5.665-7.241C19.319 2.942 22 1.96 22 5.086c0 .624-.35 5.244-.556 5.994-.713 2.608-3.315 3.273-5.629 2.87 4.045.704 5.074 3.035 2.852 5.366-4.22 4.426-6.066-1.111-6.54-2.53-.086-.26-.126-.382-.127-.278 0-.104-.041.018-.128.278-.473 1.419-2.318 6.956-6.539 2.53-2.222-2.331-1.193-4.662 2.852-5.366-2.314.403-4.916-.262-5.63-2.87C2.35 10.33 2 5.71 2 5.086c0-3.126 2.68-2.144 4.335-.874Z',
|
path: 'M6.335 4.212c2.293 1.76 4.76 5.327 5.665 7.241.906-1.914 3.372-5.482 5.665-7.241C19.319 2.942 22 1.96 22 5.086c0 .624-.35 5.244-.556 5.994-.713 2.608-3.315 3.273-5.629 2.87 4.045.704 5.074 3.035 2.852 5.366-4.22 4.426-6.066-1.111-6.54-2.53-.086-.26-.126-.382-.127-.278 0-.104-.041.018-.128.278-.473 1.419-2.318 6.956-6.539 2.53-2.222-2.331-1.193-4.662 2.852-5.366-2.314.403-4.916-.262-5.63-2.87C2.35 10.33 2 5.71 2 5.086c0-3.126 2.68-2.144 4.335-.874Z',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export function Full(
|
||||||
|
props: Omit<Props, 'fill' | 'size' | 'height'> & {
|
||||||
|
markFill?: Props['fill']
|
||||||
|
textFill?: Props['fill']
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const {fill, size, style, gradient, ...rest} = useCommonSVGProps(props)
|
||||||
|
const ratio = 123 / 555
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Svg
|
||||||
|
fill="none"
|
||||||
|
{...rest}
|
||||||
|
viewBox="0 0 555 123"
|
||||||
|
width={size}
|
||||||
|
height={size * ratio}
|
||||||
|
style={[style]}>
|
||||||
|
{gradient}
|
||||||
|
<Path
|
||||||
|
fill={props.markFill ?? fill}
|
||||||
|
fillRule="evenodd"
|
||||||
|
clipRule="evenodd"
|
||||||
|
d="M101.821 7.673C112.575-.367 130-6.589 130 13.21c0 3.953-2.276 33.214-3.611 37.965-4.641 16.516-21.549 20.729-36.591 18.179 26.292 4.457 32.979 19.218 18.535 33.98-27.433 28.035-39.428-7.034-42.502-16.02-.563-1.647-.827-2.418-.831-1.763-.004-.655-.268.116-.831 1.763-3.074 8.986-15.07 44.055-42.502 16.02C7.223 88.571 13.91 73.81 40.202 69.353c-15.041 2.55-31.95-1.663-36.59-18.179C2.275 46.424 0 17.162 0 13.21 0-6.59 17.426-.368 28.18 7.673 43.084 18.817 59.114 41.413 65 53.54c5.886-12.125 21.917-34.722 36.821-45.866Z"
|
||||||
|
/>
|
||||||
|
<Path
|
||||||
|
fill={props.textFill ?? fill}
|
||||||
|
fillRule="evenodd"
|
||||||
|
clipRule="evenodd"
|
||||||
|
d="m454.459 63.823 24.128-25.056h32.638l4.825 15.104c3.561 11.357 6.664 22.598 9.422 33.72 2.527-9.6 5.744-20.84 9.536-33.603l4.826-15.221H555l-22.864 65.335c-2.413 6.673-5.4 11.475-9.192 14.168-3.791 2.693-9.192 3.98-16.315 3.98-2.413 0-4.481-.117-6.319-.352v-11.59h5.514c6.549 0 9.767-4.099 9.767-9.719 0-2.81-.92-6.908-2.758-12.177l-17.177-49.478-22.239 22.665L497.2 99.184h-16.545l-17.234-28.101-8.962 9.133v18.968h-14.246V15.817h14.246v48.006Zm-48.373-26.46c16.889 0 25.622 6.79 26.196 20.49h-13.673c-.344-7.377-4.595-9.954-12.523-9.954-6.894 0-10.341 2.342-10.341 7.026 0 4.215 2.987 6.089 9.881 7.377l7.469 1.17c14.361 2.694 20.566 8.08 20.566 18.384 0 12.176-9.652 18.967-26.311 18.967-17.235 0-26.311-6.908-27.116-20.842h14.132c.804 7.494 4.481 10.304 13.213 10.304 7.813 0 11.72-2.459 11.72-7.26 0-4.332-2.758-6.44-11.605-7.962l-6.778-1.17c-12.983-2.224-19.418-8.313-19.418-18.265 0-11.358 8.847-18.266 24.588-18.266ZM270.534 76.351c0 7.61 3.677 11.474 11.145 11.474 7.008 0 13.212-5.268 13.213-15.22v-33.84h14.476v60.418h-14.016v-8.782c-4.481 6.791-10.686 10.187-18.614 10.187-12.523 0-20.68-7.728-20.68-21.778V38.767h14.476v37.585Zm75.432-38.99c8.961 0 16.085 3.045 21.37 9.016s7.928 13.933 7.928 23.651v3.513h-44.35c1.034 10.42 6.664 15.572 15.396 15.572 6.663 0 11.144-2.927 13.557-8.664h13.903c-3.103 12.294-13.443 20.139-27.575 20.139-8.847 0-15.971-2.927-21.371-8.664-5.4-5.737-8.157-13.348-8.157-22.95 0-9.483 2.643-17.094 8.043-22.949 5.4-5.737 12.409-8.664 21.256-8.664ZM195.628 15.817c17.809 0 26.426 9.251 26.426 21.545 0 8.196-3.677 14.168-10.915 17.914 9.306 3.396 14.247 11.24 14.247 20.022 0 14.87-9.767 23.886-28.494 23.886h-38.26V15.817h36.996Zm51.264 83.367h-14.477V15.817h14.477v83.367ZM174.143 86.07h21.944c8.732 0 13.443-4.098 13.443-11.474 0-7.728-4.481-11.592-13.443-11.592h-21.944V86.07Zm171.708-37.233c-7.928 0-13.443 4.683-14.822 14.401h29.758c-1.264-8.781-6.549-14.401-14.936-14.401Zm-171.708 1.756h20.336c7.927 0 12.178-4.215 12.178-11.24 0-6.44-4.366-10.539-12.178-10.539h-20.336v21.779Z"
|
||||||
|
/>
|
||||||
|
</Svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, {useEffect} from 'react'
|
import {useCallback, useEffect, useMemo} from 'react'
|
||||||
import {ScrollView, View} from 'react-native'
|
import {ScrollView, View} from 'react-native'
|
||||||
import {AppBskyEmbedVideo, AtUri} from '@atproto/api'
|
import {AppBskyEmbedVideo, AtUri} from '@atproto/api'
|
||||||
import {msg, Trans} from '@lingui/macro'
|
import {msg, Trans} from '@lingui/macro'
|
||||||
@@ -55,7 +55,7 @@ export function TrendingVideos() {
|
|||||||
const {setTrendingVideoDisabled} = useTrendingSettingsApi()
|
const {setTrendingVideoDisabled} = useTrendingSettingsApi()
|
||||||
const trendingPrompt = Prompt.usePromptControl()
|
const trendingPrompt = Prompt.usePromptControl()
|
||||||
|
|
||||||
const onConfirmHide = React.useCallback(() => {
|
const onConfirmHide = useCallback(() => {
|
||||||
setTrendingVideoDisabled(true)
|
setTrendingVideoDisabled(true)
|
||||||
logEvent('trendingVideos:hide', {context: 'interstitial:discover'})
|
logEvent('trendingVideos:hide', {context: 'interstitial:discover'})
|
||||||
}, [setTrendingVideoDisabled])
|
}, [setTrendingVideoDisabled])
|
||||||
@@ -147,9 +147,7 @@ function VideoCards({
|
|||||||
}: {
|
}: {
|
||||||
data: Exclude<ReturnType<typeof usePostFeedQuery>['data'], undefined>
|
data: Exclude<ReturnType<typeof usePostFeedQuery>['data'], undefined>
|
||||||
}) {
|
}) {
|
||||||
const t = useTheme()
|
const items = useMemo(() => {
|
||||||
const {_} = useLingui()
|
|
||||||
const items = React.useMemo(() => {
|
|
||||||
return data.pages
|
return data.pages
|
||||||
.flatMap(page => page.slices)
|
.flatMap(page => page.slices)
|
||||||
.map(slice => slice.items[0])
|
.map(slice => slice.items[0])
|
||||||
@@ -157,10 +155,6 @@ function VideoCards({
|
|||||||
.filter(item => AppBskyEmbedVideo.isView(item.post.embed))
|
.filter(item => AppBskyEmbedVideo.isView(item.post.embed))
|
||||||
.slice(0, 8)
|
.slice(0, 8)
|
||||||
}, [data])
|
}, [data])
|
||||||
const href = React.useMemo(() => {
|
|
||||||
const urip = new AtUri(VIDEO_FEED_URI)
|
|
||||||
return makeCustomFeedLink(urip.host, urip.rkey, undefined, 'discover')
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -183,50 +177,58 @@ function VideoCards({
|
|||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<View style={[{width: CARD_WIDTH * 2}]}>
|
<ViewMoreCard />
|
||||||
<Link
|
|
||||||
to={href}
|
|
||||||
label={_(msg`View more`)}
|
|
||||||
style={[
|
|
||||||
a.justify_center,
|
|
||||||
a.align_center,
|
|
||||||
a.flex_1,
|
|
||||||
a.rounded_lg,
|
|
||||||
a.border,
|
|
||||||
t.atoms.border_contrast_low,
|
|
||||||
t.atoms.bg,
|
|
||||||
t.atoms.shadow_sm,
|
|
||||||
]}>
|
|
||||||
{({pressed}) => (
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
a.flex_row,
|
|
||||||
a.align_center,
|
|
||||||
a.gap_md,
|
|
||||||
{
|
|
||||||
opacity: pressed ? 0.6 : 1,
|
|
||||||
},
|
|
||||||
]}>
|
|
||||||
<Text style={[a.text_md]}>
|
|
||||||
<Trans>View more</Trans>
|
|
||||||
</Text>
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
a.align_center,
|
|
||||||
a.justify_center,
|
|
||||||
a.rounded_full,
|
|
||||||
{
|
|
||||||
width: 34,
|
|
||||||
height: 34,
|
|
||||||
backgroundColor: t.palette.primary_500,
|
|
||||||
},
|
|
||||||
]}>
|
|
||||||
<ButtonIcon icon={ChevronRight} />
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</Link>
|
|
||||||
</View>
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ViewMoreCard() {
|
||||||
|
const t = useTheme()
|
||||||
|
const {_} = useLingui()
|
||||||
|
|
||||||
|
const href = useMemo(() => {
|
||||||
|
const urip = new AtUri(VIDEO_FEED_URI)
|
||||||
|
return makeCustomFeedLink(urip.host, urip.rkey, undefined, 'discover')
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[{width: CARD_WIDTH * 2}]}>
|
||||||
|
<Link
|
||||||
|
to={href}
|
||||||
|
label={_(msg`View more`)}
|
||||||
|
style={[
|
||||||
|
a.justify_center,
|
||||||
|
a.align_center,
|
||||||
|
a.flex_1,
|
||||||
|
a.rounded_lg,
|
||||||
|
a.border,
|
||||||
|
t.atoms.border_contrast_low,
|
||||||
|
t.atoms.bg,
|
||||||
|
t.atoms.shadow_sm,
|
||||||
|
]}>
|
||||||
|
{({pressed}) => (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.flex_row,
|
||||||
|
a.align_center,
|
||||||
|
a.gap_md,
|
||||||
|
{
|
||||||
|
opacity: pressed ? 0.6 : 1,
|
||||||
|
},
|
||||||
|
]}>
|
||||||
|
<Text style={[a.text_md]}>
|
||||||
|
<Trans>View more</Trans>
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
color="primary"
|
||||||
|
size="small"
|
||||||
|
shape="round"
|
||||||
|
label={_(msg`View more trending videos`)}>
|
||||||
|
<ButtonIcon icon={ChevronRight} />
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -215,7 +215,7 @@ function ContentHiderActive({
|
|||||||
control.open()
|
control.open()
|
||||||
}}
|
}}
|
||||||
label={_(
|
label={_(
|
||||||
msg`Learn more about the moderation applied to this content.`,
|
msg`Learn more about the moderation applied to this content`,
|
||||||
)}
|
)}
|
||||||
style={[a.pt_sm]}>
|
style={[a.pt_sm]}>
|
||||||
{state => (
|
{state => (
|
||||||
|
|||||||
@@ -147,7 +147,12 @@ function Inner({
|
|||||||
<Link
|
<Link
|
||||||
overridePresentation
|
overridePresentation
|
||||||
to={urls.website.blog.initialVerificationAnnouncement}
|
to={urls.website.blog.initialVerificationAnnouncement}
|
||||||
label={_(msg`Learn more about verification on Bluesky`)}
|
label={_(
|
||||||
|
msg({
|
||||||
|
message: `Learn more about verification on Bluesky`,
|
||||||
|
context: `english-only-resource`,
|
||||||
|
}),
|
||||||
|
)}
|
||||||
size="small"
|
size="small"
|
||||||
variant="solid"
|
variant="solid"
|
||||||
color="secondary"
|
color="secondary"
|
||||||
@@ -162,7 +167,7 @@ function Inner({
|
|||||||
)
|
)
|
||||||
}}>
|
}}>
|
||||||
<ButtonText>
|
<ButtonText>
|
||||||
<Trans>Learn more</Trans>
|
<Trans context="english-only-resource">Learn more</Trans>
|
||||||
</ButtonText>
|
</ButtonText>
|
||||||
</Link>
|
</Link>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -114,7 +114,12 @@ function Inner({
|
|||||||
<Link
|
<Link
|
||||||
overridePresentation
|
overridePresentation
|
||||||
to={urls.website.blog.initialVerificationAnnouncement}
|
to={urls.website.blog.initialVerificationAnnouncement}
|
||||||
label={_(msg`Learn more about verification on Bluesky`)}
|
label={_(
|
||||||
|
msg({
|
||||||
|
message: `Learn more about verification on Bluesky`,
|
||||||
|
context: `english-only-resource`,
|
||||||
|
}),
|
||||||
|
)}
|
||||||
size="small"
|
size="small"
|
||||||
variant="solid"
|
variant="solid"
|
||||||
color="primary"
|
color="primary"
|
||||||
@@ -129,7 +134,7 @@ function Inner({
|
|||||||
)
|
)
|
||||||
}}>
|
}}>
|
||||||
<ButtonText>
|
<ButtonText>
|
||||||
<Trans>Learn more</Trans>
|
<Trans context="english-only-resource">Learn more</Trans>
|
||||||
</ButtonText>
|
</ButtonText>
|
||||||
</Link>
|
</Link>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export const RELEASE_VERSION: string =
|
|||||||
process.env.EXPO_PUBLIC_RELEASE_VERSION || packageJson.version
|
process.env.EXPO_PUBLIC_RELEASE_VERSION || packageJson.version
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The env the app is running in e.g. development, testflight, production
|
* The env the app is running in e.g. development, testflight, production, e2e
|
||||||
*/
|
*/
|
||||||
export const ENV: string = process.env.EXPO_PUBLIC_ENV
|
export const ENV: string = process.env.EXPO_PUBLIC_ENV
|
||||||
|
|
||||||
@@ -62,6 +62,12 @@ export const LOG_LEVEL = (process.env.EXPO_PUBLIC_LOG_LEVEL || 'info') as
|
|||||||
*/
|
*/
|
||||||
export const LOG_DEBUG: string = process.env.EXPO_PUBLIC_LOG_DEBUG || ''
|
export const LOG_DEBUG: string = process.env.EXPO_PUBLIC_LOG_DEBUG || ''
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The DID of the Bluesky appview to proxy to
|
||||||
|
*/
|
||||||
|
export const BLUESKY_PROXY_DID: Did =
|
||||||
|
process.env.EXPO_PUBLIC_BLUESKY_PROXY_DID || 'did:web:api.bsky.app'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The DID of the chat service to proxy to
|
* The DID of the chat service to proxy to
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import {type Insets, Platform} from 'react-native'
|
import {type Insets, Platform} from 'react-native'
|
||||||
import {type AppBskyActorDefs} from '@atproto/api'
|
import {type AppBskyActorDefs} from '@atproto/api'
|
||||||
|
|
||||||
|
import {type ProxyHeaderValue} from '#/state/session/agent'
|
||||||
|
import {BLUESKY_PROXY_DID, CHAT_PROXY_DID} from '#/env'
|
||||||
|
|
||||||
export const LOCAL_DEV_SERVICE =
|
export const LOCAL_DEV_SERVICE =
|
||||||
Platform.OS === 'android' ? 'http://10.0.2.2:2583' : 'http://localhost:2583'
|
Platform.OS === 'android' ? 'http://10.0.2.2:2583' : 'http://localhost:2583'
|
||||||
export const STAGING_SERVICE = 'https://staging.bsky.dev'
|
export const STAGING_SERVICE = 'https://staging.bsky.dev'
|
||||||
@@ -90,8 +93,6 @@ export const STAGING_FEEDS = [
|
|||||||
`feedgen|${STAGING_DEFAULT_FEED('thevids')}`,
|
`feedgen|${STAGING_DEFAULT_FEED('thevids')}`,
|
||||||
]
|
]
|
||||||
|
|
||||||
export const FEEDBACK_FEEDS = [...PROD_FEEDS, ...STAGING_FEEDS]
|
|
||||||
|
|
||||||
export const POST_IMG_MAX = {
|
export const POST_IMG_MAX = {
|
||||||
width: 2000,
|
width: 2000,
|
||||||
height: 2000,
|
height: 2000,
|
||||||
@@ -213,6 +214,20 @@ export const PUBLIC_STAGING_APPVIEW_DID = 'did:web:api.staging.bsky.dev'
|
|||||||
|
|
||||||
export const DEV_ENV_APPVIEW = `http://localhost:2584` // always the same
|
export const DEV_ENV_APPVIEW = `http://localhost:2584` // always the same
|
||||||
|
|
||||||
|
// temp hack for e2e - esb
|
||||||
|
export let BLUESKY_PROXY_HEADER: ProxyHeaderValue = `${BLUESKY_PROXY_DID}#bsky_appview`
|
||||||
|
export function setBlueskyProxyHeader(header: ProxyHeaderValue) {
|
||||||
|
BLUESKY_PROXY_HEADER = header
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BLUESKY_SERVICE_HEADERS = {
|
||||||
|
'atproto-proxy': BLUESKY_PROXY_HEADER,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DM_SERVICE_HEADERS = {
|
||||||
|
'atproto-proxy': `${CHAT_PROXY_DID}#bsky_chat`,
|
||||||
|
}
|
||||||
|
|
||||||
export const webLinks = {
|
export const webLinks = {
|
||||||
tos: `https://bsky.social/about/support/tos`,
|
tos: `https://bsky.social/about/support/tos`,
|
||||||
privacy: `https://bsky.social/about/support/privacy-policy`,
|
privacy: `https://bsky.social/about/support/privacy-policy`,
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import {
|
||||||
|
type LayoutChangeEvent,
|
||||||
|
type StyleProp,
|
||||||
|
View,
|
||||||
|
type ViewStyle,
|
||||||
|
} from 'react-native'
|
||||||
|
import Animated, {
|
||||||
|
Easing,
|
||||||
|
FadeInUp,
|
||||||
|
FadeOutUp,
|
||||||
|
useAnimatedStyle,
|
||||||
|
useSharedValue,
|
||||||
|
withTiming,
|
||||||
|
} from 'react-native-reanimated'
|
||||||
|
|
||||||
|
import {isIOS, isWeb} from '#/platform/detection'
|
||||||
|
|
||||||
|
type AccordionAnimationProps = React.PropsWithChildren<{
|
||||||
|
isExpanded: boolean
|
||||||
|
duration?: number
|
||||||
|
style?: StyleProp<ViewStyle>
|
||||||
|
}>
|
||||||
|
|
||||||
|
function WebAccordion({
|
||||||
|
isExpanded,
|
||||||
|
duration = 300,
|
||||||
|
style,
|
||||||
|
children,
|
||||||
|
}: AccordionAnimationProps) {
|
||||||
|
const heightValue = useSharedValue(0)
|
||||||
|
|
||||||
|
const animatedStyle = useAnimatedStyle(() => {
|
||||||
|
const targetHeight = isExpanded ? heightValue.get() : 0
|
||||||
|
return {
|
||||||
|
height: withTiming(targetHeight, {
|
||||||
|
duration,
|
||||||
|
easing: Easing.out(Easing.cubic),
|
||||||
|
}),
|
||||||
|
overflow: 'hidden',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const onLayout = (e: LayoutChangeEvent) => {
|
||||||
|
if (heightValue.get() === 0) {
|
||||||
|
heightValue.set(e.nativeEvent.layout.height)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View style={[animatedStyle, style]}>
|
||||||
|
<View onLayout={onLayout}>{children}</View>
|
||||||
|
</Animated.View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MobileAccordion({
|
||||||
|
isExpanded,
|
||||||
|
duration = 200,
|
||||||
|
style,
|
||||||
|
children,
|
||||||
|
}: AccordionAnimationProps) {
|
||||||
|
if (!isExpanded) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View
|
||||||
|
style={style}
|
||||||
|
entering={FadeInUp.duration(duration)}
|
||||||
|
exiting={FadeOutUp.duration(duration / 2)}
|
||||||
|
pointerEvents={isIOS ? 'auto' : 'box-none'}>
|
||||||
|
{children}
|
||||||
|
</Animated.View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AccordionAnimation(props: AccordionAnimationProps) {
|
||||||
|
return isWeb ? <WebAccordion {...props} /> : <MobileAccordion {...props} />
|
||||||
|
}
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import {
|
import {
|
||||||
$Typed,
|
type $Typed,
|
||||||
AppBskyActorDefs,
|
type AppBskyActorDefs,
|
||||||
AppBskyGraphGetStarterPack,
|
type AppBskyGraphGetStarterPack,
|
||||||
BskyAgent,
|
type BskyAgent,
|
||||||
ComAtprotoRepoApplyWrites,
|
type ComAtprotoRepoApplyWrites,
|
||||||
Facet,
|
type Facet,
|
||||||
} from '@atproto/api'
|
} from '@atproto/api'
|
||||||
import {msg} from '@lingui/macro'
|
import {msg} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
@@ -15,7 +15,7 @@ import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
|||||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||||
import {enforceLen} from '#/lib/strings/helpers'
|
import {enforceLen} from '#/lib/strings/helpers'
|
||||||
import {useAgent} from '#/state/session'
|
import {useAgent} from '#/state/session'
|
||||||
import * as bsky from '#/types/bsky'
|
import type * as bsky from '#/types/bsky'
|
||||||
|
|
||||||
export const createStarterPackList = async ({
|
export const createStarterPackList = async ({
|
||||||
name,
|
name,
|
||||||
@@ -46,14 +46,7 @@ export const createStarterPackList = async ({
|
|||||||
if (!list) throw new Error('List creation failed')
|
if (!list) throw new Error('List creation failed')
|
||||||
await agent.com.atproto.repo.applyWrites({
|
await agent.com.atproto.repo.applyWrites({
|
||||||
repo: agent.session!.did,
|
repo: agent.session!.did,
|
||||||
writes: [
|
writes: profiles.map(p => createListItem({did: p.did, listUri: list.uri})),
|
||||||
createListItem({did: agent.session!.did, listUri: list.uri}),
|
|
||||||
].concat(
|
|
||||||
profiles
|
|
||||||
// Ensure we don't have ourselves in this list twice
|
|
||||||
.filter(p => p.did !== agent.session!.did)
|
|
||||||
.map(p => createListItem({did: p.did, listUri: list.uri})),
|
|
||||||
),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return list
|
return list
|
||||||
|
|||||||