Merge remote-tracking branch 'origin/main' into hailey/safelink

This commit is contained in:
Hailey
2025-08-31 13:28:14 -07:00
256 changed files with 59196 additions and 54104 deletions
+3
View File
@@ -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=
+3
View File
@@ -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'],
+9 -3
View File
@@ -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:
+18 -5
View File
@@ -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:
+4 -1
View File
@@ -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"
+7 -5
View File
@@ -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"
+10 -10
View File
@@ -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
+10 -6
View File
@@ -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"
+10 -6
View File
@@ -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"
+4 -1
View File
@@ -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"
+4 -4
View File
@@ -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"
+6 -4
View File
@@ -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"
+5 -1
View File
@@ -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:
+38 -9
View File
@@ -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.*"
+23 -14
View File
@@ -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()
+5
View File
@@ -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
+5 -2
View File
@@ -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
+5
View File
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 486 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

+10 -9
View File
@@ -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"
} }
} }
+1 -1
View File
@@ -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>
) )
} }
+31 -9
View File
@@ -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}`
} }
+36 -14
View File
@@ -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>
)
}
+10 -3
View File
@@ -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 ''
} }
+1 -1
View File
@@ -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')
+49
View File
@@ -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
}
+10
View File
@@ -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
+11
View File
@@ -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',
})}`
}
+152
View File
@@ -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,
}
}
}
+9
View File
@@ -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)
}
+6
View File
@@ -0,0 +1,6 @@
import {AtUri} from '@atproto/api'
export function getRkey({uri}: {uri: string}): string {
const at = new AtUri(uri)
return at.rkey
}
+31
View File
@@ -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,
}
}
-28
View File
@@ -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)
}
+2 -2
View File
@@ -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"]
} }
+1192 -1220
View File
File diff suppressed because it is too large Load Diff
+28
View File
@@ -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
}
+61
View File
@@ -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)
}
})
}
}
+2 -2
View File
@@ -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)
+1
View File
@@ -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 %}
+1 -1
View File
@@ -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 %}
+5 -1
View File
@@ -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 }}">
+1
View File
@@ -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 }})">
+3 -1
View File
@@ -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() {
+4 -4
View File
@@ -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",
+1 -1
View File
@@ -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'
+31
View File
@@ -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
+109
View File
@@ -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>
)
}
+55 -6
View File
@@ -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()
+45 -9
View File
@@ -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)
+3 -2
View File
@@ -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>
) )
+11 -10
View File
@@ -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
} }
+160 -167
View File
@@ -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>
+390
View File
@@ -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
}
}
+1 -1
View File
@@ -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
+87 -1
View File
@@ -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,
+82
View File
@@ -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>
)
}
+3 -5
View File
@@ -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(
+9 -3
View File
@@ -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 && (
+31 -6
View File
@@ -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,
+24 -122
View File
@@ -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})
+220 -78
View File
@@ -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>
) )
} }
+8
View File
@@ -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
} }
+43 -12
View File
@@ -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}`,
)
}
} }
+47 -7
View File
@@ -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}`,
)
}
} }
+3
View File
@@ -0,0 +1,3 @@
import {toast} from 'sonner-native'
export const dismiss = toast.dismiss
+3
View File
@@ -0,0 +1,3 @@
import {toast} from 'sonner'
export const dismiss = toast.dismiss
+63 -28
View File
@@ -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>
+6 -8
View File
@@ -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>
)
}
+20 -20
View File
@@ -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>
+67 -104
View File
@@ -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
+12 -3
View File
@@ -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,
+4 -2
View File
@@ -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'
? { ? {
+37
View File
@@ -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>
)
}
+55 -53
View File
@@ -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>
)
}
+1 -1
View File
@@ -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
+7 -1
View File
@@ -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
*/ */
+17 -2
View File
@@ -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} />
}
+8 -15
View File
@@ -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

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