Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b410ba867f | |||
| f5994af620 | |||
| 8b67a3ec2e | |||
| 6187e79b0e | |||
| 2bd9811652 | |||
| 8bdb1c30c8 | |||
| aa897f55a0 | |||
| 18d7e775f6 | |||
| b8aae166d9 | |||
| f8886fbfe6 | |||
| 041e348581 | |||
| e2c54a858c | |||
| 60a0edbbe2 | |||
| 562bf3be22 | |||
| 290e0f2b54 | |||
| ece6dc251c | |||
| 8c705864a2 | |||
| 7a08b82810 | |||
| 20bf2cd117 | |||
| fe8e8ce7de | |||
| 8b8acb7bd1 | |||
| 1a487d0943 |
@@ -1,529 +0,0 @@
|
||||
import {createServer as createHTTPServer} from 'node:http'
|
||||
import {parse} from 'node:url'
|
||||
|
||||
import {createServer, type TestPDS} from '../jest/test-pds'
|
||||
|
||||
async function main() {
|
||||
let server: TestPDS
|
||||
createHTTPServer(async (req, res) => {
|
||||
const url = parse(req.url || '/', true)
|
||||
if (req.method !== 'POST') {
|
||||
return res.writeHead(200).end()
|
||||
}
|
||||
try {
|
||||
console.log('Closing old server')
|
||||
await server?.close()
|
||||
console.log('Starting new server')
|
||||
const inviteRequired = url?.query && 'invite' in url.query
|
||||
server = await createServer({inviteRequired})
|
||||
console.log('Listening at', server.pdsUrl)
|
||||
if (url?.query) {
|
||||
if ('users' in url.query) {
|
||||
console.log('Generating mock users')
|
||||
await server.mocker.createUser('alice')
|
||||
await server.mocker.createUser('bob')
|
||||
await server.mocker.createUser('carla')
|
||||
await server.mocker.users.alice.agent.upsertProfile(() => ({
|
||||
displayName: 'Alice',
|
||||
description: 'Test user 1',
|
||||
}))
|
||||
await server.mocker.users.bob.agent.upsertProfile(() => ({
|
||||
displayName: 'Bob',
|
||||
description: 'Test user 2',
|
||||
}))
|
||||
await server.mocker.users.carla.agent.upsertProfile(() => ({
|
||||
displayName: 'Carla',
|
||||
description: 'Test user 3',
|
||||
}))
|
||||
if (inviteRequired) {
|
||||
await server.mocker.createInvite(server.mocker.users.alice.did)
|
||||
}
|
||||
}
|
||||
if ('follows' in url.query) {
|
||||
console.log('Generating mock follows')
|
||||
await server.mocker.follow('alice', 'bob')
|
||||
await server.mocker.follow('alice', 'carla')
|
||||
await server.mocker.follow('bob', 'alice')
|
||||
await server.mocker.follow('bob', 'carla')
|
||||
await server.mocker.follow('carla', 'alice')
|
||||
await server.mocker.follow('carla', 'bob')
|
||||
}
|
||||
if ('posts' in url.query) {
|
||||
console.log('Generating mock posts')
|
||||
for (let user in server.mocker.users) {
|
||||
await server.mocker.users[user].agent.post({text: 'Post'})
|
||||
}
|
||||
}
|
||||
if ('feeds' in url.query) {
|
||||
console.log('Generating mock feed')
|
||||
await server.mocker.createFeed('alice', 'alice-favs', [])
|
||||
}
|
||||
if ('thread' in url.query) {
|
||||
console.log('Generating mock posts')
|
||||
const res = await server.mocker.users.bob.agent.post({
|
||||
text: 'Thread root',
|
||||
})
|
||||
await server.mocker.users.carla.agent.post({
|
||||
text: 'Thread reply',
|
||||
reply: {
|
||||
parent: {cid: res.cid, uri: res.uri},
|
||||
root: {cid: res.cid, uri: res.uri},
|
||||
},
|
||||
})
|
||||
}
|
||||
if ('mergefeed' in url.query) {
|
||||
console.log('Generating mock users')
|
||||
await server.mocker.createUser('alice')
|
||||
await server.mocker.createUser('bob')
|
||||
await server.mocker.createUser('carla')
|
||||
await server.mocker.createUser('dan')
|
||||
await server.mocker.users.alice.agent.upsertProfile(() => ({
|
||||
displayName: 'Alice',
|
||||
description: 'Test user 1',
|
||||
}))
|
||||
await server.mocker.users.bob.agent.upsertProfile(() => ({
|
||||
displayName: 'Bob',
|
||||
description: 'Test user 2',
|
||||
}))
|
||||
await server.mocker.users.carla.agent.upsertProfile(() => ({
|
||||
displayName: 'Carla',
|
||||
description: 'Test user 3',
|
||||
}))
|
||||
await server.mocker.users.dan.agent.upsertProfile(() => ({
|
||||
displayName: 'Dan',
|
||||
description: 'Test user 4',
|
||||
}))
|
||||
console.log('Generating mock follows')
|
||||
await server.mocker.follow('alice', 'bob')
|
||||
await server.mocker.follow('alice', 'carla')
|
||||
console.log('Generating mock posts')
|
||||
let posts: Record<string, any[]> = {
|
||||
alice: [],
|
||||
bob: [],
|
||||
carla: [],
|
||||
dan: [],
|
||||
}
|
||||
for (let i = 0; i < 10; i++) {
|
||||
for (let user in server.mocker.users) {
|
||||
if (user === 'alice') continue
|
||||
posts[user].push(
|
||||
await server.mocker.createPost(user, `Post ${i}`),
|
||||
)
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < 10; i++) {
|
||||
for (let user in server.mocker.users) {
|
||||
if (user === 'alice') continue
|
||||
if (i % 5 === 0) {
|
||||
await server.mocker.createReply(user, 'Self reply', {
|
||||
cid: posts[user][i].cid,
|
||||
uri: posts[user][i].uri,
|
||||
})
|
||||
}
|
||||
if (i % 5 === 1) {
|
||||
await server.mocker.createReply(user, 'Reply to bob', {
|
||||
cid: posts.bob[i].cid,
|
||||
uri: posts.bob[i].uri,
|
||||
})
|
||||
}
|
||||
if (i % 5 === 2) {
|
||||
await server.mocker.createReply(user, 'Reply to dan', {
|
||||
cid: posts.dan[i].cid,
|
||||
uri: posts.dan[i].uri,
|
||||
})
|
||||
}
|
||||
await server.mocker.users[user].agent.post({text: `Post ${i}`})
|
||||
}
|
||||
}
|
||||
console.log('Generating mock feeds')
|
||||
await server.mocker.createFeed(
|
||||
'alice',
|
||||
'alice-favs',
|
||||
posts.dan.map(p => p.uri),
|
||||
)
|
||||
await server.mocker.createFeed(
|
||||
'alice',
|
||||
'alice-favs2',
|
||||
posts.dan.map(p => p.uri),
|
||||
)
|
||||
}
|
||||
if ('labels' in url.query) {
|
||||
console.log('Generating naughty users with labels')
|
||||
|
||||
const anchorPost = await server.mocker.createPost(
|
||||
'alice',
|
||||
'Anchor post',
|
||||
)
|
||||
|
||||
for (const user of [
|
||||
'dmca-account',
|
||||
'dmca-profile',
|
||||
'dmca-posts',
|
||||
'porn-account',
|
||||
'porn-profile',
|
||||
'porn-posts',
|
||||
'nudity-account',
|
||||
'nudity-profile',
|
||||
'nudity-posts',
|
||||
'scam-account',
|
||||
'scam-profile',
|
||||
'scam-posts',
|
||||
'unknown-account',
|
||||
'unknown-profile',
|
||||
'unknown-posts',
|
||||
'hide-account',
|
||||
'hide-profile',
|
||||
'hide-posts',
|
||||
'no-promote-account',
|
||||
'no-promote-profile',
|
||||
'no-promote-posts',
|
||||
'warn-account',
|
||||
'warn-profile',
|
||||
'warn-posts',
|
||||
'muted-account',
|
||||
'muted-by-list-acc',
|
||||
'blocking-account',
|
||||
'blockedby-account',
|
||||
'mutual-block-acc',
|
||||
]) {
|
||||
await server.mocker.createUser(user)
|
||||
await server.mocker.follow('alice', user)
|
||||
await server.mocker.follow(user, 'alice')
|
||||
await server.mocker.createPost(user, `Unlabeled post from ${user}`)
|
||||
await server.mocker.createReply(
|
||||
user,
|
||||
`Unlabeled reply from ${user}`,
|
||||
anchorPost,
|
||||
)
|
||||
await server.mocker.like(user, anchorPost)
|
||||
}
|
||||
|
||||
await server.mocker.labelAccount('dmca-violation', 'dmca-account')
|
||||
await server.mocker.labelProfile('dmca-violation', 'dmca-profile')
|
||||
await server.mocker.labelPost(
|
||||
'dmca-violation',
|
||||
await server.mocker.createPost('dmca-posts', 'dmca post'),
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'dmca-violation',
|
||||
await server.mocker.createQuotePost(
|
||||
'dmca-posts',
|
||||
'dmca quote post',
|
||||
anchorPost,
|
||||
),
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'dmca-violation',
|
||||
await server.mocker.createReply(
|
||||
'dmca-posts',
|
||||
'dmca reply',
|
||||
anchorPost,
|
||||
),
|
||||
)
|
||||
|
||||
await server.mocker.labelAccount('porn', 'porn-account')
|
||||
await server.mocker.labelProfile('porn', 'porn-profile')
|
||||
await server.mocker.labelPost(
|
||||
'porn',
|
||||
await server.mocker.createImagePost('porn-posts', 'porn post'),
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'porn',
|
||||
await server.mocker.createQuotePost(
|
||||
'porn-posts',
|
||||
'porn quote post',
|
||||
anchorPost,
|
||||
),
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'porn',
|
||||
await server.mocker.createReply(
|
||||
'porn-posts',
|
||||
'porn reply',
|
||||
anchorPost,
|
||||
),
|
||||
)
|
||||
|
||||
await server.mocker.labelAccount('nudity', 'nudity-account')
|
||||
await server.mocker.labelProfile('nudity', 'nudity-profile')
|
||||
await server.mocker.labelPost(
|
||||
'nudity',
|
||||
await server.mocker.createImagePost('nudity-posts', 'nudity post'),
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'nudity',
|
||||
await server.mocker.createQuotePost(
|
||||
'nudity-posts',
|
||||
'nudity quote post',
|
||||
anchorPost,
|
||||
),
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'nudity',
|
||||
await server.mocker.createReply(
|
||||
'nudity-posts',
|
||||
'nudity reply',
|
||||
anchorPost,
|
||||
),
|
||||
)
|
||||
|
||||
await server.mocker.labelAccount('scam', 'scam-account')
|
||||
await server.mocker.labelProfile('scam', 'scam-profile')
|
||||
await server.mocker.labelPost(
|
||||
'scam',
|
||||
await server.mocker.createPost('scam-posts', 'scam post'),
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'scam',
|
||||
await server.mocker.createQuotePost(
|
||||
'scam-posts',
|
||||
'scam quote post',
|
||||
anchorPost,
|
||||
),
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'scam',
|
||||
await server.mocker.createReply(
|
||||
'scam-posts',
|
||||
'scam reply',
|
||||
anchorPost,
|
||||
),
|
||||
)
|
||||
|
||||
await server.mocker.labelAccount(
|
||||
'not-a-real-label',
|
||||
'unknown-account',
|
||||
)
|
||||
await server.mocker.labelProfile(
|
||||
'not-a-real-label',
|
||||
'unknown-profile',
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'not-a-real-label',
|
||||
await server.mocker.createPost('unknown-posts', 'unknown post'),
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'not-a-real-label',
|
||||
await server.mocker.createQuotePost(
|
||||
'unknown-posts',
|
||||
'unknown quote post',
|
||||
anchorPost,
|
||||
),
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'not-a-real-label',
|
||||
await server.mocker.createReply(
|
||||
'unknown-posts',
|
||||
'unknown reply',
|
||||
anchorPost,
|
||||
),
|
||||
)
|
||||
|
||||
await server.mocker.labelAccount('!hide', 'hide-account')
|
||||
await server.mocker.labelProfile('!hide', 'hide-profile')
|
||||
await server.mocker.labelPost(
|
||||
'!hide',
|
||||
await server.mocker.createPost('hide-posts', 'hide post'),
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'!hide',
|
||||
await server.mocker.createQuotePost(
|
||||
'hide-posts',
|
||||
'hide quote post',
|
||||
anchorPost,
|
||||
),
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'!hide',
|
||||
await server.mocker.createReply(
|
||||
'hide-posts',
|
||||
'hide reply',
|
||||
anchorPost,
|
||||
),
|
||||
)
|
||||
|
||||
await server.mocker.labelAccount('!no-promote', 'no-promote-account')
|
||||
await server.mocker.labelProfile('!no-promote', 'no-promote-profile')
|
||||
await server.mocker.labelPost(
|
||||
'!no-promote',
|
||||
await server.mocker.createPost(
|
||||
'no-promote-posts',
|
||||
'no-promote post',
|
||||
),
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'!no-promote',
|
||||
await server.mocker.createQuotePost(
|
||||
'no-promote-posts',
|
||||
'no-promote quote post',
|
||||
anchorPost,
|
||||
),
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'!no-promote',
|
||||
await server.mocker.createReply(
|
||||
'no-promote-posts',
|
||||
'no-promote reply',
|
||||
anchorPost,
|
||||
),
|
||||
)
|
||||
|
||||
await server.mocker.labelAccount('!warn', 'warn-account')
|
||||
await server.mocker.labelProfile('!warn', 'warn-profile')
|
||||
await server.mocker.labelPost(
|
||||
'!warn',
|
||||
await server.mocker.createPost('warn-posts', 'warn post'),
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'!warn',
|
||||
await server.mocker.createQuotePost(
|
||||
'warn-posts',
|
||||
'warn quote post',
|
||||
anchorPost,
|
||||
),
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'!warn',
|
||||
await server.mocker.createReply(
|
||||
'warn-posts',
|
||||
'warn reply',
|
||||
anchorPost,
|
||||
),
|
||||
)
|
||||
|
||||
await server.mocker.users.alice.agent.mute('muted-account.test')
|
||||
await server.mocker.createPost('muted-account', 'muted post')
|
||||
await server.mocker.createQuotePost(
|
||||
'muted-account',
|
||||
'muted quote post',
|
||||
anchorPost,
|
||||
)
|
||||
await server.mocker.createReply(
|
||||
'muted-account',
|
||||
'muted reply',
|
||||
anchorPost,
|
||||
)
|
||||
|
||||
const list = await server.mocker.createMuteList(
|
||||
'alice',
|
||||
'Muted Users',
|
||||
)
|
||||
await server.mocker.addToMuteList(
|
||||
'alice',
|
||||
list,
|
||||
server.mocker.users['muted-by-list-acc'].did,
|
||||
)
|
||||
await server.mocker.createPost('muted-by-list-acc', 'muted post')
|
||||
await server.mocker.createQuotePost(
|
||||
'muted-by-list-acc',
|
||||
'account quote post',
|
||||
anchorPost,
|
||||
)
|
||||
await server.mocker.createReply(
|
||||
'muted-by-list-acc',
|
||||
'account reply',
|
||||
anchorPost,
|
||||
)
|
||||
|
||||
await server.mocker.createPost('blocking-account', 'blocking post')
|
||||
await server.mocker.createQuotePost(
|
||||
'blocking-account',
|
||||
'blocking quote post',
|
||||
anchorPost,
|
||||
)
|
||||
await server.mocker.createReply(
|
||||
'blocking-account',
|
||||
'blocking reply',
|
||||
anchorPost,
|
||||
)
|
||||
await server.mocker.users.alice.agent.app.bsky.graph.block.create(
|
||||
{
|
||||
repo: server.mocker.users.alice.did,
|
||||
},
|
||||
{
|
||||
subject: server.mocker.users['blocking-account'].did,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
)
|
||||
|
||||
await server.mocker.createPost('blockedby-account', 'blockedby post')
|
||||
await server.mocker.createQuotePost(
|
||||
'blockedby-account',
|
||||
'blockedby quote post',
|
||||
anchorPost,
|
||||
)
|
||||
await server.mocker.createReply(
|
||||
'blockedby-account',
|
||||
'blockedby reply',
|
||||
anchorPost,
|
||||
)
|
||||
await server.mocker.users[
|
||||
'blockedby-account'
|
||||
].agent.app.bsky.graph.block.create(
|
||||
{
|
||||
repo: server.mocker.users['blockedby-account'].did,
|
||||
},
|
||||
{
|
||||
subject: server.mocker.users.alice.did,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
)
|
||||
|
||||
await server.mocker.createPost(
|
||||
'mutual-block-acc',
|
||||
'mutual-block post',
|
||||
)
|
||||
await server.mocker.createQuotePost(
|
||||
'mutual-block-acc',
|
||||
'mutual-block quote post',
|
||||
anchorPost,
|
||||
)
|
||||
await server.mocker.createReply(
|
||||
'mutual-block-acc',
|
||||
'mutual-block reply',
|
||||
anchorPost,
|
||||
)
|
||||
await server.mocker.users.alice.agent.app.bsky.graph.block.create(
|
||||
{
|
||||
repo: server.mocker.users.alice.did,
|
||||
},
|
||||
{
|
||||
subject: server.mocker.users['mutual-block-acc'].did,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
)
|
||||
await server.mocker.users[
|
||||
'mutual-block-acc'
|
||||
].agent.app.bsky.graph.block.create(
|
||||
{
|
||||
repo: server.mocker.users['mutual-block-acc'].did,
|
||||
},
|
||||
{
|
||||
subject: server.mocker.users.alice.did,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
)
|
||||
|
||||
// flush caches
|
||||
await server.mocker.testNet.processAll()
|
||||
}
|
||||
}
|
||||
console.log('Ready')
|
||||
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()
|
||||
}
|
||||
}).listen(1986)
|
||||
console.log('Mock server manager listening on 1986')
|
||||
}
|
||||
main()
|
||||
+1
-1
@@ -268,7 +268,7 @@ module.exports = function (_config) {
|
||||
],
|
||||
},
|
||||
android: {
|
||||
compileSdkVersion: 35,
|
||||
compileSdkVersion: 36,
|
||||
targetSdkVersion: 35,
|
||||
buildToolsVersion: '35.0.0',
|
||||
buildReactNativeFromSource: IS_PRODUCTION,
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M12 0a2 2 0 0 1 1 3.73V5h4.2c1.68 0 2.52 0 3.162.327a3 3 0 0 1 1.31 1.31C22 7.28 22 8.12 22 9.8v.25a2.501 2.501 0 0 1 0 4.9V15c0 2.8 0 4.2-.545 5.27a5 5 0 0 1-2.185 2.185C18.2 23 16.8 23 14 23h-4c-2.8 0-4.2 0-5.27-.545a5 5 0 0 1-2.185-2.185C2 19.2 2 17.8 2 15v-.05a2.5 2.5 0 0 1 0-4.9V9.8c0-1.68 0-2.52.327-3.162a3 3 0 0 1 1.31-1.31C4.28 5 5.12 5 6.8 5H11V3.73A2 2 0 0 1 12 0M8 10a2 2 0 0 0-2 2v2a2 2 0 1 0 4 0v-2a2 2 0 0 0-2-2m8 0a2 2 0 0 0-2 2v2a2 2 0 1 0 4 0v-2a2 2 0 0 0-2-2" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 621 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M12 2a1 1 0 0 1 1 1v2h3.2l1.113.005c.975.015 1.568.077 2.05.322a3 3 0 0 1 1.31 1.31C21 7.28 21 8.12 21 9.8v.287a1.498 1.498 0 0 1-.005 2.827c-.006 2.204-.058 3.41-.54 4.356l-.093.174a5 5 0 0 1-2.092 2.011l-.205.096c-.766.33-1.72.417-3.21.44L13 20h-2l-1.854-.009c-1.49-.023-2.445-.11-3.211-.44l-.205-.096a5 5 0 0 1-2.185-2.185c-.409-.803-.51-1.79-.536-3.415l-.005-.94A1.498 1.498 0 0 1 3 10.086V9.8c0-1.575 0-2.412.27-3.04l.057-.122a3 3 0 0 1 1.105-1.196l.206-.115C5.279 5 6.12 5 7.8 5H11V3a1 1 0 0 1 1-1M7.8 7c-.873 0-1.408.002-1.808.034a3 3 0 0 0-.367.051l-.063.018-.016.006a1 1 0 0 0-.437.437l-.006.016-.018.063a3 3 0 0 0-.05.367C5.001 8.392 5 8.927 5 9.8V12c0 1.433.002 2.388.062 3.121.058.71.16 1.036.265 1.241a3 3 0 0 0 1.31 1.31c.207.106.532.209 1.242.267.733.06 1.688.061 3.121.061h2c1.433 0 2.388-.002 3.121-.061.71-.058 1.036-.161 1.241-.266a3 3 0 0 0 1.31-1.31c.106-.206.209-.532.267-1.242.06-.733.061-1.688.061-3.121V9.8c0-.873-.002-1.408-.034-1.808a2.5 2.5 0 0 0-.051-.367l-.017-.063-.007-.016a1 1 0 0 0-.437-.437l-.015-.006-.064-.018a3 3 0 0 0-.367-.05C17.608 7.001 17.073 7 16.2 7zM9 10a1 1 0 0 1 1 1v2a1 1 0 1 1-2 0v-2a1 1 0 0 1 1-1m6 0a1 1 0 0 1 1 1v2a1 1 0 1 1-2 0v-2a1 1 0 0 1 1-1"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -10,6 +10,7 @@ import logo from '../../assets/logo_full_name.svg'
|
||||
import {Like as LikeIcon} from '../icons/Like'
|
||||
import {Reply as ReplyIcon} from '../icons/Reply'
|
||||
import {Repost as RepostIcon} from '../icons/Repost'
|
||||
import {Robot as RobotIcon} from '../icons/Robot'
|
||||
import {CONTENT_LABELS} from '../labels'
|
||||
import * as bsky from '../types/bsky'
|
||||
import {niceDate} from '../util/nice-date'
|
||||
@@ -43,6 +44,9 @@ export function Post({thread}: Props) {
|
||||
}
|
||||
|
||||
const verification = getVerificationState({profile: post.author})
|
||||
const isBot = post.author.labels?.some(
|
||||
l => l.val === 'bot' && l.src === post.author.did,
|
||||
)
|
||||
|
||||
const href = `/profile/${post.author.did}/post/${getRkey(post)}`
|
||||
|
||||
@@ -76,6 +80,12 @@ export function Post({thread}: Props) {
|
||||
size={15}
|
||||
/>
|
||||
)}
|
||||
{isBot && (
|
||||
<RobotIcon
|
||||
className="pl-[3px] mt-px shrink-0 text-slate-500 dark:text-slate-400"
|
||||
size={15}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Link
|
||||
href={`/profile/${post.author.did}`}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import {h} from 'preact'
|
||||
|
||||
export const Robot = ({
|
||||
size = 14,
|
||||
className,
|
||||
}: {
|
||||
size?: number
|
||||
className?: string
|
||||
}) => (
|
||||
<svg
|
||||
className={className}
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M12 0C13.1046 0 14 0.89543 14 2C14 2.73976 13.5971 3.3835 13 3.72949V5H17.2002C18.8802 5 19.7206 5.00018 20.3623 5.32715C20.9265 5.61472 21.3853 6.07347 21.6729 6.6377C21.9998 7.27941 22 8.11978 22 9.7998V10.0498C23.1411 10.2814 24 11.2905 24 12.5C24 13.7094 23.141 14.7175 22 14.9492V15C22 17.8 21.9999 19.2 21.4551 20.2695C20.9757 21.2103 20.2103 21.9757 19.2695 22.4551C18.2 22.9999 16.8 23 14 23H10C7.20005 23 5.79998 22.9999 4.73047 22.4551C3.78966 21.9757 3.02429 21.2103 2.54492 20.2695C2.00013 19.2 2 17.8 2 15V14.9492C0.858955 14.7175 0 13.7094 0 12.5C0 11.2905 0.85886 10.2814 2 10.0498V9.7998C2 8.11978 2.00018 7.27941 2.32715 6.6377C2.61472 6.07347 3.07347 5.61472 3.6377 5.32715C4.27941 5.00018 5.11978 5 6.7998 5H11V3.72949C10.4029 3.3835 10 2.73976 10 2C10 0.89543 10.8954 0 12 0ZM8 10C6.89543 10 6 10.8954 6 12V14C6 15.1046 6.89543 16 8 16C9.10457 16 10 15.1046 10 14V12C10 10.8954 9.10457 10 8 10ZM16 10C14.8954 10 14 10.8954 14 12V14C14 15.1046 14.8954 16 16 16C17.1046 16 18 15.1046 18 14V12C18 10.8954 17.1046 10 16 10Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react'
|
||||
import {type SVGAttributes} from 'react'
|
||||
|
||||
export function Butterfly(props: React.SVGAttributes<SVGSVGElement>) {
|
||||
export function Butterfly(props: SVGAttributes<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react'
|
||||
import {type ImgHTMLAttributes} from 'react'
|
||||
|
||||
// @NOTE satori does not currently support webp, see vercel/satori#273
|
||||
function detectMime(buf: Buffer): string {
|
||||
if (buf[0] === 0xff && buf[1] === 0xd8) return 'image/jpeg'
|
||||
if (buf[0] === 0x89 && buf[1] === 0x50) return 'image/png'
|
||||
@@ -9,7 +10,7 @@ function detectMime(buf: Buffer): string {
|
||||
}
|
||||
|
||||
export function Img(
|
||||
props: Omit<React.ImgHTMLAttributes<HTMLImageElement>, 'src'> & {src: Buffer},
|
||||
props: Omit<ImgHTMLAttributes<HTMLImageElement>, 'src'> & {src: Buffer},
|
||||
) {
|
||||
const {src, ...others} = props
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
/* eslint-disable bsky-internal/avoid-unwrapped-text */
|
||||
import React from 'react'
|
||||
import {AppBskyGraphDefs, AppBskyGraphStarterpack} from '@atproto/api'
|
||||
import {type AppBskyGraphDefs, AppBskyGraphStarterpack} from '@atproto/api'
|
||||
|
||||
import {Butterfly} from './Butterfly.js'
|
||||
import {Img} from './Img.js'
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import assert from 'node:assert'
|
||||
|
||||
import React from 'react'
|
||||
import {AppBskyGraphDefs, AtUri} from '@atproto/api'
|
||||
import {type AppBskyGraphDefs, AtUri} from '@atproto/api'
|
||||
import resvg from '@resvg/resvg-js'
|
||||
import {Express} from 'express'
|
||||
import {type Express} from 'express'
|
||||
import satori from 'satori'
|
||||
|
||||
import {
|
||||
@@ -11,7 +10,7 @@ import {
|
||||
STARTERPACK_HEIGHT,
|
||||
STARTERPACK_WIDTH,
|
||||
} from '../components/StarterPack.js'
|
||||
import {AppContext} from '../context.js'
|
||||
import {type AppContext} from '../context.js'
|
||||
import {httpLogger} from '../logger.js'
|
||||
import {loadEmojiAsSvg} from '../util.js'
|
||||
import {handler, originVerifyMiddleware} from './util.js'
|
||||
@@ -83,12 +82,18 @@ export default function (ctx: AppContext, app: Express) {
|
||||
}
|
||||
|
||||
async function getImage(url: string) {
|
||||
const response = await fetch(url)
|
||||
const response = await fetch(ensureJpeg(url))
|
||||
const arrayBuf = await response.arrayBuffer() // must drain body even if it will be discarded
|
||||
if (response.status !== 200) return null
|
||||
return Buffer.from(arrayBuf)
|
||||
}
|
||||
|
||||
// CDN URLs end with @jpeg, @webp, or no extension (which may default to webp).
|
||||
// We want to ensure the image URLs we use are for jpegs, required for compat with satori.
|
||||
function ensureJpeg(url: string) {
|
||||
return url.replace(/(@[a-z]{3,5})?$/, '@jpeg')
|
||||
}
|
||||
|
||||
const hideAvatarLabels = new Set([
|
||||
'!hide',
|
||||
'!warn',
|
||||
|
||||
@@ -292,6 +292,7 @@ func serve(cctx *cli.Context) error {
|
||||
e.GET("/settings/accessibility", server.WebGeneric)
|
||||
e.GET("/settings/appearance", server.WebGeneric)
|
||||
e.GET("/settings/account", server.WebGeneric)
|
||||
e.GET("/settings/automation-label", server.WebGeneric)
|
||||
e.GET("/settings/privacy-and-security", server.WebGeneric)
|
||||
e.GET("/settings/privacy-and-security/activity", server.WebGeneric)
|
||||
e.GET("/settings/content-and-media", server.WebGeneric)
|
||||
|
||||
@@ -0,0 +1,510 @@
|
||||
import {createServer as createHTTPServer} from 'node:http'
|
||||
import {parse} from 'node:url'
|
||||
|
||||
import {createServer, type TestPDS} from './test-pds'
|
||||
|
||||
let server: TestPDS
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
||||
createHTTPServer(async (req, res) => {
|
||||
const url = parse(req.url || '/', true)
|
||||
if (req.method !== 'POST') {
|
||||
return res.writeHead(200).end()
|
||||
}
|
||||
try {
|
||||
console.log('Closing old server')
|
||||
await server?.close()
|
||||
console.log('Starting new server')
|
||||
const inviteRequired = url?.query && 'invite' in url.query
|
||||
server = await createServer({inviteRequired})
|
||||
console.log('Listening at', server.pdsUrl)
|
||||
if (url?.query) {
|
||||
if ('users' in url.query) {
|
||||
console.log('Generating mock users')
|
||||
await server.mocker.createUser('alice')
|
||||
await server.mocker.createUser('bob')
|
||||
await server.mocker.createUser('carla')
|
||||
await server.mocker.users.alice.agent.upsertProfile(() => ({
|
||||
displayName: 'Alice',
|
||||
description: 'Test user 1',
|
||||
}))
|
||||
await server.mocker.users.bob.agent.upsertProfile(() => ({
|
||||
displayName: 'Bob',
|
||||
description: 'Test user 2',
|
||||
}))
|
||||
await server.mocker.users.carla.agent.upsertProfile(() => ({
|
||||
displayName: 'Carla',
|
||||
description: 'Test user 3',
|
||||
}))
|
||||
if (inviteRequired) {
|
||||
await server.mocker.createInvite(server.mocker.users.alice.did)
|
||||
}
|
||||
}
|
||||
if ('follows' in url.query) {
|
||||
console.log('Generating mock follows')
|
||||
await server.mocker.follow('alice', 'bob')
|
||||
await server.mocker.follow('alice', 'carla')
|
||||
await server.mocker.follow('bob', 'alice')
|
||||
await server.mocker.follow('bob', 'carla')
|
||||
await server.mocker.follow('carla', 'alice')
|
||||
await server.mocker.follow('carla', 'bob')
|
||||
}
|
||||
if ('posts' in url.query) {
|
||||
console.log('Generating mock posts')
|
||||
for (let user in server.mocker.users) {
|
||||
await server.mocker.users[user].agent.post({text: 'Post'})
|
||||
}
|
||||
}
|
||||
if ('feeds' in url.query) {
|
||||
console.log('Generating mock feed')
|
||||
await server.mocker.createFeed('alice', 'alice-favs', [])
|
||||
}
|
||||
if ('thread' in url.query) {
|
||||
console.log('Generating mock posts')
|
||||
const res = await server.mocker.users.bob.agent.post({
|
||||
text: 'Thread root',
|
||||
})
|
||||
await server.mocker.users.carla.agent.post({
|
||||
text: 'Thread reply',
|
||||
reply: {
|
||||
parent: {cid: res.cid, uri: res.uri},
|
||||
root: {cid: res.cid, uri: res.uri},
|
||||
},
|
||||
})
|
||||
}
|
||||
if ('mergefeed' in url.query) {
|
||||
console.log('Generating mock users')
|
||||
await server.mocker.createUser('alice')
|
||||
await server.mocker.createUser('bob')
|
||||
await server.mocker.createUser('carla')
|
||||
await server.mocker.createUser('dan')
|
||||
await server.mocker.users.alice.agent.upsertProfile(() => ({
|
||||
displayName: 'Alice',
|
||||
description: 'Test user 1',
|
||||
}))
|
||||
await server.mocker.users.bob.agent.upsertProfile(() => ({
|
||||
displayName: 'Bob',
|
||||
description: 'Test user 2',
|
||||
}))
|
||||
await server.mocker.users.carla.agent.upsertProfile(() => ({
|
||||
displayName: 'Carla',
|
||||
description: 'Test user 3',
|
||||
}))
|
||||
await server.mocker.users.dan.agent.upsertProfile(() => ({
|
||||
displayName: 'Dan',
|
||||
description: 'Test user 4',
|
||||
}))
|
||||
console.log('Generating mock follows')
|
||||
await server.mocker.follow('alice', 'bob')
|
||||
await server.mocker.follow('alice', 'carla')
|
||||
console.log('Generating mock posts')
|
||||
let posts: Record<string, any[]> = {
|
||||
alice: [],
|
||||
bob: [],
|
||||
carla: [],
|
||||
dan: [],
|
||||
}
|
||||
for (let i = 0; i < 10; i++) {
|
||||
for (let user in server.mocker.users) {
|
||||
if (user === 'alice') continue
|
||||
posts[user].push(await server.mocker.createPost(user, `Post ${i}`))
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < 10; i++) {
|
||||
for (let user in server.mocker.users) {
|
||||
if (user === 'alice') continue
|
||||
if (i % 5 === 0) {
|
||||
await server.mocker.createReply(user, 'Self reply', {
|
||||
cid: posts[user][i].cid,
|
||||
uri: posts[user][i].uri,
|
||||
})
|
||||
}
|
||||
if (i % 5 === 1) {
|
||||
await server.mocker.createReply(user, 'Reply to bob', {
|
||||
cid: posts.bob[i].cid,
|
||||
uri: posts.bob[i].uri,
|
||||
})
|
||||
}
|
||||
if (i % 5 === 2) {
|
||||
await server.mocker.createReply(user, 'Reply to dan', {
|
||||
cid: posts.dan[i].cid,
|
||||
uri: posts.dan[i].uri,
|
||||
})
|
||||
}
|
||||
await server.mocker.users[user].agent.post({text: `Post ${i}`})
|
||||
}
|
||||
}
|
||||
console.log('Generating mock feeds')
|
||||
await server.mocker.createFeed(
|
||||
'alice',
|
||||
'alice-favs',
|
||||
posts.dan.map(p => p.uri),
|
||||
)
|
||||
await server.mocker.createFeed(
|
||||
'alice',
|
||||
'alice-favs2',
|
||||
posts.dan.map(p => p.uri),
|
||||
)
|
||||
}
|
||||
if ('labels' in url.query) {
|
||||
console.log('Generating naughty users with labels')
|
||||
|
||||
const anchorPost = await server.mocker.createPost(
|
||||
'alice',
|
||||
'Anchor post',
|
||||
)
|
||||
|
||||
for (const user of [
|
||||
'dmca-account',
|
||||
'dmca-profile',
|
||||
'dmca-posts',
|
||||
'porn-account',
|
||||
'porn-profile',
|
||||
'porn-posts',
|
||||
'nudity-account',
|
||||
'nudity-profile',
|
||||
'nudity-posts',
|
||||
'scam-account',
|
||||
'scam-profile',
|
||||
'scam-posts',
|
||||
'unknown-account',
|
||||
'unknown-profile',
|
||||
'unknown-posts',
|
||||
'hide-account',
|
||||
'hide-profile',
|
||||
'hide-posts',
|
||||
'no-promote-account',
|
||||
'no-promote-profile',
|
||||
'no-promote-posts',
|
||||
'warn-account',
|
||||
'warn-profile',
|
||||
'warn-posts',
|
||||
'muted-account',
|
||||
'muted-by-list-acc',
|
||||
'blocking-account',
|
||||
'blockedby-account',
|
||||
'mutual-block-acc',
|
||||
]) {
|
||||
await server.mocker.createUser(user)
|
||||
await server.mocker.follow('alice', user)
|
||||
await server.mocker.follow(user, 'alice')
|
||||
await server.mocker.createPost(user, `Unlabeled post from ${user}`)
|
||||
await server.mocker.createReply(
|
||||
user,
|
||||
`Unlabeled reply from ${user}`,
|
||||
anchorPost,
|
||||
)
|
||||
await server.mocker.like(user, anchorPost)
|
||||
}
|
||||
|
||||
await server.mocker.labelAccount('dmca-violation', 'dmca-account')
|
||||
await server.mocker.labelProfile('dmca-violation', 'dmca-profile')
|
||||
await server.mocker.labelPost(
|
||||
'dmca-violation',
|
||||
await server.mocker.createPost('dmca-posts', 'dmca post'),
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'dmca-violation',
|
||||
await server.mocker.createQuotePost(
|
||||
'dmca-posts',
|
||||
'dmca quote post',
|
||||
anchorPost,
|
||||
),
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'dmca-violation',
|
||||
await server.mocker.createReply(
|
||||
'dmca-posts',
|
||||
'dmca reply',
|
||||
anchorPost,
|
||||
),
|
||||
)
|
||||
|
||||
await server.mocker.labelAccount('porn', 'porn-account')
|
||||
await server.mocker.labelProfile('porn', 'porn-profile')
|
||||
await server.mocker.labelPost(
|
||||
'porn',
|
||||
await server.mocker.createImagePost('porn-posts', 'porn post'),
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'porn',
|
||||
await server.mocker.createQuotePost(
|
||||
'porn-posts',
|
||||
'porn quote post',
|
||||
anchorPost,
|
||||
),
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'porn',
|
||||
await server.mocker.createReply(
|
||||
'porn-posts',
|
||||
'porn reply',
|
||||
anchorPost,
|
||||
),
|
||||
)
|
||||
|
||||
await server.mocker.labelAccount('nudity', 'nudity-account')
|
||||
await server.mocker.labelProfile('nudity', 'nudity-profile')
|
||||
await server.mocker.labelPost(
|
||||
'nudity',
|
||||
await server.mocker.createImagePost('nudity-posts', 'nudity post'),
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'nudity',
|
||||
await server.mocker.createQuotePost(
|
||||
'nudity-posts',
|
||||
'nudity quote post',
|
||||
anchorPost,
|
||||
),
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'nudity',
|
||||
await server.mocker.createReply(
|
||||
'nudity-posts',
|
||||
'nudity reply',
|
||||
anchorPost,
|
||||
),
|
||||
)
|
||||
|
||||
await server.mocker.labelAccount('scam', 'scam-account')
|
||||
await server.mocker.labelProfile('scam', 'scam-profile')
|
||||
await server.mocker.labelPost(
|
||||
'scam',
|
||||
await server.mocker.createPost('scam-posts', 'scam post'),
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'scam',
|
||||
await server.mocker.createQuotePost(
|
||||
'scam-posts',
|
||||
'scam quote post',
|
||||
anchorPost,
|
||||
),
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'scam',
|
||||
await server.mocker.createReply(
|
||||
'scam-posts',
|
||||
'scam reply',
|
||||
anchorPost,
|
||||
),
|
||||
)
|
||||
|
||||
await server.mocker.labelAccount('not-a-real-label', 'unknown-account')
|
||||
await server.mocker.labelProfile('not-a-real-label', 'unknown-profile')
|
||||
await server.mocker.labelPost(
|
||||
'not-a-real-label',
|
||||
await server.mocker.createPost('unknown-posts', 'unknown post'),
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'not-a-real-label',
|
||||
await server.mocker.createQuotePost(
|
||||
'unknown-posts',
|
||||
'unknown quote post',
|
||||
anchorPost,
|
||||
),
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'not-a-real-label',
|
||||
await server.mocker.createReply(
|
||||
'unknown-posts',
|
||||
'unknown reply',
|
||||
anchorPost,
|
||||
),
|
||||
)
|
||||
|
||||
await server.mocker.labelAccount('!hide', 'hide-account')
|
||||
await server.mocker.labelProfile('!hide', 'hide-profile')
|
||||
await server.mocker.labelPost(
|
||||
'!hide',
|
||||
await server.mocker.createPost('hide-posts', 'hide post'),
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'!hide',
|
||||
await server.mocker.createQuotePost(
|
||||
'hide-posts',
|
||||
'hide quote post',
|
||||
anchorPost,
|
||||
),
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'!hide',
|
||||
await server.mocker.createReply(
|
||||
'hide-posts',
|
||||
'hide reply',
|
||||
anchorPost,
|
||||
),
|
||||
)
|
||||
|
||||
await server.mocker.labelAccount('!no-promote', 'no-promote-account')
|
||||
await server.mocker.labelProfile('!no-promote', 'no-promote-profile')
|
||||
await server.mocker.labelPost(
|
||||
'!no-promote',
|
||||
await server.mocker.createPost('no-promote-posts', 'no-promote post'),
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'!no-promote',
|
||||
await server.mocker.createQuotePost(
|
||||
'no-promote-posts',
|
||||
'no-promote quote post',
|
||||
anchorPost,
|
||||
),
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'!no-promote',
|
||||
await server.mocker.createReply(
|
||||
'no-promote-posts',
|
||||
'no-promote reply',
|
||||
anchorPost,
|
||||
),
|
||||
)
|
||||
|
||||
await server.mocker.labelAccount('!warn', 'warn-account')
|
||||
await server.mocker.labelProfile('!warn', 'warn-profile')
|
||||
await server.mocker.labelPost(
|
||||
'!warn',
|
||||
await server.mocker.createPost('warn-posts', 'warn post'),
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'!warn',
|
||||
await server.mocker.createQuotePost(
|
||||
'warn-posts',
|
||||
'warn quote post',
|
||||
anchorPost,
|
||||
),
|
||||
)
|
||||
await server.mocker.labelPost(
|
||||
'!warn',
|
||||
await server.mocker.createReply(
|
||||
'warn-posts',
|
||||
'warn reply',
|
||||
anchorPost,
|
||||
),
|
||||
)
|
||||
|
||||
await server.mocker.users.alice.agent.mute('muted-account.test')
|
||||
await server.mocker.createPost('muted-account', 'muted post')
|
||||
await server.mocker.createQuotePost(
|
||||
'muted-account',
|
||||
'muted quote post',
|
||||
anchorPost,
|
||||
)
|
||||
await server.mocker.createReply(
|
||||
'muted-account',
|
||||
'muted reply',
|
||||
anchorPost,
|
||||
)
|
||||
|
||||
const list = await server.mocker.createMuteList('alice', 'Muted Users')
|
||||
await server.mocker.addToMuteList(
|
||||
'alice',
|
||||
list,
|
||||
server.mocker.users['muted-by-list-acc'].did,
|
||||
)
|
||||
await server.mocker.createPost('muted-by-list-acc', 'muted post')
|
||||
await server.mocker.createQuotePost(
|
||||
'muted-by-list-acc',
|
||||
'account quote post',
|
||||
anchorPost,
|
||||
)
|
||||
await server.mocker.createReply(
|
||||
'muted-by-list-acc',
|
||||
'account reply',
|
||||
anchorPost,
|
||||
)
|
||||
|
||||
await server.mocker.createPost('blocking-account', 'blocking post')
|
||||
await server.mocker.createQuotePost(
|
||||
'blocking-account',
|
||||
'blocking quote post',
|
||||
anchorPost,
|
||||
)
|
||||
await server.mocker.createReply(
|
||||
'blocking-account',
|
||||
'blocking reply',
|
||||
anchorPost,
|
||||
)
|
||||
await server.mocker.users.alice.agent.app.bsky.graph.block.create(
|
||||
{
|
||||
repo: server.mocker.users.alice.did,
|
||||
},
|
||||
{
|
||||
subject: server.mocker.users['blocking-account'].did,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
)
|
||||
|
||||
await server.mocker.createPost('blockedby-account', 'blockedby post')
|
||||
await server.mocker.createQuotePost(
|
||||
'blockedby-account',
|
||||
'blockedby quote post',
|
||||
anchorPost,
|
||||
)
|
||||
await server.mocker.createReply(
|
||||
'blockedby-account',
|
||||
'blockedby reply',
|
||||
anchorPost,
|
||||
)
|
||||
await server.mocker.users[
|
||||
'blockedby-account'
|
||||
].agent.app.bsky.graph.block.create(
|
||||
{
|
||||
repo: server.mocker.users['blockedby-account'].did,
|
||||
},
|
||||
{
|
||||
subject: server.mocker.users.alice.did,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
)
|
||||
|
||||
await server.mocker.createPost('mutual-block-acc', 'mutual-block post')
|
||||
await server.mocker.createQuotePost(
|
||||
'mutual-block-acc',
|
||||
'mutual-block quote post',
|
||||
anchorPost,
|
||||
)
|
||||
await server.mocker.createReply(
|
||||
'mutual-block-acc',
|
||||
'mutual-block reply',
|
||||
anchorPost,
|
||||
)
|
||||
await server.mocker.users.alice.agent.app.bsky.graph.block.create(
|
||||
{
|
||||
repo: server.mocker.users.alice.did,
|
||||
},
|
||||
{
|
||||
subject: server.mocker.users['mutual-block-acc'].did,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
)
|
||||
await server.mocker.users[
|
||||
'mutual-block-acc'
|
||||
].agent.app.bsky.graph.block.create(
|
||||
{
|
||||
repo: server.mocker.users['mutual-block-acc'].did,
|
||||
},
|
||||
{
|
||||
subject: server.mocker.users.alice.did,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
)
|
||||
|
||||
// flush caches
|
||||
await server.mocker.testNet.processAll()
|
||||
}
|
||||
}
|
||||
console.log('Ready')
|
||||
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()
|
||||
}
|
||||
}).listen(1986)
|
||||
console.log('Mock server manager listening on 1986')
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "dev-env",
|
||||
"version": "0.0.0",
|
||||
"scripts": {
|
||||
"e2e:mock-server": "NODE_ENV=development ./dev-infra/with-test-redis-and-db.sh ts-node ./mock-server.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atproto/dev-env": "^0.3.213",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
+4308
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,12 @@
|
||||
Make sure you've copied `.env.example` to `.env.test` and provided any required
|
||||
values.
|
||||
|
||||
Install dependencies in `/dev-env`
|
||||
|
||||
```
|
||||
cd dev-env && yarn
|
||||
```
|
||||
|
||||
## Using Maestro
|
||||
|
||||
1. Install Maestro by following [these instructions](https://maestro.mobile.dev/getting-started/installing-maestro). This will help us run the E2E tests.
|
||||
|
||||
+2
-2
@@ -25,8 +25,8 @@ class BottomSheetModule : Module() {
|
||||
view.dismiss()
|
||||
}
|
||||
|
||||
AsyncFunction("updateLayout") { view: BottomSheetView ->
|
||||
view.updateLayout()
|
||||
Prop("fullHeight") { view: BottomSheetView, prop: Boolean ->
|
||||
view.fullHeight = prop
|
||||
}
|
||||
|
||||
Prop("disableDrag") { view: BottomSheetView, prop: Boolean ->
|
||||
|
||||
+147
-55
@@ -8,10 +8,7 @@ import android.view.ViewStructure
|
||||
import android.view.Window
|
||||
import android.view.accessibility.AccessibilityEvent
|
||||
import android.widget.FrameLayout
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
import androidx.core.view.allViews
|
||||
import com.facebook.react.bridge.LifecycleEventListener
|
||||
import com.facebook.react.bridge.ReactContext
|
||||
import com.facebook.react.bridge.UiThreadUtil
|
||||
@@ -34,11 +31,20 @@ class BottomSheetView(
|
||||
|
||||
private lateinit var dialogRootViewGroup: DialogRootViewGroup
|
||||
private var eventDispatcher: EventDispatcher? = null
|
||||
private var isKeyboardVisible: Boolean = false
|
||||
|
||||
private val screenHeight =
|
||||
context.resources.displayMetrics.heightPixels
|
||||
.toFloat()
|
||||
// Native content height observation (eliminates JS bridge round-trip)
|
||||
private var contentLayoutListener: View.OnLayoutChangeListener? = null
|
||||
private var observedChildren: List<View> = emptyList()
|
||||
private var lastObservedContentHeight: Float = 0f
|
||||
private var pendingLayoutUpdate: Boolean = false
|
||||
|
||||
private val screenHeight: Float =
|
||||
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.VANILLA_ICE_CREAM) {
|
||||
context.resources.displayMetrics.heightPixels.toFloat()
|
||||
} else {
|
||||
val wm = context.getSystemService(Context.WINDOW_SERVICE) as android.view.WindowManager
|
||||
wm.currentWindowMetrics.bounds.height().toFloat()
|
||||
}
|
||||
|
||||
private fun getNavigationBarHeight(): Int {
|
||||
val resourceId = resources.getIdentifier("navigation_bar_height", "dimen", "android")
|
||||
@@ -64,8 +70,15 @@ class BottomSheetView(
|
||||
set(value) {
|
||||
field = value
|
||||
this.dialog?.setCancelable(!value)
|
||||
// Full-height sheets have no half-expanded snap point, so any drag
|
||||
// would dismiss. Disable dragging when dismiss is prevented.
|
||||
if (fullHeight) {
|
||||
this.setDraggable(!value && !disableDrag)
|
||||
}
|
||||
}
|
||||
|
||||
var fullHeight = false
|
||||
|
||||
var preventExpansion = false
|
||||
|
||||
var minHeight = 0f
|
||||
@@ -129,6 +142,7 @@ class BottomSheetView(
|
||||
}
|
||||
|
||||
private fun destroy() {
|
||||
this.stopObservingContentHeight()
|
||||
this.isClosing = false
|
||||
this.isOpen = false
|
||||
this.dialog = null
|
||||
@@ -193,31 +207,40 @@ class BottomSheetView(
|
||||
val bottomSheet = dialog.findViewById<FrameLayout>(com.google.android.material.R.id.design_bottom_sheet)
|
||||
bottomSheet?.let {
|
||||
it.setBackgroundColor(0)
|
||||
it.elevation = 0f
|
||||
|
||||
val behavior = BottomSheetBehavior.from(it)
|
||||
behavior.state = BottomSheetBehavior.STATE_HIDDEN
|
||||
behavior.isFitToContents = true
|
||||
behavior.halfExpandedRatio = getHalfExpandedRatio(contentHeight)
|
||||
behavior.skipCollapsed = true
|
||||
behavior.isDraggable = true
|
||||
behavior.isHideable = true
|
||||
|
||||
if (preventExpansion) {
|
||||
behavior.maxHeight = (behavior.halfExpandedRatio * screenHeight).toInt()
|
||||
} else {
|
||||
behavior.maxHeight = (screenHeight - getStatusBarHeight()).toInt()
|
||||
}
|
||||
|
||||
val targetHeight = this.getTargetHeight()
|
||||
val availableHeight = screenHeight - getStatusBarHeight() - getNavigationBarHeight()
|
||||
val shouldBeExpanded = targetHeight >= availableHeight
|
||||
|
||||
if (shouldBeExpanded) {
|
||||
if (fullHeight) {
|
||||
behavior.isFitToContents = false
|
||||
behavior.expandedOffset = getStatusBarHeight()
|
||||
behavior.state = BottomSheetBehavior.STATE_EXPANDED
|
||||
this.selectedSnapPoint = 2
|
||||
} else {
|
||||
} else if (preventExpansion) {
|
||||
behavior.isFitToContents = true
|
||||
behavior.halfExpandedRatio = getHalfExpandedRatio(contentHeight)
|
||||
behavior.maxHeight = (behavior.halfExpandedRatio * screenHeight).toInt()
|
||||
behavior.state = BottomSheetBehavior.STATE_HALF_EXPANDED
|
||||
this.selectedSnapPoint = 1
|
||||
} else {
|
||||
behavior.isFitToContents = false
|
||||
behavior.halfExpandedRatio = getHalfExpandedRatio(contentHeight)
|
||||
behavior.expandedOffset = getStatusBarHeight()
|
||||
|
||||
val targetHeight = this.getTargetHeight()
|
||||
val availableHeight = screenHeight - getStatusBarHeight() - getNavigationBarHeight()
|
||||
val shouldBeExpanded = targetHeight >= availableHeight
|
||||
|
||||
if (shouldBeExpanded) {
|
||||
behavior.state = BottomSheetBehavior.STATE_EXPANDED
|
||||
this.selectedSnapPoint = 2
|
||||
} else {
|
||||
behavior.state = BottomSheetBehavior.STATE_HALF_EXPANDED
|
||||
this.selectedSnapPoint = 1
|
||||
}
|
||||
}
|
||||
|
||||
behavior.addBottomSheetCallback(
|
||||
@@ -226,12 +249,23 @@ class BottomSheetView(
|
||||
bottomSheet: View,
|
||||
newState: Int,
|
||||
) {
|
||||
if (newState == BottomSheetBehavior.STATE_EXPANDED && preventExpansion) {
|
||||
behavior.state = BottomSheetBehavior.STATE_HALF_EXPANDED
|
||||
return
|
||||
}
|
||||
when (newState) {
|
||||
BottomSheetBehavior.STATE_EXPANDED -> selectedSnapPoint = 2
|
||||
BottomSheetBehavior.STATE_COLLAPSED -> selectedSnapPoint = 1
|
||||
BottomSheetBehavior.STATE_HALF_EXPANDED -> selectedSnapPoint = 1
|
||||
BottomSheetBehavior.STATE_HIDDEN -> selectedSnapPoint = 0
|
||||
}
|
||||
// Apply deferred layout update after gesture completes
|
||||
if (newState != BottomSheetBehavior.STATE_DRAGGING &&
|
||||
newState != BottomSheetBehavior.STATE_SETTLING &&
|
||||
pendingLayoutUpdate) {
|
||||
pendingLayoutUpdate = false
|
||||
updateLayout()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSlide(
|
||||
@@ -245,25 +279,14 @@ class BottomSheetView(
|
||||
this.isOpening = true
|
||||
dialog.show()
|
||||
this.dialog = dialog
|
||||
|
||||
ViewCompat.setOnApplyWindowInsetsListener(dialogRootViewGroup) { view, insets ->
|
||||
val imeVisible = insets.isVisible(WindowInsetsCompat.Type.ime())
|
||||
val bottomSheet = dialog.findViewById<FrameLayout>(com.google.android.material.R.id.design_bottom_sheet)
|
||||
val behavior = bottomSheet?.let { BottomSheetBehavior.from(it) }
|
||||
|
||||
val wasKeyboardVisible = isKeyboardVisible
|
||||
isKeyboardVisible = imeVisible
|
||||
|
||||
if (imeVisible && behavior?.state == BottomSheetBehavior.STATE_HALF_EXPANDED) {
|
||||
behavior.state = BottomSheetBehavior.STATE_EXPANDED
|
||||
} else if (!imeVisible && wasKeyboardVisible) {
|
||||
updateLayout()
|
||||
}
|
||||
insets
|
||||
if (!fullHeight) {
|
||||
this.startObservingContentHeight()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
fun updateLayout() {
|
||||
if (fullHeight) return
|
||||
val dialog = this.dialog ?: return
|
||||
val contentHeight = this.getContentHeight()
|
||||
|
||||
@@ -274,21 +297,34 @@ class BottomSheetView(
|
||||
|
||||
val oldRatio = behavior.halfExpandedRatio
|
||||
val newRatio = getHalfExpandedRatio(contentHeight)
|
||||
behavior.halfExpandedRatio = newRatio
|
||||
|
||||
if (preventExpansion) {
|
||||
behavior.maxHeight = (behavior.halfExpandedRatio * screenHeight).toInt()
|
||||
}
|
||||
|
||||
val targetHeight = this.getTargetHeight()
|
||||
val availableHeight = screenHeight - getStatusBarHeight() - getNavigationBarHeight()
|
||||
val shouldBeExpanded = targetHeight >= availableHeight
|
||||
|
||||
if (isKeyboardVisible) {
|
||||
if (behavior.state != BottomSheetBehavior.STATE_EXPANDED) {
|
||||
behavior.state = BottomSheetBehavior.STATE_EXPANDED
|
||||
// Don't update during user gestures — defer until the gesture completes.
|
||||
if (currentState == BottomSheetBehavior.STATE_DRAGGING) {
|
||||
pendingLayoutUpdate = true
|
||||
return
|
||||
}
|
||||
|
||||
behavior.halfExpandedRatio = newRatio
|
||||
|
||||
if (preventExpansion) {
|
||||
behavior.maxHeight = (behavior.halfExpandedRatio * screenHeight).toInt()
|
||||
it.requestLayout()
|
||||
}
|
||||
|
||||
// During settling (programmatic animation from our own state change),
|
||||
// redirect the animation to the new position if the ratio changed.
|
||||
if (currentState == BottomSheetBehavior.STATE_SETTLING) {
|
||||
if (oldRatio != newRatio) {
|
||||
behavior.state = BottomSheetBehavior.STATE_HALF_EXPANDED
|
||||
}
|
||||
} else if (shouldBeExpanded && behavior.state != BottomSheetBehavior.STATE_EXPANDED && !preventExpansion) {
|
||||
return
|
||||
}
|
||||
|
||||
if (shouldBeExpanded && behavior.state != BottomSheetBehavior.STATE_EXPANDED && !preventExpansion) {
|
||||
behavior.state = BottomSheetBehavior.STATE_EXPANDED
|
||||
} else if (!shouldBeExpanded && behavior.state != BottomSheetBehavior.STATE_HALF_EXPANDED) {
|
||||
behavior.state = BottomSheetBehavior.STATE_HALF_EXPANDED
|
||||
@@ -299,21 +335,77 @@ class BottomSheetView(
|
||||
}
|
||||
|
||||
fun dismiss() {
|
||||
this.dialog?.dismiss()
|
||||
val dialog = this.dialog ?: return
|
||||
// Mark as closing so the content observer doesn't fight the dismiss
|
||||
// animation by calling updateLayout() mid-hide.
|
||||
this.isClosing = true
|
||||
// Temporarily make cancelable so cancel() works — cancel() gives the
|
||||
// slide-out animation, while dismiss() does a plain fade.
|
||||
dialog.setCancelable(true)
|
||||
dialog.cancel()
|
||||
}
|
||||
|
||||
// Observe each direct child of innerView via OnLayoutChangeListener so that
|
||||
// height updates are detected purely on the native side. We use OnLayoutChangeListener
|
||||
// (not OnGlobalLayoutListener) because React Native calls view.layout() directly
|
||||
// via Yoga, bypassing requestLayout()/performTraversals(). OnLayoutChangeListener
|
||||
// fires from setFrame() which IS called by layout(), so it catches RN updates.
|
||||
private fun startObservingContentHeight() {
|
||||
stopObservingContentHeight()
|
||||
|
||||
val innerViewGroup = this.innerView as? ViewGroup ?: return
|
||||
|
||||
val listener = View.OnLayoutChangeListener { _, _, top, _, bottom, _, _, oldTop, oldBottom ->
|
||||
val newHeight = bottom - top
|
||||
val oldHeight = oldBottom - oldTop
|
||||
if (newHeight != oldHeight) {
|
||||
val contentHeight = getContentHeight()
|
||||
if (contentHeight != lastObservedContentHeight && contentHeight > 0 && (isOpen || isOpening) && !isClosing) {
|
||||
lastObservedContentHeight = contentHeight
|
||||
updateLayout()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val children = mutableListOf<View>()
|
||||
for (i in 0 until innerViewGroup.childCount) {
|
||||
val child = innerViewGroup.getChildAt(i)
|
||||
child.addOnLayoutChangeListener(listener)
|
||||
children.add(child)
|
||||
}
|
||||
|
||||
this.contentLayoutListener = listener
|
||||
this.observedChildren = children
|
||||
|
||||
// Pick up current height if content is already laid out
|
||||
val contentHeight = getContentHeight()
|
||||
if (contentHeight > 0 && contentHeight != lastObservedContentHeight) {
|
||||
lastObservedContentHeight = contentHeight
|
||||
updateLayout()
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopObservingContentHeight() {
|
||||
contentLayoutListener?.let { listener ->
|
||||
observedChildren.forEach { it.removeOnLayoutChangeListener(listener) }
|
||||
}
|
||||
contentLayoutListener = null
|
||||
observedChildren = emptyList()
|
||||
lastObservedContentHeight = 0f
|
||||
}
|
||||
|
||||
// Util
|
||||
|
||||
private fun getContentHeight(): Float {
|
||||
val innerView = this.innerView ?: return 0f
|
||||
var index = 0
|
||||
innerView.allViews.forEach {
|
||||
if (index == 1) {
|
||||
return it.height.toFloat()
|
||||
}
|
||||
index++
|
||||
val innerView = this.innerView as? ViewGroup ?: return 0f
|
||||
// Use the tallest direct child's height. The handle is absolutely positioned
|
||||
// (overlaps the content), so summing would double-count its height as padding.
|
||||
var maxChildHeight = 0f
|
||||
for (i in 0 until innerView.childCount) {
|
||||
val h = innerView.getChildAt(i).height.toFloat()
|
||||
if (h > maxChildHeight) maxChildHeight = h
|
||||
}
|
||||
return 0f
|
||||
return maxChildHeight
|
||||
}
|
||||
|
||||
private fun getTargetHeight(): Float {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="EdgeToEdgeBottomSheetDialogTheme" parent="Theme.Material3.DayNight.BottomSheetDialog">
|
||||
<!-- Enable edge-to-edge -->
|
||||
<style name="EdgeToEdgeBottomSheetDialogTheme" parent="ThemeOverlay.Material3.DayNight.BottomSheetDialog">
|
||||
<!-- Enable edge-to-edge, matching react-native-edge-to-edge's setup -->
|
||||
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||
<item name="android:windowIsFloating">false</item>
|
||||
<item name="android:windowDrawsSystemBarBackgrounds">true</item>
|
||||
<item name="android:fitsSystemWindows">false</item>
|
||||
<item name="enableEdgeToEdge">true</item>
|
||||
|
||||
<!-- Configure bottom sheet to respect system window insets -->
|
||||
@@ -16,5 +18,6 @@
|
||||
<item name="paddingLeftSystemWindowInsets">true</item>
|
||||
<item name="paddingRightSystemWindowInsets">true</item>
|
||||
<item name="paddingTopSystemWindowInsets">false</item>
|
||||
<item name="backgroundTint">@android:color/transparent</item>
|
||||
</style>
|
||||
</resources>
|
||||
|
||||
@@ -19,8 +19,8 @@ public class BottomSheetModule: Module {
|
||||
view.dismiss()
|
||||
}
|
||||
|
||||
AsyncFunction("updateLayout") { (view: SheetView) in
|
||||
view.updateLayout()
|
||||
Prop("fullHeight") { (view: SheetView, prop: Bool) in
|
||||
view.fullHeight = prop
|
||||
}
|
||||
|
||||
Prop("cornerRadius") { (view: SheetView, prop: Float) in
|
||||
|
||||
@@ -8,6 +8,9 @@ class SheetView: ExpoView, UISheetPresentationControllerDelegate {
|
||||
private var innerView: UIView?
|
||||
private var touchHandler: RCTTouchHandler?
|
||||
|
||||
// Native content height observation (eliminates JS bridge round-trip)
|
||||
private var contentHeightObservation: NSKeyValueObservation?
|
||||
|
||||
// Events
|
||||
private let onAttemptDismiss = EventDispatcher()
|
||||
private let onSnapPointChange = EventDispatcher()
|
||||
@@ -23,6 +26,7 @@ class SheetView: ExpoView, UISheetPresentationControllerDelegate {
|
||||
}
|
||||
|
||||
// React view props
|
||||
var fullHeight = false
|
||||
var preventDismiss = false
|
||||
var preventExpansion = false
|
||||
var cornerRadius: CGFloat?
|
||||
@@ -68,7 +72,6 @@ class SheetView: ExpoView, UISheetPresentationControllerDelegate {
|
||||
}
|
||||
}
|
||||
}
|
||||
private var prevLayoutDetentIdentifier: UISheetPresentationController.Detent.Identifier?
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
@@ -106,6 +109,8 @@ class SheetView: ExpoView, UISheetPresentationControllerDelegate {
|
||||
}
|
||||
|
||||
private func destroy() {
|
||||
self.contentHeightObservation?.invalidate()
|
||||
self.contentHeightObservation = nil
|
||||
self.isClosing = false
|
||||
self.isOpen = false
|
||||
self.sheetVc = nil
|
||||
@@ -128,7 +133,7 @@ class SheetView: ExpoView, UISheetPresentationControllerDelegate {
|
||||
}
|
||||
|
||||
let sheetVc = SheetViewController()
|
||||
sheetVc.setDetents(contentHeight: self.clampHeight(contentHeight), preventExpansion: self.preventExpansion)
|
||||
sheetVc.setDetents(contentHeight: self.clampHeight(contentHeight), preventExpansion: self.preventExpansion, fullHeight: self.fullHeight)
|
||||
if let sheet = sheetVc.sheetPresentationController {
|
||||
sheet.delegate = self
|
||||
sheet.preferredCornerRadius = self.cornerRadius
|
||||
@@ -147,6 +152,9 @@ class SheetView: ExpoView, UISheetPresentationControllerDelegate {
|
||||
|
||||
self.sheetVc = sheetVc
|
||||
self.isOpening = true
|
||||
if !self.fullHeight {
|
||||
self.startObservingContentHeight()
|
||||
}
|
||||
|
||||
rvc.present(sheetVc, animated: true) { [weak self] in
|
||||
self?.isOpening = false
|
||||
@@ -154,15 +162,30 @@ class SheetView: ExpoView, UISheetPresentationControllerDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
func updateLayout() {
|
||||
// Allow updates either when identifiers match OR when prevLayoutDetentIdentifier is nil (first real content update)
|
||||
if self.prevLayoutDetentIdentifier == self.selectedDetentIdentifier || self.prevLayoutDetentIdentifier == nil,
|
||||
let contentHeight = self.innerView?.subviews.first?.frame.size.height {
|
||||
self.sheetVc?.updateDetents(contentHeight: self.clampHeight(contentHeight),
|
||||
preventExpansion: self.preventExpansion)
|
||||
// Observe the content view's bounds via KVO so that height changes are detected
|
||||
// purely on the native side, without a JS bridge round-trip through onLayout.
|
||||
// Calls updateDetents directly with the observed height rather than going through
|
||||
// updateLayout(), which has a prevLayoutDetentIdentifier guard that can block
|
||||
// legitimate content-driven updates when detent identifiers drift during animations.
|
||||
private func startObservingContentHeight() {
|
||||
self.contentHeightObservation?.invalidate()
|
||||
|
||||
guard let contentView = self.innerView?.subviews.first else { return }
|
||||
|
||||
self.contentHeightObservation = contentView.observe(
|
||||
\.bounds,
|
||||
options: [.old, .new]
|
||||
) { [weak self] _, change in
|
||||
guard let self = self,
|
||||
(self.isOpen || self.isOpening) && !self.isClosing,
|
||||
let oldBounds = change.oldValue,
|
||||
let newBounds = change.newValue,
|
||||
oldBounds.height != newBounds.height,
|
||||
newBounds.height > 0 else { return }
|
||||
let clampedHeight = self.clampHeight(newBounds.height)
|
||||
self.sheetVc?.updateDetents(contentHeight: clampedHeight, preventExpansion: self.preventExpansion)
|
||||
self.selectedDetentIdentifier = self.sheetVc?.getCurrentDetentIdentifier()
|
||||
}
|
||||
self.prevLayoutDetentIdentifier = self.selectedDetentIdentifier
|
||||
}
|
||||
|
||||
func dismiss() {
|
||||
|
||||
@@ -20,13 +20,19 @@ class SheetViewController: UIViewController {
|
||||
}
|
||||
}
|
||||
|
||||
func setDetents(contentHeight: CGFloat, preventExpansion: Bool) {
|
||||
func setDetents(contentHeight: CGFloat, preventExpansion: Bool, fullHeight: Bool = false) {
|
||||
guard let sheet = self.sheetPresentationController,
|
||||
let screenHeight = Util.getScreenHeight()
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
if fullHeight {
|
||||
sheet.detents = [.large()]
|
||||
sheet.selectedDetentIdentifier = .large
|
||||
return
|
||||
}
|
||||
|
||||
// On iOS 26, the floaty sheet presentation adds the device bottom safe area
|
||||
// on top of the custom detent value, creating visible padding inside the pill.
|
||||
// Subtract it so the pill height matches our actual content.
|
||||
|
||||
@@ -26,6 +26,7 @@ export interface BottomSheetViewProps {
|
||||
disableDrag?: boolean
|
||||
sourceViewTag?: number
|
||||
|
||||
fullHeight?: boolean
|
||||
minHeight?: number
|
||||
maxHeight?: number
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as React from 'react'
|
||||
import {Component, createRef} from 'react'
|
||||
import {type ComponentType, type ContextType, type RefObject} from 'react'
|
||||
import {
|
||||
Dimensions,
|
||||
type LayoutChangeEvent,
|
||||
@@ -12,7 +13,6 @@ import {
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import {requireNativeModule, requireNativeViewManager} from 'expo-modules-core'
|
||||
|
||||
import {IS_IOS} from '#/env'
|
||||
import {
|
||||
type BottomSheetState,
|
||||
type BottomSheetViewProps,
|
||||
@@ -22,9 +22,9 @@ import {
|
||||
Context as PortalContext,
|
||||
} from './BottomSheetPortal'
|
||||
|
||||
const NativeView: React.ComponentType<
|
||||
const NativeView: ComponentType<
|
||||
BottomSheetViewProps & {
|
||||
ref: React.RefObject<any>
|
||||
ref: RefObject<any>
|
||||
style: StyleProp<ViewStyle>
|
||||
}
|
||||
> = requireNativeViewManager('BottomSheet')
|
||||
@@ -35,15 +35,19 @@ const IS_IOS15 =
|
||||
Platform.OS === 'ios' &&
|
||||
// semvar - can be 3 segments, so can't use Number(Platform.Version)
|
||||
Number(Platform.Version.split('.').at(0)) < 16
|
||||
// older android versions (15 and below) aren't naturally edge-to-edge
|
||||
// and behave a little differently
|
||||
const IS_NON_E2E_ANDROID =
|
||||
Platform.OS === 'android' && Number(Platform.Version) < 35
|
||||
|
||||
export class BottomSheetNativeComponent extends React.Component<
|
||||
export class BottomSheetNativeComponent extends Component<
|
||||
BottomSheetViewProps,
|
||||
{
|
||||
open: boolean
|
||||
viewHeight?: number
|
||||
}
|
||||
> {
|
||||
ref = React.createRef<any>()
|
||||
ref = createRef<any>()
|
||||
|
||||
static contextType = PortalContext
|
||||
|
||||
@@ -71,16 +75,12 @@ export class BottomSheetNativeComponent extends React.Component<
|
||||
this.props.onStateChange?.(event)
|
||||
}
|
||||
|
||||
private updateLayout = () => {
|
||||
this.ref.current?.updateLayout()
|
||||
}
|
||||
|
||||
static dismissAll = async () => {
|
||||
await NativeModule.dismissAll()
|
||||
}
|
||||
|
||||
render() {
|
||||
const Portal = this.context as React.ContextType<typeof PortalContext>
|
||||
const Portal = this.context as ContextType<typeof PortalContext>
|
||||
if (!Portal) {
|
||||
throw new Error(
|
||||
'BottomSheet: You need to wrap your component tree with a <BottomSheetPortalProvider> to use the bottom sheet.',
|
||||
@@ -113,23 +113,14 @@ export class BottomSheetNativeComponent extends React.Component<
|
||||
nativeViewRef={this.ref}
|
||||
onStateChange={this.onStateChange}
|
||||
extraStyles={extraStyles}
|
||||
onLayout={e => {
|
||||
if (IS_IOS15) {
|
||||
const {height} = e.nativeEvent.layout
|
||||
this.setState({viewHeight: height})
|
||||
}
|
||||
if (Platform.OS === 'android') {
|
||||
// TEMP HACKFIX: I had to timebox this, but this is Bad.
|
||||
// On Android, if you run updateLayout() immediately,
|
||||
// it will take ages to actually run on the native side.
|
||||
// However, adding literally any delay will fix this, including
|
||||
// a console.log() - just sending the log to the CLI is enough.
|
||||
// TODO: Get to the bottom of this and fix it properly! -sfn
|
||||
setTimeout(() => this.updateLayout())
|
||||
} else {
|
||||
this.updateLayout()
|
||||
}
|
||||
}}
|
||||
onLayout={
|
||||
IS_IOS15
|
||||
? e => {
|
||||
const {height} = e.nativeEvent.layout
|
||||
this.setState({viewHeight: height})
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</Portal>
|
||||
)
|
||||
@@ -149,14 +140,19 @@ function BottomSheetNativeComponentInner({
|
||||
onStateChange: (
|
||||
event: NativeSyntheticEvent<{state: BottomSheetState}>,
|
||||
) => void
|
||||
nativeViewRef: React.RefObject<View>
|
||||
onLayout: (event: LayoutChangeEvent) => void
|
||||
nativeViewRef: RefObject<View>
|
||||
onLayout?: (event: LayoutChangeEvent) => void
|
||||
}) {
|
||||
const insets = useSafeAreaInsets()
|
||||
const cornerRadius = rest.cornerRadius ?? 0
|
||||
const {height: screenHeight} = useWindowDimensions()
|
||||
|
||||
const sheetHeight = IS_IOS ? screenHeight - insets.top : screenHeight
|
||||
// sigh... on older Android versions, screenHeight does not include safe area insets
|
||||
// on newer Androids + iOS, it does. we need to find the inner bit + the bottom inset
|
||||
// for the sheet content
|
||||
const sheetHeight = IS_NON_E2E_ANDROID
|
||||
? screenHeight + insets.bottom
|
||||
: screenHeight - insets.top
|
||||
|
||||
return (
|
||||
<NativeView
|
||||
|
||||
@@ -1,20 +1,17 @@
|
||||
import React from 'react'
|
||||
import {createContext, useContext, useMemo} from 'react'
|
||||
import {type ElementType, type ReactNode} from 'react'
|
||||
|
||||
import {createPortalGroup_INTERNAL} from './lib/Portal'
|
||||
|
||||
type PortalContext = React.ElementType<{children: React.ReactNode}>
|
||||
type PortalContext = ElementType<{children: ReactNode}>
|
||||
|
||||
export const Context = React.createContext({} as PortalContext)
|
||||
export const Context = createContext({} as PortalContext)
|
||||
Context.displayName = 'BottomSheetPortalContext'
|
||||
|
||||
export const useBottomSheetPortal_INTERNAL = () => React.useContext(Context)
|
||||
export const useBottomSheetPortal_INTERNAL = () => useContext(Context)
|
||||
|
||||
export function BottomSheetPortalProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const portal = React.useMemo(() => {
|
||||
export function BottomSheetPortalProvider({children}: {children: ReactNode}) {
|
||||
const portal = useMemo(() => {
|
||||
return createPortalGroup_INTERNAL()
|
||||
}, [])
|
||||
|
||||
@@ -32,7 +29,7 @@ const defaultPortal = createPortalGroup_INTERNAL()
|
||||
|
||||
export const BottomSheetOutlet = defaultPortal.Outlet
|
||||
|
||||
export function BottomSheetProvider({children}: {children: React.ReactNode}) {
|
||||
export function BottomSheetProvider({children}: {children: ReactNode}) {
|
||||
return (
|
||||
<Context.Provider value={defaultPortal.Portal}>
|
||||
<defaultPortal.Provider>{children}</defaultPortal.Provider>
|
||||
|
||||
+9
-9
@@ -1,6 +1,7 @@
|
||||
import React from 'react'
|
||||
import {createContext, useContext, useEffect, useMemo, useState} from 'react'
|
||||
import {type ReactNode} from 'react'
|
||||
|
||||
import {BackgroundNotificationHandlerPreferences} from './ExpoBackgroundNotificationHandler.types'
|
||||
import {type BackgroundNotificationHandlerPreferences} from './ExpoBackgroundNotificationHandler.types'
|
||||
import {BackgroundNotificationHandler} from './ExpoBackgroundNotificationHandlerModule'
|
||||
|
||||
interface BackgroundNotificationPreferencesContext {
|
||||
@@ -11,30 +12,29 @@ interface BackgroundNotificationPreferencesContext {
|
||||
) => void
|
||||
}
|
||||
|
||||
const Context = React.createContext<BackgroundNotificationPreferencesContext>(
|
||||
const Context = createContext<BackgroundNotificationPreferencesContext>(
|
||||
{} as BackgroundNotificationPreferencesContext,
|
||||
)
|
||||
export const useBackgroundNotificationPreferences = () =>
|
||||
React.useContext(Context)
|
||||
export const useBackgroundNotificationPreferences = () => useContext(Context)
|
||||
|
||||
export function BackgroundNotificationPreferencesProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
children: ReactNode
|
||||
}) {
|
||||
const [preferences, setPreferences] =
|
||||
React.useState<BackgroundNotificationHandlerPreferences>({
|
||||
useState<BackgroundNotificationHandlerPreferences>({
|
||||
playSoundChat: true,
|
||||
})
|
||||
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
;(async () => {
|
||||
const prefs = await BackgroundNotificationHandler.getAllPrefsAsync()
|
||||
setPreferences(prefs)
|
||||
})()
|
||||
}, [])
|
||||
|
||||
const value = React.useMemo(
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
preferences,
|
||||
setPref: async <
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import React from 'react'
|
||||
import {createRef, PureComponent} from 'react'
|
||||
import {type ComponentType, type RefObject} from 'react'
|
||||
import {requireNativeModule} from 'expo'
|
||||
import {requireNativeViewManager} from 'expo-modules-core'
|
||||
|
||||
import {GifViewProps} from './GifView.types'
|
||||
import {type GifViewProps} from './GifView.types'
|
||||
|
||||
const NativeModule = requireNativeModule('ExpoBlueskyGifView')
|
||||
const NativeView: React.ComponentType<
|
||||
GifViewProps & {ref: React.RefObject<any>}
|
||||
> = requireNativeViewManager('ExpoBlueskyGifView')
|
||||
const NativeView: ComponentType<GifViewProps & {ref: RefObject<any>}> =
|
||||
requireNativeViewManager('ExpoBlueskyGifView')
|
||||
|
||||
export class GifView extends React.PureComponent<GifViewProps> {
|
||||
export class GifView extends PureComponent<GifViewProps> {
|
||||
// TODO native types, should all be the same as those in this class
|
||||
private nativeRef: React.RefObject<any> = React.createRef()
|
||||
private nativeRef: RefObject<any> = createRef()
|
||||
|
||||
constructor(props: GifViewProps | Readonly<GifViewProps>) {
|
||||
super(props)
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import * as React from 'react'
|
||||
import {createRef, PureComponent} from 'react'
|
||||
import {type RefObject} from 'react'
|
||||
import {StyleSheet} from 'react-native'
|
||||
|
||||
import {GifViewProps} from './GifView.types'
|
||||
import {type GifViewProps} from './GifView.types'
|
||||
|
||||
export class GifView extends React.PureComponent<GifViewProps> {
|
||||
private readonly videoPlayerRef: React.RefObject<HTMLMediaElement> =
|
||||
React.createRef()
|
||||
export class GifView extends PureComponent<GifViewProps> {
|
||||
private readonly videoPlayerRef: RefObject<HTMLMediaElement> = createRef()
|
||||
private isLoaded = false
|
||||
|
||||
constructor(props: GifViewProps | Readonly<GifViewProps>) {
|
||||
|
||||
+7
-9
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bsky.app",
|
||||
"version": "1.118.0",
|
||||
"version": "1.119.0",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
@@ -52,7 +52,7 @@
|
||||
"lint-native": "swiftlint ./modules && ktlint ./modules",
|
||||
"lint-native:fix": "swiftlint --fix ./modules && ktlint --format ./modules",
|
||||
"typecheck": "tsc --project ./tsconfig.check.json",
|
||||
"e2e:mock-server": "NODE_ENV=development ./jest/dev-infra/with-test-redis-and-db.sh ts-node --project tsconfig.e2e.json __e2e__/mock-server.ts",
|
||||
"e2e:mock-server": "cd dev-env && yarn e2e:mock-server",
|
||||
"e2e:build": "EXPO_PUBLIC_ENV=e2e NODE_ENV=test RN_SRC_EXT=e2e.ts,e2e.tsx expo run:ios",
|
||||
"e2e:build-android": "EXPO_PUBLIC_ENV=e2e NODE_ENV=test RN_SRC_EXT=e2e.ts,e2e.tsx expo run:android",
|
||||
"e2e:start": "EXPO_PUBLIC_ENV=e2e NODE_ENV=test RN_SRC_EXT=e2e.ts,e2e.tsx expo start -c",
|
||||
@@ -112,9 +112,9 @@
|
||||
"@miblanchard/react-native-slider": "^2.6.0",
|
||||
"@mozzius/expo-dynamic-app-icon": "^1.8.0",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
"@react-navigation/bottom-tabs": "^7.9.0",
|
||||
"@react-navigation/native": "^7.1.26",
|
||||
"@react-navigation/native-stack": "^7.9.0",
|
||||
"@react-navigation/bottom-tabs": "^7.15.5",
|
||||
"@react-navigation/native": "^7.1.33",
|
||||
"@react-navigation/native-stack": "^7.14.4",
|
||||
"@sentry/react-native": "~6.20.0",
|
||||
"@tanstack/query-async-storage-persister": "^5.25.0",
|
||||
"@tanstack/react-query": "5.25.0",
|
||||
@@ -205,7 +205,7 @@
|
||||
"react-native-compressor": "^1.13.0",
|
||||
"react-native-date-picker": "^5.0.13",
|
||||
"react-native-device-attest": "^0.1.6",
|
||||
"react-native-drawer-layout": "^4.2.1",
|
||||
"react-native-drawer-layout": "^4.2.2",
|
||||
"react-native-edge-to-edge": "^1.6.0",
|
||||
"react-native-gesture-handler": "~2.28.0",
|
||||
"react-native-keyboard-controller": "^1.20.7",
|
||||
@@ -214,7 +214,7 @@
|
||||
"react-native-qrcode-styled": "^0.3.3",
|
||||
"react-native-reanimated": "^3.19.1",
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
"react-native-screens": "^4.19.0",
|
||||
"react-native-screens": "^4.24.0",
|
||||
"react-native-svg": "15.12.1",
|
||||
"react-native-uitextview": "^1.4.0",
|
||||
"react-native-uuid": "^2.0.3",
|
||||
@@ -235,7 +235,6 @@
|
||||
"zod": "^3.20.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@atproto/dev-env": "^0.3.209",
|
||||
"@babel/core": "^7.26.0",
|
||||
"@babel/preset-env": "^7.26.0",
|
||||
"@babel/runtime": "^7.26.0",
|
||||
@@ -284,7 +283,6 @@
|
||||
"react-native-dotenv": "^3.4.11",
|
||||
"react-refresh": "^0.14.0",
|
||||
"svgo": "^3.3.2",
|
||||
"ts-node": "^10.9.1",
|
||||
"ts-plugin-sort-import-suggestions": "^1.0.4",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.56.0",
|
||||
|
||||
+2
-1
@@ -1,7 +1,8 @@
|
||||
import '#/logger/sentry/setup'
|
||||
import '#/view/icons'
|
||||
|
||||
import React, {useEffect, useState} from 'react'
|
||||
import {useEffect, useState} from 'react'
|
||||
import * as React from 'react'
|
||||
import {GestureHandlerRootView} from 'react-native-gesture-handler'
|
||||
import {KeyboardProvider as KeyboardControllerProvider} from 'react-native-keyboard-controller'
|
||||
import {
|
||||
|
||||
@@ -103,6 +103,7 @@ import {ActivityPrivacySettingsScreen} from '#/screens/Settings/ActivityPrivacyS
|
||||
import {AppearanceSettingsScreen} from '#/screens/Settings/AppearanceSettings'
|
||||
import {AppIconSettingsScreen} from '#/screens/Settings/AppIconSettings'
|
||||
import {AppPasswordsScreen} from '#/screens/Settings/AppPasswords'
|
||||
import {AutomationLabelSettingsScreen} from '#/screens/Settings/AutomationLabelSettings'
|
||||
import {ContentAndMediaSettingsScreen} from '#/screens/Settings/ContentAndMediaSettings'
|
||||
import {ExternalMediaPreferencesScreen} from '#/screens/Settings/ExternalMediaPreferences'
|
||||
import {FindContactsSettingsScreen} from '#/screens/Settings/FindContactsSettings'
|
||||
@@ -403,6 +404,14 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) {
|
||||
requireAuth: true,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="AutomationLabelSettings"
|
||||
getComponent={() => AutomationLabelSettingsScreen}
|
||||
options={{
|
||||
title: title(msg`Automation Label`),
|
||||
requireAuth: true,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="PrivacyAndSecuritySettings"
|
||||
getComponent={() => PrivacyAndSecuritySettingsScreen}
|
||||
|
||||
+8
-9
@@ -1,4 +1,5 @@
|
||||
import React, {useCallback, useEffect} from 'react'
|
||||
import {forwardRef, useCallback, useEffect, useState} from 'react'
|
||||
import {type PropsWithChildren} from 'react'
|
||||
import {
|
||||
AccessibilityInfo,
|
||||
Image as RNImage,
|
||||
@@ -29,7 +30,7 @@ const darkSplashImageUri = RNImage.resolveAssetSource(
|
||||
darkSplashImagePointer,
|
||||
).uri
|
||||
|
||||
export const Logo = React.forwardRef(function LogoImpl(props: SvgProps, ref) {
|
||||
export const Logo = forwardRef(function LogoImpl(props: SvgProps, ref) {
|
||||
const width = 1000
|
||||
const height = width * (67 / 64)
|
||||
return (
|
||||
@@ -51,19 +52,17 @@ type Props = {
|
||||
isReady: boolean
|
||||
}
|
||||
|
||||
export function Splash(props: React.PropsWithChildren<Props>) {
|
||||
export function Splash(props: PropsWithChildren<Props>) {
|
||||
'use no memo'
|
||||
const insets = useSafeAreaInsets()
|
||||
const intro = useSharedValue(0)
|
||||
const outroLogo = useSharedValue(0)
|
||||
const outroApp = useSharedValue(0)
|
||||
const outroAppOpacity = useSharedValue(0)
|
||||
const [isAnimationComplete, setIsAnimationComplete] = React.useState(false)
|
||||
const [isImageLoaded, setIsImageLoaded] = React.useState(false)
|
||||
const [isLayoutReady, setIsLayoutReady] = React.useState(false)
|
||||
const [reduceMotion, setReduceMotion] = React.useState<boolean | undefined>(
|
||||
false,
|
||||
)
|
||||
const [isAnimationComplete, setIsAnimationComplete] = useState(false)
|
||||
const [isImageLoaded, setIsImageLoaded] = useState(false)
|
||||
const [isLayoutReady, setIsLayoutReady] = useState(false)
|
||||
const [reduceMotion, setReduceMotion] = useState<boolean | undefined>(false)
|
||||
const isReady =
|
||||
props.isReady &&
|
||||
isImageLoaded &&
|
||||
|
||||
+13
-16
@@ -1,4 +1,5 @@
|
||||
import React from 'react'
|
||||
import {createContext, useCallback, useContext, useMemo, useState} from 'react'
|
||||
import {type PropsWithChildren} from 'react'
|
||||
import {type Theme, type ThemeName} from '@bsky.app/alf'
|
||||
|
||||
import {
|
||||
@@ -46,7 +47,7 @@ export type Alf = {
|
||||
/*
|
||||
* Context
|
||||
*/
|
||||
export const Context = React.createContext<Alf>({
|
||||
export const Context = createContext<Alf>({
|
||||
themeName: 'light',
|
||||
theme: themes.light,
|
||||
themes,
|
||||
@@ -64,16 +65,14 @@ Context.displayName = 'AlfContext'
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
theme: themeName,
|
||||
}: React.PropsWithChildren<{theme: ThemeName}>) {
|
||||
const [fontScale, setFontScale] = React.useState<Alf['fonts']['scale']>(() =>
|
||||
}: PropsWithChildren<{theme: ThemeName}>) {
|
||||
const [fontScale, setFontScale] = useState<Alf['fonts']['scale']>(() =>
|
||||
getFontScale(),
|
||||
)
|
||||
const [fontScaleMultiplier, setFontScaleMultiplier] = React.useState(() =>
|
||||
const [fontScaleMultiplier, setFontScaleMultiplier] = useState(() =>
|
||||
computeFontScaleMultiplier(fontScale),
|
||||
)
|
||||
const setFontScaleAndPersist = React.useCallback<
|
||||
Alf['fonts']['setFontScale']
|
||||
>(
|
||||
const setFontScaleAndPersist = useCallback<Alf['fonts']['setFontScale']>(
|
||||
fs => {
|
||||
setFontScale(fs)
|
||||
persistFontScale(fs)
|
||||
@@ -81,12 +80,10 @@ export function ThemeProvider({
|
||||
},
|
||||
[setFontScale],
|
||||
)
|
||||
const [fontFamily, setFontFamily] = React.useState<Alf['fonts']['family']>(
|
||||
() => getFontFamily(),
|
||||
const [fontFamily, setFontFamily] = useState<Alf['fonts']['family']>(() =>
|
||||
getFontFamily(),
|
||||
)
|
||||
const setFontFamilyAndPersist = React.useCallback<
|
||||
Alf['fonts']['setFontFamily']
|
||||
>(
|
||||
const setFontFamilyAndPersist = useCallback<Alf['fonts']['setFontFamily']>(
|
||||
ff => {
|
||||
setFontFamily(ff)
|
||||
persistFontFamily(ff)
|
||||
@@ -94,7 +91,7 @@ export function ThemeProvider({
|
||||
[setFontFamily],
|
||||
)
|
||||
|
||||
const value = React.useMemo<Alf>(
|
||||
const value = useMemo<Alf>(
|
||||
() => ({
|
||||
themes,
|
||||
themeName: themeName,
|
||||
@@ -122,12 +119,12 @@ export function ThemeProvider({
|
||||
}
|
||||
|
||||
export function useAlf() {
|
||||
return React.useContext(Context)
|
||||
return useContext(Context)
|
||||
}
|
||||
|
||||
export function useTheme(theme?: ThemeName) {
|
||||
const alf = useAlf()
|
||||
return React.useMemo(() => {
|
||||
return useMemo(() => {
|
||||
return theme ? alf.themes[theme] : alf.theme
|
||||
}, [theme, alf])
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react'
|
||||
import {useLayoutEffect} from 'react'
|
||||
import {type ColorSchemeName, useColorScheme} from 'react-native'
|
||||
import {type ThemeName} from '@bsky.app/alf'
|
||||
|
||||
@@ -9,7 +9,7 @@ import {IS_WEB} from '#/env'
|
||||
export function useColorModeTheme(): ThemeName {
|
||||
const theme = useThemeName()
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
useLayoutEffect(() => {
|
||||
updateDocument(theme)
|
||||
}, [theme])
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react'
|
||||
import {useMemo} from 'react'
|
||||
|
||||
import {type Breakpoint, useBreakpoints} from '#/alf/breakpoints'
|
||||
import * as tokens from '#/alf/tokens'
|
||||
@@ -52,7 +52,7 @@ export function useGutters([top, right, bottom, left]: Gutter[]) {
|
||||
bottom = top
|
||||
left = right
|
||||
}
|
||||
return React.useMemo(() => {
|
||||
return useMemo(() => {
|
||||
return {
|
||||
paddingTop: top === 0 ? 0 : gutters[top][activeBreakpoint || 'default'],
|
||||
paddingRight:
|
||||
|
||||
@@ -732,6 +732,9 @@ export type Events = {
|
||||
'verification:settings:hideBadges': {}
|
||||
'verification:settings:unHideBadges': {}
|
||||
|
||||
'bot:label:toggle': {state: 'add' | 'remove'}
|
||||
'bot:badge:click': {}
|
||||
|
||||
'live:create': {duration: number}
|
||||
'live:edit': {}
|
||||
'live:remove': {}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, {useCallback} from 'react'
|
||||
import {Fragment, useCallback} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {type AppBskyActorDefs} from '@atproto/api'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
@@ -16,9 +16,8 @@ import {Button} from '#/components/Button'
|
||||
import {CheckThick_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check'
|
||||
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronIcon} from '#/components/icons/Chevron'
|
||||
import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus'
|
||||
import {ProfileBadges} from '#/components/ProfileBadges'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {useSimpleVerificationState} from '#/components/verification'
|
||||
import {VerificationCheck} from '#/components/verification/VerificationCheck'
|
||||
import {useActorStatus} from '#/features/liveNow'
|
||||
|
||||
export function AccountList({
|
||||
@@ -53,7 +52,7 @@ export function AccountList({
|
||||
t.atoms.border_contrast_low,
|
||||
]}>
|
||||
{accounts.map(account => (
|
||||
<React.Fragment key={account.did}>
|
||||
<Fragment key={account.did}>
|
||||
<AccountItem
|
||||
profile={profiles?.profiles.find(p => p.did === account.did)}
|
||||
account={account}
|
||||
@@ -62,7 +61,7 @@ export function AccountList({
|
||||
isPendingAccount={account.did === pendingDid}
|
||||
/>
|
||||
<View style={[a.border_b, t.atoms.border_contrast_low]} />
|
||||
</React.Fragment>
|
||||
</Fragment>
|
||||
))}
|
||||
<Button
|
||||
testID="chooseAddAccountBtn"
|
||||
@@ -116,7 +115,6 @@ function AccountItem({
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const verification = useSimpleVerificationState({profile})
|
||||
const {isActive: live} = useActorStatus(profile)
|
||||
|
||||
const onPress = useCallback(() => {
|
||||
@@ -164,13 +162,12 @@ function AccountItem({
|
||||
profile?.displayName || profile?.handle || account.handle,
|
||||
)}
|
||||
</Text>
|
||||
{verification.showBadge && (
|
||||
<View>
|
||||
<VerificationCheck
|
||||
width={12}
|
||||
verifier={verification.role === 'verifier'}
|
||||
/>
|
||||
</View>
|
||||
{profile && (
|
||||
<ProfileBadges
|
||||
profile={profile}
|
||||
size="sm"
|
||||
style={[{marginTop: -2}]}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
<Text
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react'
|
||||
import {useCallback} from 'react'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
@@ -20,7 +20,7 @@ export function AppLanguageDropdown() {
|
||||
const setLangPrefs = useLanguagePrefsApi()
|
||||
const sanitizedLang = sanitizeAppLanguageSetting(langPrefs.appLanguage)
|
||||
|
||||
const onChangeAppLanguage = React.useCallback(
|
||||
const onChangeAppLanguage = useCallback(
|
||||
(value: string) => {
|
||||
if (!value) return
|
||||
if (sanitizedLang !== value) {
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import {View} from 'react-native'
|
||||
import {Trans, useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {useSession} from '#/state/session'
|
||||
import {atoms as a, useTheme, web} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {Bot_Filled as RobotIcon} from '#/components/icons/Bot'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {navigate} from '#/Navigation'
|
||||
import type * as bsky from '#/types/bsky'
|
||||
|
||||
export function BotAccountAlert({
|
||||
control,
|
||||
profile,
|
||||
}: {
|
||||
control: Dialog.DialogControlProps
|
||||
profile: bsky.profile.AnyProfileView
|
||||
}) {
|
||||
const {t: l} = useLingui()
|
||||
const t = useTheme()
|
||||
const {currentAccount} = useSession()
|
||||
|
||||
const isSelf = profile.did === currentAccount?.did
|
||||
const description = isSelf
|
||||
? l`You have marked this account as automated. You can remove it at any time from your account settings.`
|
||||
: l`This account has been marked as automated by its owner.`
|
||||
|
||||
return (
|
||||
<Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}>
|
||||
<Dialog.ScrollableInner
|
||||
label={l`Automated account`}
|
||||
style={[web({maxWidth: 320})]}>
|
||||
<View style={[a.align_center, a.pb_md, a.shadow_sm]}>
|
||||
<RobotIcon width={48} fill={t.atoms.text_contrast_medium.color} />
|
||||
</View>
|
||||
<View style={[a.align_center]}>
|
||||
<Text
|
||||
style={[
|
||||
a.leading_snug,
|
||||
a.text_center,
|
||||
a.pb_xl,
|
||||
a.text_md,
|
||||
t.atoms.text_contrast_high,
|
||||
{maxWidth: 300},
|
||||
]}>
|
||||
{description}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={[a.w_full, a.gap_sm]}>
|
||||
<Button
|
||||
label={l`Okay`}
|
||||
onPress={() => control.close()}
|
||||
color="primary"
|
||||
size="large">
|
||||
<ButtonText>
|
||||
<Trans>Okay</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
{isSelf ? (
|
||||
<Button
|
||||
label={l`Open settings`}
|
||||
onPress={() => {
|
||||
control.close(() => {
|
||||
navigate('AutomationLabelSettings')
|
||||
})
|
||||
}}
|
||||
color="secondary"
|
||||
size="large">
|
||||
<ButtonText>
|
||||
<Trans>Open settings</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
) : null}
|
||||
</View>
|
||||
</Dialog.ScrollableInner>
|
||||
</Dialog.Outer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import {View} from 'react-native'
|
||||
import {type ComAtprotoLabelDefs} from '@atproto/api'
|
||||
import {useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {BotAccountAlert} from '#/components/BotAccountAlert'
|
||||
import {Button} from '#/components/Button'
|
||||
import {useDialogControl} from '#/components/Dialog'
|
||||
import {Bot_Filled as RobotIcon} from '#/components/icons/Bot'
|
||||
import {useAnalytics} from '#/analytics'
|
||||
import type * as bsky from '#/types/bsky'
|
||||
|
||||
export function isBotAccount(profile: {
|
||||
did: string
|
||||
labels?: ComAtprotoLabelDefs.Label[]
|
||||
}): boolean {
|
||||
return (
|
||||
profile.labels?.some(l => l.val === 'bot' && l.src === profile.did) ?? false
|
||||
)
|
||||
}
|
||||
|
||||
export function BotBadge({
|
||||
profile,
|
||||
alwaysShow = false,
|
||||
width,
|
||||
}: {
|
||||
profile: bsky.profile.AnyProfileView
|
||||
alwaysShow?: boolean
|
||||
width: number
|
||||
}) {
|
||||
const t = useTheme()
|
||||
|
||||
if (!isBotAccount(profile) && !alwaysShow) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<View>
|
||||
<RobotIcon width={width} fill={t.atoms.text_contrast_medium.color} />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export function BotBadgeButton({
|
||||
profile,
|
||||
width,
|
||||
}: {
|
||||
profile: bsky.profile.AnyProfileView
|
||||
width: number
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const ax = useAnalytics()
|
||||
const {t: l} = useLingui()
|
||||
const control = useDialogControl()
|
||||
|
||||
if (!isBotAccount(profile)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
label={l`Automated account`}
|
||||
hitSlop={20}
|
||||
onPress={evt => {
|
||||
evt.preventDefault()
|
||||
ax.metric('bot:badge:click', {})
|
||||
control.open()
|
||||
}}>
|
||||
{({hovered}) => (
|
||||
<View
|
||||
style={[
|
||||
a.justify_end,
|
||||
a.align_end,
|
||||
a.transition_transform,
|
||||
{
|
||||
width: width,
|
||||
height: width,
|
||||
transform: [{scale: hovered ? 1.1 : 1}],
|
||||
},
|
||||
]}>
|
||||
<RobotIcon
|
||||
width={width}
|
||||
fill={t.atoms.text_contrast_medium.color}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</Button>
|
||||
<BotAccountAlert control={control} profile={profile} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
+84
-77
@@ -1,4 +1,12 @@
|
||||
import React from 'react'
|
||||
import {
|
||||
createContext,
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react'
|
||||
import {type ComponentType, type ReactElement, type ReactNode} from 'react'
|
||||
import {
|
||||
type AccessibilityProps,
|
||||
type GestureResponderEvent,
|
||||
@@ -75,8 +83,8 @@ export type ButtonState = {
|
||||
export type ButtonContext = VariantProps & ButtonState
|
||||
|
||||
type NonTextElements =
|
||||
| React.ReactElement<any>
|
||||
| Iterable<React.ReactElement<any> | null | undefined | boolean>
|
||||
| ReactElement<any>
|
||||
| Iterable<ReactElement<any> | null | undefined | boolean>
|
||||
|
||||
export type ButtonProps = Pick<
|
||||
PressableProps,
|
||||
@@ -102,13 +110,13 @@ export type ButtonProps = Pick<
|
||||
style?: StyleProp<ViewStyle>
|
||||
hoverStyle?: StyleProp<ViewStyle>
|
||||
children: NonTextElements | ((context: ButtonContext) => NonTextElements)
|
||||
PressableComponent?: React.ComponentType<PressableProps>
|
||||
PressableComponent?: ComponentType<PressableProps>
|
||||
}
|
||||
|
||||
export type ButtonTextProps = TextProps &
|
||||
VariantProps & {disabled?: boolean; emoji?: boolean}
|
||||
|
||||
const Context = React.createContext<VariantProps & ButtonState>({
|
||||
const Context = createContext<VariantProps & ButtonState>({
|
||||
hovered: false,
|
||||
focused: false,
|
||||
pressed: false,
|
||||
@@ -117,10 +125,10 @@ const Context = React.createContext<VariantProps & ButtonState>({
|
||||
Context.displayName = 'ButtonContext'
|
||||
|
||||
export function useButtonContext() {
|
||||
return React.useContext(Context)
|
||||
return useContext(Context)
|
||||
}
|
||||
|
||||
export const Button = React.forwardRef<View, ButtonProps>(
|
||||
export const Button = forwardRef<View, ButtonProps>(
|
||||
(
|
||||
{
|
||||
children,
|
||||
@@ -153,13 +161,13 @@ export const Button = React.forwardRef<View, ButtonProps>(
|
||||
}
|
||||
|
||||
const t = useTheme()
|
||||
const [state, setState] = React.useState({
|
||||
const [state, setState] = useState({
|
||||
pressed: false,
|
||||
hovered: false,
|
||||
focused: false,
|
||||
})
|
||||
|
||||
const onPressIn = React.useCallback(
|
||||
const onPressIn = useCallback(
|
||||
(e: GestureResponderEvent) => {
|
||||
setState(s => ({
|
||||
...s,
|
||||
@@ -169,7 +177,7 @@ export const Button = React.forwardRef<View, ButtonProps>(
|
||||
},
|
||||
[setState, onPressInOuter],
|
||||
)
|
||||
const onPressOut = React.useCallback(
|
||||
const onPressOut = useCallback(
|
||||
(e: GestureResponderEvent) => {
|
||||
setState(s => ({
|
||||
...s,
|
||||
@@ -179,7 +187,7 @@ export const Button = React.forwardRef<View, ButtonProps>(
|
||||
},
|
||||
[setState, onPressOutOuter],
|
||||
)
|
||||
const onHoverIn = React.useCallback(
|
||||
const onHoverIn = useCallback(
|
||||
(e: MouseEvent) => {
|
||||
setState(s => ({
|
||||
...s,
|
||||
@@ -189,7 +197,7 @@ export const Button = React.forwardRef<View, ButtonProps>(
|
||||
},
|
||||
[setState, onHoverInOuter],
|
||||
)
|
||||
const onHoverOut = React.useCallback(
|
||||
const onHoverOut = useCallback(
|
||||
(e: MouseEvent) => {
|
||||
setState(s => ({
|
||||
...s,
|
||||
@@ -199,7 +207,7 @@ export const Button = React.forwardRef<View, ButtonProps>(
|
||||
},
|
||||
[setState, onHoverOutOuter],
|
||||
)
|
||||
const onFocus = React.useCallback(
|
||||
const onFocus = useCallback(
|
||||
(e: NativeSyntheticEvent<TargetedEvent>) => {
|
||||
setState(s => ({
|
||||
...s,
|
||||
@@ -209,7 +217,7 @@ export const Button = React.forwardRef<View, ButtonProps>(
|
||||
},
|
||||
[setState, onFocusOuter],
|
||||
)
|
||||
const onBlur = React.useCallback(
|
||||
const onBlur = useCallback(
|
||||
(e: NativeSyntheticEvent<TargetedEvent>) => {
|
||||
setState(s => ({
|
||||
...s,
|
||||
@@ -220,7 +228,7 @@ export const Button = React.forwardRef<View, ButtonProps>(
|
||||
[setState, onBlurOuter],
|
||||
)
|
||||
|
||||
const {baseStyles, hoverStyles} = React.useMemo(() => {
|
||||
const {baseStyles, hoverStyles} = useMemo(() => {
|
||||
const baseStyles: ViewStyle[] = []
|
||||
const hoverStyles: ViewStyle[] = []
|
||||
|
||||
@@ -526,7 +534,7 @@ export const Button = React.forwardRef<View, ButtonProps>(
|
||||
}
|
||||
}, [t, variant, color, size, shape, disabled])
|
||||
|
||||
const context = React.useMemo<ButtonContext>(
|
||||
const context = useMemo<ButtonContext>(
|
||||
() => ({
|
||||
...state,
|
||||
variant,
|
||||
@@ -581,7 +589,7 @@ Button.displayName = 'Button'
|
||||
export function useSharedButtonTextStyles() {
|
||||
const t = useTheme()
|
||||
const {color, variant, disabled, size} = useButtonContext()
|
||||
return React.useMemo(() => {
|
||||
return useMemo(() => {
|
||||
const baseStyles: TextStyle[] = []
|
||||
|
||||
/*
|
||||
@@ -769,7 +777,7 @@ export function ButtonIcon({
|
||||
icon: Comp,
|
||||
size,
|
||||
}: {
|
||||
icon: React.ComponentType<SVGIconProps>
|
||||
icon: ComponentType<SVGIconProps>
|
||||
/**
|
||||
* @deprecated no longer needed
|
||||
*/
|
||||
@@ -778,67 +786,66 @@ export function ButtonIcon({
|
||||
}) {
|
||||
const {size: buttonSize, shape: buttonShape} = useButtonContext()
|
||||
const textStyles = useSharedButtonTextStyles()
|
||||
const {iconSize, iconContainerSize, iconNegativeMargin} =
|
||||
React.useMemo(() => {
|
||||
/**
|
||||
* Pre-set icon sizes for different button sizes
|
||||
*/
|
||||
const iconSizeShorthand =
|
||||
size ??
|
||||
(({
|
||||
large: 'md',
|
||||
small: 'sm',
|
||||
tiny: 'xs',
|
||||
}[buttonSize || 'small'] || 'sm') as Exclude<
|
||||
SVGIconProps['size'],
|
||||
undefined
|
||||
>)
|
||||
const {iconSize, iconContainerSize, iconNegativeMargin} = useMemo(() => {
|
||||
/**
|
||||
* Pre-set icon sizes for different button sizes
|
||||
*/
|
||||
const iconSizeShorthand =
|
||||
size ??
|
||||
(({
|
||||
large: 'md',
|
||||
small: 'sm',
|
||||
tiny: 'xs',
|
||||
}[buttonSize || 'small'] || 'sm') as Exclude<
|
||||
SVGIconProps['size'],
|
||||
undefined
|
||||
>)
|
||||
|
||||
/*
|
||||
* Copied here from icons/common.tsx so we can tweak if we need to, but
|
||||
* also so that we can calculate transforms.
|
||||
*/
|
||||
const iconSize = {
|
||||
xs: 12,
|
||||
sm: 16,
|
||||
md: 18,
|
||||
lg: 24,
|
||||
xl: 28,
|
||||
'2xs': 8,
|
||||
'2xl': 32,
|
||||
'3xl': 40,
|
||||
}[iconSizeShorthand]
|
||||
/*
|
||||
* Copied here from icons/common.tsx so we can tweak if we need to, but
|
||||
* also so that we can calculate transforms.
|
||||
*/
|
||||
const iconSize = {
|
||||
xs: 12,
|
||||
sm: 16,
|
||||
md: 18,
|
||||
lg: 24,
|
||||
xl: 28,
|
||||
'2xs': 8,
|
||||
'2xl': 32,
|
||||
'3xl': 40,
|
||||
}[iconSizeShorthand]
|
||||
|
||||
/*
|
||||
* Goal here is to match rendered text size so that different size icons
|
||||
* don't increase button size
|
||||
*/
|
||||
const iconContainerSize = {
|
||||
large: 20,
|
||||
small: 17,
|
||||
tiny: 15,
|
||||
/*
|
||||
* Goal here is to match rendered text size so that different size icons
|
||||
* don't increase button size
|
||||
*/
|
||||
const iconContainerSize = {
|
||||
large: 20,
|
||||
small: 17,
|
||||
tiny: 15,
|
||||
}[buttonSize || 'small']
|
||||
|
||||
/*
|
||||
* The icon needs to be closer to the edge of the button than the text. Therefore
|
||||
* we make the gap slightly too large, and then pull in the sides using negative margins.
|
||||
*/
|
||||
let iconNegativeMargin = 0
|
||||
|
||||
if (buttonShape === 'default') {
|
||||
iconNegativeMargin = {
|
||||
large: -2,
|
||||
small: -2,
|
||||
tiny: -1,
|
||||
}[buttonSize || 'small']
|
||||
}
|
||||
|
||||
/*
|
||||
* The icon needs to be closer to the edge of the button than the text. Therefore
|
||||
* we make the gap slightly too large, and then pull in the sides using negative margins.
|
||||
*/
|
||||
let iconNegativeMargin = 0
|
||||
|
||||
if (buttonShape === 'default') {
|
||||
iconNegativeMargin = {
|
||||
large: -2,
|
||||
small: -2,
|
||||
tiny: -1,
|
||||
}[buttonSize || 'small']
|
||||
}
|
||||
|
||||
return {
|
||||
iconSize,
|
||||
iconContainerSize,
|
||||
iconNegativeMargin,
|
||||
}
|
||||
}, [buttonSize, buttonShape, size])
|
||||
return {
|
||||
iconSize,
|
||||
iconContainerSize,
|
||||
iconNegativeMargin,
|
||||
}
|
||||
}, [buttonSize, buttonShape, size])
|
||||
|
||||
return (
|
||||
<View
|
||||
@@ -888,8 +895,8 @@ export type StackedButtonProps = Omit<
|
||||
keyof VariantProps | 'children'
|
||||
> &
|
||||
Pick<VariantProps, 'color'> & {
|
||||
children: React.ReactNode
|
||||
icon: React.ComponentType<SVGIconProps>
|
||||
children: ReactNode
|
||||
icon: ComponentType<SVGIconProps>
|
||||
}
|
||||
|
||||
export function StackedButton({children, ...props}: StackedButtonProps) {
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import React, {
|
||||
import {
|
||||
cloneElement,
|
||||
Fragment,
|
||||
isValidElement,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useId,
|
||||
@@ -6,6 +9,7 @@ import React, {
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import {type ReactNode} from 'react'
|
||||
import {
|
||||
BackHandler,
|
||||
Keyboard,
|
||||
@@ -99,7 +103,7 @@ const SPRING_OUT: WithSpringConfig = {
|
||||
/**
|
||||
* Needs placing near the top of the provider stack, but BELOW the theme provider.
|
||||
*/
|
||||
export function Provider({children}: {children: React.ReactNode}) {
|
||||
export function Provider({children}: {children: ReactNode}) {
|
||||
return (
|
||||
<PortalProvider>
|
||||
{children}
|
||||
@@ -108,7 +112,7 @@ export function Provider({children}: {children: React.ReactNode}) {
|
||||
)
|
||||
}
|
||||
|
||||
export function Root({children}: {children: React.ReactNode}) {
|
||||
export function Root({children}: {children: ReactNode}) {
|
||||
const playHaptic = useHaptics()
|
||||
const [mode, setMode] = useState<'full' | 'auxiliary-only'>('full')
|
||||
const [measurement, setMeasurement] = useState<Measurement | null>(null)
|
||||
@@ -569,7 +573,7 @@ export function Outer({
|
||||
style,
|
||||
align = 'left',
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
children: ReactNode
|
||||
style?: StyleProp<ViewStyle>
|
||||
align?: 'left' | 'right'
|
||||
}) {
|
||||
@@ -689,22 +693,22 @@ export function Outer({
|
||||
t.atoms.border_contrast_low,
|
||||
]}>
|
||||
{flattenReactChildren(children).map((child, i) => {
|
||||
return React.isValidElement(child) &&
|
||||
return isValidElement(child) &&
|
||||
(child.type === Item || child.type === Divider) ? (
|
||||
<React.Fragment key={i}>
|
||||
<Fragment key={i}>
|
||||
{i > 0 ? (
|
||||
<View
|
||||
style={[a.border_b, t.atoms.border_contrast_low]}
|
||||
/>
|
||||
) : null}
|
||||
{React.cloneElement(child, {
|
||||
{cloneElement(child, {
|
||||
// @ts-expect-error not typed
|
||||
style: {
|
||||
borderRadius: 0,
|
||||
borderWidth: 0,
|
||||
},
|
||||
})}
|
||||
</React.Fragment>
|
||||
</Fragment>
|
||||
) : null
|
||||
})}
|
||||
</View>
|
||||
@@ -892,7 +896,7 @@ export function ItemRadio({selected}: {selected: boolean}) {
|
||||
)
|
||||
}
|
||||
|
||||
export function LabelText({children}: {children: React.ReactNode}) {
|
||||
export function LabelText({children}: {children: ReactNode}) {
|
||||
const t = useTheme()
|
||||
return (
|
||||
<Text
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import React, {useImperativeHandle} from 'react'
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import {
|
||||
Keyboard,
|
||||
type KeyboardEventListener,
|
||||
type LayoutChangeEvent,
|
||||
type NativeScrollEvent,
|
||||
type NativeSyntheticEvent,
|
||||
@@ -34,6 +43,7 @@ import {
|
||||
type DialogOuterProps,
|
||||
} from '#/components/Dialog/types'
|
||||
import {createInput} from '#/components/forms/TextField'
|
||||
import {useOnKeyboard} from '#/components/hooks/useOnKeyboard'
|
||||
import {IS_ANDROID, IS_IOS, IS_LIQUID_GLASS} from '#/env'
|
||||
import {BottomSheet, BottomSheetSnapPoint} from '../../../modules/bottom-sheet'
|
||||
import {
|
||||
@@ -58,21 +68,21 @@ export function Outer({
|
||||
}: React.PropsWithChildren<DialogOuterProps>) {
|
||||
const themeName = useThemeName()
|
||||
const t = useTheme(themeName)
|
||||
const ref = React.useRef<BottomSheetNativeComponent>(null)
|
||||
const closeCallbacks = React.useRef<(() => void)[]>([])
|
||||
const ref = useRef<BottomSheetNativeComponent>(null)
|
||||
const closeCallbacks = useRef<(() => void)[]>([])
|
||||
const {setDialogIsOpen, setFullyExpandedCount} =
|
||||
useDialogStateControlContext()
|
||||
|
||||
const prevSnapPoint = React.useRef<BottomSheetSnapPoint>(
|
||||
const prevSnapPoint = useRef<BottomSheetSnapPoint>(
|
||||
BottomSheetSnapPoint.Hidden,
|
||||
)
|
||||
|
||||
const [disableDrag, setDisableDrag] = React.useState(false)
|
||||
const [snapPoint, setSnapPoint] = React.useState<BottomSheetSnapPoint>(
|
||||
const [disableDrag, setDisableDrag] = useState(false)
|
||||
const [snapPoint, setSnapPoint] = useState<BottomSheetSnapPoint>(
|
||||
BottomSheetSnapPoint.Partial,
|
||||
)
|
||||
|
||||
const callQueuedCallbacks = React.useCallback(() => {
|
||||
const callQueuedCallbacks = useCallback(() => {
|
||||
for (const cb of closeCallbacks.current) {
|
||||
try {
|
||||
cb()
|
||||
@@ -84,7 +94,7 @@ export function Outer({
|
||||
closeCallbacks.current = []
|
||||
}, [])
|
||||
|
||||
const open = React.useCallback<DialogControlProps['open']>(() => {
|
||||
const open = useCallback<DialogControlProps['open']>(() => {
|
||||
// Run any leftover callbacks that might have been queued up before calling `.open()`
|
||||
callQueuedCallbacks()
|
||||
setDialogIsOpen(control.id, true)
|
||||
@@ -92,7 +102,7 @@ export function Outer({
|
||||
}, [setDialogIsOpen, control.id, callQueuedCallbacks])
|
||||
|
||||
// This is the function that we call when we want to dismiss the dialog.
|
||||
const close = React.useCallback<DialogControlProps['close']>(cb => {
|
||||
const close = useCallback<DialogControlProps['close']>(cb => {
|
||||
if (typeof cb === 'function') {
|
||||
closeCallbacks.current.push(cb)
|
||||
}
|
||||
@@ -101,7 +111,7 @@ export function Outer({
|
||||
|
||||
// This is the actual thing we are doing once we "confirm" the dialog. We want the dialog's close animation to
|
||||
// happen before we run this. It is passed to the `BottomSheet` component.
|
||||
const onCloseAnimationComplete = React.useCallback(() => {
|
||||
const onCloseAnimationComplete = useCallback(() => {
|
||||
// This removes the dialog from our list of stored dialogs. Not super necessary on iOS, but on Android this
|
||||
// tells us that we need to toggle the accessibility overlay setting
|
||||
setDialogIsOpen(control.id, false)
|
||||
@@ -147,7 +157,7 @@ export function Outer({
|
||||
[open, close],
|
||||
)
|
||||
|
||||
const context = React.useMemo(
|
||||
const context = useMemo(
|
||||
() => ({
|
||||
close,
|
||||
isNativeDialog: true,
|
||||
@@ -201,25 +211,23 @@ export function Inner({children, style, header}: DialogInnerProps) {
|
||||
)
|
||||
}
|
||||
|
||||
export const ScrollableInner = React.forwardRef<ScrollView, DialogInnerProps>(
|
||||
export const ScrollableInner = forwardRef<ScrollView, DialogInnerProps>(
|
||||
function ScrollableInner(
|
||||
{children, contentContainerStyle, header, ...props},
|
||||
ref,
|
||||
) {
|
||||
const {nativeSnapPoint, disableDrag, setDisableDrag} = useDialogContext()
|
||||
const insets = useSafeAreaInsets()
|
||||
const isAtMaxSnapPoint = nativeSnapPoint === BottomSheetSnapPoint.Full
|
||||
const insets = useSafeAreaInsets()
|
||||
const [keyboardHeight, setKeyboardHeight] = useState(() =>
|
||||
IS_ANDROID ? (Keyboard.metrics()?.height ?? 0) : 0,
|
||||
)
|
||||
|
||||
let paddingBottom = 0
|
||||
if (IS_IOS) {
|
||||
paddingBottom = tokens.space._2xl
|
||||
} else {
|
||||
paddingBottom =
|
||||
Math.max(insets.bottom, tokens.space._5xl) + tokens.space._2xl
|
||||
if (isAtMaxSnapPoint) {
|
||||
paddingBottom += insets.top
|
||||
}
|
||||
}
|
||||
const keyboardEventHandler = useCallback<KeyboardEventListener>(e => {
|
||||
setKeyboardHeight(e.endCoordinates.height)
|
||||
}, [])
|
||||
useOnKeyboard('keyboardDidShow', keyboardEventHandler)
|
||||
useOnKeyboard('keyboardDidHide', keyboardEventHandler)
|
||||
|
||||
const onScroll = (e: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
if (!IS_ANDROID) {
|
||||
@@ -238,7 +246,12 @@ export const ScrollableInner = React.forwardRef<ScrollView, DialogInnerProps>(
|
||||
contentContainerStyle={[
|
||||
a.pt_2xl,
|
||||
IS_LIQUID_GLASS ? a.px_2xl : a.px_xl,
|
||||
{paddingBottom},
|
||||
platform({
|
||||
ios: a.pb_2xl,
|
||||
android: {
|
||||
paddingBottom: keyboardHeight + insets.bottom + tokens.space.xl,
|
||||
},
|
||||
}),
|
||||
contentContainerStyle,
|
||||
]}
|
||||
ref={ref}
|
||||
@@ -250,7 +263,12 @@ export const ScrollableInner = React.forwardRef<ScrollView, DialogInnerProps>(
|
||||
{...props}
|
||||
bounces={isAtMaxSnapPoint}
|
||||
scrollEventThrottle={50}
|
||||
onScroll={IS_ANDROID ? onScroll : undefined}
|
||||
// set drag state based on scroll on android.
|
||||
// we want to detect if it's at the top or not, so watch
|
||||
// scrollEndDrag and momentumScrollEnd as well
|
||||
onScroll={android(onScroll)}
|
||||
onScrollEndDrag={android(onScroll)}
|
||||
onMomentumScrollEnd={android(onScroll)}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
// TODO: figure out why this positions the header absolutely (rather than stickily)
|
||||
// on Android. fine to disable for now, because we don't have any
|
||||
@@ -263,7 +281,7 @@ export const ScrollableInner = React.forwardRef<ScrollView, DialogInnerProps>(
|
||||
},
|
||||
)
|
||||
|
||||
export const InnerFlatList = React.forwardRef<
|
||||
export const InnerFlatList = forwardRef<
|
||||
ListMethods,
|
||||
ListProps<any> & {
|
||||
webInnerStyle?: StyleProp<ViewStyle>
|
||||
@@ -293,7 +311,10 @@ export const InnerFlatList = React.forwardRef<
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollProvider onScroll={onScroll}>
|
||||
<ScrollProvider
|
||||
onScroll={onScroll}
|
||||
onEndDrag={onScroll}
|
||||
onMomentumEnd={onScroll}>
|
||||
<List
|
||||
keyboardShouldPersistTaps="handled"
|
||||
contentInsetAdjustmentBehavior={
|
||||
@@ -327,7 +348,7 @@ export function FlatListFooter({
|
||||
onLayout?: (event: LayoutChangeEvent) => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {top, bottom} = useSafeAreaInsets()
|
||||
const {bottom} = useSafeAreaInsets()
|
||||
const {height} = useReanimatedKeyboardAnimation()
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => {
|
||||
@@ -350,12 +371,7 @@ export function FlatListFooter({
|
||||
t.atoms.border_contrast_low,
|
||||
a.px_lg,
|
||||
a.pt_md,
|
||||
{
|
||||
paddingBottom: platform({
|
||||
ios: tokens.space.md + bottom + (IS_LIQUID_GLASS ? top : 0),
|
||||
android: tokens.space.md + bottom + top,
|
||||
}),
|
||||
},
|
||||
{paddingBottom: bottom + tokens.space.md},
|
||||
// TODO: had to admit defeat here, but we should
|
||||
// try and get this to work for Android as well -sfn
|
||||
ios(animatedStyle),
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import React, {useImperativeHandle} from 'react'
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useContext,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react'
|
||||
import {type PropsWithChildren, type ReactNode} from 'react'
|
||||
import {
|
||||
FlatList,
|
||||
type FlatListProps,
|
||||
@@ -45,18 +53,18 @@ export function Outer({
|
||||
control,
|
||||
onClose,
|
||||
webOptions,
|
||||
}: React.PropsWithChildren<DialogOuterProps>) {
|
||||
}: PropsWithChildren<DialogOuterProps>) {
|
||||
const {_} = useLingui()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const [isOpen, setIsOpen] = React.useState(false)
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const {setDialogIsOpen} = useDialogStateControlContext()
|
||||
|
||||
const open = React.useCallback(() => {
|
||||
const open = useCallback(() => {
|
||||
setDialogIsOpen(control.id, true)
|
||||
setIsOpen(true)
|
||||
}, [setIsOpen, setDialogIsOpen, control.id])
|
||||
|
||||
const close = React.useCallback<DialogControlProps['close']>(
|
||||
const close = useCallback<DialogControlProps['close']>(
|
||||
cb => {
|
||||
setDialogIsOpen(control.id, false)
|
||||
setIsOpen(false)
|
||||
@@ -80,7 +88,7 @@ export function Outer({
|
||||
[control.id, onClose, setDialogIsOpen],
|
||||
)
|
||||
|
||||
const handleBackgroundPress = React.useCallback(
|
||||
const handleBackgroundPress = useCallback(
|
||||
async (e: GestureResponderEvent) => {
|
||||
webOptions?.onBackgroundPress ? webOptions.onBackgroundPress(e) : close()
|
||||
},
|
||||
@@ -96,7 +104,7 @@ export function Outer({
|
||||
[close, open],
|
||||
)
|
||||
|
||||
const context = React.useMemo(
|
||||
const context = useMemo(
|
||||
() => ({
|
||||
close,
|
||||
isNativeDialog: false,
|
||||
@@ -165,7 +173,7 @@ export function Inner({
|
||||
contentContainerStyle,
|
||||
}: DialogInnerProps) {
|
||||
const t = useTheme()
|
||||
const {close} = React.useContext(Context)
|
||||
const {close} = useContext(Context)
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const {reduceMotionEnabled} = useA11y()
|
||||
FocusGuards.useFocusGuards()
|
||||
@@ -215,7 +223,7 @@ export function Inner({
|
||||
|
||||
export const ScrollableInner = Inner
|
||||
|
||||
export const InnerFlatList = React.forwardRef<
|
||||
export const InnerFlatList = forwardRef<
|
||||
FlatList,
|
||||
FlatListProps<any> & {label: string} & {
|
||||
webInnerStyle?: StyleProp<ViewStyle>
|
||||
@@ -258,7 +266,7 @@ export function FlatListFooter({
|
||||
children,
|
||||
onLayout,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
children: ReactNode
|
||||
onLayout?: (event: LayoutChangeEvent) => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
@@ -284,7 +292,7 @@ export function FlatListFooter({
|
||||
|
||||
export function Close() {
|
||||
const {_} = useLingui()
|
||||
const {close} = React.useContext(Context)
|
||||
const {close} = useContext(Context)
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
|
||||
@@ -67,7 +67,9 @@ export function HeaderText({
|
||||
style?: StyleProp<TextStyle>
|
||||
}) {
|
||||
return (
|
||||
<Text style={[a.text_lg, a.text_center, a.font_semi_bold, style]}>
|
||||
<Text
|
||||
style={[a.text_lg, a.text_center, a.font_semi_bold, style]}
|
||||
maxFontSizeMultiplier={2}>
|
||||
{children}
|
||||
</Text>
|
||||
)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import React from 'react'
|
||||
import {useEffect} from 'react'
|
||||
|
||||
import {type DialogControlProps} from '#/components/Dialog/types'
|
||||
|
||||
export function useAutoOpen(control: DialogControlProps, showTimeout?: number) {
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
if (showTimeout) {
|
||||
const timeout = setTimeout(() => {
|
||||
control.open()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react'
|
||||
import {useRef} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {
|
||||
type AppBskyActorDefs,
|
||||
@@ -46,9 +46,7 @@ export function KnownFollowers({
|
||||
minimal?: boolean
|
||||
showIfEmpty?: boolean
|
||||
}) {
|
||||
const cache = React.useRef<Map<string, AppBskyActorDefs.KnownFollowers>>(
|
||||
new Map(),
|
||||
)
|
||||
const cache = useRef<Map<string, AppBskyActorDefs.KnownFollowers>>(new Map())
|
||||
|
||||
/*
|
||||
* Results for `knownFollowers` are not sorted consistently, so when
|
||||
@@ -190,7 +188,7 @@ function KnownFollowersInner({
|
||||
numberOfLines={2}>
|
||||
{slice.length >= 2 ? (
|
||||
// 2-n followers, including blocks
|
||||
serverCount > 2 ? (
|
||||
serverCount > 2 ? ( // only 2
|
||||
<Trans>
|
||||
Followed by{' '}
|
||||
<Text emoji key={slice[0].profile.did} style={textStyle}>
|
||||
@@ -206,7 +204,7 @@ function KnownFollowersInner({
|
||||
one="# other"
|
||||
other="# others"
|
||||
/>
|
||||
</Trans> // only 2
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
Followed by{' '}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react'
|
||||
import {useCallback} from 'react'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
@@ -22,7 +22,7 @@ export function LanguageSelect({
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
|
||||
const handleOnChange = React.useCallback(
|
||||
const handleOnChange = useCallback(
|
||||
(value: string) => {
|
||||
if (!value) return
|
||||
onChange(sanitizeAppLanguageSetting(value))
|
||||
|
||||
@@ -191,7 +191,8 @@ export function TitleText({
|
||||
style,
|
||||
]}
|
||||
numberOfLines={2}
|
||||
emoji>
|
||||
emoji
|
||||
maxFontSizeMultiplier={2}>
|
||||
{children}
|
||||
</Text>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react'
|
||||
import {createContext} from 'react'
|
||||
|
||||
export const ScrollbarOffsetContext = React.createContext({
|
||||
export const ScrollbarOffsetContext = createContext({
|
||||
isWithinOffsetView: false,
|
||||
})
|
||||
ScrollbarOffsetContext.displayName = 'ScrollbarOffsetContext'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react'
|
||||
import {useCallback, useMemo, useState} from 'react'
|
||||
import {type AppBskyFeedGetLikes as GetLikes} from '@atproto/api'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
@@ -29,7 +29,7 @@ function keyExtractor(item: GetLikes.Like) {
|
||||
export function LikedByList({uri}: {uri: string}) {
|
||||
const {_} = useLingui()
|
||||
const initialNumToRender = useInitialNumToRender()
|
||||
const [isPTRing, setIsPTRing] = React.useState(false)
|
||||
const [isPTRing, setIsPTRing] = useState(false)
|
||||
|
||||
const {
|
||||
data: resolvedUri,
|
||||
@@ -49,14 +49,14 @@ export function LikedByList({uri}: {uri: string}) {
|
||||
const error = resolveError || likedByError
|
||||
const isError = !!resolveError || !!likedByError
|
||||
|
||||
const likes = React.useMemo(() => {
|
||||
const likes = useMemo(() => {
|
||||
if (data?.pages) {
|
||||
return data.pages.flatMap(page => page.likes)
|
||||
}
|
||||
return []
|
||||
}, [data])
|
||||
|
||||
const onRefresh = React.useCallback(async () => {
|
||||
const onRefresh = useCallback(async () => {
|
||||
setIsPTRing(true)
|
||||
try {
|
||||
await refetch()
|
||||
@@ -66,7 +66,7 @@ export function LikedByList({uri}: {uri: string}) {
|
||||
setIsPTRing(false)
|
||||
}, [refetch, setIsPTRing])
|
||||
|
||||
const onEndReached = React.useCallback(async () => {
|
||||
const onEndReached = useCallback(async () => {
|
||||
if (isFetchingNextPage || !hasNextPage || isError) return
|
||||
try {
|
||||
await fetchNextPage()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, {useMemo} from 'react'
|
||||
import {useCallback, useMemo} from 'react'
|
||||
import {type PropsWithChildren} from 'react'
|
||||
import {type GestureResponderEvent, Linking} from 'react-native'
|
||||
import {sanitizeUrl} from '@braintree/sanitize-url'
|
||||
import {
|
||||
@@ -117,7 +118,7 @@ export function useLink({
|
||||
const {linkWarningDialogControl} = useGlobalDialogsControlContext()
|
||||
const openLink = useOpenLink()
|
||||
|
||||
const onPress = React.useCallback(
|
||||
const onPress = useCallback(
|
||||
(e: GestureResponderEvent) => {
|
||||
const exitEarlyIfFalse = outerOnPress?.(e)
|
||||
|
||||
@@ -217,7 +218,7 @@ export function useLink({
|
||||
],
|
||||
)
|
||||
|
||||
const handleLongPress = React.useCallback(() => {
|
||||
const handleLongPress = useCallback(() => {
|
||||
const requiresWarning = Boolean(
|
||||
!disableMismatchWarning &&
|
||||
displayText &&
|
||||
@@ -242,7 +243,7 @@ export function useLink({
|
||||
linkWarningDialogControl,
|
||||
])
|
||||
|
||||
const onLongPress = React.useCallback(
|
||||
const onLongPress = useCallback(
|
||||
(e: GestureResponderEvent) => {
|
||||
const exitEarlyIfFalse = outerOnLongPress?.(e)
|
||||
if (exitEarlyIfFalse === false) return
|
||||
@@ -318,7 +319,7 @@ export function Link({
|
||||
)
|
||||
}
|
||||
|
||||
export type InlineLinkProps = React.PropsWithChildren<
|
||||
export type InlineLinkProps = PropsWithChildren<
|
||||
BaseLinkProps &
|
||||
TextStyleProp &
|
||||
Pick<TextProps, 'selectable' | 'numberOfLines' | 'emoji'> &
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react'
|
||||
import {useEffect, useMemo} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {
|
||||
type AppBskyGraphDefs,
|
||||
@@ -88,11 +88,11 @@ export function Link({
|
||||
}: Props & Omit<LinkProps, 'to' | 'label'>) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const href = React.useMemo(() => {
|
||||
const href = useMemo(() => {
|
||||
return createProfileListHref({list: view})
|
||||
}, [view])
|
||||
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
precacheList(queryClient, view)
|
||||
}, [view, queryClient])
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react'
|
||||
import {useEffect} from 'react'
|
||||
import Animated, {
|
||||
Easing,
|
||||
useAnimatedStyle,
|
||||
@@ -20,7 +20,7 @@ export function Loader(props: Props) {
|
||||
transform: [{rotate: rotation.get() + 'deg'}],
|
||||
}))
|
||||
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
rotation.set(() =>
|
||||
withRepeat(withTiming(360, {duration: 500, easing: Easing.linear}), -1),
|
||||
)
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import React from 'react'
|
||||
import {createContext, useContext} from 'react'
|
||||
|
||||
import {type ContextType, type ItemContextType} from '#/components/Menu/types'
|
||||
|
||||
export const Context = React.createContext<ContextType | null>(null)
|
||||
export const Context = createContext<ContextType | null>(null)
|
||||
Context.displayName = 'MenuContext'
|
||||
|
||||
export const ItemContext = React.createContext<ItemContextType | null>(null)
|
||||
export const ItemContext = createContext<ItemContextType | null>(null)
|
||||
ItemContext.displayName = 'MenuItemContext'
|
||||
|
||||
export function useMenuContext() {
|
||||
const context = React.useContext(Context)
|
||||
const context = useContext(Context)
|
||||
|
||||
if (!context) {
|
||||
throw new Error('useMenuContext must be used within a Context.Provider')
|
||||
@@ -19,7 +19,7 @@ export function useMenuContext() {
|
||||
}
|
||||
|
||||
export function useMenuItemContext() {
|
||||
const context = React.useContext(ItemContext)
|
||||
const context = useContext(ItemContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error('useMenuItemContext must be used within a Context.Provider')
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react'
|
||||
import {useMemo} from 'react'
|
||||
import {type ReactNode} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {BSKY_LABELER_DID, type ModerationCause} from '@atproto/api'
|
||||
import {Trans} from '@lingui/react/macro'
|
||||
@@ -30,9 +31,8 @@ export function Row({
|
||||
children,
|
||||
style,
|
||||
size = 'sm',
|
||||
}: {children: React.ReactNode | React.ReactNode[]} & CommonProps &
|
||||
ViewStyleProp) {
|
||||
const styles = React.useMemo(() => {
|
||||
}: {children: ReactNode | ReactNode[]} & CommonProps & ViewStyleProp) {
|
||||
const styles = useMemo(() => {
|
||||
switch (size) {
|
||||
case 'lg':
|
||||
return [{gap: 5}]
|
||||
@@ -67,7 +67,7 @@ export function Label({
|
||||
const isBlueskyLabel =
|
||||
desc.sourceType === 'labeler' && desc.sourceDid === BSKY_LABELER_DID
|
||||
|
||||
const {outer, avi, text} = React.useMemo(() => {
|
||||
const {outer, avi, text} = useMemo(() => {
|
||||
switch (size) {
|
||||
case 'lg': {
|
||||
return {
|
||||
@@ -154,7 +154,7 @@ export function Label({
|
||||
export function FollowsYou({size = 'sm'}: CommonProps) {
|
||||
const t = useTheme()
|
||||
|
||||
const variantStyles = React.useMemo(() => {
|
||||
const variantStyles = useMemo(() => {
|
||||
switch (size) {
|
||||
case 'sm':
|
||||
case 'lg':
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
import {useCallback, useMemo} from 'react'
|
||||
import {Platform, View} from 'react-native'
|
||||
import {Platform, type StyleProp, type TextStyle, View} from 'react-native'
|
||||
import {type AppBskyFeedDefs} from '@atproto/api'
|
||||
import {Trans, useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {HITSLOP_30} from '#/lib/constants'
|
||||
import {useGoogleTranslate} from '#/lib/hooks/useGoogleTranslate'
|
||||
import {guessLanguage, useTranslate} from '#/lib/translation'
|
||||
import {useTranslate} from '#/lib/translation'
|
||||
import {type TranslationFunction} from '#/lib/translation'
|
||||
import {codeToLanguageName, languageName} from '#/locale/helpers'
|
||||
import {
|
||||
codeToLanguageName,
|
||||
isPostInLanguage,
|
||||
languageName,
|
||||
} from '#/locale/helpers'
|
||||
import {LANGUAGES} from '#/locale/languages'
|
||||
import {useLanguagePrefs} from '#/state/preferences'
|
||||
import {atoms as a, native, useTheme, web} from '#/alf'
|
||||
import {atoms as a, flatten, native, useTheme, web} from '#/alf'
|
||||
import {Button} from '#/components/Button'
|
||||
import {ArrowRight_Stroke2_Corner0_Rounded as ArrowRightIcon} from '#/components/icons/Arrow'
|
||||
import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
|
||||
@@ -22,22 +26,28 @@ import {Text} from '#/components/Typography'
|
||||
import {useAnalytics} from '#/analytics'
|
||||
import {IS_WEB} from '#/env'
|
||||
|
||||
const X_ICON_OFFSET = 16
|
||||
|
||||
export function TranslatedPost({
|
||||
hideTranslateLink = false,
|
||||
post,
|
||||
postText,
|
||||
postTextStyle = a.text_md,
|
||||
}: {
|
||||
hideTranslateLink?: boolean
|
||||
post: AppBskyFeedDefs.PostView
|
||||
postText: string
|
||||
postTextStyle?: StyleProp<TextStyle>
|
||||
}) {
|
||||
const langPrefs = useLanguagePrefs()
|
||||
const {clearTranslation, translate, translationState} = useTranslate({
|
||||
key: post.uri,
|
||||
})
|
||||
|
||||
const postLanguage = useMemo(() => guessLanguage(postText), [postText])
|
||||
const needsTranslation = postLanguage !== langPrefs.primaryLanguage
|
||||
const needsTranslation = useMemo(() => {
|
||||
if (hideTranslateLink) return false
|
||||
return !isPostInLanguage(post, [langPrefs.primaryLanguage])
|
||||
}, [hideTranslateLink, post, langPrefs.primaryLanguage])
|
||||
|
||||
switch (translationState.status) {
|
||||
case 'loading':
|
||||
@@ -48,8 +58,9 @@ export function TranslatedPost({
|
||||
clearTranslation={clearTranslation}
|
||||
translate={translate}
|
||||
postText={postText}
|
||||
postTextStyle={postTextStyle}
|
||||
sourceLanguage={
|
||||
translationState.sourceLanguage ?? postLanguage ?? null // Fallback primarily for iOS
|
||||
translationState.sourceLanguage ?? null // Fallback primarily for iOS
|
||||
}
|
||||
translatedText={translationState.translatedText}
|
||||
/>
|
||||
@@ -65,12 +76,10 @@ export function TranslatedPost({
|
||||
)
|
||||
default:
|
||||
return (
|
||||
!hideTranslateLink &&
|
||||
needsTranslation && (
|
||||
<TranslationLink
|
||||
postText={postText}
|
||||
primaryLanguage={langPrefs.primaryLanguage}
|
||||
sourceLanguage={postLanguage}
|
||||
translate={translate}
|
||||
/>
|
||||
)
|
||||
@@ -82,12 +91,12 @@ function TranslationLoading() {
|
||||
const t = useTheme()
|
||||
|
||||
return (
|
||||
<View style={[a.gap_md, a.pt_md, a.align_start]}>
|
||||
<View style={[a.gap_md, a.mt_sm, a.align_start]}>
|
||||
<View style={[a.flex_row, a.align_center, a.gap_xs]}>
|
||||
<Loader size="xs" />
|
||||
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
|
||||
<Trans>Translating…</Trans>
|
||||
<Trans>Translating</Trans>
|
||||
</Text>
|
||||
<Loader size="xs" fill={t.atoms.text_contrast_medium.color} />
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
@@ -96,12 +105,10 @@ function TranslationLoading() {
|
||||
function TranslationLink({
|
||||
postText,
|
||||
primaryLanguage,
|
||||
sourceLanguage,
|
||||
translate,
|
||||
}: {
|
||||
postText: string
|
||||
primaryLanguage: string
|
||||
sourceLanguage: string | null
|
||||
translate: TranslationFunction
|
||||
}) {
|
||||
const t = useTheme()
|
||||
@@ -115,17 +122,17 @@ function TranslationLink({
|
||||
})
|
||||
|
||||
ax.metric('translate', {
|
||||
sourceLanguages: sourceLanguage ? [sourceLanguage] : [],
|
||||
sourceLanguages: [], // todo: get from post maybe?
|
||||
targetLanguage: primaryLanguage,
|
||||
textLength: postText.length,
|
||||
})
|
||||
}, [ax, postText, primaryLanguage, translate, sourceLanguage])
|
||||
}, [ax, postText, primaryLanguage, translate])
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.gap_md,
|
||||
a.pt_md,
|
||||
a.mt_sm,
|
||||
a.align_start,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
@@ -172,30 +179,41 @@ function TranslationError({
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.px_lg,
|
||||
a.pt_sm,
|
||||
a.pb_md,
|
||||
a.p_md,
|
||||
a.mt_sm,
|
||||
a.border,
|
||||
a.rounded_lg,
|
||||
a.gap_xs,
|
||||
t.atoms.border_contrast_high,
|
||||
]}>
|
||||
<View style={[a.flex_row, a.align_center, a.justify_between]}>
|
||||
<View style={[a.flex_row, a.align_center, a.mb_sm, a.gap_xs]}>
|
||||
<WarningIcon size="sm" fill={t.atoms.text_contrast_medium.color} />
|
||||
<Text style={[a.text_xs, a.font_medium, t.atoms.text_contrast_high]}>
|
||||
{message}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={[a.flex_row, a.align_center, a.mb_xs]}>
|
||||
<Button
|
||||
label={l`Hide translation`}
|
||||
hitSlop={HITSLOP_30}
|
||||
hoverStyle={{opacity: 0.5}}
|
||||
onPress={clearTranslation}>
|
||||
<XIcon size="sm" fill={t.atoms.text_contrast_medium.color} />
|
||||
</Button>
|
||||
</View>
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.align_start,
|
||||
a.gap_xs,
|
||||
{
|
||||
paddingRight: X_ICON_OFFSET,
|
||||
},
|
||||
]}>
|
||||
<WarningIcon size="sm" fill={t.atoms.text_contrast_medium.color} />
|
||||
<Text
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.text_xs,
|
||||
a.leading_snug,
|
||||
t.atoms.text_contrast_high,
|
||||
]}>
|
||||
{message}
|
||||
</Text>
|
||||
|
||||
<Button
|
||||
label={l`Hide translation`}
|
||||
hitSlop={HITSLOP_30}
|
||||
hoverStyle={native({opacity: 0.5})}
|
||||
style={[a.absolute, a.z_10, {top: 0, right: 0}]}
|
||||
onPress={clearTranslation}>
|
||||
<XIcon size="sm" fill={t.atoms.text_contrast_medium.color} />
|
||||
</Button>
|
||||
</View>
|
||||
<View style={[a.flex_row, a.align_center]}>
|
||||
<Link
|
||||
@@ -209,7 +227,12 @@ function TranslationError({
|
||||
]}
|
||||
hitSlop={HITSLOP_30}>
|
||||
<Text
|
||||
style={[a.text_xs, a.font_medium, {color: t.palette.primary_500}]}>
|
||||
style={[
|
||||
a.text_xs,
|
||||
a.font_medium,
|
||||
a.leading_snug,
|
||||
{color: t.palette.primary_500},
|
||||
]}>
|
||||
<Trans>Try Google Translate</Trans>
|
||||
</Text>
|
||||
</Link>
|
||||
@@ -222,12 +245,14 @@ function TranslationResult({
|
||||
clearTranslation,
|
||||
translate,
|
||||
postText,
|
||||
postTextStyle,
|
||||
sourceLanguage,
|
||||
translatedText,
|
||||
}: {
|
||||
clearTranslation: () => void
|
||||
translate: TranslationFunction
|
||||
postText: string
|
||||
postTextStyle?: StyleProp<TextStyle>
|
||||
sourceLanguage: string | null
|
||||
translatedText: string
|
||||
}) {
|
||||
@@ -239,39 +264,47 @@ function TranslationResult({
|
||||
? codeToLanguageName(sourceLanguage, i18n.locale)
|
||||
: undefined
|
||||
|
||||
const flattenedStyle = flatten(postTextStyle) ?? {}
|
||||
const fontSize = flattenedStyle.fontSize
|
||||
|
||||
return (
|
||||
<View>
|
||||
<View
|
||||
style={[
|
||||
a.px_lg,
|
||||
a.pt_sm,
|
||||
a.pb_md,
|
||||
a.p_md,
|
||||
a.mt_sm,
|
||||
a.border,
|
||||
a.rounded_lg,
|
||||
a.gap_xs,
|
||||
t.atoms.border_contrast_high,
|
||||
]}>
|
||||
<View style={[a.flex_row, a.align_center, a.mb_xs]}>
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.flex_wrap,
|
||||
{
|
||||
paddingRight: X_ICON_OFFSET,
|
||||
},
|
||||
]}>
|
||||
{langName ? (
|
||||
<View style={[a.flex_row, a.align_center]}>
|
||||
<>
|
||||
<Text
|
||||
style={[
|
||||
a.text_xs,
|
||||
a.font_medium,
|
||||
a.leading_snug,
|
||||
t.atoms.text_contrast_medium,
|
||||
]}>
|
||||
{langName}{' '}
|
||||
</Text>
|
||||
<View style={[a.mt_2xs]}>
|
||||
<ArrowRightIcon
|
||||
size="xs"
|
||||
fill={t.atoms.text_contrast_medium.color}
|
||||
/>
|
||||
</View>
|
||||
<ArrowRightIcon
|
||||
size="xs"
|
||||
fill={t.atoms.text_contrast_medium.color}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
a.text_xs,
|
||||
a.font_medium,
|
||||
a.leading_snug,
|
||||
t.atoms.text_contrast_medium,
|
||||
]}>
|
||||
{' '}
|
||||
@@ -280,15 +313,10 @@ function TranslationResult({
|
||||
langPrefs.appLanguage,
|
||||
)}
|
||||
</Text>
|
||||
</View>
|
||||
</>
|
||||
) : (
|
||||
<Text
|
||||
style={[
|
||||
a.text_xs,
|
||||
a.font_medium,
|
||||
t.atoms.text_contrast_medium,
|
||||
a.mb_xs,
|
||||
]}>
|
||||
style={[a.text_xs, a.leading_snug, t.atoms.text_contrast_medium]}>
|
||||
<Trans>Translated</Trans>
|
||||
</Text>
|
||||
)}
|
||||
@@ -298,6 +326,7 @@ function TranslationResult({
|
||||
style={[
|
||||
a.text_xs,
|
||||
a.font_medium,
|
||||
a.leading_snug,
|
||||
t.atoms.text_contrast_medium,
|
||||
]}>
|
||||
{' '}
|
||||
@@ -310,18 +339,19 @@ function TranslationResult({
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button
|
||||
label={l`Hide translation`}
|
||||
hitSlop={HITSLOP_30}
|
||||
hoverStyle={native({opacity: 0.5})}
|
||||
style={[a.absolute, a.z_10, {top: 0, right: 0}]}
|
||||
onPress={clearTranslation}>
|
||||
<XIcon size="sm" fill={t.atoms.text_contrast_medium.color} />
|
||||
</Button>
|
||||
</View>
|
||||
<Text emoji selectable style={[a.text_md, a.leading_snug]}>
|
||||
<Text emoji selectable style={[a.leading_snug, {fontSize}]}>
|
||||
{translatedText}
|
||||
</Text>
|
||||
<Button
|
||||
label={l`Hide translation`}
|
||||
hitSlop={HITSLOP_30}
|
||||
hoverStyle={native({opacity: 0.5})}
|
||||
style={[a.absolute, a.z_10, {top: 12, right: 14}]}
|
||||
onPress={clearTranslation}>
|
||||
<XIcon size="sm" fill={t.atoms.text_contrast_medium.color} />
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
@@ -391,7 +421,12 @@ function TranslationLanguageSelect({
|
||||
hitSlop={HITSLOP_30}
|
||||
hoverStyle={native({opacity: 0.5})}>
|
||||
<Text
|
||||
style={[a.text_xs, a.font_medium, t.atoms.text_contrast_high]}>
|
||||
style={[
|
||||
a.text_xs,
|
||||
a.font_medium,
|
||||
a.leading_snug,
|
||||
t.atoms.text_contrast_high,
|
||||
]}>
|
||||
<Trans>Change</Trans>
|
||||
</Text>
|
||||
</Button>
|
||||
|
||||
@@ -17,9 +17,8 @@ import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {atoms as a, tokens, useTheme} from '#/alf'
|
||||
import {Button} from '#/components/Button'
|
||||
import {useDialogContext} from '#/components/Dialog'
|
||||
import {ProfileBadges} from '#/components/ProfileBadges'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {useSimpleVerificationState} from '#/components/verification'
|
||||
import {VerificationCheck} from '#/components/verification/VerificationCheck'
|
||||
import {useAnalytics} from '#/analytics'
|
||||
import type * as bsky from '#/types/bsky'
|
||||
|
||||
@@ -111,7 +110,6 @@ function RecentChatItem({
|
||||
profile.displayName || sanitizeHandle(profile.handle),
|
||||
moderation.ui('displayName'),
|
||||
)
|
||||
const verification = useSimpleVerificationState({profile})
|
||||
|
||||
if (isBlockedOrBlocking(profile) || isMuted(profile)) {
|
||||
return null
|
||||
@@ -141,14 +139,7 @@ function RecentChatItem({
|
||||
numberOfLines={1}>
|
||||
{name}
|
||||
</Text>
|
||||
{verification.showBadge && (
|
||||
<View style={[a.pl_2xs]}>
|
||||
<VerificationCheck
|
||||
width={10}
|
||||
verifier={verification.role === 'verifier'}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
<ProfileBadges profile={profile} size="xs" style={[a.pl_2xs]} />
|
||||
</View>
|
||||
</Button>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import {View} from 'react-native'
|
||||
|
||||
import {useProfileShadow} from '#/state/cache/profile-shadow'
|
||||
import {atoms as a, type ViewStyleProp} from '#/alf'
|
||||
import {BotBadge, BotBadgeButton, isBotAccount} from '#/components/BotBadge'
|
||||
import {useSimpleVerificationState} from '#/components/verification'
|
||||
import {VerificationCheck} from '#/components/verification/VerificationCheck'
|
||||
import {VerificationCheckButton} from '#/components/verification/VerificationCheckButton'
|
||||
import type * as bsky from '#/types/bsky'
|
||||
|
||||
export type Size = 'xs' | 'sm' | 'md' | 'lg' | 'xl'
|
||||
|
||||
const verificationIconSizes: Record<Size, number> = {
|
||||
xs: 10,
|
||||
sm: 12,
|
||||
md: 14,
|
||||
lg: 18,
|
||||
xl: 22,
|
||||
} as const
|
||||
|
||||
const botIconSizes: Record<Size, number> = {
|
||||
xs: 11,
|
||||
sm: 13,
|
||||
md: 15,
|
||||
lg: 19,
|
||||
xl: 23,
|
||||
} as const
|
||||
|
||||
export function ProfileBadges({
|
||||
profile,
|
||||
interactive = false,
|
||||
size,
|
||||
style,
|
||||
}: ViewStyleProp & {
|
||||
profile: bsky.profile.AnyProfileView
|
||||
interactive?: boolean
|
||||
size: Size
|
||||
}) {
|
||||
const shadowed = useProfileShadow(profile)
|
||||
const verification = useSimpleVerificationState({profile})
|
||||
|
||||
// if nothing to show, don't render the container at all
|
||||
if (!verification.showBadge && !isBotAccount(shadowed)) return null
|
||||
|
||||
const isOnTheSmallSide = size === 'xs' || size === 'sm'
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
isOnTheSmallSide ? a.gap_2xs : a.gap_xs,
|
||||
style,
|
||||
]}>
|
||||
{interactive ? (
|
||||
<>
|
||||
<VerificationCheckButton
|
||||
profile={shadowed}
|
||||
width={verificationIconSizes[size]}
|
||||
/>
|
||||
<BotBadgeButton profile={shadowed} width={botIconSizes[size]} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{verification.showBadge && (
|
||||
<VerificationCheck
|
||||
verifier={verification.role === 'verifier'}
|
||||
width={verificationIconSizes[size]}
|
||||
/>
|
||||
)}
|
||||
<BotBadge profile={shadowed} width={botIconSizes[size]} />
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import {useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {getModerationCauseKey} from '#/lib/moderation'
|
||||
import {makeProfileLink} from '#/lib/routes/links'
|
||||
import {forceLTR} from '#/lib/strings/bidi'
|
||||
import {NON_BREAKING_SPACE} from '#/lib/strings/constants'
|
||||
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||
@@ -40,10 +41,9 @@ import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
|
||||
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
|
||||
import {Link as InternalLink, type LinkProps} from '#/components/Link'
|
||||
import * as Pills from '#/components/Pills'
|
||||
import {ProfileBadges} from '#/components/ProfileBadges'
|
||||
import {RichText} from '#/components/RichText'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {useSimpleVerificationState} from '#/components/verification'
|
||||
import {VerificationCheck} from '#/components/verification/VerificationCheck'
|
||||
import {type Metrics} from '#/analytics'
|
||||
import {useActorStatus} from '#/features/liveNow'
|
||||
import type * as bsky from '#/types/bsky'
|
||||
@@ -138,15 +138,17 @@ export function Link({
|
||||
} & Omit<LinkProps, 'to' | 'label'>) {
|
||||
const {t: l} = useLingui()
|
||||
|
||||
const profileURL = makeProfileLink({
|
||||
did: profile.did,
|
||||
handle: profile.handle,
|
||||
})
|
||||
|
||||
return (
|
||||
<InternalLink
|
||||
label={l`View ${
|
||||
profile.displayName || sanitizeHandle(profile.handle)
|
||||
}’s profile`}
|
||||
to={{
|
||||
screen: 'Profile',
|
||||
params: {name: profile.did},
|
||||
}}
|
||||
to={profileURL}
|
||||
style={[a.flex_col, style]}
|
||||
{...rest}>
|
||||
{children}
|
||||
@@ -239,7 +241,6 @@ function InlineNameAndHandle({
|
||||
moderationOpts: ModerationOpts
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const verification = useSimpleVerificationState({profile})
|
||||
const moderation = moderateProfile(profile, moderationOpts)
|
||||
const name = sanitizeDisplayName(
|
||||
profile.displayName || sanitizeHandle(profile.handle),
|
||||
@@ -259,19 +260,15 @@ function InlineNameAndHandle({
|
||||
numberOfLines={1}>
|
||||
{forceLTR(name)}
|
||||
</Text>
|
||||
{verification.showBadge && (
|
||||
<View
|
||||
style={[
|
||||
a.pl_2xs,
|
||||
a.self_center,
|
||||
{marginTop: platform({default: 0, android: -1})},
|
||||
]}>
|
||||
<VerificationCheck
|
||||
width={platform({android: 13, default: 12})}
|
||||
verifier={verification.role === 'verifier'}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
<ProfileBadges
|
||||
profile={profile}
|
||||
size="md"
|
||||
style={[
|
||||
a.pl_2xs,
|
||||
a.self_center,
|
||||
{marginTop: platform({default: 0, android: -1})},
|
||||
]}
|
||||
/>
|
||||
<Text
|
||||
emoji
|
||||
style={[
|
||||
@@ -302,7 +299,6 @@ export function Name({
|
||||
profile.displayName || sanitizeHandle(profile.handle),
|
||||
moderation.ui('displayName'),
|
||||
)
|
||||
const verification = useSimpleVerificationState({profile})
|
||||
return (
|
||||
<View style={[a.flex_row, a.align_center, a.max_w_full, style]}>
|
||||
<Text
|
||||
@@ -318,14 +314,7 @@ export function Name({
|
||||
numberOfLines={1}>
|
||||
{name}
|
||||
</Text>
|
||||
{verification.showBadge && (
|
||||
<View style={[a.pl_xs]}>
|
||||
<VerificationCheck
|
||||
width={14}
|
||||
verifier={verification.role === 'verifier'}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
<ProfileBadges profile={profile} size="md" style={[a.pl_xs]} />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, {useCallback} from 'react'
|
||||
import {useCallback} from 'react'
|
||||
import * as React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {
|
||||
type AppBskyActorDefs,
|
||||
@@ -36,10 +37,9 @@ import {InlineLinkText, Link} from '#/components/Link'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import * as Pills from '#/components/Pills'
|
||||
import {Portal} from '#/components/Portal'
|
||||
import {ProfileBadges} from '#/components/ProfileBadges'
|
||||
import {RichText} from '#/components/RichText'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {useSimpleVerificationState} from '#/components/verification'
|
||||
import {VerificationCheck} from '#/components/verification/VerificationCheck'
|
||||
import {IS_WEB_TOUCH_DEVICE} from '#/env'
|
||||
import {useActorStatus} from '#/features/liveNow'
|
||||
import {LiveStatus} from '#/features/liveNow/components/LiveStatusDialog'
|
||||
@@ -459,7 +459,6 @@ function Inner({
|
||||
[currentAccount, profile],
|
||||
)
|
||||
const isLabeler = profile.associated?.labeler
|
||||
const verification = useSimpleVerificationState({profile})
|
||||
|
||||
return (
|
||||
<View>
|
||||
@@ -527,20 +526,16 @@ function Inner({
|
||||
moderation.ui('displayName'),
|
||||
)}
|
||||
</Text>
|
||||
{verification.showBadge && (
|
||||
<View
|
||||
style={[
|
||||
a.pl_xs,
|
||||
{
|
||||
marginTop: -2,
|
||||
},
|
||||
]}>
|
||||
<VerificationCheck
|
||||
width={16}
|
||||
verifier={verification.role === 'verifier'}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
<ProfileBadges
|
||||
profile={profile}
|
||||
size="md"
|
||||
style={[
|
||||
a.pl_xs,
|
||||
{
|
||||
marginTop: -1,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<ProfileHeaderHandle profile={profileShadow} disableTaps />
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import {memo, useCallback, useEffect, useMemo, useRef, useState} from 'react'
|
||||
import {
|
||||
TextInput,
|
||||
useWindowDimensions,
|
||||
View,
|
||||
type ViewToken,
|
||||
} from 'react-native'
|
||||
import {TextInput, View, type ViewToken} from 'react-native'
|
||||
import {type ModerationOpts} from '@atproto/api'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
@@ -72,7 +67,6 @@ export function FollowDialog({
|
||||
const {_} = useLingui()
|
||||
const control = Dialog.useDialogControl()
|
||||
const {gtPhone} = useBreakpoints()
|
||||
const {height: minHeight} = useWindowDimensions()
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -89,7 +83,7 @@ export function FollowDialog({
|
||||
</ButtonText>
|
||||
{showArrow && <ButtonIcon icon={ArrowRightIcon} />}
|
||||
</Button>
|
||||
<Dialog.Outer control={control} nativeOptions={{minHeight}}>
|
||||
<Dialog.Outer control={control} nativeOptions={{fullHeight: true}}>
|
||||
<Dialog.Handle />
|
||||
<DialogInner guide={guide} />
|
||||
</Dialog.Outer>
|
||||
@@ -105,9 +99,8 @@ export function FollowDialogWithoutGuide({
|
||||
}: {
|
||||
control: Dialog.DialogOuterProps['control']
|
||||
}) {
|
||||
const {height: minHeight} = useWindowDimensions()
|
||||
return (
|
||||
<Dialog.Outer control={control} nativeOptions={{minHeight}}>
|
||||
<Dialog.Outer control={control} nativeOptions={{fullHeight: true}}>
|
||||
<Dialog.Handle />
|
||||
<DialogInner />
|
||||
</Dialog.Outer>
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import React, {useImperativeHandle} from 'react'
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import {Pressable, useWindowDimensions, View} from 'react-native'
|
||||
import Animated, {
|
||||
Easing,
|
||||
@@ -28,25 +35,25 @@ export interface ProgressGuideToastProps {
|
||||
visibleDuration?: number // default 5s
|
||||
}
|
||||
|
||||
export const ProgressGuideToast = React.forwardRef<
|
||||
export const ProgressGuideToast = forwardRef<
|
||||
ProgressGuideToastRef,
|
||||
ProgressGuideToastProps
|
||||
>(function ProgressGuideToast({title, subtitle, visibleDuration}, ref) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const insets = useSafeAreaInsets()
|
||||
const [isOpen, setIsOpen] = React.useState(false)
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const translateY = useSharedValue(0)
|
||||
const opacity = useSharedValue(0)
|
||||
const animatedCheckRef = React.useRef<AnimatedCheckRef | null>(null)
|
||||
const timeoutRef = React.useRef<NodeJS.Timeout | undefined>(undefined)
|
||||
const animatedCheckRef = useRef<AnimatedCheckRef | null>(null)
|
||||
const timeoutRef = useRef<NodeJS.Timeout | undefined>(undefined)
|
||||
const winDim = useWindowDimensions()
|
||||
|
||||
/**
|
||||
* Methods
|
||||
*/
|
||||
|
||||
const close = React.useCallback(() => {
|
||||
const close = useCallback(() => {
|
||||
// clear the timeout, in case this was called imperatively
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current)
|
||||
@@ -67,7 +74,7 @@ export const ProgressGuideToast = React.forwardRef<
|
||||
)
|
||||
}, [setIsOpen, opacity])
|
||||
|
||||
const open = React.useCallback(() => {
|
||||
const open = useCallback(() => {
|
||||
// set isOpen=true to render
|
||||
setIsOpen(true)
|
||||
|
||||
@@ -105,7 +112,7 @@ export const ProgressGuideToast = React.forwardRef<
|
||||
[open, close],
|
||||
)
|
||||
|
||||
const containerStyle = React.useMemo(() => {
|
||||
const containerStyle = useMemo(() => {
|
||||
let left = 10
|
||||
let right = 10
|
||||
if (IS_WEB && winDim.width > 400) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react'
|
||||
import {useMemo} from 'react'
|
||||
import {type StyleProp, Text as RNText, type TextStyle} from 'react-native'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
@@ -68,7 +68,7 @@ export function RichTextTag({
|
||||
/*
|
||||
* Mute word records that exactly match the tag in question.
|
||||
*/
|
||||
const removeableMuteWords = React.useMemo(() => {
|
||||
const removeableMuteWords = useMemo(() => {
|
||||
return (
|
||||
preferences?.moderationPrefs.mutedWords?.filter(word => {
|
||||
return word.value === tag
|
||||
|
||||
@@ -151,7 +151,7 @@ export function Content<T>({
|
||||
}, [items, context.value, valueExtractor, setValue])
|
||||
|
||||
return (
|
||||
<Dialog.Outer control={control}>
|
||||
<Dialog.Outer control={control} nativeOptions={{fullHeight: true}}>
|
||||
<ContentInner
|
||||
control={control}
|
||||
items={items}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react'
|
||||
import {useMemo} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {Image} from 'expo-image'
|
||||
import {AppBskyGraphStarterpack, AtUri} from '@atproto/api'
|
||||
@@ -115,7 +115,7 @@ export function useStarterPackLink({
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const qc = useQueryClient()
|
||||
const {rkey, handleOrDid} = React.useMemo(() => {
|
||||
const {rkey, handleOrDid} = useMemo(() => {
|
||||
const rkey = new AtUri(view.uri).rkey
|
||||
const {creator} = view
|
||||
return {rkey, handleOrDid: creator.handle || creator.did}
|
||||
@@ -148,7 +148,7 @@ export function Link({
|
||||
const {_} = useLingui()
|
||||
const queryClient = useQueryClient()
|
||||
const {record} = starterPack
|
||||
const {rkey, handleOrDid} = React.useMemo(() => {
|
||||
const {rkey, handleOrDid} = useMemo(() => {
|
||||
const rkey = new AtUri(starterPack.uri).rkey
|
||||
const {creator} = starterPack
|
||||
return {rkey, handleOrDid: creator.handle || creator.did}
|
||||
|
||||
@@ -78,7 +78,10 @@ export function WizardEditListDialog({
|
||||
)
|
||||
|
||||
return (
|
||||
<Dialog.Outer control={control} testID="newChatDialog">
|
||||
<Dialog.Outer
|
||||
control={control}
|
||||
testID="newChatDialog"
|
||||
nativeOptions={{fullHeight: true}}>
|
||||
<Dialog.Handle />
|
||||
<Dialog.InnerFlatList
|
||||
ref={listRef}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react'
|
||||
import {isValidElement} from 'react'
|
||||
import {type ReactNode} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {nanoid} from 'nanoid/non-secure'
|
||||
import {toast as sonner, Toaster} from 'sonner-native'
|
||||
@@ -25,7 +26,7 @@ export function ToastOutlet() {
|
||||
return <Toaster pauseWhenPageIsHidden gap={a.gap_sm.gap} />
|
||||
}
|
||||
|
||||
export function Outer({children}: {children: React.ReactNode}) {
|
||||
export function Outer({children}: {children: ReactNode}) {
|
||||
return (
|
||||
<View style={[a.px_xl, a.w_full]}>
|
||||
<BaseOuter>{children}</BaseOuter>
|
||||
@@ -42,7 +43,7 @@ export const api = sonner
|
||||
* Our base toast API, using the `Toast` export of this file.
|
||||
*/
|
||||
export function show(
|
||||
content: React.ReactNode,
|
||||
content: ReactNode,
|
||||
{type = 'default', ...options}: BaseToastOptions = {},
|
||||
) {
|
||||
const id = nanoid()
|
||||
@@ -61,7 +62,7 @@ export function show(
|
||||
duration: options?.duration ?? DURATION,
|
||||
},
|
||||
)
|
||||
} else if (React.isValidElement(content)) {
|
||||
} else if (isValidElement(content)) {
|
||||
sonner.custom(
|
||||
<ToastConfigProvider id={id} type={type}>
|
||||
{content}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react'
|
||||
import {isValidElement} from 'react'
|
||||
import {type ReactNode} from 'react'
|
||||
import {nanoid} from 'nanoid/non-secure'
|
||||
import {toast as sonner, Toaster} from 'sonner'
|
||||
|
||||
@@ -40,7 +41,7 @@ export const api = sonner
|
||||
* Our base toast API, using the `Toast` export of this file.
|
||||
*/
|
||||
export function show(
|
||||
content: React.ReactNode,
|
||||
content: ReactNode,
|
||||
{type = 'default', ...options}: BaseToastOptions = {},
|
||||
) {
|
||||
const id = nanoid()
|
||||
@@ -60,7 +61,7 @@ export function show(
|
||||
duration: options?.duration ?? DURATION,
|
||||
},
|
||||
)
|
||||
} else if (React.isValidElement(content)) {
|
||||
} else if (isValidElement(content)) {
|
||||
sonner(
|
||||
<ToastConfigProvider id={id} type={type}>
|
||||
{content}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react'
|
||||
import {useMemo} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {type AtUri} from '@atproto/api'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
@@ -170,7 +170,7 @@ type ParsedTrendingTopic =
|
||||
|
||||
export function useTopic(raw: TrendingTopic): ParsedTrendingTopic {
|
||||
const {_} = useLingui()
|
||||
return React.useMemo(() => {
|
||||
return useMemo(() => {
|
||||
const {topic: displayName, link} = raw
|
||||
|
||||
if (link.startsWith('/search')) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react'
|
||||
import {useState} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {ToolsOzoneReportDefs} from '@atproto/api'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
@@ -44,7 +44,7 @@ function Inner({control}: {control: Dialog.DialogControlProps}) {
|
||||
const {gtPhone} = useBreakpoints()
|
||||
const agent = useAgent()
|
||||
|
||||
const [details, setDetails] = React.useState('')
|
||||
const [details, setDetails] = useState('')
|
||||
const isInvalid = details.length > 1000
|
||||
|
||||
const {mutate, isPending} = useMutation({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react'
|
||||
import {forwardRef, useCallback, useEffect, useImperativeHandle} from 'react'
|
||||
import Animated, {
|
||||
Easing,
|
||||
useAnimatedProps,
|
||||
@@ -23,74 +23,73 @@ export interface AnimatedCheckProps extends Props {
|
||||
playOnMount?: boolean
|
||||
}
|
||||
|
||||
export const AnimatedCheck = React.forwardRef<
|
||||
AnimatedCheckRef,
|
||||
AnimatedCheckProps
|
||||
>(function AnimatedCheck({playOnMount, ...props}, ref) {
|
||||
const {fill, size, style, ...rest} = useCommonSVGProps(props)
|
||||
const circleAnim = useSharedValue(0)
|
||||
const checkAnim = useSharedValue(0)
|
||||
export const AnimatedCheck = forwardRef<AnimatedCheckRef, AnimatedCheckProps>(
|
||||
function AnimatedCheck({playOnMount, ...props}, ref) {
|
||||
const {fill, size, style, ...rest} = useCommonSVGProps(props)
|
||||
const circleAnim = useSharedValue(0)
|
||||
const checkAnim = useSharedValue(0)
|
||||
|
||||
const circleAnimatedProps = useAnimatedProps(() => ({
|
||||
strokeDashoffset: 166 - circleAnim.get() * 166,
|
||||
}))
|
||||
const checkAnimatedProps = useAnimatedProps(() => ({
|
||||
strokeDashoffset: 48 - 48 * checkAnim.get(),
|
||||
}))
|
||||
const circleAnimatedProps = useAnimatedProps(() => ({
|
||||
strokeDashoffset: 166 - circleAnim.get() * 166,
|
||||
}))
|
||||
const checkAnimatedProps = useAnimatedProps(() => ({
|
||||
strokeDashoffset: 48 - 48 * checkAnim.get(),
|
||||
}))
|
||||
|
||||
const play = React.useCallback(
|
||||
(cb?: () => void) => {
|
||||
circleAnim.set(0)
|
||||
checkAnim.set(0)
|
||||
const play = useCallback(
|
||||
(cb?: () => void) => {
|
||||
circleAnim.set(0)
|
||||
checkAnim.set(0)
|
||||
|
||||
circleAnim.set(() =>
|
||||
withTiming(1, {duration: 500, easing: Easing.linear}),
|
||||
)
|
||||
checkAnim.set(() =>
|
||||
withDelay(
|
||||
500,
|
||||
withTiming(1, {duration: 300, easing: Easing.linear}, cb),
|
||||
),
|
||||
)
|
||||
},
|
||||
[circleAnim, checkAnim],
|
||||
)
|
||||
circleAnim.set(() =>
|
||||
withTiming(1, {duration: 500, easing: Easing.linear}),
|
||||
)
|
||||
checkAnim.set(() =>
|
||||
withDelay(
|
||||
500,
|
||||
withTiming(1, {duration: 300, easing: Easing.linear}, cb),
|
||||
),
|
||||
)
|
||||
},
|
||||
[circleAnim, checkAnim],
|
||||
)
|
||||
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
play,
|
||||
}))
|
||||
useImperativeHandle(ref, () => ({
|
||||
play,
|
||||
}))
|
||||
|
||||
React.useEffect(() => {
|
||||
if (playOnMount) {
|
||||
play()
|
||||
}
|
||||
}, [play, playOnMount])
|
||||
useEffect(() => {
|
||||
if (playOnMount) {
|
||||
play()
|
||||
}
|
||||
}, [play, playOnMount])
|
||||
|
||||
return (
|
||||
<Svg
|
||||
fill="none"
|
||||
{...rest}
|
||||
viewBox="0 0 52 52"
|
||||
width={size}
|
||||
height={size}
|
||||
style={style}>
|
||||
<AnimatedCircle
|
||||
animatedProps={circleAnimatedProps}
|
||||
cx="26"
|
||||
cy="26"
|
||||
r="24"
|
||||
return (
|
||||
<Svg
|
||||
fill="none"
|
||||
stroke={fill}
|
||||
strokeWidth={4}
|
||||
strokeDasharray={166}
|
||||
/>
|
||||
<AnimatedPath
|
||||
animatedProps={checkAnimatedProps}
|
||||
stroke={fill}
|
||||
d={PATH}
|
||||
strokeWidth={4}
|
||||
strokeDasharray={48}
|
||||
/>
|
||||
</Svg>
|
||||
)
|
||||
})
|
||||
{...rest}
|
||||
viewBox="0 0 52 52"
|
||||
width={size}
|
||||
height={size}
|
||||
style={style}>
|
||||
<AnimatedCircle
|
||||
animatedProps={circleAnimatedProps}
|
||||
cx="26"
|
||||
cy="26"
|
||||
r="24"
|
||||
fill="none"
|
||||
stroke={fill}
|
||||
strokeWidth={4}
|
||||
strokeDasharray={166}
|
||||
/>
|
||||
<AnimatedPath
|
||||
animatedProps={checkAnimatedProps}
|
||||
stroke={fill}
|
||||
d={PATH}
|
||||
strokeWidth={4}
|
||||
strokeDasharray={48}
|
||||
/>
|
||||
</Svg>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react'
|
||||
import {useCallback, useMemo, useState} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
@@ -125,12 +125,10 @@ function BirthdayInner({
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const cleanError = useCleanError()
|
||||
const [date, setDate] = React.useState(
|
||||
preferences.birthDate || getDateAgo(18),
|
||||
)
|
||||
const [date, setDate] = useState(preferences.birthDate || getDateAgo(18))
|
||||
const {isPending, error, mutateAsync: setBirthDate} = useBirthdateMutation()
|
||||
const hasChanged = date !== preferences.birthDate
|
||||
const errorMessage = React.useMemo(() => {
|
||||
const errorMessage = useMemo(() => {
|
||||
if (error) {
|
||||
const {raw, clean} = cleanError(error)
|
||||
return clean || raw || error.toString()
|
||||
@@ -141,7 +139,7 @@ function BirthdayInner({
|
||||
const isUnder13 = age < 13
|
||||
const isUnder18 = age >= 13 && age < 18
|
||||
|
||||
const onSave = React.useCallback(async () => {
|
||||
const onSave = useCallback(async () => {
|
||||
try {
|
||||
// skip if date is the same
|
||||
if (hasChanged) {
|
||||
|
||||
@@ -68,6 +68,7 @@ export function GifSelectDialog({
|
||||
bottomInset: 0,
|
||||
// use system corner radius on iOS
|
||||
...ios({cornerRadius: undefined}),
|
||||
fullHeight: true,
|
||||
}}>
|
||||
<Dialog.Handle />
|
||||
<ErrorBoundary renderError={renderErrorBoundary}>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {useCallback, useMemo, useState} from 'react'
|
||||
import {useWindowDimensions, View} from 'react-native'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import {View} from 'react-native'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {Trans} from '@lingui/react/macro'
|
||||
@@ -17,7 +16,7 @@ import {SearchInput} from '#/components/forms/SearchInput'
|
||||
import * as Toggle from '#/components/forms/Toggle'
|
||||
import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {IS_LIQUID_GLASS, IS_NATIVE, IS_WEB} from '#/env'
|
||||
import {IS_NATIVE, IS_WEB} from '#/env'
|
||||
|
||||
type FlatListItem =
|
||||
| {
|
||||
@@ -51,20 +50,13 @@ export function LanguageSelectDialog({
|
||||
onSelectLanguages: (languages: string[]) => void
|
||||
maxLanguages?: number
|
||||
}) {
|
||||
const {height} = useWindowDimensions()
|
||||
const insets = useSafeAreaInsets()
|
||||
|
||||
const renderErrorBoundary = useCallback(
|
||||
(error: any) => <DialogError details={String(error)} />,
|
||||
[],
|
||||
)
|
||||
|
||||
return (
|
||||
<Dialog.Outer
|
||||
control={control}
|
||||
nativeOptions={{
|
||||
minHeight: IS_LIQUID_GLASS ? height : height - insets.top,
|
||||
}}>
|
||||
<Dialog.Outer control={control} nativeOptions={{fullHeight: true}}>
|
||||
<Dialog.Handle />
|
||||
<ErrorBoundary renderError={renderErrorBoundary}>
|
||||
<DialogInner
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react'
|
||||
import {useCallback, useState} from 'react'
|
||||
import {type PropsWithChildren} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {type AppBskyActorDefs, sanitizeMutedWordValue} from '@atproto/api'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
@@ -58,13 +59,13 @@ function MutedWordsInner() {
|
||||
error: preferencesError,
|
||||
} = usePreferencesQuery()
|
||||
const {isPending, mutateAsync: addMutedWord} = useUpsertMutedWordsMutation()
|
||||
const [field, setField] = React.useState('')
|
||||
const [targets, setTargets] = React.useState(['content'])
|
||||
const [error, setError] = React.useState('')
|
||||
const [durations, setDurations] = React.useState(['forever'])
|
||||
const [excludeFollowing, setExcludeFollowing] = React.useState(false)
|
||||
const [field, setField] = useState('')
|
||||
const [targets, setTargets] = useState(['content'])
|
||||
const [error, setError] = useState('')
|
||||
const [durations, setDurations] = useState(['forever'])
|
||||
const [excludeFollowing, setExcludeFollowing] = useState(false)
|
||||
|
||||
const submit = React.useCallback(async () => {
|
||||
const submit = useCallback(async () => {
|
||||
const sanitizedValue = sanitizeMutedWordValue(field)
|
||||
const surfaces = ['tag', targets.includes('content') && 'content'].filter(
|
||||
Boolean,
|
||||
@@ -431,7 +432,7 @@ function MutedWordRow({
|
||||
const isExpired = expiryDate && expiryDate < new Date()
|
||||
const formatDistance = useFormatDistance()
|
||||
|
||||
const remove = React.useCallback(async () => {
|
||||
const remove = useCallback(async () => {
|
||||
control.close()
|
||||
removeMutedWord(word)
|
||||
}, [removeMutedWord, word, control])
|
||||
@@ -624,7 +625,7 @@ function MutedWordRow({
|
||||
)
|
||||
}
|
||||
|
||||
function TargetToggle({children}: React.PropsWithChildren<{}>) {
|
||||
function TargetToggle({children}: PropsWithChildren<{}>) {
|
||||
const t = useTheme()
|
||||
const ctx = Toggle.useItemContext()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {useCallback, useImperativeHandle, useRef, useState} from 'react'
|
||||
import {useWindowDimensions, View} from 'react-native'
|
||||
import {View} from 'react-native'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {Trans} from '@lingui/react/macro'
|
||||
@@ -28,7 +28,6 @@ export function ServerInputDialog({
|
||||
onSelect: (url: string) => void
|
||||
}) {
|
||||
const ax = useAnalytics()
|
||||
const {height} = useWindowDimensions()
|
||||
const formRef = useRef<DialogInnerRef>(null)
|
||||
|
||||
// persist these options between dialog open/close
|
||||
@@ -53,10 +52,7 @@ export function ServerInputDialog({
|
||||
<Dialog.Outer
|
||||
control={control}
|
||||
onClose={onClose}
|
||||
nativeOptions={platform({
|
||||
android: {minHeight: height / 2},
|
||||
ios: {preventExpansion: true},
|
||||
})}>
|
||||
nativeOptions={{preventExpansion: true}}>
|
||||
<Dialog.Handle />
|
||||
<DialogInner
|
||||
formRef={formRef}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react'
|
||||
import {useCallback} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
@@ -32,12 +32,12 @@ function SigninDialogInner({}: {control: Dialog.DialogOuterProps['control']}) {
|
||||
const {requestSwitchToAccount} = useLoggedOutViewControls()
|
||||
const closeAllActiveElements = useCloseAllActiveElements()
|
||||
|
||||
const showSignIn = React.useCallback(() => {
|
||||
const showSignIn = useCallback(() => {
|
||||
closeAllActiveElements()
|
||||
requestSwitchToAccount({requestedAccount: 'none'})
|
||||
}, [requestSwitchToAccount, closeAllActiveElements])
|
||||
|
||||
const showCreateAccount = React.useCallback(() => {
|
||||
const showCreateAccount = useCallback(() => {
|
||||
closeAllActiveElements()
|
||||
requestSwitchToAccount({requestedAccount: 'new'})
|
||||
}, [requestSwitchToAccount, closeAllActiveElements])
|
||||
|
||||
@@ -75,7 +75,7 @@ export function StarterPackDialog({
|
||||
})
|
||||
|
||||
return (
|
||||
<Dialog.Outer control={control}>
|
||||
<Dialog.Outer control={control} nativeOptions={{fullHeight: true}}>
|
||||
<Dialog.Handle />
|
||||
<StarterPackList
|
||||
onStartWizard={wrappedNavToWizard}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {useCallback, useEffect, useMemo, useState} from 'react'
|
||||
import {useWindowDimensions, View} from 'react-native'
|
||||
import {View} from 'react-native'
|
||||
import {type AppBskyGraphDefs, RichText as RichTextAPI} from '@atproto/api'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
@@ -53,7 +53,6 @@ export function CreateOrEditListDialog({
|
||||
const {_} = useLingui()
|
||||
const cancelControl = Dialog.useDialogControl()
|
||||
const [dirty, setDirty] = useState(false)
|
||||
const {height} = useWindowDimensions()
|
||||
|
||||
// 'You might lose unsaved changes' warning
|
||||
useEffect(() => {
|
||||
@@ -82,7 +81,7 @@ export function CreateOrEditListDialog({
|
||||
control={control}
|
||||
nativeOptions={{
|
||||
preventDismiss: dirty,
|
||||
minHeight: height,
|
||||
fullHeight: true,
|
||||
}}
|
||||
testID="createOrEditListDialog">
|
||||
<DialogInner
|
||||
|
||||
@@ -39,7 +39,10 @@ export function ListAddRemoveUsersDialog({
|
||||
) => void | undefined
|
||||
}) {
|
||||
return (
|
||||
<Dialog.Outer control={control} testID="listAddRemoveUsersDialog">
|
||||
<Dialog.Outer
|
||||
control={control}
|
||||
testID="listAddRemoveUsersDialog"
|
||||
nativeOptions={{fullHeight: true}}>
|
||||
<Dialog.Handle />
|
||||
<DialogInner list={list} onChange={onChange} />
|
||||
</Dialog.Outer>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react'
|
||||
import {Fragment} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {type ModerationCause} from '@atproto/api'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
@@ -25,7 +25,6 @@ export function BlockedByListDialog({
|
||||
return (
|
||||
<Prompt.Outer control={control} testID="blockedByListDialog">
|
||||
<Prompt.TitleText>{_(msg`User blocked by list`)}</Prompt.TitleText>
|
||||
|
||||
<View style={[a.gap_sm, a.pb_lg]}>
|
||||
<Text
|
||||
selectable
|
||||
@@ -39,7 +38,7 @@ export function BlockedByListDialog({
|
||||
{_(msg`Lists blocking this user:`)}{' '}
|
||||
{listBlocks.map((block, i) =>
|
||||
block.source.type === 'list' ? (
|
||||
<React.Fragment key={block.source.list.uri}>
|
||||
<Fragment key={block.source.list.uri}>
|
||||
{i === 0 ? null : ', '}
|
||||
<InlineLinkText
|
||||
label={block.source.list.name}
|
||||
@@ -47,16 +46,14 @@ export function BlockedByListDialog({
|
||||
style={[a.text_md, a.leading_snug]}>
|
||||
{block.source.list.name}
|
||||
</InlineLinkText>
|
||||
</React.Fragment>
|
||||
</Fragment>
|
||||
) : null,
|
||||
)}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Prompt.Actions>
|
||||
<Prompt.Action cta={_(msg`I understand`)} onPress={() => {}} />
|
||||
</Prompt.Actions>
|
||||
|
||||
<Dialog.Close />
|
||||
</Prompt.Outer>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react'
|
||||
import {useCallback, useMemo, useState} from 'react'
|
||||
import {Pressable, View} from 'react-native'
|
||||
import Animated, {
|
||||
runOnJS,
|
||||
@@ -24,11 +24,11 @@ export function ChatEmptyPill() {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const playHaptic = useHaptics()
|
||||
const [promptIndex, setPromptIndex] = React.useState(lastIndex)
|
||||
const [promptIndex, setPromptIndex] = useState(lastIndex)
|
||||
|
||||
const scale = useSharedValue(1)
|
||||
|
||||
const prompts = React.useMemo(() => {
|
||||
const prompts = useMemo(() => {
|
||||
return [
|
||||
_(msg`Say hello!`),
|
||||
_(msg`Share your favorite feed!`),
|
||||
@@ -40,17 +40,17 @@ export function ChatEmptyPill() {
|
||||
]
|
||||
}, [_])
|
||||
|
||||
const onPressIn = React.useCallback(() => {
|
||||
const onPressIn = useCallback(() => {
|
||||
if (IS_WEB) return
|
||||
scale.set(() => withTiming(1.075, {duration: 100}))
|
||||
}, [scale])
|
||||
|
||||
const onPressOut = React.useCallback(() => {
|
||||
const onPressOut = useCallback(() => {
|
||||
if (IS_WEB) return
|
||||
scale.set(() => withTiming(1, {duration: 100}))
|
||||
}, [scale])
|
||||
|
||||
const onPress = React.useCallback(() => {
|
||||
const onPress = useCallback(() => {
|
||||
runOnJS(playHaptic)()
|
||||
let randomPromptIndex = Math.floor(Math.random() * prompts.length)
|
||||
while (randomPromptIndex === lastIndex) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, {useCallback} from 'react'
|
||||
import {useCallback} from 'react'
|
||||
import * as React from 'react'
|
||||
import {Keyboard, View} from 'react-native'
|
||||
import {type ChatBskyConvoDefs, type ModerationCause} from '@atproto/api'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react'
|
||||
import {memo} from 'react'
|
||||
import {type ReactNode} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
@@ -28,7 +29,7 @@ const longDateFormatterWithYear = new Intl.DateTimeFormat(undefined, {
|
||||
year: 'numeric',
|
||||
})
|
||||
|
||||
let DateDivider = ({date: dateStr}: {date: string}): React.ReactNode => {
|
||||
let DateDivider = ({date: dateStr}: {date: string}): ReactNode => {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
|
||||
@@ -78,5 +79,5 @@ let DateDivider = ({date: dateStr}: {date: string}): React.ReactNode => {
|
||||
</View>
|
||||
)
|
||||
}
|
||||
DateDivider = React.memo(DateDivider)
|
||||
DateDivider = memo(DateDivider)
|
||||
export {DateDivider}
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
import React from 'react'
|
||||
import {createContext, useContext} from 'react'
|
||||
import {type ReactNode} from 'react'
|
||||
|
||||
const MessageContext = React.createContext(false)
|
||||
const MessageContext = createContext(false)
|
||||
MessageContext.displayName = 'MessageContext'
|
||||
|
||||
export function MessageContextProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
export function MessageContextProvider({children}: {children: ReactNode}) {
|
||||
return (
|
||||
<MessageContext.Provider value={true}>{children}</MessageContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useIsWithinMessage() {
|
||||
return React.useContext(MessageContext)
|
||||
return useContext(MessageContext)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, {useCallback, useMemo} from 'react'
|
||||
import {memo, useCallback, useMemo} from 'react'
|
||||
import {type ReactNode} from 'react'
|
||||
import {
|
||||
type GestureResponderEvent,
|
||||
type StyleProp,
|
||||
@@ -40,7 +41,7 @@ let MessageItem = ({
|
||||
item,
|
||||
}: {
|
||||
item: ConvoItem & {type: 'message' | 'pending-message'}
|
||||
}): React.ReactNode => {
|
||||
}): ReactNode => {
|
||||
const t = useTheme()
|
||||
const {currentAccount} = useSession()
|
||||
const {_} = useLingui()
|
||||
@@ -233,7 +234,7 @@ let MessageItem = ({
|
||||
</>
|
||||
)
|
||||
}
|
||||
MessageItem = React.memo(MessageItem)
|
||||
MessageItem = memo(MessageItem)
|
||||
export {MessageItem}
|
||||
|
||||
let MessageItemMetadata = ({
|
||||
@@ -242,7 +243,7 @@ let MessageItemMetadata = ({
|
||||
}: {
|
||||
item: ConvoItem & {type: 'message' | 'pending-message'}
|
||||
style: StyleProp<TextStyle>
|
||||
}): React.ReactNode => {
|
||||
}): ReactNode => {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {message} = item
|
||||
@@ -328,5 +329,5 @@ let MessageItemMetadata = ({
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
MessageItemMetadata = React.memo(MessageItemMetadata)
|
||||
MessageItemMetadata = memo(MessageItemMetadata)
|
||||
export {MessageItemMetadata}
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import React from 'react'
|
||||
import {memo} from 'react'
|
||||
import {type ReactNode} from 'react'
|
||||
import {useWindowDimensions, View} from 'react-native'
|
||||
import {type $Typed, type AppBskyEmbedRecord} from '@atproto/api'
|
||||
|
||||
import {atoms as a, native, tokens, useTheme, web} from '#/alf'
|
||||
import {PostEmbedViewContext} from '#/components/Post/Embed'
|
||||
import {Embed} from '#/components/Post/Embed'
|
||||
import {Embed, PostEmbedViewContext} from '#/components/Post/Embed'
|
||||
import {MessageContextProvider} from './MessageContext'
|
||||
|
||||
let MessageItemEmbed = ({
|
||||
embed,
|
||||
}: {
|
||||
embed: $Typed<AppBskyEmbedRecord.View>
|
||||
}): React.ReactNode => {
|
||||
}): ReactNode => {
|
||||
const t = useTheme()
|
||||
const screen = useWindowDimensions()
|
||||
|
||||
@@ -43,5 +43,5 @@ let MessageItemEmbed = ({
|
||||
</MessageContextProvider>
|
||||
)
|
||||
}
|
||||
MessageItemEmbed = React.memo(MessageItemEmbed)
|
||||
MessageItemEmbed = memo(MessageItemEmbed)
|
||||
export {MessageItemEmbed}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react'
|
||||
import {useCallback} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {type AppBskyActorDefs} from '@atproto/api'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
@@ -39,7 +39,7 @@ export function MessageProfileButton({
|
||||
},
|
||||
})
|
||||
|
||||
const onPress = React.useCallback(() => {
|
||||
const onPress = useCallback(() => {
|
||||
if (!convoAvailability?.canChat) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react'
|
||||
import {useCallback, useMemo} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {type ModerationDecision} from '@atproto/api'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
@@ -38,7 +38,7 @@ export function MessagesListBlockedFooter({
|
||||
const reportControl = useDialogControl()
|
||||
const blockedByListControl = useDialogControl()
|
||||
|
||||
const {listBlocks, userBlock} = React.useMemo(() => {
|
||||
const {listBlocks, userBlock} = useMemo(() => {
|
||||
const modui = moderation.ui('profileView')
|
||||
const blocks = modui.alerts.filter(alert => alert.type === 'blocking')
|
||||
const listBlocks = blocks.filter(alert => alert.source.type === 'list')
|
||||
@@ -51,7 +51,7 @@ export function MessagesListBlockedFooter({
|
||||
|
||||
const isBlocking = !!userBlock || !!listBlocks.length
|
||||
|
||||
const onUnblockPress = React.useCallback(() => {
|
||||
const onUnblockPress = useCallback(() => {
|
||||
if (listBlocks.length) {
|
||||
blockedByListControl.open()
|
||||
} else {
|
||||
|
||||
@@ -20,9 +20,8 @@ import {Bell2Off_Filled_Corner0_Rounded as BellStroke} from '#/components/icons/
|
||||
import * as Layout from '#/components/Layout'
|
||||
import {Link} from '#/components/Link'
|
||||
import {PostAlerts} from '#/components/moderation/PostAlerts'
|
||||
import {ProfileBadges} from '#/components/ProfileBadges'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {useSimpleVerificationState} from '#/components/verification'
|
||||
import {VerificationCheck} from '#/components/verification/VerificationCheck'
|
||||
import {IS_WEB} from '#/env'
|
||||
|
||||
const PFP_SIZE = IS_WEB ? 40 : Layout.HEADER_SLOT_SIZE
|
||||
@@ -112,9 +111,6 @@ function HeaderReady({
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const convoState = useConvo()
|
||||
const verification = useSimpleVerificationState({
|
||||
profile,
|
||||
})
|
||||
|
||||
const isDeletedAccount = profile?.handle === 'missing.invalid'
|
||||
const displayName = isDeletedAccount
|
||||
@@ -161,14 +157,7 @@ function HeaderReady({
|
||||
numberOfLines={1}>
|
||||
{displayName}
|
||||
</Text>
|
||||
{verification.showBadge && (
|
||||
<View style={[a.pl_xs]}>
|
||||
<VerificationCheck
|
||||
width={14}
|
||||
verifier={verification.role === 'verifier'}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
<ProfileBadges profile={profile} size="md" style={[a.pl_xs]} />
|
||||
</View>
|
||||
{!isDeletedAccount && (
|
||||
<Text
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react'
|
||||
import {useCallback} from 'react'
|
||||
import {Pressable, View} from 'react-native'
|
||||
import Animated, {
|
||||
runOnJS,
|
||||
@@ -33,17 +33,17 @@ export function NewMessagesPill({
|
||||
|
||||
const scale = useSharedValue(1)
|
||||
|
||||
const onPressIn = React.useCallback(() => {
|
||||
const onPressIn = useCallback(() => {
|
||||
if (IS_WEB) return
|
||||
scale.set(() => withTiming(1.075, {duration: 100}))
|
||||
}, [scale])
|
||||
|
||||
const onPressOut = React.useCallback(() => {
|
||||
const onPressOut = useCallback(() => {
|
||||
if (IS_WEB) return
|
||||
scale.set(() => withTiming(1, {duration: 100}))
|
||||
}, [scale])
|
||||
|
||||
const onPress = React.useCallback(() => {
|
||||
const onPress = useCallback(() => {
|
||||
runOnJS(playHaptic)()
|
||||
onPressInner?.()
|
||||
}, [onPressInner, playHaptic])
|
||||
|
||||
@@ -70,7 +70,10 @@ export function NewChat({
|
||||
accessibilityHint=""
|
||||
/>
|
||||
|
||||
<Dialog.Outer control={control} testID="newChatDialog">
|
||||
<Dialog.Outer
|
||||
control={control}
|
||||
testID="newChatDialog"
|
||||
nativeOptions={{fullHeight: true}}>
|
||||
<Dialog.Handle />
|
||||
<SearchablePeopleList
|
||||
title={_(msg`Start a new chat`)}
|
||||
|
||||
@@ -17,7 +17,10 @@ export function SendViaChatDialog({
|
||||
onSelectChat: (chatId: string) => void
|
||||
}) {
|
||||
return (
|
||||
<Dialog.Outer control={control} testID="sendViaChatChatDialog">
|
||||
<Dialog.Outer
|
||||
control={control}
|
||||
testID="sendViaChatChatDialog"
|
||||
nativeOptions={{fullHeight: true}}>
|
||||
<Dialog.Handle />
|
||||
<SendViaChatDialogInner control={control} onSelectChat={onSelectChat} />
|
||||
</Dialog.Outer>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user