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

This commit is contained in:
Hailey
2025-08-31 13:28:14 -07:00
256 changed files with 59196 additions and 54104 deletions
+3
View File
@@ -16,6 +16,9 @@ EXPO_PUBLIC_LOG_LEVEL=debug
# Enable debug logs for specific logger instances
EXPO_PUBLIC_LOG_DEBUG=session
# Bluesky appview DID
EXPO_PUBLIC_BLUESKY_PROXY_DID=
# Chat service DID
EXPO_PUBLIC_CHAT_PROXY_DID=
+3
View File
@@ -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'],
+9 -3
View File
@@ -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:
+18 -5
View File
@@ -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:
+4 -1
View File
@@ -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"
+7 -5
View File
@@ -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"
+10 -10
View File
@@ -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
+10 -6
View File
@@ -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"
+10 -6
View File
@@ -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"
+4 -1
View File
@@ -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"
+4 -4
View File
@@ -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"
+6 -4
View File
@@ -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"
+5 -1
View File
@@ -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:
+38 -9
View File
@@ -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.*"
+23 -14
View File
@@ -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()
+5
View File
@@ -11,3 +11,8 @@ appId: xyz.blueskyweb.app
- swipe:
from: "Bluesky"
direction: DOWN
- tapOn:
id: e2eProxyHeaderInput
- inputText: ${output.result}
- pressKey: Enter
- hideKeyboard
+5 -2
View File
@@ -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
+5
View File
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 486 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

