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