+10 -9
View File
@@ -6,24 +6,25 @@
"dev": "vite",
"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"
}
}
+1 -1
View File
@@ -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>
)
}
+31 -9
View File
@@ -17,8 +17,11 @@ import infoIcon from '../../assets/circleInfo_stroke2_corner0_rounded.svg'
import playIcon from '../../assets/play_filled_corner2_rounded.svg'
import 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}`
}
+36 -14
View File
@@ -11,10 +11,15 @@ import likeIcon from '../../assets/heart2_filled_stroke2_corner0_rounded.svg'
import logo from '../../assets/logo.svg'
import 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>
)
}
+10 -3
View File
@@ -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 ''
}
+1 -1
View File
@@ -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')
+49
View File
@@ -0,0 +1,49 @@
import {ValidationResult} from '@atproto/lexicon'
export * as profile from './profile'
/**
* Fast type checking without full schema validation, for use with data we
* trust, or for non-critical path use cases. Why? Our SDK's `is*` identity
* utils do not assert the type of the entire object, only the `$type` string.
*
* For full validation of the object schema, use the `validate` export from
* this file.
*
* Usage:
* ```ts
* import * as bsky from '#/types/bsky'
*
* if (bsky.dangerousIsType<AppBskyFeedPost.Record>(item, AppBskyFeedPost.isRecord)) {
* // `item` has type `$Typed<AppBskyFeedPost.Record>` here
* }
* ```
*/
export function dangerousIsType<R extends {$type?: string}>(
record: unknown,
identity: <V>(v: V) => v is V & {$type: NonNullable<R['$type']>},
): record is R {
return identity(record)
}
/**
* Fully validates the object schema, which has a performance cost.
*
* For faster checks with data we trust, like that from our app view, use the
* `dangerousIsType` export from this same file.
*
* Usage:
* ```ts
* import * as bsky from '#/types/bsky'
*
* if (bsky.validate(item, AppBskyFeedPost.validateRecord)) {
* // `item` has type `$Typed<AppBskyFeedPost.Record>` here
* }
* ```
*/
export function validate<R extends {$type?: string}>(
record: unknown,
validator: (v: unknown) => ValidationResult<R>,
): record is R {
return validator(record).success
}
+10
View File
@@ -0,0 +1,10 @@
import {type AppBskyActorDefs, type ChatBskyActorDefs} from '@atproto/api'
/**
* Matches any profile view exported by our SDK
*/
export type AnyProfileView =
| AppBskyActorDefs.ProfileViewBasic
| AppBskyActorDefs.ProfileView
| AppBskyActorDefs.ProfileViewDetailed
| ChatBskyActorDefs.ProfileViewBasic
+11
View File
@@ -0,0 +1,11 @@
export function niceDate(date: number | string | Date) {
const d = new Date(date)
return `${d.toLocaleDateString('en-us', {
year: 'numeric',
month: 'short',
day: 'numeric',
})} at ${d.toLocaleTimeString(undefined, {
hour: 'numeric',
minute: '2-digit',
})}`
}
+152
View File
@@ -0,0 +1,152 @@
/**
* This file is a copy of what exists in the social-app
*/
import {
AppBskyEmbedExternal,
AppBskyEmbedImages,
AppBskyEmbedRecord,
AppBskyEmbedRecordWithMedia,
AppBskyEmbedVideo,
AppBskyFeedDefs,
AppBskyGraphDefs,
AppBskyLabelerDefs,
} from '@atproto/api'
export type Embed =
| {
type: 'post'
view: AppBskyEmbedRecord.ViewRecord
}
| {
type: 'post_not_found'
view: AppBskyEmbedRecord.ViewNotFound
}
| {
type: 'post_blocked'
view: AppBskyEmbedRecord.ViewBlocked
}
| {
type: 'post_detached'
view: AppBskyEmbedRecord.ViewDetached
}
| {
type: 'feed'
view: AppBskyFeedDefs.GeneratorView
}
| {
type: 'list'
view: AppBskyGraphDefs.ListView
}
| {
type: 'labeler'
view: AppBskyLabelerDefs.LabelerView
}
| {
type: 'starter_pack'
view: AppBskyGraphDefs.StarterPackViewBasic
}
| {
type: 'images'
view: AppBskyEmbedImages.View
}
| {
type: 'link'
view: AppBskyEmbedExternal.View
}
| {
type: 'video'
view: AppBskyEmbedVideo.View
}
| {
type: 'post_with_media'
view: Embed
media: Embed
}
| {
type: 'unknown'
view: null
}
export type EmbedType<T extends Embed['type']> = Extract<Embed, {type: T}>
export function parseEmbedRecordView({record}: AppBskyEmbedRecord.View): Embed {
if (AppBskyEmbedRecord.isViewRecord(record)) {
return {
type: 'post',
view: record,
}
} else if (AppBskyEmbedRecord.isViewNotFound(record)) {
return {
type: 'post_not_found',
view: record,
}
} else if (AppBskyEmbedRecord.isViewBlocked(record)) {
return {
type: 'post_blocked',
view: record,
}
} else if (AppBskyEmbedRecord.isViewDetached(record)) {
return {
type: 'post_detached',
view: record,
}
} else if (AppBskyFeedDefs.isGeneratorView(record)) {
return {
type: 'feed',
view: record,
}
} else if (AppBskyGraphDefs.isListView(record)) {
return {
type: 'list',
view: record,
}
} else if (AppBskyLabelerDefs.isLabelerView(record)) {
return {
type: 'labeler',
view: record,
}
} else if (AppBskyGraphDefs.isStarterPackViewBasic(record)) {
return {
type: 'starter_pack',
view: record,
}
} else {
return {
type: 'unknown',
view: null,
}
}
}
export function parseEmbed(embed: AppBskyFeedDefs.PostView['embed']): Embed {
if (AppBskyEmbedImages.isView(embed)) {
return {
type: 'images',
view: embed,
}
} else if (AppBskyEmbedExternal.isView(embed)) {
return {
type: 'link',
view: embed,
}
} else if (AppBskyEmbedVideo.isView(embed)) {
return {
type: 'video',
view: embed,
}
} else if (AppBskyEmbedRecord.isView(embed)) {
return parseEmbedRecordView(embed)
} else if (AppBskyEmbedRecordWithMedia.isView(embed)) {
return {
type: 'post_with_media',
view: parseEmbedRecordView(embed.record),
media: parseEmbed(embed.media),
}
} else {
return {
type: 'unknown',
view: null,
}
}
}
+9
View File
@@ -0,0 +1,9 @@
const formatter = new Intl.NumberFormat('en-US', {
notation: 'compact',
maximumFractionDigits: 1,
roundingMode: 'trunc',
})
export function prettyNumber(number: number) {
return formatter.format(number)
}
+6
View File
@@ -0,0 +1,6 @@
import {AtUri} from '@atproto/api'
export function getRkey({uri}: {uri: string}): string {
const at = new AtUri(uri)
return at.rkey
}
+31
View File
@@ -0,0 +1,31 @@
import * as bsky from '../types/bsky'
export type VerificationState = {
role: 'default' | 'verifier'
isVerified: boolean
}
export function getVerificationState({
profile,
}: {
profile?: bsky.profile.AnyProfileView
}): VerificationState {
if (!profile || !profile.verification) {
return {
role: 'default',
isVerified: false,
}
}
const {verifiedStatus, trustedVerifierStatus} = profile.verification
const isVerifiedUser = ['valid', 'invalid'].includes(verifiedStatus)
const isVerifierUser = ['valid', 'invalid'].includes(trustedVerifierStatus)
const isVerified =
(isVerifiedUser && verifiedStatus === 'valid') ||
(isVerifierUser && trustedVerifierStatus === 'valid')
return {
role: isVerifierUser ? 'verifier' : 'default',
isVerified,
}
}
-28
View File
@@ -1,28 +0,0 @@
import {AtUri} from '@atproto/api'
export function niceDate(date: number | string | Date) {
const d = new Date(date)
return `${d.toLocaleDateString('en-us', {
year: 'numeric',
month: 'short',
day: 'numeric',
})} at ${d.toLocaleTimeString(undefined, {
hour: 'numeric',
minute: '2-digit',
})}`
}
export function getRkey({uri}: {uri: string}): string {
const at = new AtUri(uri)
return at.rkey
}
const formatter = new Intl.NumberFormat('en-US', {
notation: 'compact',
maximumFractionDigits: 1,
roundingMode: 'trunc',
})
export function prettyNumber(number: number) {
return formatter.format(number)
}
+2 -2
View File
@@ -10,7 +10,7 @@
"strict": true,
"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"]
}
+1192 -1220
View File
File diff suppressed because it is too large Load Diff
+28
View File
@@ -0,0 +1,28 @@
package main
import (
"net/url"
"github.com/flosch/pongo2/v6"
)
func init() {
pongo2.RegisterFilter("canonicalize_url", filterCanonicalizeURL)
}
func filterCanonicalizeURL(in *pongo2.Value, param *pongo2.Value) (*pongo2.Value, *pongo2.Error) {
urlStr := in.String()
parsedURL, err := url.Parse(urlStr)
if err != nil {
// If parsing fails, return the original URL
return in, nil
}
// Remove query parameters and fragment
parsedURL.RawQuery = ""
parsedURL.Fragment = ""
// Return the cleaned URL
return pongo2.AsValue(parsedURL.String()), nil
}
+61
View File
@@ -0,0 +1,61 @@
package main
import (
"testing"
"github.com/flosch/pongo2/v6"
)
func TestCanonicalizeURLFilter(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{
name: "clean URL",
input: "https://bsky.app/profile/user",
expected: "https://bsky.app/profile/user",
},
{
name: "URL with query params",
input: "https://bsky.app/profile/user?utm_source=test",
expected: "https://bsky.app/profile/user",
},
{
name: "URL with multiple params",
input: "https://bsky.app/profile/user?utm_source=twitter&utm_campaign=test",
expected: "https://bsky.app/profile/user",
},
{
name: "URL with fragment",
input: "https://bsky.app/profile/user#section",
expected: "https://bsky.app/profile/user",
},
{
name: "URL with both params and fragment",
input: "https://bsky.app/profile/user?param=1#section",
expected: "https://bsky.app/profile/user",
},
{
name: "malformed URL",
input: "not-a-url",
expected: "not-a-url", // Should return original on error
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
inputValue := pongo2.AsValue(tt.input)
result, err := filterCanonicalizeURL(inputValue, nil)
if err != nil {
t.Errorf("filterCanonicalizeURL() error = %v", err)
return
}
if result.String() != tt.expected {
t.Errorf("filterCanonicalizeURL() = %v, want %v", result.String(), tt.expected)
}
})
}
}
+2 -2
View File
@@ -606,10 +606,10 @@ type IPCCRequest struct {
type IPCCResponse struct {
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)
+1
View File
@@ -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 %}
+1 -1
View File
@@ -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 %}
+5 -1
View File
@@ -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 }}">
+1
View File
@@ -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 }})">
+3 -1
View File
@@ -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() {
+4 -4
View File
@@ -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",
+1 -1
View File
@@ -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'
+31
View File
@@ -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
+109
View File
@@ -0,0 +1,109 @@
import {useEffect} from 'react'
import {ScrollView, View} from 'react-native'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {logger} from '#/logger'
import {isWeb} from '#/platform/detection'
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
import {Full as Logo, Mark} from '#/components/icons/Logo'
import {SimpleInlineLinkText as InlineLinkText} from '#/components/Link'
import {Text} from '#/components/Typography'
export function BlockedGeoOverlay() {
const t = useTheme()
const {_} = useLingui()
const {gtPhone} = useBreakpoints()
const insets = useSafeAreaInsets()
useEffect(() => {
// just counting overall hits here
logger.metric(`blockedGeoOverlay:shown`, {})
}, [])
const textStyles = [a.text_md, a.leading_normal]
const links = {
blog: {
to: `https://bsky.social/about/blog/08-22-2025-mississippi-hb1126`,
label: _(msg`Read our blog post`),
overridePresentation: false,
disableMismatchWarning: true,
style: textStyles,
},
}
const blocks = [
_(msg`Unfortunately, Bluesky is unavailable in Mississippi right now.`),
_(
msg`A new Mississippi law requires us to implement age verification for all users before they can access Bluesky. We think this law creates challenges that go beyond its child safety goals, and creates significant barriers that limit free speech and disproportionately harm smaller platforms and emerging technologies.`,
),
_(
msg`As a small team, we cannot justify building the expensive infrastructure this requirement demands while legal challenges to this law are pending.`,
),
_(
msg`For now, we have made the difficult decision to block access to Bluesky in the state of Mississippi.`,
),
<>
To learn more, read our{' '}
<InlineLinkText {...links.blog}>blog post</InlineLinkText>.
</>,
]
return (
<ScrollView
contentContainerStyle={[
a.px_2xl,
{
paddingTop: isWeb ? a.p_5xl.padding : insets.top + a.p_2xl.padding,
paddingBottom: 100,
},
]}>
<View
style={[
a.mx_auto,
web({
maxWidth: 440,
paddingTop: gtPhone ? '8vh' : undefined,
}),
]}>
<View style={[a.align_start]}>
<View
style={[
a.pl_md,
a.pr_lg,
a.py_sm,
a.rounded_full,
a.flex_row,
a.align_center,
a.gap_xs,
{
backgroundColor: t.palette.primary_25,
},
]}>
<Mark fill={t.palette.primary_600} width={14} />
<Text
style={[
a.font_bold,
{
color: t.palette.primary_600,
},
]}>
<Trans>Announcement</Trans>
</Text>
</View>
</View>
<View style={[a.gap_lg, {paddingTop: 32, paddingBottom: 48}]}>
{blocks.map((block, index) => (
<Text key={index} style={[textStyles]}>
{block}
</Text>
))}
</View>
<Logo width={120} textFill={t.atoms.text.color} />
</View>
</ScrollView>
)
}
+55 -6
View File
@@ -12,9 +12,13 @@ import {
import {
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()
+45 -9
View File
@@ -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)
+3 -2
View File
@@ -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>
)
+11 -10
View File
@@ -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
}
+160 -167
View File
@@ -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>
+390
View File
@@ -0,0 +1,390 @@
import {useEffect, useRef, useState} from 'react'
import {
type ScrollView,
type StyleProp,
View,
type ViewStyle,
} from 'react-native'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {isWeb} from '#/platform/detection'
import {DraggableScrollView} from '#/view/com/pager/DraggableScrollView'
import {atoms as a, tokens, useTheme, web} from '#/alf'
import {transparentifyColor} from '#/alf/util/colorGeneration'
import {Button, ButtonIcon} from '#/components/Button'
import {
ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeft,
ArrowRight_Stroke2_Corner0_Rounded as ArrowRight,
} from '#/components/icons/Arrow'
import {Text} from '#/components/Typography'
/**
* Tab component that automatically scrolls the selected tab into view - used for interests
* in the Find Follows dialog, Explore screen, etc.
*/
export function InterestTabs({
onSelectTab,
interests,
selectedInterest,
disabled,
interestsDisplayNames,
TabComponent = Tab,
contentContainerStyle,
gutterWidth = tokens.space.lg,
}: {
onSelectTab: (tab: string) => void
interests: string[]
selectedInterest: string
interestsDisplayNames: Record<string, string>
/** still allows changing tab, but removes the active state from the selected tab */
disabled?: boolean
TabComponent?: React.ComponentType<React.ComponentProps<typeof Tab>>
contentContainerStyle?: StyleProp<ViewStyle>
gutterWidth?: number
}) {
const t = useTheme()
const {_} = useLingui()
const listRef = useRef<ScrollView>(null)
const [totalWidth, setTotalWidth] = useState(0)
const [scrollX, setScrollX] = useState(0)
const [contentWidth, setContentWidth] = useState(0)
const pendingTabOffsets = useRef<{x: number; width: number}[]>([])
const [tabOffsets, setTabOffsets] = useState<{x: number; width: number}[]>([])
const onInitialLayout = useNonReactiveCallback(() => {
const index = interests.indexOf(selectedInterest)
scrollIntoViewIfNeeded(index)
})
useEffect(() => {
if (tabOffsets) {
onInitialLayout()
}
}, [tabOffsets, onInitialLayout])
function scrollIntoViewIfNeeded(index: number) {
const btnLayout = tabOffsets[index]
if (!btnLayout) return
listRef.current?.scrollTo({
// centered
x: btnLayout.x - (totalWidth / 2 - btnLayout.width / 2),
animated: true,
})
}
function handleSelectTab(index: number) {
const tab = interests[index]
onSelectTab(tab)
scrollIntoViewIfNeeded(index)
}
function handleTabLayout(index: number, x: number, width: number) {
if (!tabOffsets.length) {
pendingTabOffsets.current[index] = {x, width}
if (pendingTabOffsets.current.length === interests.length) {
setTabOffsets(pendingTabOffsets.current)
}
}
}
const canScrollLeft = scrollX > 0
const canScrollRight = scrollX < contentWidth - totalWidth
const cleanupRef = useRef<(() => void) | null>(null)
function scrollLeft() {
if (isContinuouslyScrollingRef.current) {
return
}
if (listRef.current && canScrollLeft) {
const newScrollX = Math.max(0, scrollX - 200)
listRef.current.scrollTo({x: newScrollX, animated: true})
}
}
function scrollRight() {
if (isContinuouslyScrollingRef.current) {
return
}
if (listRef.current && canScrollRight) {
const maxScroll = contentWidth - totalWidth
const newScrollX = Math.min(maxScroll, scrollX + 200)
listRef.current.scrollTo({x: newScrollX, animated: true})
}
}
const isContinuouslyScrollingRef = useRef(false)
function startContinuousScroll(direction: 'left' | 'right') {
// Clear any existing continuous scroll
if (cleanupRef.current) {
cleanupRef.current()
}
let holdTimeout: NodeJS.Timeout | null = null
let animationFrame: number | null = null
let isActive = true
isContinuouslyScrollingRef.current = false
const cleanup = () => {
isActive = false
if (holdTimeout) clearTimeout(holdTimeout)
if (animationFrame) cancelAnimationFrame(animationFrame)
cleanupRef.current = null
// Reset flag after a delay to prevent onPress from firing
setTimeout(() => {
isContinuouslyScrollingRef.current = false
}, 100)
}
cleanupRef.current = cleanup
// Start continuous scrolling after hold delay
holdTimeout = setTimeout(() => {
if (!isActive) return
isContinuouslyScrollingRef.current = true
let currentScrollPosition = scrollX
const scroll = () => {
if (!isActive || !listRef.current) return
const scrollAmount = 3
const maxScroll = contentWidth - totalWidth
let newScrollX: number
let canContinue = false
if (direction === 'left' && currentScrollPosition > 0) {
newScrollX = Math.max(0, currentScrollPosition - scrollAmount)
canContinue = newScrollX > 0
} else if (direction === 'right' && currentScrollPosition < maxScroll) {
newScrollX = Math.min(maxScroll, currentScrollPosition + scrollAmount)
canContinue = newScrollX < maxScroll
} else {
return
}
currentScrollPosition = newScrollX
listRef.current.scrollTo({x: newScrollX, animated: false})
if (canContinue && isActive) {
animationFrame = requestAnimationFrame(scroll)
}
}
scroll()
}, 500)
}
function stopContinuousScroll() {
if (cleanupRef.current) {
cleanupRef.current()
}
}
useEffect(() => {
return () => {
if (cleanupRef.current) {
cleanupRef.current()
}
}
}, [])
return (
<View style={[a.relative, a.flex_row]}>
<DraggableScrollView
ref={listRef}
contentContainerStyle={[
a.gap_sm,
{paddingHorizontal: gutterWidth},
contentContainerStyle,
]}
showsHorizontalScrollIndicator={false}
decelerationRate="fast"
snapToOffsets={
tabOffsets.length === interests.length
? tabOffsets.map(o => o.x - tokens.space.xl)
: undefined
}
onLayout={evt => setTotalWidth(evt.nativeEvent.layout.width)}
onContentSizeChange={width => setContentWidth(width)}
onScroll={evt => {
const newScrollX = evt.nativeEvent.contentOffset.x
setScrollX(newScrollX)
}}
scrollEventThrottle={16}>
{interests.map((interest, i) => {
const active = interest === selectedInterest && !disabled
return (
<TabComponent
key={interest}
onSelectTab={handleSelectTab}
active={active}
index={i}
interest={interest}
interestsDisplayName={interestsDisplayNames[interest]}
onLayout={handleTabLayout}
/>
)
})}
</DraggableScrollView>
{isWeb && canScrollLeft && (
<View
style={[
a.absolute,
a.top_0,
a.left_0,
a.bottom_0,
a.justify_center,
{paddingLeft: gutterWidth},
a.pr_md,
a.z_10,
web({
background: `linear-gradient(to right, ${t.atoms.bg.backgroundColor} 0%, ${t.atoms.bg.backgroundColor} 70%, ${transparentifyColor(t.atoms.bg.backgroundColor, 0)} 100%)`,
}),
]}>
<Button
label={_(msg`Scroll left`)}
onPress={scrollLeft}
onPressIn={() => startContinuousScroll('left')}
onPressOut={stopContinuousScroll}
color="secondary"
size="small"
style={[
a.border,
t.atoms.border_contrast_low,
t.atoms.bg,
a.h_full,
{aspectRatio: 1},
a.rounded_full,
]}>
<ButtonIcon icon={ArrowLeft} />
</Button>
</View>
)}
{isWeb && canScrollRight && (
<View
style={[
a.absolute,
a.top_0,
a.right_0,
a.bottom_0,
a.justify_center,
{paddingRight: gutterWidth},
a.pl_md,
a.z_10,
web({
background: `linear-gradient(to left, ${t.atoms.bg.backgroundColor} 0%, ${t.atoms.bg.backgroundColor} 70%, ${transparentifyColor(t.atoms.bg.backgroundColor, 0)} 100%)`,
}),
]}>
<Button
label={_(msg`Scroll right`)}
onPress={scrollRight}
onPressIn={() => startContinuousScroll('right')}
onPressOut={stopContinuousScroll}
color="secondary"
size="small"
style={[
a.border,
t.atoms.border_contrast_low,
t.atoms.bg,
a.h_full,
{aspectRatio: 1},
a.rounded_full,
]}>
<ButtonIcon icon={ArrowRight} />
</Button>
</View>
)}
</View>
)
}
function Tab({
onSelectTab,
interest,
active,
index,
interestsDisplayName,
onLayout,
}: {
onSelectTab: (index: number) => void
interest: string
active: boolean
index: number
interestsDisplayName: string
onLayout: (index: number, x: number, width: number) => void
}) {
const t = useTheme()
const {_} = useLingui()
const label = active
? _(
msg({
message: `"${interestsDisplayName}" category (active)`,
comment:
'Accessibility label for a category (e.g. Art, Video Games, Sports, etc.) that shows suggested accounts for the user to follow. The tab is currently selected.',
}),
)
: _(
msg({
message: `Select "${interestsDisplayName}" category`,
comment:
'Accessibility label for a category (e.g. Art, Video Games, Sports, etc.) that shows suggested accounts for the user to follow. The tab is not currently active and can be selected.',
}),
)
return (
<View
key={interest}
onLayout={e =>
onLayout(index, e.nativeEvent.layout.x, e.nativeEvent.layout.width)
}>
<Button
label={label}
onPress={() => onSelectTab(index)}
// disable focus ring, we handle it
style={web({outline: 'none'})}>
{({hovered, pressed, focused}) => (
<View
style={[
a.rounded_full,
a.px_lg,
a.py_sm,
a.border,
active || hovered || pressed
? [t.atoms.bg_contrast_25, t.atoms.border_contrast_medium]
: focused
? {
borderColor: t.palette.primary_300,
backgroundColor: t.palette.primary_25,
}
: [t.atoms.bg, t.atoms.border_contrast_low],
]}>
<Text
style={[
a.font_medium,
active || hovered || pressed
? t.atoms.text
: t.atoms.text_contrast_medium,
]}>
{interestsDisplayName}
</Text>
</View>
)}
</Button>
</View>
)
}
export function boostInterests(boosts?: string[]) {
return (_a: string, _b: string) => {
const indexA = boosts?.indexOf(_a) ?? -1
const indexB = boosts?.indexOf(_b) ?? -1
const rankA = indexA === -1 ? Infinity : indexA
const rankB = indexB === -1 ? Infinity : indexB
return rankA - rankB
}
}
+1 -1
View File
@@ -13,7 +13,7 @@ export const BUTTON_VISUAL_ALIGNMENT_OFFSET = 3
/**
* Corresponds to the width of a small square or round button
*/
export const HEADER_SLOT_SIZE = 34
export const HEADER_SLOT_SIZE = 33
/**
* How far to shift the center column when in the tablet breakpoint
+87 -1
View File
@@ -1,5 +1,5 @@
import React, {useMemo} from 'react'
import {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,
+82
View File
@@ -0,0 +1,82 @@
import {View, type ViewStyle} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {type Gate} from '#/lib/statsig/gates'
import {useGate} from '#/lib/statsig/statsig'
import {isWeb} from '#/platform/detection'
import {useSession} from '#/state/session'
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
import {Logo} from '#/view/icons/Logo'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import {Text} from '#/components/Typography'
interface LoggedOutCTAProps {
style?: ViewStyle
gateName: Gate
}
export function LoggedOutCTA({style, gateName}: LoggedOutCTAProps) {
const {hasSession} = useSession()
const {requestSwitchToAccount} = useLoggedOutViewControls()
const gate = useGate()
const t = useTheme()
const {_} = useLingui()
// Only show for logged-out users on web
if (hasSession || !isWeb) {
return null
}
// Check gate at the last possible moment to avoid counting users as exposed when they won't see the element
if (!gate(gateName)) {
return null
}
return (
<View style={[a.pb_md, style]}>
<View
style={[
a.flex_row,
a.align_center,
a.justify_between,
a.px_lg,
a.py_md,
a.rounded_md,
a.mb_xs,
t.atoms.bg_contrast_25,
]}>
<View style={[a.flex_row, a.align_center, a.flex_1, a.pr_md]}>
<Logo width={30} style={[a.mr_md]} />
<View style={[a.flex_1]}>
<Text style={[a.text_lg, a.font_bold, a.leading_snug]}>
<Trans>Join Bluesky</Trans>
</Text>
<Text
style={[
a.text_md,
a.font_medium,
a.leading_snug,
t.atoms.text_contrast_medium,
]}>
<Trans>The open social network.</Trans>
</Text>
</View>
</View>
<Button
onPress={() => {
requestSwitchToAccount({requestedAccount: 'new'})
}}
label={_(msg`Create account`)}
size="small"
variant="solid"
color="primary">
<ButtonText>
<Trans>Create account</Trans>
</ButtonText>
</Button>
</View>
</View>
)
}
+3 -5
View File
@@ -1,7 +1,6 @@
import React from 'react'
import {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(
+9 -3
View File
@@ -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 && (
+31 -6
View File
@@ -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,
+24 -122
View File
@@ -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})
+220 -78
View File
@@ -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],
)
}
+8
View File
@@ -1,3 +1,11 @@
export const DURATION = 0
export const Action = () => null
export const Icon = () => null
export const Outer = () => null
export const Text = () => null
export const ToastConfigProvider = () => null
export function ToastOutlet() {
return null
}
+43 -12
View File
@@ -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}`,
)
}
}
+47 -7
View File
@@ -1,10 +1,21 @@
import React from 'react'
import {nanoid} from 'nanoid/non-secure'
import {toast as sonner, Toaster} from 'sonner'
import {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}`,
)
}
}
+3
View File
@@ -0,0 +1,3 @@
import {toast} from 'sonner-native'
export const dismiss = toast.dismiss
+3
View File
@@ -0,0 +1,3 @@
import {toast} from 'sonner'
export const dismiss = toast.dismiss
+63 -28
View File
@@ -4,21 +4,23 @@ import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {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>
+6 -8
View File
@@ -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>
)
}
+20 -20
View File
@@ -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>
+67 -104
View File
@@ -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
+12 -3
View File
@@ -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,
+4 -2
View File
@@ -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'
? {
+37
View File
@@ -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>
)
}
+55 -53
View File
@@ -1,4 +1,4 @@
import React, {useEffect} from 'react'
import {useCallback, useEffect, useMemo} from 'react'
import {ScrollView, View} from 'react-native'
import {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>
)
}
+1 -1
View File
@@ -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
+7 -1
View File
@@ -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
*/
+17 -2
View File
@@ -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} />
}
+8 -15
View File
@@ -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

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