Merge remote-tracking branch 'origin/main' into 0.74-fabric

This commit is contained in:
Hailey
2024-09-08 10:21:17 -07:00
313 changed files with 52267 additions and 25802 deletions
+13
View File
@@ -71,6 +71,19 @@ module.exports = {
'simple-import-sort/exports': 'warn',
// TODO: Reenable when we figure out why it gets stuck on CI.
// 'react-compiler/react-compiler': 'error',
'no-restricted-imports': [
'error',
{
paths: [
{
name: '@atproto/api',
importNames: ['moderatePost'],
message:
'Please use `moderatePost_wrapped` from `#/lib/moderatePost_wrapped` instead.',
},
],
},
],
},
ignorePatterns: [
'**/__mocks__/*.ts',
+6 -1
View File
@@ -10,8 +10,13 @@ appId: xyz.blueskyweb.app
id: "e2eSignInAlice"
# Pin alice's feed
- extendedWaitUntil:
visible:
id: "viewHeaderDrawerBtn"
- tapOn:
id: "bottomBarProfileBtn"
id: "viewHeaderDrawerBtn"
- tapOn:
id: "profileCardButton"
- swipe:
from:
id: "profilePager-selector"
+3 -10
View File
@@ -9,6 +9,9 @@ appId: xyz.blueskyweb.app
- tapOn:
id: "e2eSignInAlice"
- extendedWaitUntil:
visible:
text: "Feeds ✨"
- tapOn:
label: "Can go to feeds page using feeds button in tab bar"
text: "Feeds ✨"
@@ -34,26 +37,16 @@ appId: xyz.blueskyweb.app
- tapOn:
label: "Can like posts"
id: "likeBtn"
- assertVisible:
id: "likeCount"
text: "1"
- tapOn:
id: "likeBtn"
- assertNotVisible:
id: "likeCount"
- tapOn:
label: "Can repost posts"
id: "repostBtn"
- tapOn: "Repost"
- assertVisible:
id: "repostCount"
text: "1"
- tapOn:
id: "repostBtn"
- tapOn: "Remove repost"
- assertNotVisible:
id: "repostCount"
- tapOn:
label: "Can delete posts"
+3
View File
@@ -11,6 +11,9 @@ appId: xyz.blueskyweb.app
# Navigate to my profile
- extendedWaitUntil:
visible:
id: "bottomBarSearchBtn"
- tapOn:
id: "bottomBarProfileBtn"
+3
View File
@@ -10,6 +10,9 @@ appId: xyz.blueskyweb.app
id: "e2eSignInAlice"
# Navigate to another user profile
- extendedWaitUntil:
visible:
id: "bottomBarSearchBtn"
- tapOn:
id: "bottomBarSearchBtn"
- tapOn:
+2 -8
View File
@@ -11,6 +11,8 @@ appId: xyz.blueskyweb.app
# Navigate to thread
- extendedWaitUntil:
visible: "Thread root"
- tapOn: "Thread root"
- assertVisible: "Thread reply"
@@ -33,18 +35,10 @@ appId: xyz.blueskyweb.app
id: "likeBtn"
childOf:
id: "postThreadItem-by-carla.test"
- assertVisible:
id: "likeCount"
childOf:
id: "postThreadItem-by-carla.test"
- tapOn:
id: "likeBtn"
childOf:
id: "postThreadItem-by-carla.test"
- assertNotVisible:
id: "likeCount"
childOf:
id: "postThreadItem-by-carla.test"
# Can repost the root post
- tapOn:
+8
View File
@@ -340,12 +340,14 @@ describe('parseEmbedPlayerFromUrl', () => {
'https://youtube.com/watch?v=videoId',
'https://youtube.com/watch?v=videoId&feature=share',
'https://youtube.com/shorts/videoId',
'https://youtube.com/live/videoId',
'https://m.youtube.com/watch?v=videoId',
'https://music.youtube.com/watch?v=videoId',
'https://youtube.com/shorts/',
'https://youtube.com/',
'https://youtube.com/random',
'https://youtube.com/live/',
'https://twitch.tv/channelName',
'https://www.twitch.tv/channelName',
@@ -475,10 +477,16 @@ describe('parseEmbedPlayerFromUrl', () => {
source: 'youtube',
playerUri: 'https://bsky.app/iframe/youtube.html?videoId=videoId&start=0',
},
{
type: 'youtube_video',
source: 'youtube',
playerUri: 'https://bsky.app/iframe/youtube.html?videoId=videoId&start=0',
},
undefined,
undefined,
undefined,
undefined,
{
type: 'twitch_video',
+1 -1
View File
@@ -191,7 +191,7 @@ module.exports = function (config) {
'expo-build-properties',
{
ios: {
deploymentTarget: '14.0',
deploymentTarget: '15.1',
newArchEnabled: true,
},
android: {
@@ -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="M6 2a1 1 0 0 1 1 1v2h11a1 1 0 0 1 1 1v11h2a1 1 0 1 1 0 2h-2v2a1 1 0 1 1-2 0v-2H6a1 1 0 0 1-1-1V7H3a1 1 0 0 1 0-2h2V3a1 1 0 0 1 1-1Zm1 5v10h10V7H7Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 289 B

@@ -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="M3.135 12C5.413 16.088 8.77 18 12 18s6.587-1.912 8.865-6C18.587 7.912 15.23 6 12 6c-3.228 0-6.587 1.912-8.865 6ZM12 4c4.24 0 8.339 2.611 10.888 7.54a1 1 0 0 1 0 .92C20.338 17.388 16.24 20 12 20c-4.24 0-8.339-2.611-10.888-7.54a1 1 0 0 1 0-.92C3.662 6.612 7.76 4 12 4Zm0 6a2 2 0 1 0 0 4 2 2 0 0 0 0-4Zm-4 2a4 4 0 1 1 8 0 4 4 0 0 1-8 0Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 476 B

+2 -1
View File
@@ -10,7 +10,8 @@
],
"rules": {
"simple-import-sort/imports": "warn",
"simple-import-sort/exports": "warn"
"simple-import-sort/exports": "warn",
'no-else-return': 'off'
},
"parserOptions": {
"sourceType": "module",
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#fff" d="M9.576 2.534C7.578 1.299 5 2.737 5 5.086v13.828c0 2.35 2.578 3.787 4.576 2.552l11.194-6.914c1.899-1.172 1.899-3.932 0-5.104L9.576 2.534Z"/></svg>

After

Width:  |  Height:  |  Size: 239 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><defs><linearGradient x1="0" y1="0" x2="100%" y2="0" gradientTransform="rotate(45)" id="sky_V5w1FF_xb91wVQ_1euhBX"><stop offset="0" stop-color="#0A7AFF"></stop><stop offset="1" stop-color="#59B9FF"></stop></linearGradient></defs><path fill="url(#sky_V5w1FF_xb91wVQ_1euhBX)" fill-rule="evenodd" clip-rule="evenodd" d="M11.26 5.227 5.02 6.899c-.734.197-1.17.95-.973 1.685l1.672 6.24c.197.734.951 1.17 1.685.973l6.24-1.672c.734-.197 1.17-.951.973-1.685L12.945 6.2a1.375 1.375 0 0 0-1.685-.973Zm-6.566.459a2.632 2.632 0 0 0-1.86 3.223l1.672 6.24a2.632 2.632 0 0 0 3.223 1.861l6.24-1.672a2.631 2.631 0 0 0 1.861-3.223l-1.672-6.24a2.632 2.632 0 0 0-3.223-1.861l-6.24 1.672Z"></path><path fill="url(#sky_V5w1FF_xb91wVQ_1euhBX)" fill-rule="evenodd" clip-rule="evenodd" d="M15.138 18.411a4.606 4.606 0 1 0 0-9.211 4.606 4.606 0 0 0 0 9.211Zm0 1.257a5.862 5.862 0 1 0 0-11.724 5.862 5.862 0 0 0 0 11.724Z"></path></svg>

After

Width:  |  Height:  |  Size: 982 B

+2 -2
View File
@@ -9,7 +9,7 @@
"lint": "eslint --cache --ext .js,.jsx,.ts,.tsx src"
},
"dependencies": {
"@atproto/api": "^0.12.2",
"@atproto/api": "0.13.6",
"@preact/preset-vite": "^2.8.2",
"@vitejs/plugin-legacy": "^5.3.2",
"preact": "^10.4.8",
@@ -22,7 +22,7 @@
"eslint-plugin-simple-import-sort": "^12.0.0",
"postcss": "^8.4.38",
"tailwindcss": "^3.4.3",
"typescript": "^4.0.5",
"typescript": "^5.5.4",
"vite": "^5.2.8",
"vite-tsconfig-paths": "^4.3.2"
}
+113 -17
View File
@@ -3,15 +3,20 @@ import {
AppBskyEmbedImages,
AppBskyEmbedRecord,
AppBskyEmbedRecordWithMedia,
AppBskyEmbedVideo,
AppBskyFeedDefs,
AppBskyFeedPost,
AppBskyGraphDefs,
AppBskyGraphStarterpack,
AppBskyLabelerDefs,
AtUri,
} from '@atproto/api'
import {ComponentChildren, h} from 'preact'
import {useMemo} from 'preact/hooks'
import infoIcon from '../../assets/circleInfo_stroke2_corner0_rounded.svg'
import playIcon from '../../assets/play_filled_corner2_rounded.svg'
import starterPackIcon from '../../assets/starterPack.svg'
import {CONTENT_LABELS, labelsToInfo} from '../labels'
import {getRkey} from '../utils'
import {Link} from './link'
@@ -105,7 +110,7 @@ export function Embed({
// Case 3.2: List
if (AppBskyGraphDefs.isListView(record)) {
return (
<GenericWithImage
<GenericWithImageEmbed
image={record.avatar}
title={record.name}
href={`/profile/${record.creator.did}/lists/${getRkey(record)}`}
@@ -122,7 +127,7 @@ export function Embed({
// Case 3.3: Feed
if (AppBskyFeedDefs.isGeneratorView(record)) {
return (
<GenericWithImage
<GenericWithImageEmbed
image={record.avatar}
title={record.displayName}
href={`/profile/${record.creator.did}/feed/${getRkey(record)}`}
@@ -134,31 +139,35 @@ export function Embed({
// Case 3.4: Labeler
if (AppBskyLabelerDefs.isLabelerView(record)) {
return (
<GenericWithImage
image={record.creator.avatar}
title={record.creator.displayName || record.creator.handle}
href={`/profile/${record.creator.did}`}
subtitle="Labeler"
description={`Liked by ${record.likeCount ?? 0} users`}
/>
)
// Embed type does not exist in the app, so show nothing
return null
}
// Case 3.5: Post not found
// Case 3.5: Starter pack
if (AppBskyGraphDefs.isStarterPackViewBasic(record)) {
return <StarterPackEmbed content={record} />
}
// Case 3.6: Post not found
if (AppBskyEmbedRecord.isViewNotFound(record)) {
return <Info>Quoted post not found, it may have been deleted.</Info>
}
// Case 3.6: Post blocked
// Case 3.7: Post blocked
if (AppBskyEmbedRecord.isViewBlocked(record)) {
return <Info>The quoted post is blocked.</Info>
}
throw new Error('Unknown embed type')
// Unknown embed type
return null
}
// Case 4: Record with media
// Case 4: Video
if (AppBskyEmbedVideo.isView(content)) {
return <VideoEmbed content={content} />
}
// Case 5: Record with media
if (
AppBskyEmbedRecordWithMedia.isView(content) &&
AppBskyEmbedRecord.isViewRecord(content.record.record)
@@ -182,7 +191,8 @@ export function Embed({
)
}
throw new Error('Unsupported embed type')
// Unknown embed type
return null
} catch (err) {
return (
<Info>{err instanceof Error ? err.message : 'An error occurred'}</Info>
@@ -314,7 +324,7 @@ function ExternalEmbed({
)
}
function GenericWithImage({
function GenericWithImageEmbed({
title,
subtitle,
href,
@@ -350,3 +360,89 @@ function GenericWithImage({
</Link>
)
}
// just the thumbnail and a play button
function VideoEmbed({content}: {content: AppBskyEmbedVideo.View}) {
let aspectRatio = 1
if (content.aspectRatio) {
const {width, height} = content.aspectRatio
aspectRatio = clamp(width / height, 1 / 1, 3 / 1)
}
return (
<div
className="w-full overflow-hidden rounded-lg aspect-square"
style={{aspectRatio: `${aspectRatio} / 1`}}>
<img
src={content.thumbnail}
alt={content.alt}
className="object-cover size-full"
/>
<div className="size-24 absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-black/50 flex items-center justify-center">
<img src={playIcon} className="object-cover size-3/5" />
</div>
</div>
)
}
function StarterPackEmbed({
content,
}: {
content: AppBskyGraphDefs.StarterPackViewBasic
}) {
if (!AppBskyGraphStarterpack.isRecord(content.record)) {
return null
}
const starterPackHref = getStarterPackHref(content)
const imageUri = getStarterPackImage(content)
return (
<Link
href={starterPackHref}
className="w-full rounded-lg overflow-hidden border flex flex-col items-stretch">
<img src={imageUri} className="aspect-[1.91/1] object-cover" />
<div className="py-3 px-4">
<div className="flex space-x-2 items-center">
<img src={starterPackIcon} className="w-10 h-10" />
<div>
<p className="font-semibold leading-[21px]">
{content.record.name}
</p>
<p className="text-sm text-textLight line-clamp-2 leading-[18px]">
Starter pack by{' '}
{content.creator.displayName || `@${content.creator.handle}`}
</p>
</div>
</div>
{content.record.description && (
<p className="text-sm mt-1">{content.record.description}</p>
)}
{!!content.joinedAllTimeCount && content.joinedAllTimeCount > 50 && (
<p className="text-sm font-semibold text-textLight mt-1">
{content.joinedAllTimeCount} users have joined!
</p>
)}
</div>
</Link>
)
}
// from #/lib/strings/starter-pack.ts
function getStarterPackImage(starterPack: AppBskyGraphDefs.StarterPackView) {
const rkey = new AtUri(starterPack.uri).rkey
return `https://ogcard.cdn.bsky.app/start/${starterPack.creator.did}/${rkey}`
}
function getStarterPackHref(
starterPack: AppBskyGraphDefs.StarterPackViewBasic,
) {
const rkey = new AtUri(starterPack.uri).rkey
const handleOrDid = starterPack.creator.handle || starterPack.creator.did
return `/starter-pack/${handleOrDid}/${rkey}`
}
function clamp(num: number, min: number, max: number) {
return Math.max(min, Math.min(num, max))
}
+8 -5
View File
@@ -11,7 +11,7 @@ import likeIcon from '../../assets/heart2_filled_stroke2_corner0_rounded.svg'
import logo from '../../assets/logo.svg'
import repostIcon from '../../assets/repost_stroke2_corner2_rounded.svg'
import {CONTENT_LABELS} from '../labels'
import {getRkey, niceDate} from '../utils'
import {getRkey, niceDate, prettyNumber} from '../utils'
import {Container} from './container'
import {Embed} from './embed'
import {Link} from './link'
@@ -78,7 +78,7 @@ export function Post({thread}: Props) {
<div className="flex items-center gap-2 cursor-pointer">
<img src={likeIcon} className="w-5 h-5" />
<p className="font-bold text-neutral-500 mb-px">
{post.likeCount}
{prettyNumber(post.likeCount)}
</p>
</div>
)}
@@ -86,7 +86,7 @@ export function Post({thread}: Props) {
<div className="flex items-center gap-2 cursor-pointer">
<img src={repostIcon} className="w-5 h-5" />
<p className="font-bold text-neutral-500 mb-px">
{post.repostCount}
{prettyNumber(post.repostCount)}
</p>
</div>
)}
@@ -97,7 +97,7 @@ export function Post({thread}: Props) {
<div className="flex-1" />
<p className="cursor-pointer text-brand font-bold hover:underline hidden min-[450px]:inline">
{post.replyCount
? `Read ${post.replyCount} ${
? `Read ${prettyNumber(post.replyCount)} ${
post.replyCount > 1 ? 'replies' : 'reply'
} on Bluesky`
: `View on Bluesky`}
@@ -132,7 +132,10 @@ function PostContent({record}: {record: AppBskyFeedPost.Record | null}) {
key={counter}
href={segment.link.uri}
className="text-blue-400 hover:underline"
disableTracking={!segment.link.uri.startsWith('https://bsky.app')}>
disableTracking={
!segment.link.uri.startsWith('https://bsky.app') &&
!segment.link.uri.startsWith('https://go.bsky.app')
}>
{segment.text}
</Link>,
)
+2 -2
View File
@@ -1,6 +1,6 @@
import '../index.css'
import {AppBskyFeedDefs, BskyAgent} from '@atproto/api'
import {AppBskyFeedDefs, AtpAgent} from '@atproto/api'
import {h, render} from 'preact'
import logo from '../../assets/logo.svg'
@@ -12,7 +12,7 @@ import {getRkey} from '../utils'
const root = document.getElementById('app')
if (!root) throw new Error('No root element')
const agent = new BskyAgent({
const agent = new AtpAgent({
service: 'https://public.api.bsky.app',
})
+10
View File
@@ -16,3 +16,13 @@ export function getRkey({uri}: {uri: string}): string {
const at = new AtUri(uri)
return at.rkey
}
const formatter = new Intl.NumberFormat('en-US', {
notation: 'compact',
maximumFractionDigits: 1,
roundingMode: 'trunc',
})
export function prettyNumber(number: number) {
return formatter.format(number)
}
+1 -1
View File
@@ -20,5 +20,5 @@
"jsxFragmentFactory": "Fragment",
"downlevelIteration": true
},
"include": ["src"]
"include": ["src", "vite.config.ts"]
}
+1 -1
View File
@@ -6,5 +6,5 @@
"strict": true,
"outDir": "dist"
},
"include": ["snippet"],
"include": ["snippet"]
}
+50 -24
View File
@@ -20,15 +20,16 @@
"@jridgewell/gen-mapping" "^0.3.5"
"@jridgewell/trace-mapping" "^0.3.24"
"@atproto/api@^0.12.2":
version "0.12.2"
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.12.2.tgz#5df6d4f60dea0395c84fdebd9e81a7e853edf130"
integrity sha512-UVzCiDZH2j0wrr/O8nb1edD5cYLVqB5iujueXUCbHS3rAwIxgmyLtA3Hzm2QYsGPo/+xsIg1fNvpq9rNT6KWUA==
"@atproto/api@0.13.6":
version "0.13.6"
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.13.6.tgz#2500e9d7143e6718089632300c42ce50149f8cd5"
integrity sha512-58emFFZhqY8nVWD3xFWK0yYqAmJ2un+NaTtZxBbRo00mGq1rz9VXTpVmfoHFcuXL1hoDQN3WyJfsub8r6xGOgg==
dependencies:
"@atproto/common-web" "^0.3.0"
"@atproto/lexicon" "^0.4.0"
"@atproto/lexicon" "^0.4.1"
"@atproto/syntax" "^0.3.0"
"@atproto/xrpc" "^0.5.0"
"@atproto/xrpc" "^0.6.1"
await-lock "^2.2.2"
multiformats "^9.9.0"
tlds "^1.234.0"
@@ -42,29 +43,29 @@
uint8arrays "3.0.0"
zod "^3.21.4"
"@atproto/lexicon@^0.4.0":
version "0.4.0"
resolved "https://registry.yarnpkg.com/@atproto/lexicon/-/lexicon-0.4.0.tgz#63e8829945d80c25524882caa8ed27b1151cc576"
integrity sha512-RvCBKdSI4M8qWm5uTNz1z3R2yIvIhmOsMuleOj8YR6BwRD+QbtUBy3l+xQ7iXf4M5fdfJFxaUNa6Ty0iRwdKqQ==
"@atproto/lexicon@^0.4.1":
version "0.4.1"
resolved "https://registry.yarnpkg.com/@atproto/lexicon/-/lexicon-0.4.1.tgz#19155210570a2fafbcc7d4f655d9b813948e72a0"
integrity sha512-bzyr+/VHXLQWbumViX5L7h1NKQObfs8Z+XZJl43OUK8nYFUI4e/sW1IZKRNfw7Wvi5YVNK+J+yP3DWIBZhkCYA==
dependencies:
"@atproto/common-web" "^0.3.0"
"@atproto/syntax" "^0.3.0"
iso-datestring-validator "^2.2.2"
multiformats "^9.9.0"
zod "^3.21.4"
zod "^3.23.8"
"@atproto/syntax@^0.3.0":
version "0.3.0"
resolved "https://registry.yarnpkg.com/@atproto/syntax/-/syntax-0.3.0.tgz#fafa2dbea9add37253005cb663e7373e05e618b3"
integrity sha512-Weq0ZBxffGHDXHl9U7BQc2BFJi/e23AL+k+i5+D9hUq/bzT4yjGsrCejkjq0xt82xXDjmhhvQSZ0LqxyZ5woxA==
"@atproto/xrpc@^0.5.0":
version "0.5.0"
resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.5.0.tgz#dacbfd8f7b13f0ab5bd56f8fdd4b460e132a6032"
integrity sha512-swu+wyOLvYW4l3n+VAuJbHcPcES+tin2Lsrp8Bw5aIXIICiuFn1YMFlwK9JwVUzTH21Py1s1nHEjr4CJeElJog==
"@atproto/xrpc@^0.6.1":
version "0.6.1"
resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.6.1.tgz#dcd1315c8c60eef5af2db7fa4e35a38ebc6d79d5"
integrity sha512-Zy5ydXEdk6sY7FDUZcEVfCL1jvbL4tXu5CcdPqbEaW6LQtk9GLds/DK1bCX9kswTGaBC88EMuqQMfkxOhp2t4A==
dependencies:
"@atproto/lexicon" "^0.4.0"
zod "^3.21.4"
"@atproto/lexicon" "^0.4.1"
zod "^3.23.8"
"@babel/code-frame@^7.23.5", "@babel/code-frame@^7.24.1", "@babel/code-frame@^7.24.2":
version "7.24.2"
@@ -1710,6 +1711,11 @@ available-typed-arrays@^1.0.7:
dependencies:
possible-typed-array-names "^1.0.0"
await-lock@^2.2.2:
version "2.2.2"
resolved "https://registry.yarnpkg.com/await-lock/-/await-lock-2.2.2.tgz#a95a9b269bfd2f69d22b17a321686f551152bcef"
integrity sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw==
babel-plugin-polyfill-corejs2@^0.4.10:
version "0.4.10"
resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.10.tgz#276f41710b03a64f6467433cab72cbc2653c38b1"
@@ -3730,8 +3736,16 @@ stack-trace@^1.0.0-pre2:
resolved "https://registry.yarnpkg.com/stack-trace/-/stack-trace-1.0.0-pre2.tgz#46a83a79f1b287807e9aaafc6a5dd8bcde626f9c"
integrity sha512-2ztBJRek8IVofG9DBJqdy2N5kulaacX30Nz7xmkYF6ale9WBVmIy6mFBchvGX7Vx/MyjBhx+Rcxqrj+dbOnQ6A==
"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0:
name string-width-cjs
"string-width-cjs@npm:string-width@^4.2.0":
version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
dependencies:
emoji-regex "^8.0.0"
is-fullwidth-code-point "^3.0.0"
strip-ansi "^6.0.1"
string-width@^4.1.0:
version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
@@ -3795,7 +3809,14 @@ string.prototype.trimstart@^1.0.8:
define-properties "^1.2.1"
es-object-atoms "^1.0.0"
"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
dependencies:
ansi-regex "^5.0.1"
strip-ansi@^6.0.0, strip-ansi@^6.0.1:
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
@@ -4003,10 +4024,10 @@ typed-array-length@^1.0.6:
is-typed-array "^1.1.13"
possible-typed-array-names "^1.0.0"
typescript@^4.0.5:
version "4.9.5"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.9.5.tgz#095979f9bcc0d09da324d58d03ce8f8374cbe65a"
integrity sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==
typescript@^5.5.4:
version "5.5.4"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.5.4.tgz#d9852d6c82bad2d2eda4fd74a5762a8f5909e9ba"
integrity sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==
uint8arrays@3.0.0:
version "3.0.0"
@@ -4192,3 +4213,8 @@ zod@^3.21.4:
version "3.22.4"
resolved "https://registry.yarnpkg.com/zod/-/zod-3.22.4.tgz#f31c3a9386f61b1f228af56faa9255e845cf3fff"
integrity sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==
zod@^3.23.8:
version "3.23.8"
resolved "https://registry.yarnpkg.com/zod/-/zod-3.23.8.tgz#e37b957b5d52079769fb8097099b592f0ef4067d"
integrity sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==
+6
View File
@@ -60,6 +60,12 @@ func run(args []string) {
Value: "",
EnvVars: []string{"LINK_HOST"},
},
&cli.StringFlag{
Name: "ipcc-host",
Usage: "scheme, hostname, and port of ipcc service",
Value: "https://localhost:8730",
EnvVars: []string{"IPCC_HOST"},
},
&cli.BoolFlag{
Name: "debug",
Usage: "Enable debug mode",
+70
View File
@@ -1,12 +1,17 @@
package main
import (
"bytes"
"context"
"crypto/subtle"
"crypto/tls"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io/fs"
"net/http"
"net/netip"
"net/url"
"os"
"os/signal"
@@ -41,6 +46,7 @@ type Config struct {
appviewHost string
ogcardHost string
linkHost string
ipccHost string
}
func serve(cctx *cli.Context) error {
@@ -49,6 +55,7 @@ func serve(cctx *cli.Context) error {
appviewHost := cctx.String("appview-host")
ogcardHost := cctx.String("ogcard-host")
linkHost := cctx.String("link-host")
ipccHost := cctx.String("ipcc-host")
basicAuthPassword := cctx.String("basic-auth-password")
// Echo
@@ -91,6 +98,7 @@ func serve(cctx *cli.Context) error {
appviewHost: appviewHost,
ogcardHost: ogcardHost,
linkHost: linkHost,
ipccHost: ipccHost,
},
}
@@ -255,11 +263,15 @@ func serve(cctx *cli.Context) error {
e.GET("/profile/:handleOrDID/post/:rkey", server.WebPost)
e.GET("/profile/:handleOrDID/post/:rkey/liked-by", server.WebGeneric)
e.GET("/profile/:handleOrDID/post/:rkey/reposted-by", server.WebGeneric)
e.GET("/profile/:handleOrDID/post/:rkey/quotes", server.WebGeneric)
// starter packs
e.GET("/starter-pack/:handleOrDID/:rkey", server.WebStarterPack)
e.GET("/start/:handleOrDID/:rkey", server.WebStarterPack)
// ipcc
e.GET("/ipcc", server.WebIpCC)
if linkHost != "" {
linkUrl, err := url.Parse(linkHost)
if err != nil {
@@ -519,3 +531,61 @@ func (srv *Server) WebProfile(c echo.Context) error {
data["requestHost"] = req.Host
return c.Render(http.StatusOK, "profile.html", data)
}
type IPCCRequest struct {
IP string `json:"ip"`
}
type IPCCResponse struct {
CC string `json:"countryCode"`
}
func (srv *Server) WebIpCC(c echo.Context) error {
realIP := c.RealIP()
addr, err := netip.ParseAddr(realIP)
if err != nil {
log.Warnf("could not parse IP %q %s", realIP, err)
return c.JSON(400, IPCCResponse{})
}
var request []byte
if addr.Is4() {
ip4 := addr.As4()
var dest [8]byte
base64.StdEncoding.Encode(dest[:], ip4[:])
request, _ = json.Marshal(IPCCRequest{IP: string(dest[:])})
} else if addr.Is6() {
ip6 := addr.As16()
var dest [24]byte
base64.StdEncoding.Encode(dest[:], ip6[:])
request, _ = json.Marshal(IPCCRequest{IP: string(dest[:])})
}
ipccUrlBuilder, err := url.Parse(srv.cfg.ipccHost)
if err != nil {
log.Errorf("ipcc misconfigured bad url %s", err)
return c.JSON(500, IPCCResponse{})
}
ipccUrlBuilder.Path = "ipccdata.IpCcService/Lookup"
ipccUrl := ipccUrlBuilder.String()
cl := http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{
InsecureSkipVerify: true,
},
},
}
postBodyReader := bytes.NewReader(request)
response, err := cl.Post(ipccUrl, "application/json", postBodyReader)
if err != nil {
log.Warnf("ipcc backend error %s", err)
return c.JSON(500, IPCCResponse{})
}
defer response.Body.Close()
dec := json.NewDecoder(response.Body)
var outResponse IPCCResponse
err = dec.Decode(&outResponse)
if err != nil {
log.Warnf("ipcc bad response %s", err)
return c.JSON(500, IPCCResponse{})
}
return c.JSON(200, outResponse)
}
+5
View File
@@ -253,6 +253,11 @@
from { opacity: 1; }
to { opacity: 0; }
}
.force-no-clicks > *,
.force-no-clicks * {
pointer-events: none !important;
}
</style>
</style>
{% include "scripts.html" %}
+3 -6
View File
@@ -1,14 +1,11 @@
import 'react-native-gesture-handler' // must be first
import {LogBox} from 'react-native'
import '#/platform/polyfills'
import {IS_TEST} from '#/env'
import {LogBox} from 'react-native'
import {registerRootComponent} from 'expo'
import {doPolyfill} from '#/lib/api/api-polyfill'
import App from '#/App'
doPolyfill()
import {IS_TEST} from '#/env'
if (IS_TEST) {
LogBox.ignoreAllLogs() // suppress all logs in tests
+2 -3
View File
@@ -1,9 +1,8 @@
import '#/platform/markBundleStartTime'
import '#/platform/polyfills'
import {registerRootComponent} from 'expo'
import {doPolyfill} from '#/lib/api/api-polyfill'
import App from '#/App'
doPolyfill()
registerRootComponent(App)
+27 -26
View File
@@ -79,32 +79,33 @@ export async function createServer(
plc: {port: port2},
})
// DISABLED - looks like dev-env added this and now it conflicts
// add the test mod authority
const agent = new BskyAgent({service: pdsUrl})
const res = await agent.api.com.atproto.server.createAccount({
email: 'mod-authority@test.com',
handle: 'mod-authority.test',
password: 'hunter2',
})
agent.api.setHeader('Authorization', `Bearer ${res.data.accessJwt}`)
await agent.api.app.bsky.actor.profile.create(
{repo: res.data.did},
{
displayName: 'Dev-env Moderation',
description: `The pretend version of mod.bsky.app`,
},
)
// const agent = new BskyAgent({service: pdsUrl})
// const res = await agent.api.com.atproto.server.createAccount({
// email: 'mod-authority@test.com',
// handle: 'mod-authority.test',
// password: 'hunter2',
// })
// agent.api.setHeader('Authorization', `Bearer ${res.data.accessJwt}`)
// await agent.api.app.bsky.actor.profile.create(
// {repo: res.data.did},
// {
// displayName: 'Dev-env Moderation',
// description: `The pretend version of mod.bsky.app`,
// },
// )
await agent.api.app.bsky.labeler.service.create(
{repo: res.data.did, rkey: 'self'},
{
policies: {
labelValues: ['!hide', '!warn'],
labelValueDefinitions: [],
},
createdAt: new Date().toISOString(),
},
)
// await agent.api.app.bsky.labeler.service.create(
// {repo: res.data.did, rkey: 'self'},
// {
// policies: {
// labelValues: ['!hide', '!warn'],
// labelValueDefinitions: [],
// },
// createdAt: new Date().toISOString(),
// },
// )
const pic = fs.readFileSync(
path.join(__dirname, '..', 'assets', 'default-avatar.png'),
@@ -156,7 +157,7 @@ class Mocker {
}
async createUser(name: string) {
const agent = new BskyAgent({service: this.agent.service})
const agent = new BskyAgent({service: this.service})
const inviteRes = await agent.api.com.atproto.server.createInviteCode(
{useCount: 1},
@@ -332,7 +333,7 @@ class Mocker {
}
async createInvite(forAccount: string) {
const agent = new BskyAgent({service: this.agent.service})
const agent = new BskyAgent({service: this.service})
await agent.api.com.atproto.server.createInviteCode(
{useCount: 1, forAccount},
{
+1
View File
@@ -14,6 +14,7 @@ module.exports = {
'ja',
'ko',
'pt-BR',
'ru',
'tr',
'uk',
'zh-CN',
@@ -1,6 +1,9 @@
import ExpoModulesCore
public class ExpoPlatformInfoModule: Module {
private var prevAudioActive: Bool?
private var prevAudioCategory: AVAudioSession.Category?
public func definition() -> ModuleDefinition {
Name("ExpoPlatformInfo")
@@ -10,18 +13,26 @@ public class ExpoPlatformInfoModule: Module {
Function("setAudioCategory") { (audioCategoryString: String) in
let audioCategory = AVAudioSession.Category(rawValue: audioCategoryString)
if audioCategory == self.prevAudioCategory {
return
}
self.prevAudioCategory = audioCategory
DispatchQueue.global(qos: .background).async {
try? AVAudioSession.sharedInstance().setCategory(audioCategory)
}
}
Function("setAudioActive") { (active: Bool) in
var categoryOptions: AVAudioSession.CategoryOptions
let currentCategory = AVAudioSession.sharedInstance().category
if active == self.prevAudioActive {
return
}
self.prevAudioActive = active
if active {
categoryOptions = [.mixWithOthers]
DispatchQueue.global(qos: .background).async {
try? AVAudioSession.sharedInstance().setActive(true)
}
} else {
categoryOptions = [.duckOthers]
DispatchQueue.global(qos: .background).async {
try? AVAudioSession
.sharedInstance()
.setActive(
@@ -29,14 +40,7 @@ public class ExpoPlatformInfoModule: Module {
options: [.notifyOthersOnDeactivation]
)
}
try? AVAudioSession
.sharedInstance()
.setCategory(
currentCategory,
mode: .default,
options: categoryOptions
)
}
}
}
}
+9 -9
View File
@@ -1,6 +1,6 @@
{
"name": "bsky.app",
"version": "1.90.0",
"version": "1.91.0",
"private": true,
"engines": {
"node": ">=18"
@@ -32,7 +32,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": "./jest/dev-infra/with-test-redis-and-db.sh ts-node --project tsconfig.e2e.json __e2e__/mock-server.ts",
"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:metro": "EXPO_PUBLIC_ENV=e2e NODE_ENV=test RN_SRC_EXT=e2e.ts,e2e.tsx expo run:ios",
"e2e:metro-android": "EXPO_PUBLIC_ENV=e2e NODE_ENV=test RN_SRC_EXT=e2e.ts,e2e.tsx expo run:android",
"e2e:run": "maestro test __e2e__",
@@ -52,7 +52,7 @@
"open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web"
},
"dependencies": {
"@atproto/api": "0.12.29",
"@atproto/api": "0.13.5",
"@bam.tech/react-native-image-resizer": "^3.0.10",
"@braintree/sanitize-url": "^6.0.2",
"@gorhom/bottom-sheet": "5.0.0-alpha.11",
@@ -136,8 +136,8 @@
"expo-splash-screen": "~0.27.4",
"expo-status-bar": "~1.12.1",
"expo-system-ui": "~3.0.4",
"expo-task-manager": "~11.8.2",
"expo-updates": "~0.25.21",
"expo-task-manager": "~11.8.1",
"expo-updates": "~0.25.14",
"expo-video": "^1.2.4",
"expo-web-browser": "~13.0.3",
"fast-text-encoding": "^1.0.6",
@@ -176,8 +176,9 @@
"react-native-fs": "^2.20.0",
"react-native-gesture-handler": "~2.16.2",
"react-native-get-random-values": "~1.11.0",
"react-native-image-crop-picker": "0.41.2",
"react-native-keyboard-controller": "^1.12.7",
"react-native-image-crop-picker": "0.40.3",
"react-native-ios-context-menu": "^1.15.3",
"react-native-keyboard-controller": "^1.12.1",
"react-native-pager-view": "6.2.3",
"react-native-picker-select": "^9.1.3",
"react-native-progress": "bluesky-social/react-native-progress",
@@ -197,7 +198,6 @@
"react-responsive": "^9.0.2",
"react-textarea-autosize": "^8.5.3",
"rn-fetch-blob": "^0.12.0",
"rn-tourguide": "bluesky-social/rn-tourguide",
"sentry-expo": "~7.0.1",
"statsig-react-native-expo": "^4.6.1",
"tippy.js": "^6.3.7",
@@ -206,7 +206,7 @@
"zod": "^3.20.2"
},
"devDependencies": {
"@atproto/dev-env": "^0.3.5",
"@atproto/dev-env": "^0.3.39",
"@babel/core": "^7.23.2",
"@babel/preset-env": "^7.20.0",
"@babel/runtime": "^7.20.0",
-28
View File
@@ -1,28 +0,0 @@
diff --git a/node_modules/@atproto/lexicon/dist/validators/complex.js b/node_modules/@atproto/lexicon/dist/validators/complex.js
index 32d7798..9d688b7 100644
--- a/node_modules/@atproto/lexicon/dist/validators/complex.js
+++ b/node_modules/@atproto/lexicon/dist/validators/complex.js
@@ -113,7 +113,22 @@ function object(lexicons, path, def, value) {
if (value[key] === null && nullableProps.has(key)) {
continue;
}
- const propDef = def.properties[key];
+ const propDef = def.properties[key]
+ if (typeof value[key] === 'undefined' && !requiredProps.has(key)) {
+ // Fast path for non-required undefined props.
+ if (
+ propDef.type === 'integer' ||
+ propDef.type === 'boolean' ||
+ propDef.type === 'string'
+ ) {
+ if (typeof propDef.default === 'undefined') {
+ continue
+ }
+ } else {
+ // Other types have no defaults.
+ continue
+ }
+ }
const propPath = `${path}/${key}`;
const validated = (0, util_1.validateOneOf)(lexicons, propPath, propDef, value[key]);
const propValue = validated.success ? validated.value : value[key];
+14 -1
View File
@@ -1,5 +1,5 @@
diff --git a/node_modules/expo-modules-core/build/uuid/uuid.js b/node_modules/expo-modules-core/build/uuid/uuid.js
index 109d3fe..c7fce9e 100644
index 109d3fe..c421931 100644
--- a/node_modules/expo-modules-core/build/uuid/uuid.js
+++ b/node_modules/expo-modules-core/build/uuid/uuid.js
@@ -1,5 +1,7 @@
@@ -10,3 +10,16 @@ index 109d3fe..c7fce9e 100644
const nativeUuidv4 = globalThis?.expo?.uuidv4;
const nativeUuidv5 = globalThis?.expo?.uuidv5;
function uuidv4() {
diff --git a/node_modules/expo-modules-core/ios/Core/SharedObjects/SharedObjectRegistry.swift b/node_modules/expo-modules-core/ios/Core/SharedObjects/SharedObjectRegistry.swift
index ee2268a..4851b67 100644
--- a/node_modules/expo-modules-core/ios/Core/SharedObjects/SharedObjectRegistry.swift
+++ b/node_modules/expo-modules-core/ios/Core/SharedObjects/SharedObjectRegistry.swift
@@ -173,7 +173,7 @@ public final class SharedObjectRegistry {
}
internal func clear() {
- Self.lockQueue.async {
+ Self.lockQueue.sync {
self.pairs.removeAll()
}
}
-135
View File
@@ -1,135 +0,0 @@
diff --git a/node_modules/expo-video/android/src/main/java/expo/modules/video/PlayerViewExtension.kt b/node_modules/expo-video/android/src/main/java/expo/modules/video/PlayerViewExtension.kt
index 9905e13..47342ff 100644
--- a/node_modules/expo-video/android/src/main/java/expo/modules/video/PlayerViewExtension.kt
+++ b/node_modules/expo-video/android/src/main/java/expo/modules/video/PlayerViewExtension.kt
@@ -11,6 +11,7 @@ internal fun PlayerView.applyRequiresLinearPlayback(requireLinearPlayback: Boole
setShowPreviousButton(!requireLinearPlayback)
setShowNextButton(!requireLinearPlayback)
setTimeBarInteractive(requireLinearPlayback)
+ setShowSubtitleButton(true)
}
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
@@ -27,7 +28,8 @@ internal fun PlayerView.setTimeBarInteractive(interactive: Boolean) {
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
internal fun PlayerView.setFullscreenButtonVisibility(visible: Boolean) {
- val fullscreenButton = findViewById<android.widget.ImageButton>(androidx.media3.ui.R.id.exo_fullscreen)
+ val fullscreenButton =
+ findViewById<android.widget.ImageButton>(androidx.media3.ui.R.id.exo_fullscreen)
fullscreenButton?.visibility = if (visible) {
android.view.View.VISIBLE
} else {
diff --git a/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoModule.kt b/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoModule.kt
index ec3da2a..5a1397a 100644
--- a/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoModule.kt
+++ b/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoModule.kt
@@ -43,7 +43,9 @@ class VideoModule : Module() {
View(VideoView::class) {
Events(
"onPictureInPictureStart",
- "onPictureInPictureStop"
+ "onPictureInPictureStop",
+ "onEnterFullscreen",
+ "onExitFullscreen"
)
Prop("player") { view: VideoView, player: VideoPlayer ->
diff --git a/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoView.kt b/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoView.kt
index a951d80..3932535 100644
--- a/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoView.kt
+++ b/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoView.kt
@@ -36,6 +36,8 @@ class VideoView(context: Context, appContext: AppContext) : ExpoView(context, ap
val playerView: PlayerView = PlayerView(context.applicationContext)
val onPictureInPictureStart by EventDispatcher<Unit>()
val onPictureInPictureStop by EventDispatcher<Unit>()
+ val onEnterFullscreen by EventDispatcher()
+ val onExitFullscreen by EventDispatcher()
var willEnterPiP: Boolean = false
var isInFullscreen: Boolean = false
@@ -154,6 +156,7 @@ class VideoView(context: Context, appContext: AppContext) : ExpoView(context, ap
@Suppress("DEPRECATION")
currentActivity.overridePendingTransition(0, 0)
}
+ onEnterFullscreen(mapOf())
isInFullscreen = true
}
@@ -162,6 +165,7 @@ class VideoView(context: Context, appContext: AppContext) : ExpoView(context, ap
val fullScreenButton: ImageButton = playerView.findViewById(androidx.media3.ui.R.id.exo_fullscreen)
fullScreenButton.setImageResource(androidx.media3.ui.R.drawable.exo_icon_fullscreen_enter)
videoPlayer?.changePlayerView(playerView)
+ this.onExitFullscreen(mapOf())
isInFullscreen = false
}
diff --git a/node_modules/expo-video/build/VideoView.types.d.ts b/node_modules/expo-video/build/VideoView.types.d.ts
index cb9ca6d..60e9f4e 100644
--- a/node_modules/expo-video/build/VideoView.types.d.ts
+++ b/node_modules/expo-video/build/VideoView.types.d.ts
@@ -89,5 +89,8 @@ export interface VideoViewProps extends ViewProps {
* @platform ios 16.0+
*/
allowsVideoFrameAnalysis?: boolean;
+
+ onEnterFullscreen?: () => void;
+ onExitFullscreen?: () => void;
}
//# sourceMappingURL=VideoView.types.d.ts.map
diff --git a/node_modules/expo-video/ios/VideoModule.swift b/node_modules/expo-video/ios/VideoModule.swift
index c537a12..e4a918f 100644
--- a/node_modules/expo-video/ios/VideoModule.swift
+++ b/node_modules/expo-video/ios/VideoModule.swift
@@ -16,7 +16,9 @@ public final class VideoModule: Module {
View(VideoView.self) {
Events(
"onPictureInPictureStart",
- "onPictureInPictureStop"
+ "onPictureInPictureStop",
+ "onEnterFullscreen",
+ "onExitFullscreen"
)
Prop("player") { (view, player: VideoPlayer?) in
diff --git a/node_modules/expo-video/ios/VideoView.swift b/node_modules/expo-video/ios/VideoView.swift
index f4579e4..10c5908 100644
--- a/node_modules/expo-video/ios/VideoView.swift
+++ b/node_modules/expo-video/ios/VideoView.swift
@@ -41,6 +41,8 @@ public final class VideoView: ExpoView, AVPlayerViewControllerDelegate {
let onPictureInPictureStart = EventDispatcher()
let onPictureInPictureStop = EventDispatcher()
+ let onEnterFullscreen = EventDispatcher()
+ let onExitFullscreen = EventDispatcher()
public override var bounds: CGRect {
didSet {
@@ -163,6 +165,7 @@ public final class VideoView: ExpoView, AVPlayerViewControllerDelegate {
_ playerViewController: AVPlayerViewController,
willBeginFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator
) {
+ onEnterFullscreen()
isFullscreen = true
}
@@ -179,6 +182,7 @@ public final class VideoView: ExpoView, AVPlayerViewControllerDelegate {
if wasPlaying {
self.player?.pointer.play()
}
+ self.onExitFullscreen()
self.isFullscreen = false
}
}
diff --git a/node_modules/expo-video/src/VideoView.types.ts b/node_modules/expo-video/src/VideoView.types.ts
index 29fe5db..e1fbf59 100644
--- a/node_modules/expo-video/src/VideoView.types.ts
+++ b/node_modules/expo-video/src/VideoView.types.ts
@@ -100,4 +100,7 @@ export interface VideoViewProps extends ViewProps {
* @platform ios 16.0+
*/
allowsVideoFrameAnalysis?: boolean;
+
+ onEnterFullscreen?: () => void;
+ onExitFullscreen?: () => void;
}
-6
View File
@@ -1,6 +0,0 @@
## uwu woad beawing, do not wemove
## `expo-video` Patch
This patch adds two props to `VideoView`: `onEnterFullscreen` and `onExitFullscreen` which do exactly what they say on
the tin.
@@ -57,7 +57,7 @@ const withXcodeTarget = (config, {targetName}) => {
buildSettingsObj.SWIFT_VERSION = '5.0'
buildSettingsObj.TARGETED_DEVICE_FAMILY = `"1"`
buildSettingsObj.DEVELOPMENT_TEAM = 'B3LX46C5HS'
buildSettingsObj.IPHONEOS_DEPLOYMENT_TARGET = '14.0'
buildSettingsObj.IPHONEOS_DEPLOYMENT_TARGET = '15.1'
buildSettingsObj.ASSETCATALOG_COMPILER_APPICON_NAME = 'AppIcon'
}
}
+10 -11
View File
@@ -2,7 +2,6 @@ const path = require('path')
const fs = require('fs')
const projectRoot = path.join(__dirname, '..')
const webBuildJs = path.join(projectRoot, 'web-build', 'static', 'js')
const templateFile = path.join(
projectRoot,
'bskyweb',
@@ -10,18 +9,18 @@ const templateFile = path.join(
'scripts.html',
)
const jsFiles = fs.readdirSync(webBuildJs).filter(name => name.endsWith('.js'))
jsFiles.sort((a, b) => {
// make sure main is written last
if (a.startsWith('main')) return 1
if (b.startsWith('main')) return -1
return a.localeCompare(b)
})
const {entrypoints} = require(path.join(
projectRoot,
'web-build/asset-manifest.json',
))
console.log(`Found ${jsFiles.length} js files in web-build`)
console.log(`Found ${entrypoints.length} entrypoints`)
console.log(`Writing ${templateFile}`)
const outputFile = jsFiles
.map(name => `<script defer="defer" src="/static/js/${name}"></script>`)
const outputFile = entrypoints
.map(name => {
const file = path.basename(name)
return `<script defer="defer" src="/static/js/${file}"></script>`
})
.join('\n')
fs.writeFileSync(templateFile, outputFile)
+15 -11
View File
@@ -50,16 +50,17 @@ import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out'
import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide'
import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
import {Provider as HiddenRepliesProvider} from '#/state/threadgate-hidden-replies'
import {TestCtrls} from '#/view/com/testing/TestCtrls'
import {ActiveVideoProvider} from '#/view/com/util/post-embeds/ActiveVideoContext'
import {Provider as ActiveVideoProvider} from '#/view/com/util/post-embeds/ActiveVideoNativeContext'
import * as Toast from '#/view/com/util/Toast'
import {Shell} from '#/view/shell'
import {ThemeProvider as Alf} from '#/alf'
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialogs'
import {Provider as PortalProvider} from '#/components/Portal'
import {Splash} from '#/Splash'
import {Provider as TourProvider} from '#/tours'
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
import {AudioCategory, PlatformInfo} from '../modules/expo-bluesky-swiss-army'
@@ -105,7 +106,6 @@ function InnerApp() {
}, [_])
return (
<SafeAreaProvider initialMetrics={initialWindowMetrics}>
<Alf theme={theme}>
<ThemeProvider theme={theme}>
<Splash isReady={isReady && hasCheckedReferrer}>
@@ -122,10 +122,10 @@ function InnerApp() {
<ModerationOptsProvider>
<LoggedOutViewProvider>
<SelectedFeedProvider>
<HiddenRepliesProvider>
<UnreadNotifsProvider>
<BackgroundNotificationPreferencesProvider>
<MutedThreadsProvider>
<TourProvider>
<ProgressGuideProvider>
<GestureHandlerRootView
style={s.h100pct}>
@@ -133,10 +133,10 @@ function InnerApp() {
<Shell />
</GestureHandlerRootView>
</ProgressGuideProvider>
</TourProvider>
</MutedThreadsProvider>
</BackgroundNotificationPreferencesProvider>
</UnreadNotifsProvider>
</HiddenRepliesProvider>
</SelectedFeedProvider>
</LoggedOutViewProvider>
</ModerationOptsProvider>
@@ -150,7 +150,6 @@ function InnerApp() {
</Splash>
</ThemeProvider>
</Alf>
</SafeAreaProvider>
)
}
@@ -159,7 +158,7 @@ function App() {
React.useEffect(() => {
PlatformInfo.setAudioCategory(AudioCategory.Ambient)
PlatformInfo.setAudioActive(true)
PlatformInfo.setAudioActive(false)
initPersistedState().then(() => setReady(true))
}, [])
@@ -175,25 +174,30 @@ function App() {
<A11yProvider>
<KeyboardProvider enabled={false} statusBarTranslucent={true}>
<SessionProvider>
<ShellStateProvider>
<PrefsStateProvider>
<I18nProvider>
<ShellStateProvider>
<InvitesStateProvider>
<ModalStateProvider>
<DialogStateProvider>
<LightboxStateProvider>
<I18nProvider>
<PortalProvider>
<StarterPackProvider>
<SafeAreaProvider
initialMetrics={initialWindowMetrics}>
<IntentDialogProvider>
<InnerApp />
</IntentDialogProvider>
</SafeAreaProvider>
</StarterPackProvider>
</PortalProvider>
</I18nProvider>
</LightboxStateProvider>
</DialogStateProvider>
</ModalStateProvider>
</InvitesStateProvider>
</PrefsStateProvider>
</ShellStateProvider>
</I18nProvider>
</PrefsStateProvider>
</SessionProvider>
</KeyboardProvider>
</A11yProvider>
+11 -8
View File
@@ -39,15 +39,16 @@ import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out'
import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide'
import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
import {ActiveVideoProvider} from '#/view/com/util/post-embeds/ActiveVideoContext'
import {Provider as HiddenRepliesProvider} from '#/state/threadgate-hidden-replies'
import {Provider as ActiveVideoProvider} from '#/view/com/util/post-embeds/ActiveVideoWebContext'
import * as Toast from '#/view/com/util/Toast'
import {ToastContainer} from '#/view/com/util/Toast.web'
import {Shell} from '#/view/shell/index'
import {ThemeProvider as Alf} from '#/alf'
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialogs'
import {Provider as PortalProvider} from '#/components/Portal'
import {Provider as TourProvider} from '#/tours'
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
function InnerApp() {
@@ -105,19 +106,19 @@ function InnerApp() {
<ModerationOptsProvider>
<LoggedOutViewProvider>
<SelectedFeedProvider>
<HiddenRepliesProvider>
<UnreadNotifsProvider>
<BackgroundNotificationPreferencesProvider>
<MutedThreadsProvider>
<SafeAreaProvider>
<TourProvider>
<ProgressGuideProvider>
<Shell />
</ProgressGuideProvider>
</TourProvider>
</SafeAreaProvider>
</MutedThreadsProvider>
</BackgroundNotificationPreferencesProvider>
</UnreadNotifsProvider>
</HiddenRepliesProvider>
</SelectedFeedProvider>
</LoggedOutViewProvider>
</ModerationOptsProvider>
@@ -153,25 +154,27 @@ function App() {
return (
<A11yProvider>
<SessionProvider>
<ShellStateProvider>
<PrefsStateProvider>
<I18nProvider>
<ShellStateProvider>
<InvitesStateProvider>
<ModalStateProvider>
<DialogStateProvider>
<LightboxStateProvider>
<I18nProvider>
<PortalProvider>
<StarterPackProvider>
<IntentDialogProvider>
<InnerApp />
</IntentDialogProvider>
</StarterPackProvider>
</PortalProvider>
</I18nProvider>
</LightboxStateProvider>
</DialogStateProvider>
</ModalStateProvider>
</InvitesStateProvider>
</PrefsStateProvider>
</ShellStateProvider>
</I18nProvider>
</PrefsStateProvider>
</SessionProvider>
</A11yProvider>
)
+71 -63
View File
@@ -15,10 +15,12 @@ import {
StackActions,
} from '@react-navigation/native'
import {timeout} from 'lib/async/timeout'
import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle'
import {usePalette} from 'lib/hooks/usePalette'
import {buildStateObject} from 'lib/routes/helpers'
import {init as initAnalytics} from '#/lib/analytics/analytics'
import {timeout} from '#/lib/async/timeout'
import {useColorSchemeStyle} from '#/lib/hooks/useColorSchemeStyle'
import {usePalette} from '#/lib/hooks/usePalette'
import {useWebScrollRestoration} from '#/lib/hooks/useWebScrollRestoration'
import {buildStateObject} from '#/lib/routes/helpers'
import {
AllNavigatorParams,
BottomTabNavigatorParams,
@@ -28,20 +30,62 @@ import {
MyProfileTabNavigatorParams,
NotificationsTabNavigatorParams,
SearchTabNavigatorParams,
} from 'lib/routes/types'
import {RouteParams, State} from 'lib/routes/types'
import {bskyTitle} from 'lib/strings/headings'
import {isAndroid, isNative, isWeb} from 'platform/detection'
} from '#/lib/routes/types'
import {RouteParams, State} from '#/lib/routes/types'
import {attachRouteToLogEvents, logEvent} from '#/lib/statsig/statsig'
import {bskyTitle} from '#/lib/strings/headings'
import {isAndroid, isNative, isWeb} from '#/platform/detection'
import {useModalControls} from '#/state/modals'
import {useUnreadNotifications} from '#/state/queries/notifications/unread'
import {useSession} from '#/state/session'
import {
shouldRequestEmailConfirmation,
snoozeEmailConfirmationPrompt,
} from '#/state/shell/reminders'
import {AccessibilitySettingsScreen} from '#/view/screens/AccessibilitySettings'
import {AppPasswords} from '#/view/screens/AppPasswords'
import {CommunityGuidelinesScreen} from '#/view/screens/CommunityGuidelines'
import {CopyrightPolicyScreen} from '#/view/screens/CopyrightPolicy'
import {DebugModScreen} from '#/view/screens/DebugMod'
import {FeedsScreen} from '#/view/screens/Feeds'
import {HomeScreen} from '#/view/screens/Home'
import {LanguageSettingsScreen} from '#/view/screens/LanguageSettings'
import {ListsScreen} from '#/view/screens/Lists'
import {LogScreen} from '#/view/screens/Log'
import {ModerationBlockedAccounts} from '#/view/screens/ModerationBlockedAccounts'
import {ModerationModlistsScreen} from '#/view/screens/ModerationModlists'
import {ModerationMutedAccounts} from '#/view/screens/ModerationMutedAccounts'
import {NotFoundScreen} from '#/view/screens/NotFound'
import {NotificationsScreen} from '#/view/screens/Notifications'
import {NotificationsSettingsScreen} from '#/view/screens/NotificationsSettings'
import {PostThreadScreen} from '#/view/screens/PostThread'
import {PreferencesExternalEmbeds} from '#/view/screens/PreferencesExternalEmbeds'
import {AppPasswords} from 'view/screens/AppPasswords'
import {ModerationBlockedAccounts} from 'view/screens/ModerationBlockedAccounts'
import {ModerationMutedAccounts} from 'view/screens/ModerationMutedAccounts'
import {PreferencesFollowingFeed} from 'view/screens/PreferencesFollowingFeed'
import {PreferencesThreads} from 'view/screens/PreferencesThreads'
import {SavedFeeds} from 'view/screens/SavedFeeds'
import {PreferencesFollowingFeed} from '#/view/screens/PreferencesFollowingFeed'
import {PreferencesThreads} from '#/view/screens/PreferencesThreads'
import {PrivacyPolicyScreen} from '#/view/screens/PrivacyPolicy'
import {ProfileScreen} from '#/view/screens/Profile'
import {ProfileFeedScreen} from '#/view/screens/ProfileFeed'
import {ProfileFeedLikedByScreen} from '#/view/screens/ProfileFeedLikedBy'
import {ProfileFollowersScreen} from '#/view/screens/ProfileFollowers'
import {ProfileFollowsScreen} from '#/view/screens/ProfileFollows'
import {ProfileListScreen} from '#/view/screens/ProfileList'
import {SavedFeeds} from '#/view/screens/SavedFeeds'
import {SearchScreen} from '#/view/screens/Search'
import {SettingsScreen} from '#/view/screens/Settings'
import {Storybook} from '#/view/screens/Storybook'
import {SupportScreen} from '#/view/screens/Support'
import {TermsOfServiceScreen} from '#/view/screens/TermsOfService'
import {BottomBar} from '#/view/shell/bottom-bar/BottomBar'
import {createNativeStackNavigatorWithAuth} from '#/view/shell/createNativeStackNavigatorWithAuth'
import {SharedPreferencesTesterScreen} from '#/screens/E2E/SharedPreferencesTesterScreen'
import HashtagScreen from '#/screens/Hashtag'
import {MessagesConversationScreen} from '#/screens/Messages/Conversation'
import {MessagesScreen} from '#/screens/Messages/List'
import {MessagesSettingsScreen} from '#/screens/Messages/Settings'
import {ModerationScreen} from '#/screens/Moderation'
import {PostLikedByScreen} from '#/screens/Post/PostLikedBy'
import {PostQuotesScreen} from '#/screens/Post/PostQuotes'
import {PostRepostedByScreen} from '#/screens/Post/PostRepostedBy'
import {ProfileKnownFollowersScreen} from '#/screens/Profile/KnownFollowers'
import {ProfileLabelerLikedByScreen} from '#/screens/Profile/ProfileLabelerLikedBy'
import {AppearanceSettingsScreen} from '#/screens/Settings/AppearanceSettings'
@@ -50,51 +94,8 @@ import {
StarterPackScreenShort,
} from '#/screens/StarterPack/StarterPackScreen'
import {Wizard} from '#/screens/StarterPack/Wizard'
import {router} from '#/routes'
import {Referrer} from '../modules/expo-bluesky-swiss-army'
import {init as initAnalytics} from './lib/analytics/analytics'
import {useWebScrollRestoration} from './lib/hooks/useWebScrollRestoration'
import {attachRouteToLogEvents, logEvent} from './lib/statsig/statsig'
import {router} from './routes'
import {MessagesConversationScreen} from './screens/Messages/Conversation'
import {MessagesScreen} from './screens/Messages/List'
import {MessagesSettingsScreen} from './screens/Messages/Settings'
import {useModalControls} from './state/modals'
import {useUnreadNotifications} from './state/queries/notifications/unread'
import {useSession} from './state/session'
import {
shouldRequestEmailConfirmation,
snoozeEmailConfirmationPrompt,
} from './state/shell/reminders'
import {AccessibilitySettingsScreen} from './view/screens/AccessibilitySettings'
import {CommunityGuidelinesScreen} from './view/screens/CommunityGuidelines'
import {CopyrightPolicyScreen} from './view/screens/CopyrightPolicy'
import {DebugModScreen} from './view/screens/DebugMod'
import {FeedsScreen} from './view/screens/Feeds'
import {HomeScreen} from './view/screens/Home'
import {LanguageSettingsScreen} from './view/screens/LanguageSettings'
import {ListsScreen} from './view/screens/Lists'
import {LogScreen} from './view/screens/Log'
import {ModerationModlistsScreen} from './view/screens/ModerationModlists'
import {NotFoundScreen} from './view/screens/NotFound'
import {NotificationsScreen} from './view/screens/Notifications'
import {NotificationsSettingsScreen} from './view/screens/NotificationsSettings'
import {PostLikedByScreen} from './view/screens/PostLikedBy'
import {PostRepostedByScreen} from './view/screens/PostRepostedBy'
import {PostThreadScreen} from './view/screens/PostThread'
import {PrivacyPolicyScreen} from './view/screens/PrivacyPolicy'
import {ProfileScreen} from './view/screens/Profile'
import {ProfileFeedScreen} from './view/screens/ProfileFeed'
import {ProfileFeedLikedByScreen} from './view/screens/ProfileFeedLikedBy'
import {ProfileFollowersScreen} from './view/screens/ProfileFollowers'
import {ProfileFollowsScreen} from './view/screens/ProfileFollows'
import {ProfileListScreen} from './view/screens/ProfileList'
import {SearchScreen} from './view/screens/Search'
import {SettingsScreen} from './view/screens/Settings'
import {Storybook} from './view/screens/Storybook'
import {SupportScreen} from './view/screens/Support'
import {TermsOfServiceScreen} from './view/screens/TermsOfService'
import {BottomBar} from './view/shell/bottom-bar/BottomBar'
import {createNativeStackNavigatorWithAuth} from './view/shell/createNativeStackNavigatorWithAuth'
const navigationRef = createNavigationContainerRef<AllNavigatorParams>()
@@ -212,6 +213,13 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
title: title(msg`Post by @${route.params.name}`),
})}
/>
<Stack.Screen
name="PostQuotes"
getComponent={() => PostQuotesScreen}
options={({route}) => ({
title: title(msg`Post by @${route.params.name}`),
})}
/>
<Stack.Screen
name="ProfileFeed"
getComponent={() => ProfileFeedScreen}
@@ -482,6 +490,7 @@ function MyProfileTabNavigator() {
getComponent={() => ProfileScreen}
initialParams={{
name: 'me',
hideBackButton: true,
}}
/>
{commonScreens(MyProfileTab as typeof HomeTab)}
@@ -652,16 +661,15 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
linking={LINKING}
theme={theme}
onStateChange={() => {
logEvent('router:navigate:sampled', {
from: prevLoggedRouteName.current,
})
prevLoggedRouteName.current = getCurrentRouteName()
const routeName = getCurrentRouteName()
if (routeName === 'Notifications') {
logEvent('router:navigate:notifications:sampled', {})
}
}}
onReady={() => {
attachRouteToLogEvents(getCurrentRouteName)
logModuleInitTime()
onReady()
logEvent('router:navigate:sampled', {})
}}>
{children}
</NavigationContainer>
+28 -1
View File
@@ -1,9 +1,14 @@
import {Platform, StyleSheet} from 'react-native'
import {Platform, StyleSheet, ViewStyle} from 'react-native'
import * as tokens from '#/alf/tokens'
import {native, web} from '#/alf/util/platform'
export const atoms = {
debug: {
borderColor: 'red',
borderWidth: 1,
},
/*
* Positioning
*/
@@ -55,6 +60,19 @@ export const atoms = {
height: '100vh',
}),
/**
* Used for the outermost components on screens, to ensure that they can fill
* the screen and extend beyond.
*/
util_screen_outer: [
web({
minHeight: '100vh',
}),
native({
height: '100%',
}),
] as ViewStyle,
/*
* Theme-independent bg colors
*/
@@ -853,6 +871,7 @@ export const atoms = {
mr_auto: {
marginRight: 'auto',
},
/*
* Pointer events & user select
*/
@@ -871,6 +890,7 @@ export const atoms = {
user_select_all: {
userSelect: 'all',
},
/*
* Text decoration
*/
@@ -880,4 +900,11 @@ export const atoms = {
strike_through: {
textDecorationLine: 'line-through',
},
/*
* Display
*/
hidden: {
display: 'none',
},
} as const
+10 -53
View File
@@ -1,5 +1,5 @@
import React from 'react'
import {Dimensions} from 'react-native'
import {useMediaQuery} from 'react-responsive'
import {createThemes, defaultTheme} from '#/alf/themes'
import {Theme, ThemeName} from '#/alf/types'
@@ -12,52 +12,15 @@ export * from '#/alf/util/flatten'
export * from '#/alf/util/platform'
export * from '#/alf/util/themeSelector'
type BreakpointName = keyof typeof breakpoints
/*
* Breakpoints
*/
const breakpoints: {
[key: string]: number
} = {
gtPhone: 500,
gtMobile: 800,
gtTablet: 1300,
}
function getActiveBreakpoints({width}: {width: number}) {
const active: (keyof typeof breakpoints)[] = Object.keys(breakpoints).filter(
breakpoint => width >= breakpoints[breakpoint],
)
return {
active: active[active.length - 1],
gtPhone: active.includes('gtPhone'),
gtMobile: active.includes('gtMobile'),
gtTablet: active.includes('gtTablet'),
}
}
/*
* Context
*/
export const Context = React.createContext<{
themeName: ThemeName
theme: Theme
breakpoints: {
active: BreakpointName | undefined
gtPhone: boolean
gtMobile: boolean
gtTablet: boolean
}
}>({
themeName: 'light',
theme: defaultTheme,
breakpoints: {
active: undefined,
gtPhone: false,
gtMobile: false,
gtTablet: false,
},
})
export function ThemeProvider({
@@ -74,18 +37,6 @@ export function ThemeProvider({
})
}, [])
const theme = themes[themeName]
const [breakpoints, setBreakpoints] = React.useState(() =>
getActiveBreakpoints({width: Dimensions.get('window').width}),
)
React.useEffect(() => {
const listener = Dimensions.addEventListener('change', ({window}) => {
const bp = getActiveBreakpoints({width: window.width})
if (bp.active !== breakpoints.active) setBreakpoints(bp)
})
return listener.remove
}, [breakpoints, setBreakpoints])
return (
<Context.Provider
@@ -93,9 +44,8 @@ export function ThemeProvider({
() => ({
themeName: themeName,
theme: theme,
breakpoints,
}),
[theme, themeName, breakpoints],
[theme, themeName],
)}>
{children}
</Context.Provider>
@@ -107,5 +57,12 @@ export function useTheme() {
}
export function useBreakpoints() {
return React.useContext(Context).breakpoints
const gtPhone = useMediaQuery({minWidth: 500})
const gtMobile = useMediaQuery({minWidth: 800})
const gtTablet = useMediaQuery({minWidth: 1300})
return {
gtPhone,
gtMobile,
gtTablet,
}
}
+39 -9
View File
@@ -1,6 +1,8 @@
import React from 'react'
import {
AccessibilityProps,
GestureResponderEvent,
MouseEvent,
Pressable,
PressableProps,
StyleProp,
@@ -65,7 +67,15 @@ type NonTextElements =
export type ButtonProps = Pick<
PressableProps,
'disabled' | 'onPress' | 'testID' | 'onLongPress' | 'hitSlop'
| 'disabled'
| 'onPress'
| 'testID'
| 'onLongPress'
| 'hitSlop'
| 'onHoverIn'
| 'onHoverOut'
| 'onPressIn'
| 'onPressOut'
> &
AccessibilityProps &
VariantProps & {
@@ -115,30 +125,50 @@ export const Button = React.forwardRef<View, ButtonProps>(
focused: false,
})
const onPressIn = React.useCallback(() => {
const onPressInOuter = rest.onPressIn
const onPressIn = React.useCallback(
(e: GestureResponderEvent) => {
setState(s => ({
...s,
pressed: true,
}))
}, [setState])
const onPressOut = React.useCallback(() => {
onPressInOuter?.(e)
},
[setState, onPressInOuter],
)
const onPressOutOuter = rest.onPressOut
const onPressOut = React.useCallback(
(e: GestureResponderEvent) => {
setState(s => ({
...s,
pressed: false,
}))
}, [setState])
const onHoverIn = React.useCallback(() => {
onPressOutOuter?.(e)
},
[setState, onPressOutOuter],
)
const onHoverInOuter = rest.onHoverIn
const onHoverIn = React.useCallback(
(e: MouseEvent) => {
setState(s => ({
...s,
hovered: true,
}))
}, [setState])
const onHoverOut = React.useCallback(() => {
onHoverInOuter?.(e)
},
[setState, onHoverInOuter],
)
const onHoverOutOuter = rest.onHoverOut
const onHoverOut = React.useCallback(
(e: MouseEvent) => {
setState(s => ({
...s,
hovered: false,
}))
}, [setState])
onHoverOutOuter?.(e)
},
[setState, onHoverOutOuter],
)
const onFocus = React.useCallback(() => {
setState(s => ({
...s,
+4 -28
View File
@@ -2,21 +2,18 @@ import React from 'react'
import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/core'
import {StackActions} from '@react-navigation/native'
import {NavigationProp} from 'lib/routes/types'
import {useGoBack} from 'lib/hooks/useGoBack'
import {CenteredView} from 'view/com/util/Views'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import {Text} from '#/components/Typography'
import {router} from '#/routes'
export function Error({
title,
message,
onRetry,
onGoBack: onGoBackProp,
onGoBack,
hideBackButton,
sideBorders = true,
}: {
@@ -27,31 +24,10 @@ export function Error({
hideBackButton?: boolean
sideBorders?: boolean
}) {
const navigation = useNavigation<NavigationProp>()
const {_} = useLingui()
const t = useTheme()
const {gtMobile} = useBreakpoints()
const canGoBack = navigation.canGoBack()
const onGoBack = React.useCallback(() => {
if (onGoBackProp) {
onGoBackProp()
return
}
if (canGoBack) {
navigation.goBack()
} else {
navigation.navigate('HomeTab')
// Checking the state for routes ensures that web doesn't encounter errors while going back
if (navigation.getState()?.routes) {
navigation.dispatch(StackActions.push(...router.matchPath('/')))
} else {
navigation.navigate('HomeTab')
navigation.dispatch(StackActions.popToTop())
}
}
}, [navigation, canGoBack, onGoBackProp])
const goBack = useGoBack(onGoBack)
return (
<CenteredView
@@ -96,7 +72,7 @@ export function Error({
variant="solid"
color={onRetry ? 'secondary' : 'primary'}
label={_(msg`Return to previous page`)}
onPress={onGoBack}
onPress={goBack}
size="large"
style={[a.rounded_sm, a.overflow_hidden, {paddingVertical: 10}]}>
<ButtonText>
+50 -4
View File
@@ -1,7 +1,7 @@
import React from 'react'
import {View} from 'react-native'
import {ScrollView} from 'react-native-gesture-handler'
import {AppBskyFeedDefs, AtUri} from '@atproto/api'
import {AppBskyActorDefs, AppBskyFeedDefs, AtUri} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
@@ -12,7 +12,9 @@ import {logEvent} from '#/lib/statsig/statsig'
import {logger} from '#/logger'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useGetPopularFeedsQuery} from '#/state/queries/feed'
import {FeedDescriptor} from '#/state/queries/post-feed'
import {useProfilesQuery} from '#/state/queries/profile'
import {useSuggestedFollowsByActorQuery} from '#/state/queries/suggested-follows'
import {useSession} from '#/state/session'
import {useProgressGuide} from '#/state/shell/progress-guide'
import * as userActionHistory from '#/state/userActionHistory'
@@ -173,14 +175,58 @@ function useExperimentalSuggestedUsersQuery() {
}
}
export function SuggestedFollows() {
const t = useTheme()
const {_} = useLingui()
export function SuggestedFollows({feed}: {feed: FeedDescriptor}) {
const [feedType, feedUri] = feed.split('|')
if (feedType === 'author') {
return <SuggestedFollowsProfile did={feedUri} />
} else {
return <SuggestedFollowsHome />
}
}
export function SuggestedFollowsProfile({did}: {did: string}) {
const {
isLoading: isSuggestionsLoading,
data,
error,
} = useSuggestedFollowsByActorQuery({
did,
})
return (
<ProfileGrid
isSuggestionsLoading={isSuggestionsLoading}
profiles={data?.suggestions ?? []}
error={error}
/>
)
}
export function SuggestedFollowsHome() {
const {
isLoading: isSuggestionsLoading,
profiles,
error,
} = useExperimentalSuggestedUsersQuery()
return (
<ProfileGrid
isSuggestionsLoading={isSuggestionsLoading}
profiles={profiles}
error={error}
/>
)
}
export function ProfileGrid({
isSuggestionsLoading,
error,
profiles,
}: {
isSuggestionsLoading: boolean
profiles: AppBskyActorDefs.ProfileViewDetailed[]
error: Error | null
}) {
const t = useTheme()
const {_} = useLingui()
const moderationOpts = useModerationOpts()
const navigation = useNavigation<NavigationProp>()
const {gtMobile} = useBreakpoints()
+40 -4
View File
@@ -1,13 +1,20 @@
import React from 'react'
import {View} from 'react-native'
import {AppBskyActorDefs, AppBskyGraphDefs, AtUri} from '@atproto/api'
import {
AppBskyActorDefs,
AppBskyGraphDefs,
AtUri,
moderateUserList,
ModerationUI,
} from '@atproto/api'
import {Trans} from '@lingui/macro'
import {useQueryClient} from '@tanstack/react-query'
import {sanitizeHandle} from 'lib/strings/handles'
import {useModerationOpts} from 'state/preferences/moderation-opts'
import {precacheList} from 'state/queries/feed'
import {useTheme} from '#/alf'
import {atoms as a} from '#/alf'
import {useSession} from 'state/session'
import {atoms as a, useTheme} from '#/alf'
import {
Avatar,
Description,
@@ -16,6 +23,7 @@ import {
SaveButton,
} from '#/components/FeedCard'
import {Link as InternalLink, LinkProps} from '#/components/Link'
import * as Hider from '#/components/moderation/Hider'
import {Text} from '#/components/Typography'
/*
@@ -43,6 +51,11 @@ type Props = {
export function Default(props: Props) {
const {view, showPinButton} = props
const moderationOpts = useModerationOpts()
const moderation = moderationOpts
? moderateUserList(view, moderationOpts)
: undefined
return (
<Link {...props}>
<Outer>
@@ -52,6 +65,7 @@ export function Default(props: Props) {
title={view.name}
creator={view.creator}
purpose={view.purpose}
modUi={moderation?.ui('contentView')}
/>
{showPinButton && view.purpose === CURATELIST && (
<SaveButton view={view} pin />
@@ -89,18 +103,40 @@ export function TitleAndByline({
title,
creator,
purpose = CURATELIST,
modUi,
}: {
title: string
creator?: AppBskyActorDefs.ProfileViewBasic
purpose?: AppBskyGraphDefs.ListView['purpose']
modUi?: ModerationUI
}) {
const t = useTheme()
const {currentAccount} = useSession()
return (
<View style={[a.flex_1]}>
<Text style={[a.text_md, a.font_bold, a.leading_snug]} numberOfLines={1}>
<Hider.Outer
modui={modUi}
isContentVisibleInitialState={
creator && currentAccount?.did === creator.did
}
allowOverride={creator && currentAccount?.did === creator.did}>
<Hider.Mask>
<Text
style={[a.text_md, a.font_bold, a.leading_snug, a.italic]}
numberOfLines={1}>
<Trans>Hidden list</Trans>
</Text>
</Hider.Mask>
<Hider.Content>
<Text
style={[a.text_md, a.font_bold, a.leading_snug]}
numberOfLines={1}>
{title}
</Text>
</Hider.Content>
</Hider.Outer>
{creator && (
<Text
style={[a.leading_snug, t.atoms.text_contrast_medium]}
+1 -1
View File
@@ -178,7 +178,7 @@ let ListMaybePlaceholder = ({
return (
<CenteredView
style={[
a.flex_1,
a.h_full_vh,
a.align_center,
!gtMobile ? a.justify_between : a.gap_5xl,
t.atoms.border_contrast_low,
+172
View File
@@ -0,0 +1,172 @@
import React from 'react'
import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
import {Image} from 'expo-image'
import {
AppBskyEmbedExternal,
AppBskyEmbedImages,
AppBskyEmbedRecordWithMedia,
AppBskyEmbedVideo,
} from '@atproto/api'
import {Trans} from '@lingui/macro'
import {parseTenorGif} from '#/lib/strings/embed-player'
import {atoms as a} from '#/alf'
import {Text} from '#/components/Typography'
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
/**
* Streamlined MediaPreview component which just handles images, gifs, and videos
*/
export function Embed({
embed,
style,
}: {
embed?:
| AppBskyEmbedImages.View
| AppBskyEmbedRecordWithMedia.View
| AppBskyEmbedExternal.View
| AppBskyEmbedVideo.View
| {[k: string]: unknown}
style?: StyleProp<ViewStyle>
}) {
let media = AppBskyEmbedRecordWithMedia.isView(embed) ? embed.media : embed
if (AppBskyEmbedImages.isView(media)) {
return (
<Outer style={style}>
{media.images.map(image => (
<ImageItem
key={image.thumb}
thumbnail={image.thumb}
alt={image.alt}
/>
))}
</Outer>
)
} else if (AppBskyEmbedExternal.isView(embed) && embed.external.thumb) {
let url: URL | undefined
try {
url = new URL(embed.external.uri)
} catch {}
if (url) {
const {success} = parseTenorGif(url)
if (success) {
return (
<Outer style={style}>
<GifItem
thumbnail={embed.external.thumb}
alt={embed.external.title}
/>
</Outer>
)
}
}
} else if (AppBskyEmbedVideo.isView(embed)) {
return (
<Outer style={style}>
<VideoItem thumbnail={embed.thumbnail} alt={embed.alt} />
</Outer>
)
}
return null
}
export function Outer({
children,
style,
}: {
children?: React.ReactNode
style?: StyleProp<ViewStyle>
}) {
return <View style={[a.flex_row, a.gap_xs, style]}>{children}</View>
}
export function ImageItem({
thumbnail,
alt,
children,
}: {
thumbnail: string
alt?: string
children?: React.ReactNode
}) {
return (
<View style={[a.relative, a.flex_1, {aspectRatio: 1, maxWidth: 100}]}>
<Image
key={thumbnail}
source={{uri: thumbnail}}
style={[a.flex_1, a.rounded_xs]}
contentFit="cover"
accessible={true}
accessibilityIgnoresInvertColors
accessibilityHint={alt}
accessibilityLabel=""
/>
{children}
</View>
)
}
export function GifItem({thumbnail, alt}: {thumbnail: string; alt?: string}) {
return (
<ImageItem thumbnail={thumbnail} alt={alt}>
<View style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}>
<PlayButtonIcon size={24} />
</View>
<View style={styles.altContainer}>
<Text style={styles.alt}>
<Trans>GIF</Trans>
</Text>
</View>
</ImageItem>
)
}
export function VideoItem({
thumbnail,
alt,
}: {
thumbnail?: string
alt?: string
}) {
if (!thumbnail) {
return (
<View
style={[
{backgroundColor: 'black'},
a.flex_1,
{aspectRatio: 1, maxWidth: 100},
a.justify_center,
a.align_center,
]}>
<PlayButtonIcon size={24} />
</View>
)
}
return (
<ImageItem thumbnail={thumbnail} alt={alt}>
<View style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}>
<PlayButtonIcon size={24} />
</View>
</ImageItem>
)
}
const styles = StyleSheet.create({
altContainer: {
backgroundColor: 'rgba(0, 0, 0, 0.75)',
borderRadius: 6,
paddingHorizontal: 6,
paddingVertical: 3,
position: 'absolute',
right: 5,
bottom: 5,
zIndex: 2,
},
alt: {
color: 'white',
fontSize: 7,
fontWeight: 'bold',
},
})
+5 -1
View File
@@ -1,8 +1,12 @@
import React from 'react'
import type {ContextType} from '#/components/Menu/types'
import type {ContextType, ItemContextType} from '#/components/Menu/types'
export const Context = React.createContext<ContextType>({
// @ts-ignore
control: null,
})
export const ItemContext = React.createContext<ItemContextType>({
disabled: false,
})
+26 -6
View File
@@ -9,7 +9,7 @@ import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import {Context} from '#/components/Menu/context'
import {Context, ItemContext} from '#/components/Menu/context'
import {
ContextType,
GroupProps,
@@ -125,8 +125,14 @@ export function Item({children, label, style, onPress, ...rest}: ItemProps) {
}}
onFocus={onFocus}
onBlur={onBlur}
onPressIn={onPressIn}
onPressOut={onPressOut}
onPressIn={e => {
onPressIn()
rest.onPressIn?.(e)
}}
onPressOut={e => {
onPressOut()
rest.onPressOut?.(e)
}}
style={[
a.flex_row,
a.align_center,
@@ -138,15 +144,18 @@ export function Item({children, label, style, onPress, ...rest}: ItemProps) {
t.atoms.border_contrast_low,
{minHeight: 44, paddingVertical: 10},
style,
(focused || pressed) && [t.atoms.bg_contrast_50],
(focused || pressed) && !rest.disabled && [t.atoms.bg_contrast_50],
]}>
<ItemContext.Provider value={{disabled: Boolean(rest.disabled)}}>
{children}
</ItemContext.Provider>
</Pressable>
)
}
export function ItemText({children, style}: ItemTextProps) {
const t = useTheme()
const {disabled} = React.useContext(ItemContext)
return (
<Text
numberOfLines={1}
@@ -155,9 +164,10 @@ export function ItemText({children, style}: ItemTextProps) {
a.flex_1,
a.text_md,
a.font_bold,
t.atoms.text_contrast_medium,
t.atoms.text_contrast_high,
{paddingTop: 3},
style,
disabled && t.atoms.text_contrast_low,
]}>
{children}
</Text>
@@ -166,7 +176,17 @@ export function ItemText({children, style}: ItemTextProps) {
export function ItemIcon({icon: Comp}: ItemIconProps) {
const t = useTheme()
return <Comp size="lg" fill={t.atoms.text_contrast_medium.color} />
const {disabled} = React.useContext(ItemContext)
return (
<Comp
size="lg"
fill={
disabled
? t.atoms.text_contrast_low.color
: t.atoms.text_contrast_medium.color
}
/>
)
}
export function Group({children, style}: GroupProps) {
+25 -7
View File
@@ -9,7 +9,7 @@ import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
import {atoms as a, flatten, useTheme, web} from '#/alf'
import * as Dialog from '#/components/Dialog'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import {Context} from '#/components/Menu/context'
import {Context, ItemContext} from '#/components/Menu/context'
import {
ContextType,
GroupProps,
@@ -239,7 +239,8 @@ export function Item({children, label, onPress, ...rest}: ItemProps) {
a.rounded_xs,
{minHeight: 32, paddingHorizontal: 10},
web({outline: 0}),
(hovered || focused) && [
(hovered || focused) &&
!rest.disabled && [
web({outline: '0 !important'}),
t.name === 'light'
? t.atoms.bg_contrast_25
@@ -250,7 +251,9 @@ export function Item({children, label, onPress, ...rest}: ItemProps) {
onMouseEnter,
onMouseLeave,
})}>
<ItemContext.Provider value={{disabled: Boolean(rest.disabled)}}>
{children}
</ItemContext.Provider>
</Pressable>
</DropdownMenu.Item>
)
@@ -258,8 +261,16 @@ export function Item({children, label, onPress, ...rest}: ItemProps) {
export function ItemText({children, style}: ItemTextProps) {
const t = useTheme()
const {disabled} = React.useContext(ItemContext)
return (
<Text style={[a.flex_1, a.font_bold, t.atoms.text_contrast_high, style]}>
<Text
style={[
a.flex_1,
a.font_bold,
t.atoms.text_contrast_high,
style,
disabled && t.atoms.text_contrast_low,
]}>
{children}
</Text>
)
@@ -267,10 +278,9 @@ export function ItemText({children, style}: ItemTextProps) {
export function ItemIcon({icon: Comp, position = 'left'}: ItemIconProps) {
const t = useTheme()
const {disabled} = React.useContext(ItemContext)
return (
<Comp
size="md"
fill={t.atoms.text_contrast_medium.color}
<View
style={[
position === 'left' && {
marginLeft: -2,
@@ -279,8 +289,16 @@ export function ItemIcon({icon: Comp, position = 'left'}: ItemIconProps) {
marginRight: -2,
marginLeft: 12,
},
]}
]}>
<Comp
size="md"
fill={
disabled
? t.atoms.text_contrast_low.color
: t.atoms.text_contrast_medium.color
}
/>
</View>
)
}
+7 -3
View File
@@ -1,18 +1,22 @@
import React from 'react'
import {
AccessibilityProps,
GestureResponderEvent,
PressableProps,
AccessibilityProps,
} from 'react-native'
import {Props as SVGIconProps} from '#/components/icons/common'
import * as Dialog from '#/components/Dialog'
import {TextStyleProp, ViewStyleProp} from '#/alf'
import * as Dialog from '#/components/Dialog'
import {Props as SVGIconProps} from '#/components/icons/common'
export type ContextType = {
control: Dialog.DialogOuterProps['control']
}
export type ItemContextType = {
disabled: boolean
}
export type RadixPassThroughTriggerProps = {
id: string
type: 'button'
+10 -1
View File
@@ -13,6 +13,15 @@ import {
} from '#/components/moderation/ModerationDetailsDialog'
import {Text} from '#/components/Typography'
export type AppModerationCause =
| ModerationCause
| {
type: 'reply-hidden'
source: {type: 'user'; did: string}
priority: 6
downgraded?: boolean
}
export type CommonProps = {
size?: 'sm' | 'lg'
}
@@ -40,7 +49,7 @@ export function Row({
}
export type LabelProps = {
cause: ModerationCause
cause: AppModerationCause
disableDetailsDialog?: boolean
noBg?: boolean
} & CommonProps
+2 -2
View File
@@ -276,8 +276,8 @@ export function DescriptionPlaceholder() {
export type FollowButtonProps = {
profile: AppBskyActorDefs.ProfileViewBasic
moderationOpts: ModerationOpts
logContext: LogEvents['profile:follow']['logContext'] &
LogEvents['profile:unfollow']['logContext']
logContext: LogEvents['profile:follow:sampled']['logContext'] &
LogEvents['profile:unfollow:sampled']['logContext']
} & Partial<ButtonProps>
export function FollowButton(props: FollowButtonProps) {
@@ -377,7 +377,7 @@ function Inner({
hide: () => void
}) {
const t = useTheme()
const {_} = useLingui()
const {_, i18n} = useLingui()
const {currentAccount} = useSession()
const moderation = React.useMemo(
() => moderateProfile(profile, moderationOpts),
@@ -393,8 +393,8 @@ function Inner({
profile.viewer?.blocking ||
profile.viewer?.blockedBy ||
profile.viewer?.blockingByList
const following = formatCount(profile.followsCount || 0)
const followers = formatCount(profile.followersCount || 0)
const following = formatCount(i18n, profile.followsCount || 0)
const followers = formatCount(i18n, profile.followersCount || 0)
const pluralizedFollowers = plural(profile.followersCount || 0, {
one: 'follower',
other: 'followers',
+5 -2
View File
@@ -8,7 +8,10 @@ import {Button, ButtonColor, ButtonProps, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {Text} from '#/components/Typography'
export {useDialogControl as usePromptControl} from '#/components/Dialog'
export {
type DialogControlProps as PromptControlProps,
useDialogControl as usePromptControl,
} from '#/components/Dialog'
const Context = React.createContext<{
titleId: string
@@ -23,7 +26,7 @@ export function Outer({
control,
testID,
}: React.PropsWithChildren<{
control: Dialog.DialogOuterProps['control']
control: Dialog.DialogControlProps
testID?: string
}>) {
const {gtMobile} = useBreakpoints()
@@ -6,7 +6,6 @@ import {useLingui} from '@lingui/react'
import {getLabelingServiceTitle} from '#/lib/moderation'
import {ReportOption} from '#/lib/moderation/useReportOptions'
import {useGate} from '#/lib/statsig/statsig'
import {useAgent} from '#/state/session'
import {CharProgress} from '#/view/com/composer/char-progress/CharProgress'
import * as Toast from '#/view/com/util/Toast'
@@ -37,7 +36,6 @@ export function SubmitView({
const t = useTheme()
const {_} = useLingui()
const agent = useAgent()
const gate = useGate()
const [details, setDetails] = React.useState<string>('')
const [submitting, setSubmitting] = React.useState<boolean>(false)
const [selectedServices, setSelectedServices] = React.useState<string[]>([
@@ -63,7 +61,6 @@ export function SubmitView({
}
const results = await Promise.all(
selectedServices.map(did => {
if (gate('session_withproxy_fix')) {
return agent
.createModerationReport(report, {
encoding: 'application/json',
@@ -75,15 +72,6 @@ export function SubmitView({
_ => true,
_ => false,
)
} else {
return agent
.withProxy('atproto_labeler', did)
.createModerationReport(report)
.then(
_ => true,
_ => false,
)
}
}),
)
@@ -108,7 +96,6 @@ export function SubmitView({
onSubmitComplete,
setError,
agent,
gate,
])
return (
@@ -9,14 +9,15 @@ import {
import {InfiniteData, UseInfiniteQueryResult} from '@tanstack/react-query'
import {useBottomBarOffset} from 'lib/hooks/useBottomBarOffset'
import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender'
import {isBlockedOrBlocking} from 'lib/moderation/blocked-and-muted'
import {isNative, isWeb} from 'platform/detection'
import {useListMembersQuery} from 'state/queries/list-members'
import {useAllListMembersQuery} from 'state/queries/list-members'
import {useSession} from 'state/session'
import {List, ListRef} from 'view/com/util/List'
import {SectionRef} from '#/screens/Profile/Sections/types'
import {atoms as a, useTheme} from '#/alf'
import {ListMaybePlaceholder} from '#/components/Lists'
import {ListFooter, ListMaybePlaceholder} from '#/components/Lists'
import {Default as ProfileCard} from '#/components/ProfileCard'
function keyExtractor(item: AppBskyActorDefs.ProfileViewBasic, index: number) {
@@ -39,17 +40,20 @@ export const ProfilesList = React.forwardRef<SectionRef, ProfilesListProps>(
ref,
) {
const t = useTheme()
const [initialHeaderHeight] = React.useState(headerHeight)
const bottomBarOffset = useBottomBarOffset(20)
const bottomBarOffset = useBottomBarOffset(300)
const initialNumToRender = useInitialNumToRender()
const {currentAccount} = useSession()
const {data, refetch, isError} = useListMembersQuery(listUri, 50)
const {data, refetch, isError} = useAllListMembersQuery(listUri)
const [isPTRing, setIsPTRing] = React.useState(false)
// The server returns these sorted by descending creation date, so we want to invert
const profiles = data?.pages
.flatMap(p => p.items.map(i => i.subject))
.filter(p => !isBlockedOrBlocking(p) && !p.associated?.labeler)
const profiles = data
?.filter(
p => !isBlockedOrBlocking(p.subject) && !p.subject.associated?.labeler,
)
.map(p => p.subject)
.reverse()
const isOwn = new AtUri(listUri).host === currentAccount?.did
@@ -99,7 +103,11 @@ export const ProfilesList = React.forwardRef<SectionRef, ProfilesListProps>(
if (!data) {
return (
<View style={{marginTop: headerHeight, marginBottom: bottomBarOffset}}>
<View
style={[
a.h_full_vh,
{marginTop: headerHeight, marginBottom: bottomBarOffset},
]}>
<ListMaybePlaceholder
isLoading={true}
isError={isError}
@@ -118,10 +126,13 @@ export const ProfilesList = React.forwardRef<SectionRef, ProfilesListProps>(
ref={scrollElRef}
headerOffset={headerHeight}
ListFooterComponent={
<View style={[{height: initialHeaderHeight + bottomBarOffset}]} />
<ListFooter
style={{paddingBottom: bottomBarOffset, borderTopWidth: 0}}
/>
}
showsVerticalScrollIndicator={false}
desktopFixedHeight
initialNumToRender={initialNumToRender}
refreshing={isPTRing}
onRefresh={async () => {
setIsPTRing(true)
+13 -9
View File
@@ -59,20 +59,24 @@ export const QrCode = React.forwardRef<ViewShot, Props>(function QrCode(
<QrCodeInner link={link} />
</View>
<View style={[a.flex_row, a.align_center, {gap: 5}]}>
<Text
style={[
a.flex,
a.flex_row,
a.align_center,
a.font_bold,
a.text_center,
{color: 'white', fontSize: 18},
{color: 'white', fontSize: 18, gap: 6},
]}>
<Trans>on</Trans>
<Trans>
on
<View style={[a.flex_row, a.align_center, {gap: 6}]}>
<Logo width={25} fill="white" />
<View style={[{marginTop: 3.5}]}>
<Logotype width={72} fill="white" />
</View>
</View>
</Trans>
</Text>
<Logo width={26} fill="white" />
<View style={[{marginTop: 5, marginLeft: 2.5}]}>
<Logotype width={68} fill="white" />
</View>
</View>
</View>
</LinearGradientBackground>
</ViewShot>
+14 -7
View File
@@ -1,8 +1,7 @@
import React from 'react'
import {View} from 'react-native'
import {Image} from 'expo-image'
import {AppBskyGraphStarterpack, AtUri} from '@atproto/api'
import {StarterPackViewBasic} from '@atproto/api/dist/client/types/app/bsky/graph/defs'
import {AppBskyGraphDefs, AppBskyGraphStarterpack, AtUri} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useQueryClient} from '@tanstack/react-query'
@@ -17,7 +16,11 @@ import {StarterPack} from '#/components/icons/StarterPack'
import {BaseLink} from '#/components/Link'
import {Text} from '#/components/Typography'
export function Default({starterPack}: {starterPack?: StarterPackViewBasic}) {
export function Default({
starterPack,
}: {
starterPack?: AppBskyGraphDefs.StarterPackViewBasic
}) {
if (!starterPack) return null
return (
<Link starterPack={starterPack}>
@@ -29,7 +32,7 @@ export function Default({starterPack}: {starterPack?: StarterPackViewBasic}) {
export function Notification({
starterPack,
}: {
starterPack?: StarterPackViewBasic
starterPack?: AppBskyGraphDefs.StarterPackViewBasic
}) {
if (!starterPack) return null
return (
@@ -44,7 +47,7 @@ export function Card({
noIcon,
noDescription,
}: {
starterPack: StarterPackViewBasic
starterPack: AppBskyGraphDefs.StarterPackViewBasic
noIcon?: boolean
noDescription?: boolean
}) {
@@ -94,7 +97,7 @@ export function Link({
starterPack,
children,
}: {
starterPack: StarterPackViewBasic
starterPack: AppBskyGraphDefs.StarterPackViewBasic
onPress?: () => void
children: React.ReactNode
}) {
@@ -129,7 +132,11 @@ export function Link({
)
}
export function Embed({starterPack}: {starterPack: StarterPackViewBasic}) {
export function Embed({
starterPack,
}: {
starterPack: AppBskyGraphDefs.StarterPackViewBasic
}) {
const t = useTheme()
const imageUri = getStarterPackOgCard(starterPack)
@@ -7,6 +7,7 @@ import {BottomSheetFlatListMethods} from '@gorhom/bottom-sheet'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender'
import {isWeb} from 'platform/detection'
import {useSession} from 'state/session'
import {WizardAction, WizardState} from '#/screens/StarterPack/Wizard/State'
@@ -42,6 +43,7 @@ export function WizardEditListDialog({
const {_} = useLingui()
const t = useTheme()
const {currentAccount} = useSession()
const initialNumToRender = useInitialNumToRender()
const listRef = useRef<BottomSheetFlatListMethods>(null)
@@ -148,6 +150,7 @@ export function WizardEditListDialog({
webInnerStyle={[a.py_0, {maxWidth: 500, minWidth: 200}]}
keyboardDismissMode="on-drag"
removeClippedSubviews={true}
initialNumToRender={initialNumToRender}
/>
</Dialog.Outer>
)
@@ -12,7 +12,7 @@ import {GeneratorView} from '@atproto/api/dist/client/types/app/bsky/feed/defs'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {DISCOVER_FEED_URI} from 'lib/constants'
import {DISCOVER_FEED_URI, STARTER_PACK_MAX_SIZE} from 'lib/constants'
import {sanitizeDisplayName} from 'lib/strings/display-names'
import {sanitizeHandle} from 'lib/strings/handles'
import {useSession} from 'state/session'
@@ -130,7 +130,8 @@ export function WizardProfileCard({
const isMe = profile.did === currentAccount?.did
const included = isMe || state.profiles.some(p => p.did === profile.did)
const disabled = isMe || (!included && state.profiles.length >= 49)
const disabled =
isMe || (!included && state.profiles.length >= STARTER_PACK_MAX_SIZE - 1)
const moderationUi = moderateProfile(profile, moderationOpts).ui('avatar')
const displayName = profile.displayName
? sanitizeDisplayName(profile.displayName)
+108 -147
View File
@@ -1,39 +1,34 @@
import React from 'react'
import {Keyboard, StyleProp, View, ViewStyle} from 'react-native'
import {Keyboard, Platform, StyleProp, View, ViewStyle} from 'react-native'
import {
AppBskyFeedDefs,
AppBskyFeedGetPostThread,
AppBskyFeedPost,
AppBskyGraphDefs,
AtUri,
BskyAgent,
} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useQueryClient} from '@tanstack/react-query'
import {createThreadgate} from '#/lib/api'
import {until} from '#/lib/async/until'
import {HITSLOP_10} from '#/lib/constants'
import {makeListLink, makeProfileLink} from '#/lib/routes/links'
import {logger} from '#/logger'
import {isNative} from '#/platform/detection'
import {RQKEY_ROOT as POST_THREAD_RQKEY_ROOT} from '#/state/queries/post-thread'
import {
ThreadgateSetting,
threadgateViewToSettings,
ThreadgateAllowUISetting,
threadgateViewToAllowUISetting,
} from '#/state/queries/threadgate'
import {useAgent} from '#/state/session'
import * as Toast from 'view/com/util/Toast'
import {atoms as a, useTheme} from '#/alf'
import {Button} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {useDialogControl} from '#/components/Dialog'
import {
PostInteractionSettingsDialog,
usePrefetchPostInteractionSettings,
} from '#/components/dialogs/PostInteractionSettingsDialog'
import {CircleBanSign_Stroke2_Corner0_Rounded as CircleBanSign} from '#/components/icons/CircleBanSign'
import {Earth_Stroke2_Corner0_Rounded as Earth} from '#/components/icons/Globe'
import {Group3_Stroke2_Corner0_Rounded as Group} from '#/components/icons/Group'
import {InlineLinkText} from '#/components/Link'
import {Text} from '#/components/Typography'
import {TextLink} from '../view/com/util/Link'
import {ThreadgateEditorDialog} from './dialogs/ThreadgateEditor'
import {PencilLine_Stroke2_Corner0_Rounded as PencilLine} from './icons/Pencil'
interface WhoCanReplyProps {
@@ -47,31 +42,34 @@ export function WhoCanReply({post, isThreadAuthor, style}: WhoCanReplyProps) {
const t = useTheme()
const infoDialogControl = useDialogControl()
const editDialogControl = useDialogControl()
const agent = useAgent()
const queryClient = useQueryClient()
const settings = React.useMemo(
() => threadgateViewToSettings(post.threadgate),
[post],
)
const isRootPost = !('reply' in post.record)
/*
* `WhoCanReply` is only used for root posts atm, in case this changes
* unexpectedly, we should check to make sure it's for sure the root URI.
*/
const rootUri =
AppBskyFeedPost.isRecord(post.record) && post.record.reply?.root
? post.record.reply.root.uri
: post.uri
const settings = React.useMemo(() => {
return threadgateViewToAllowUISetting(post.threadgate)
}, [post.threadgate])
if (!isRootPost) {
return null
}
if (!settings.length && !isThreadAuthor) {
return null
}
const prefetchPostInteractionSettings = usePrefetchPostInteractionSettings({
postUri: post.uri,
rootPostUri: rootUri,
})
const isEverybody = settings.length === 0
const isNobody = !!settings.find(gate => gate.type === 'nobody')
const description = isEverybody
const anyoneCanReply =
settings.length === 1 && settings[0].type === 'everybody'
const noOneCanReply = settings.length === 1 && settings[0].type === 'nobody'
const description = anyoneCanReply
? _(msg`Everybody can reply`)
: isNobody
: noOneCanReply
? _(msg`Replies disabled`)
: _(msg`Some people can reply`)
const onPressEdit = () => {
const onPressOpen = () => {
if (isNative && Keyboard.isVisible()) {
Keyboard.dismiss()
}
@@ -82,52 +80,23 @@ export function WhoCanReply({post, isThreadAuthor, style}: WhoCanReplyProps) {
}
}
const onEditConfirm = async (newSettings: ThreadgateSetting[]) => {
if (JSON.stringify(settings) === JSON.stringify(newSettings)) {
return
}
try {
if (newSettings.length) {
await createThreadgate(agent, post.uri, newSettings)
} else {
await agent.api.com.atproto.repo.deleteRecord({
repo: agent.session!.did,
collection: 'app.bsky.feed.threadgate',
rkey: new AtUri(post.uri).rkey,
})
}
await whenAppViewReady(agent, post.uri, res => {
const thread = res.data.thread
if (AppBskyFeedDefs.isThreadViewPost(thread)) {
const fetchedSettings = threadgateViewToSettings(
thread.post.threadgate,
)
return JSON.stringify(fetchedSettings) === JSON.stringify(newSettings)
}
return false
})
Toast.show(_(msg`Thread settings updated`))
queryClient.invalidateQueries({
queryKey: [POST_THREAD_RQKEY_ROOT],
})
} catch (err) {
Toast.show(
_(
msg`There was an issue. Please check your internet connection and try again.`,
),
'xmark',
)
logger.error('Failed to edit threadgate', {message: err})
}
}
return (
<>
<Button
label={
isThreadAuthor ? _(msg`Edit who can reply`) : _(msg`Who can reply`)
}
onPress={isThreadAuthor ? onPressEdit : infoDialogControl.open}
onPress={onPressOpen}
{...(isThreadAuthor
? Platform.select({
web: {
onHoverIn: prefetchPostInteractionSettings,
},
native: {
onPressIn: prefetchPostInteractionSettings,
},
})
: {})}
hitSlop={HITSLOP_10}>
{({hovered}) => (
<View style={[a.flex_row, a.align_center, a.gap_xs, style]}>
@@ -145,22 +114,27 @@ export function WhoCanReply({post, isThreadAuthor, style}: WhoCanReplyProps) {
]}>
{description}
</Text>
{isThreadAuthor && (
<PencilLine width={12} fill={t.palette.primary_500} />
)}
</View>
)}
</Button>
{isThreadAuthor ? (
<PostInteractionSettingsDialog
postUri={post.uri}
rootPostUri={rootUri}
control={editDialogControl}
initialThreadgateView={post.threadgate}
/>
) : (
<WhoCanReplyDialog
control={infoDialogControl}
post={post}
settings={settings}
/>
{isThreadAuthor && (
<ThreadgateEditorDialog
control={editDialogControl}
threadgate={settings}
onConfirm={onEditConfirm}
embeddingDisabled={Boolean(post.viewer?.embeddingDisabled)}
/>
)}
</>
@@ -174,7 +148,7 @@ function Icon({
}: {
color: string
width?: number
settings: ThreadgateSetting[]
settings: ThreadgateAllowUISetting[]
}) {
const isEverybody = settings.length === 0
const isNobody = !!settings.find(gate => gate.type === 'nobody')
@@ -186,79 +160,84 @@ function WhoCanReplyDialog({
control,
post,
settings,
embeddingDisabled,
}: {
control: Dialog.DialogControlProps
post: AppBskyFeedDefs.PostView
settings: ThreadgateSetting[]
}) {
return (
<Dialog.Outer control={control}>
<Dialog.Handle />
<WhoCanReplyDialogInner post={post} settings={settings} />
</Dialog.Outer>
)
}
function WhoCanReplyDialogInner({
post,
settings,
}: {
post: AppBskyFeedDefs.PostView
settings: ThreadgateSetting[]
settings: ThreadgateAllowUISetting[]
embeddingDisabled: boolean
}) {
const {_} = useLingui()
return (
<Dialog.Outer control={control}>
<Dialog.Handle />
<Dialog.ScrollableInner
label={_(msg`Who can reply dialog`)}
label={_(msg`Dialog: adjust who can interact with this post`)}
style={[{width: 'auto', maxWidth: 400, minWidth: 200}]}>
<View style={[a.gap_sm]}>
<Text style={[a.font_bold, a.text_xl]}>
<Trans>Who can reply?</Trans>
<Text style={[a.font_bold, a.text_xl, a.pb_sm]}>
<Trans>Who can interact with this post?</Trans>
</Text>
<Rules post={post} settings={settings} />
<Rules
post={post}
settings={settings}
embeddingDisabled={embeddingDisabled}
/>
</View>
</Dialog.ScrollableInner>
</Dialog.Outer>
)
}
function Rules({
post,
settings,
embeddingDisabled,
}: {
post: AppBskyFeedDefs.PostView
settings: ThreadgateSetting[]
settings: ThreadgateAllowUISetting[]
embeddingDisabled: boolean
}) {
const t = useTheme()
return (
<>
<Text
style={[
a.text_md,
a.leading_tight,
a.text_sm,
a.leading_snug,
a.flex_wrap,
t.atoms.text_contrast_medium,
]}>
{!settings.length ? (
<Trans>Everybody can reply</Trans>
{settings[0].type === 'everybody' ? (
<Trans>Everybody can reply to this post.</Trans>
) : settings[0].type === 'nobody' ? (
<Trans>Replies to this thread are disabled</Trans>
<Trans>Replies to this post are disabled.</Trans>
) : (
<Trans>
Only{' '}
{settings.map((rule, i) => (
<>
<Rule
key={`rule-${i}`}
rule={rule}
post={post}
lists={post.threadgate!.lists}
/>
<Separator key={`sep-${i}`} i={i} length={settings.length} />
</>
<React.Fragment key={`rule-${i}`}>
<Rule rule={rule} post={post} lists={post.threadgate!.lists} />
<Separator i={i} length={settings.length} />
</React.Fragment>
))}{' '}
can reply
can reply.
</Trans>
)}
)}{' '}
</Text>
{embeddingDisabled && (
<Text
style={[
a.text_sm,
a.leading_snug,
a.flex_wrap,
t.atoms.text_contrast_medium,
]}>
<Trans>No one but the author can quote this post.</Trans>
</Text>
)}
</>
)
}
@@ -267,11 +246,10 @@ function Rule({
post,
lists,
}: {
rule: ThreadgateSetting
rule: ThreadgateAllowUISetting
post: AppBskyFeedDefs.PostView
lists: AppBskyGraphDefs.ListViewBasic[] | undefined
}) {
const t = useTheme()
if (rule.type === 'mention') {
return <Trans>mentioned users</Trans>
}
@@ -279,12 +257,12 @@ function Rule({
return (
<Trans>
users followed by{' '}
<TextLink
type="sm"
href={makeProfileLink(post.author)}
text={`@${post.author.handle}`}
style={{color: t.palette.primary_500}}
/>
<InlineLinkText
label={`@${post.author.handle}`}
to={makeProfileLink(post.author)}
style={[a.text_sm, a.leading_snug]}>
@{post.author.handle}
</InlineLinkText>
</Trans>
)
}
@@ -294,12 +272,12 @@ function Rule({
const listUrip = new AtUri(list.uri)
return (
<Trans>
<TextLink
type="sm"
href={makeListLink(listUrip.hostname, listUrip.rkey)}
text={list.name}
style={{color: t.palette.primary_500}}
/>{' '}
<InlineLinkText
label={list.name}
to={makeListLink(listUrip.hostname, listUrip.rkey)}
style={[a.text_sm, a.leading_snug]}>
{list.name}
</InlineLinkText>{' '}
members
</Trans>
)
@@ -320,20 +298,3 @@ function Separator({i, length}: {i: number; length: number}) {
}
return <>, </>
}
async function whenAppViewReady(
agent: BskyAgent,
uri: string,
fn: (res: AppBskyFeedGetPostThread.Response) => boolean,
) {
await until(
5, // 5 tries
1e3, // 1s delay between tries
fn,
() =>
agent.app.bsky.feed.getPostThread({
uri,
depth: 0,
}),
)
}
+3 -3
View File
@@ -43,7 +43,7 @@ function EmbedDialogInner({
timestamp,
}: Omit<EmbedDialogProps, 'control'>) {
const t = useTheme()
const {_} = useLingui()
const {_, i18n} = useLingui()
const ref = useRef<TextInput>(null)
const [copied, setCopied] = useState(false)
@@ -86,9 +86,9 @@ function EmbedDialogInner({
)} (<a href="${escapeHtml(profileHref)}">@${escapeHtml(
postAuthor.handle,
)}</a>) <a href="${escapeHtml(href)}">${escapeHtml(
niceDate(timestamp),
niceDate(i18n, timestamp),
)}</a></blockquote><script async src="${EMBED_SCRIPT}" charset="utf-8"></script>`
}, [postUri, postCid, record, timestamp, postAuthor])
}, [i18n, postUri, postCid, record, timestamp, postAuthor])
return (
<Dialog.Inner label="Embed post" style={[a.gap_md, {maxWidth: 500}]}>
@@ -0,0 +1,538 @@
import React from 'react'
import {StyleProp, View, ViewStyle} from 'react-native'
import {AppBskyFeedDefs, AppBskyFeedPostgate, AtUri} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useQueryClient} from '@tanstack/react-query'
import isEqual from 'lodash.isequal'
import {logger} from '#/logger'
import {STALE} from '#/state/queries'
import {useMyListsQuery} from '#/state/queries/my-lists'
import {
createPostgateQueryKey,
getPostgateRecord,
usePostgateQuery,
useWritePostgateMutation,
} from '#/state/queries/postgate'
import {
createPostgateRecord,
embeddingRules,
} from '#/state/queries/postgate/util'
import {
createThreadgateViewQueryKey,
getThreadgateView,
ThreadgateAllowUISetting,
threadgateViewToAllowUISetting,
useSetThreadgateAllowMutation,
useThreadgateViewQuery,
} from '#/state/queries/threadgate'
import {useAgent, useSession} from '#/state/session'
import * as Toast from '#/view/com/util/Toast'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {Divider} from '#/components/Divider'
import * as Toggle from '#/components/forms/Toggle'
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
export type PostInteractionSettingsFormProps = {
onSave: () => void
isSaving?: boolean
postgate: AppBskyFeedPostgate.Record
onChangePostgate: (v: AppBskyFeedPostgate.Record) => void
threadgateAllowUISettings: ThreadgateAllowUISetting[]
onChangeThreadgateAllowUISettings: (v: ThreadgateAllowUISetting[]) => void
replySettingsDisabled?: boolean
}
export function PostInteractionSettingsControlledDialog({
control,
...rest
}: PostInteractionSettingsFormProps & {
control: Dialog.DialogControlProps
}) {
const {_} = useLingui()
return (
<Dialog.Outer control={control}>
<Dialog.Handle />
<Dialog.ScrollableInner
label={_(msg`Edit post interaction settings`)}
style={[{maxWidth: 500}, a.w_full]}>
<PostInteractionSettingsForm {...rest} />
<Dialog.Close />
</Dialog.ScrollableInner>
</Dialog.Outer>
)
}
export type PostInteractionSettingsDialogProps = {
control: Dialog.DialogControlProps
/**
* URI of the post to edit the interaction settings for. Could be a root post
* or could be a reply.
*/
postUri: string
/**
* The URI of the root post in the thread. Used to determine if the viewer
* owns the threadgate record and can therefore edit it.
*/
rootPostUri: string
/**
* Optional initial {@link AppBskyFeedDefs.ThreadgateView} to use if we
* happen to have one before opening the settings dialog.
*/
initialThreadgateView?: AppBskyFeedDefs.ThreadgateView
}
export function PostInteractionSettingsDialog(
props: PostInteractionSettingsDialogProps,
) {
return (
<Dialog.Outer control={props.control}>
<Dialog.Handle />
<PostInteractionSettingsDialogControlledInner {...props} />
</Dialog.Outer>
)
}
export function PostInteractionSettingsDialogControlledInner(
props: PostInteractionSettingsDialogProps,
) {
const {_} = useLingui()
const {currentAccount} = useSession()
const [isSaving, setIsSaving] = React.useState(false)
const {data: threadgateViewLoaded, isLoading: isLoadingThreadgate} =
useThreadgateViewQuery({postUri: props.rootPostUri})
const {data: postgate, isLoading: isLoadingPostgate} = usePostgateQuery({
postUri: props.postUri,
})
const {mutateAsync: writePostgateRecord} = useWritePostgateMutation()
const {mutateAsync: setThreadgateAllow} = useSetThreadgateAllowMutation()
const [editedPostgate, setEditedPostgate] =
React.useState<AppBskyFeedPostgate.Record>()
const [editedAllowUISettings, setEditedAllowUISettings] =
React.useState<ThreadgateAllowUISetting[]>()
const isLoading = isLoadingThreadgate || isLoadingPostgate
const threadgateView = threadgateViewLoaded || props.initialThreadgateView
const isThreadgateOwnedByViewer = React.useMemo(() => {
return currentAccount?.did === new AtUri(props.rootPostUri).host
}, [props.rootPostUri, currentAccount?.did])
const postgateValue = React.useMemo(() => {
return (
editedPostgate || postgate || createPostgateRecord({post: props.postUri})
)
}, [postgate, editedPostgate, props.postUri])
const allowUIValue = React.useMemo(() => {
return (
editedAllowUISettings || threadgateViewToAllowUISetting(threadgateView)
)
}, [threadgateView, editedAllowUISettings])
const onSave = React.useCallback(async () => {
if (!editedPostgate && !editedAllowUISettings) {
props.control.close()
return
}
setIsSaving(true)
try {
const requests = []
if (editedPostgate) {
requests.push(
writePostgateRecord({
postUri: props.postUri,
postgate: editedPostgate,
}),
)
}
if (editedAllowUISettings && isThreadgateOwnedByViewer) {
requests.push(
setThreadgateAllow({
postUri: props.rootPostUri,
allow: editedAllowUISettings,
}),
)
}
await Promise.all(requests)
props.control.close()
} catch (e: any) {
logger.error(`Failed to save post interaction settings`, {
context: 'PostInteractionSettingsDialogControlledInner',
safeMessage: e.message,
})
Toast.show(
_(
msg`There was an issue. Please check your internet connection and try again.`,
),
'xmark',
)
} finally {
setIsSaving(false)
}
}, [
_,
props.postUri,
props.rootPostUri,
props.control,
editedPostgate,
editedAllowUISettings,
setIsSaving,
writePostgateRecord,
setThreadgateAllow,
isThreadgateOwnedByViewer,
])
return (
<Dialog.ScrollableInner
label={_(msg`Edit post interaction settings`)}
style={[{maxWidth: 500}, a.w_full]}>
{isLoading ? (
<Loader size="xl" />
) : (
<PostInteractionSettingsForm
replySettingsDisabled={!isThreadgateOwnedByViewer}
isSaving={isSaving}
onSave={onSave}
postgate={postgateValue}
onChangePostgate={setEditedPostgate}
threadgateAllowUISettings={allowUIValue}
onChangeThreadgateAllowUISettings={setEditedAllowUISettings}
/>
)}
</Dialog.ScrollableInner>
)
}
export function PostInteractionSettingsForm({
onSave,
isSaving,
postgate,
onChangePostgate,
threadgateAllowUISettings,
onChangeThreadgateAllowUISettings,
replySettingsDisabled,
}: PostInteractionSettingsFormProps) {
const t = useTheme()
const {_} = useLingui()
const control = Dialog.useDialogContext()
const {data: lists} = useMyListsQuery('curate')
const [quotesEnabled, setQuotesEnabled] = React.useState(
!(
postgate.embeddingRules &&
postgate.embeddingRules.find(
v => v.$type === embeddingRules.disableRule.$type,
)
),
)
const onPressAudience = (setting: ThreadgateAllowUISetting) => {
// remove boolean values
let newSelected: ThreadgateAllowUISetting[] =
threadgateAllowUISettings.filter(
v => v.type !== 'nobody' && v.type !== 'everybody',
)
// toggle
const i = newSelected.findIndex(v => isEqual(v, setting))
if (i === -1) {
newSelected.push(setting)
} else {
newSelected.splice(i, 1)
}
onChangeThreadgateAllowUISettings(newSelected)
}
const onChangeQuotesEnabled = React.useCallback(
(enabled: boolean) => {
setQuotesEnabled(enabled)
onChangePostgate(
createPostgateRecord({
...postgate,
embeddingRules: enabled ? [] : [embeddingRules.disableRule],
}),
)
},
[setQuotesEnabled, postgate, onChangePostgate],
)
const noOneCanReply = !!threadgateAllowUISettings.find(
v => v.type === 'nobody',
)
return (
<View>
<View style={[a.flex_1, a.gap_md]}>
<Text style={[a.text_2xl, a.font_bold]}>
<Trans>Post interaction settings</Trans>
</Text>
<View style={[a.gap_lg]}>
<Text style={[a.text_md]}>
<Trans>Customize who can interact with this post.</Trans>
</Text>
<Divider />
<View style={[a.gap_sm]}>
<Text style={[a.font_bold, a.text_lg]}>
<Trans>Quote settings</Trans>
</Text>
<Toggle.Item
name="quoteposts"
type="checkbox"
label={
quotesEnabled
? _(msg`Click to disable quote posts of this post.`)
: _(msg`Click to enable quote posts of this post.`)
}
value={quotesEnabled}
onChange={onChangeQuotesEnabled}
style={[, a.justify_between, a.pt_xs]}>
<Text style={[t.atoms.text_contrast_medium]}>
{quotesEnabled ? (
<Trans>Quote posts enabled</Trans>
) : (
<Trans>Quote posts disabled</Trans>
)}
</Text>
<Toggle.Switch />
</Toggle.Item>
</View>
<Divider />
{replySettingsDisabled && (
<View
style={[
a.px_md,
a.py_sm,
a.rounded_sm,
a.flex_row,
a.align_center,
a.gap_sm,
t.atoms.bg_contrast_25,
]}>
<CircleInfo fill={t.atoms.text_contrast_low.color} />
<Text
style={[
a.flex_1,
a.leading_snug,
t.atoms.text_contrast_medium,
]}>
<Trans>
Reply settings are chosen by the author of the thread
</Trans>
</Text>
</View>
)}
<View
style={[
a.gap_sm,
{
opacity: replySettingsDisabled ? 0.3 : 1,
},
]}>
<Text style={[a.font_bold, a.text_lg]}>
<Trans>Reply settings</Trans>
</Text>
<Text style={[a.pt_sm, t.atoms.text_contrast_medium]}>
<Trans>Allow replies from:</Trans>
</Text>
<View style={[a.flex_row, a.gap_sm]}>
<Selectable
label={_(msg`Everybody`)}
isSelected={
!!threadgateAllowUISettings.find(v => v.type === 'everybody')
}
onPress={() =>
onChangeThreadgateAllowUISettings([{type: 'everybody'}])
}
style={{flex: 1}}
disabled={replySettingsDisabled}
/>
<Selectable
label={_(msg`Nobody`)}
isSelected={noOneCanReply}
onPress={() =>
onChangeThreadgateAllowUISettings([{type: 'nobody'}])
}
style={{flex: 1}}
disabled={replySettingsDisabled}
/>
</View>
{!noOneCanReply && (
<>
<Text style={[a.pt_sm, t.atoms.text_contrast_medium]}>
<Trans>Or combine these options:</Trans>
</Text>
<View style={[a.gap_sm]}>
<Selectable
label={_(msg`Mentioned users`)}
isSelected={
!!threadgateAllowUISettings.find(
v => v.type === 'mention',
)
}
onPress={() => onPressAudience({type: 'mention'})}
disabled={replySettingsDisabled}
/>
<Selectable
label={_(msg`Followed users`)}
isSelected={
!!threadgateAllowUISettings.find(
v => v.type === 'following',
)
}
onPress={() => onPressAudience({type: 'following'})}
disabled={replySettingsDisabled}
/>
{lists && lists.length > 0
? lists.map(list => (
<Selectable
key={list.uri}
label={_(msg`Users in "${list.name}"`)}
isSelected={
!!threadgateAllowUISettings.find(
v => v.type === 'list' && v.list === list.uri,
)
}
onPress={() =>
onPressAudience({type: 'list', list: list.uri})
}
disabled={replySettingsDisabled}
/>
))
: // No loading states to avoid jumps for the common case (no lists)
null}
</View>
</>
)}
</View>
</View>
</View>
<Button
label={_(msg`Save`)}
onPress={onSave}
onAccessibilityEscape={control.close}
color="primary"
size="medium"
variant="solid"
style={a.mt_xl}>
<ButtonText>{_(msg`Save`)}</ButtonText>
{isSaving && <ButtonIcon icon={Loader} position="right" />}
</Button>
</View>
)
}
function Selectable({
label,
isSelected,
onPress,
style,
disabled,
}: {
label: string
isSelected: boolean
onPress: () => void
style?: StyleProp<ViewStyle>
disabled?: boolean
}) {
const t = useTheme()
return (
<Button
disabled={disabled}
onPress={onPress}
label={label}
accessibilityRole="checkbox"
aria-checked={isSelected}
accessibilityState={{
checked: isSelected,
}}
style={a.flex_1}>
{({hovered, focused}) => (
<View
style={[
a.flex_1,
a.flex_row,
a.align_center,
a.justify_between,
a.rounded_sm,
a.p_md,
{height: 40}, // for consistency with checkmark icon visible or not
t.atoms.bg_contrast_50,
(hovered || focused) && t.atoms.bg_contrast_100,
isSelected && {
backgroundColor: t.palette.primary_100,
},
style,
]}>
<Text style={[a.text_sm, isSelected && a.font_semibold]}>
{label}
</Text>
{isSelected ? (
<Check size="sm" fill={t.palette.primary_500} />
) : (
<View />
)}
</View>
)}
</Button>
)
}
export function usePrefetchPostInteractionSettings({
postUri,
rootPostUri,
}: {
postUri: string
rootPostUri: string
}) {
const queryClient = useQueryClient()
const agent = useAgent()
return React.useCallback(async () => {
try {
await Promise.all([
queryClient.prefetchQuery({
queryKey: createPostgateQueryKey(postUri),
queryFn: () => getPostgateRecord({agent, postUri}),
staleTime: STALE.SECONDS.THIRTY,
}),
queryClient.prefetchQuery({
queryKey: createThreadgateViewQueryKey(rootPostUri),
queryFn: () => getThreadgateView({agent, postUri: rootPostUri}),
staleTime: STALE.SECONDS.THIRTY,
}),
])
} catch (e: any) {
logger.error(`Failed to prefetch post interaction settings`, {
safeMessage: e.message,
})
}
}, [queryClient, agent, postUri, rootPostUri])
}
-217
View File
@@ -1,217 +0,0 @@
import React from 'react'
import {StyleProp, View, ViewStyle} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import isEqual from 'lodash.isequal'
import {useMyListsQuery} from '#/state/queries/my-lists'
import {ThreadgateSetting} from '#/state/queries/threadgate'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
import {Text} from '#/components/Typography'
interface ThreadgateEditorDialogProps {
control: Dialog.DialogControlProps
threadgate: ThreadgateSetting[]
onChange?: (v: ThreadgateSetting[]) => void
onConfirm?: (v: ThreadgateSetting[]) => void
}
export function ThreadgateEditorDialog({
control,
threadgate,
onChange,
onConfirm,
}: ThreadgateEditorDialogProps) {
return (
<Dialog.Outer control={control}>
<Dialog.Handle />
<DialogContent
seedThreadgate={threadgate}
onChange={onChange}
onConfirm={onConfirm}
/>
</Dialog.Outer>
)
}
function DialogContent({
seedThreadgate,
onChange,
onConfirm,
}: {
seedThreadgate: ThreadgateSetting[]
onChange?: (v: ThreadgateSetting[]) => void
onConfirm?: (v: ThreadgateSetting[]) => void
}) {
const {_} = useLingui()
const control = Dialog.useDialogContext()
const {data: lists} = useMyListsQuery('curate')
const [draft, setDraft] = React.useState(seedThreadgate)
const [prevSeedThreadgate, setPrevSeedThreadgate] =
React.useState(seedThreadgate)
if (seedThreadgate !== prevSeedThreadgate) {
// New data flowed from above (e.g. due to update coming through).
setPrevSeedThreadgate(seedThreadgate)
setDraft(seedThreadgate) // Reset draft.
}
function updateThreadgate(nextThreadgate: ThreadgateSetting[]) {
setDraft(nextThreadgate)
onChange?.(nextThreadgate)
}
const onPressEverybody = () => {
updateThreadgate([])
}
const onPressNobody = () => {
updateThreadgate([{type: 'nobody'}])
}
const onPressAudience = (setting: ThreadgateSetting) => {
// remove nobody
let newSelected: ThreadgateSetting[] = draft.filter(
v => v.type !== 'nobody',
)
// toggle
const i = newSelected.findIndex(v => isEqual(v, setting))
if (i === -1) {
newSelected.push(setting)
} else {
newSelected.splice(i, 1)
}
updateThreadgate(newSelected)
}
const doneLabel = onConfirm ? _(msg`Save`) : _(msg`Done`)
return (
<Dialog.ScrollableInner
label={_(msg`Choose who can reply`)}
style={[{maxWidth: 500}, a.w_full]}>
<View style={[a.flex_1, a.gap_md]}>
<Text style={[a.text_2xl, a.font_bold]}>
<Trans>Choose who can reply</Trans>
</Text>
<Text style={a.mt_xs}>
<Trans>Either choose "Everybody" or "Nobody"</Trans>
</Text>
<View style={[a.flex_row, a.gap_sm]}>
<Selectable
label={_(msg`Everybody`)}
isSelected={draft.length === 0}
onPress={onPressEverybody}
style={{flex: 1}}
/>
<Selectable
label={_(msg`Nobody`)}
isSelected={!!draft.find(v => v.type === 'nobody')}
onPress={onPressNobody}
style={{flex: 1}}
/>
</View>
<Text style={a.mt_md}>
<Trans>Or combine these options:</Trans>
</Text>
<View style={[a.gap_sm]}>
<Selectable
label={_(msg`Mentioned users`)}
isSelected={!!draft.find(v => v.type === 'mention')}
onPress={() => onPressAudience({type: 'mention'})}
/>
<Selectable
label={_(msg`Followed users`)}
isSelected={!!draft.find(v => v.type === 'following')}
onPress={() => onPressAudience({type: 'following'})}
/>
{lists && lists.length > 0
? lists.map(list => (
<Selectable
key={list.uri}
label={_(msg`Users in "${list.name}"`)}
isSelected={
!!draft.find(v => v.type === 'list' && v.list === list.uri)
}
onPress={() =>
onPressAudience({type: 'list', list: list.uri})
}
/>
))
: // No loading states to avoid jumps for the common case (no lists)
null}
</View>
</View>
<Button
label={doneLabel}
onPress={() => {
control.close()
onConfirm?.(draft)
}}
onAccessibilityEscape={control.close}
color="primary"
size="medium"
variant="solid"
style={a.mt_xl}>
<ButtonText>{doneLabel}</ButtonText>
</Button>
<Dialog.Close />
</Dialog.ScrollableInner>
)
}
function Selectable({
label,
isSelected,
onPress,
style,
}: {
label: string
isSelected: boolean
onPress: () => void
style?: StyleProp<ViewStyle>
}) {
const t = useTheme()
return (
<Button
onPress={onPress}
label={label}
accessibilityHint="Select this option"
accessibilityRole="checkbox"
aria-checked={isSelected}
accessibilityState={{
checked: isSelected,
}}
style={a.flex_1}>
{({hovered, focused}) => (
<View
style={[
a.flex_1,
a.flex_row,
a.align_center,
a.justify_between,
a.rounded_sm,
a.p_md,
{height: 40}, // for consistency with checkmark icon visible or not
t.atoms.bg_contrast_50,
(hovered || focused) && t.atoms.bg_contrast_100,
isSelected && {
backgroundColor: t.palette.primary_100,
},
style,
]}>
<Text style={[a.text_sm, isSelected && a.font_semibold]}>
{label}
</Text>
{isSelected ? (
<Check size="sm" fill={t.palette.primary_500} />
) : (
<View />
)}
</View>
)}
</Button>
)
}
+6 -5
View File
@@ -11,6 +11,7 @@ import {
ChatBskyConvoDefs,
RichText as RichTextAPI,
} from '@atproto/api'
import {I18n} from '@lingui/core'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
@@ -153,14 +154,14 @@ let MessageItemMetadata = ({
)
const relativeTimestamp = useCallback(
(timestamp: string) => {
(i18n: I18n, timestamp: string) => {
const date = new Date(timestamp)
const now = new Date()
const time = new Intl.DateTimeFormat(undefined, {
const time = i18n.date(date, {
hour: 'numeric',
minute: 'numeric',
}).format(date)
})
const diff = now.getTime() - date.getTime()
@@ -182,13 +183,13 @@ let MessageItemMetadata = ({
return _(msg`Yesterday, ${time}`)
}
return new Intl.DateTimeFormat(undefined, {
return i18n.date(date, {
hour: 'numeric',
minute: 'numeric',
day: 'numeric',
month: 'numeric',
year: 'numeric',
}).format(date)
})
},
[_],
)
+6 -2
View File
@@ -2,7 +2,7 @@ import React from 'react'
import {View} from 'react-native'
import {AppBskyEmbedRecord} from '@atproto/api'
import {PostEmbeds} from '#/view/com/util/post-embeds'
import {PostEmbeds, PostEmbedViewContext} from '#/view/com/util/post-embeds'
import {atoms as a, native, useTheme} from '#/alf'
let MessageItemEmbed = ({
@@ -14,7 +14,11 @@ let MessageItemEmbed = ({
return (
<View style={[a.my_xs, t.atoms.bg, native({flexBasis: 0})]}>
<PostEmbeds embed={embed} allowNestedQuotes />
<PostEmbeds
embed={embed}
allowNestedQuotes
viewContext={PostEmbedViewContext.Feed}
/>
</View>
)
}
@@ -1,12 +1,12 @@
import React from 'react'
import {Pressable, View} from 'react-native'
import {useLingui} from '@lingui/react'
import {android, atoms as a, useTheme, web} from '#/alf'
import * as TextField from '#/components/forms/TextField'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import {CalendarDays_Stroke2_Corner0_Rounded as CalendarDays} from '#/components/icons/CalendarDays'
import {Text} from '#/components/Typography'
import {localizeDate} from './utils'
// looks like a TextField.Input, but is just a button. It'll do something different on each platform on press
// iOS: open a dialog with an inline date picker
@@ -25,6 +25,7 @@ export function DateFieldButton({
isInvalid?: boolean
accessibilityHint?: string
}) {
const {i18n} = useLingui()
const t = useTheme()
const {
@@ -91,7 +92,7 @@ export function DateFieldButton({
t.atoms.text,
{lineHeight: a.text_md.fontSize * 1.1875},
]}>
{localizeDate(value)}
{i18n.date(value, {timeZone: 'UTC'})}
</Text>
</Pressable>
</View>
-11
View File
@@ -1,16 +1,5 @@
import {getLocales} from 'expo-localization'
const LOCALE = getLocales()[0]
// we need the date in the form yyyy-MM-dd to pass to the input
export function toSimpleDateString(date: Date | string): string {
const _date = typeof date === 'string' ? new Date(date) : date
return _date.toISOString().split('T')[0]
}
export function localizeDate(date: Date | string): string {
const _date = typeof date === 'string' ? new Date(date) : date
return new Intl.DateTimeFormat(LOCALE.languageTag, {
timeZone: 'UTC',
}).format(_date)
}
+2
View File
@@ -21,6 +21,7 @@ import {
ja,
ko,
ptBR,
ru,
tr,
uk,
zhCN,
@@ -47,6 +48,7 @@ const locales: Record<AppLanguage, Locale | undefined> = {
ja,
ko,
['pt-BR']: ptBR,
ru,
tr,
uk,
['zh-CN']: zhCN,
+2 -2
View File
@@ -15,8 +15,8 @@ export function useFollowMethods({
logContext,
}: {
profile: Shadow<AppBskyActorDefs.ProfileViewBasic>
logContext: LogEvents['profile:follow']['logContext'] &
LogEvents['profile:unfollow']['logContext']
logContext: LogEvents['profile:follow:sampled']['logContext'] &
LogEvents['profile:unfollow:sampled']['logContext']
}) {
const {_} = useLingui()
const requireAuth = useRequireAuth()
+2 -2
View File
@@ -5,10 +5,10 @@ export function useInteractionState() {
const onIn = React.useCallback(() => {
setState(true)
}, [setState])
}, [])
const onOut = React.useCallback(() => {
setState(false)
}, [setState])
}, [])
return React.useMemo(
() => ({
+1 -1
View File
@@ -2,7 +2,7 @@ import {useEffect, useRef, useState} from 'react'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
export function useThrottledValue<T>(value: T, time?: number) {
export function useThrottledValue<T>(value: T, time: number) {
const pendingValueRef = useRef(value)
const [throttledValue, setThrottledValue] = useState(value)
+5
View File
@@ -0,0 +1,5 @@
import {createSinglePathSVG} from './TEMPLATE'
export const Crop_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M6 2a1 1 0 0 1 1 1v2h11a1 1 0 0 1 1 1v11h2a1 1 0 1 1 0 2h-2v2a1 1 0 1 1-2 0v-2H6a1 1 0 0 1-1-1V7H3a1 1 0 0 1 0-2h2V3a1 1 0 0 1 1-1Zm1 5v10h10V7H7Z',
})
+5
View File
@@ -0,0 +1,5 @@
import {createSinglePathSVG} from './TEMPLATE'
export const Eye_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M3.135 12C5.413 16.088 8.77 18 12 18s6.587-1.912 8.865-6C18.587 7.912 15.23 6 12 6c-3.228 0-6.587 1.912-8.865 6ZM12 4c4.24 0 8.339 2.611 10.888 7.54a1 1 0 0 1 0 .92C20.338 17.388 16.24 20 12 20c-4.24 0-8.339-2.611-10.888-7.54a1 1 0 0 1 0-.92C3.662 6.612 7.76 4 12 4Zm0 6a2 2 0 1 0 0 4 2 2 0 0 0 0-4Zm-4 2a4 4 0 1 1 8 0 4 4 0 0 1-8 0Z',
})
+1
View File
@@ -19,6 +19,7 @@ export const sizes = {
md: 20,
lg: 24,
xl: 28,
'2xl': 32,
}
export function useCommonSVGProps(props: Props) {
+37
View File
@@ -0,0 +1,37 @@
import React from 'react'
import * as Dialog from '#/components/Dialog'
import {DialogControlProps} from '#/components/Dialog'
import {VerifyEmailIntentDialog} from '#/components/intents/VerifyEmailIntentDialog'
interface Context {
verifyEmailDialogControl: DialogControlProps
verifyEmailState: {code: string} | undefined
setVerifyEmailState: (state: {code: string} | undefined) => void
}
const Context = React.createContext({} as Context)
export const useIntentDialogs = () => React.useContext(Context)
export function Provider({children}: {children: React.ReactNode}) {
const verifyEmailDialogControl = Dialog.useDialogControl()
const [verifyEmailState, setVerifyEmailState] = React.useState<
{code: string} | undefined
>()
const value = React.useMemo(
() => ({
verifyEmailDialogControl,
verifyEmailState,
setVerifyEmailState,
}),
[verifyEmailDialogControl, verifyEmailState, setVerifyEmailState],
)
return (
<Context.Provider value={value}>
{children}
<VerifyEmailIntentDialog />
</Context.Provider>
)
}
@@ -0,0 +1,140 @@
import React from 'react'
import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useAgent, useSession} from 'state/session'
import {atoms as a} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {DialogControlProps} from '#/components/Dialog'
import {useIntentDialogs} from '#/components/intents/IntentDialogs'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
export function VerifyEmailIntentDialog() {
const {verifyEmailDialogControl: control} = useIntentDialogs()
return (
<Dialog.Outer control={control}>
<Dialog.Handle />
<Inner control={control} />
</Dialog.Outer>
)
}
function Inner({control}: {control: DialogControlProps}) {
const {_} = useLingui()
const {verifyEmailState: state} = useIntentDialogs()
const [status, setStatus] = React.useState<
'loading' | 'success' | 'failure' | 'resent'
>('loading')
const [sending, setSending] = React.useState(false)
const agent = useAgent()
const {currentAccount} = useSession()
React.useEffect(() => {
;(async () => {
if (!state?.code) {
return
}
try {
await agent.com.atproto.server.confirmEmail({
email: (currentAccount?.email || '').trim(),
token: state.code.trim(),
})
setStatus('success')
} catch (e) {
setStatus('failure')
}
})()
}, [agent.com.atproto.server, currentAccount?.email, state?.code])
const onPressResendEmail = async () => {
setSending(true)
await agent.com.atproto.server.requestEmailConfirmation()
setSending(false)
setStatus('resent')
}
return (
<Dialog.ScrollableInner label={_(msg`Verify email dialog`)}>
<Dialog.Close />
<View style={[a.gap_xl]}>
{status === 'loading' ? (
<View style={[a.py_2xl, a.align_center, a.justify_center]}>
<Loader size="xl" />
</View>
) : status === 'success' ? (
<>
<Text style={[a.font_bold, a.text_2xl]}>
<Trans>Email Verified</Trans>
</Text>
<Text style={[a.text_md, a.leading_tight]}>
<Trans>
Thanks, you have successfully verified your email address.
</Trans>
</Text>
</>
) : status === 'failure' ? (
<>
<Text style={[a.font_bold, a.text_2xl]}>
<Trans>Invalid Verification Code</Trans>
</Text>
<Text style={[a.text_md, a.leading_tight]}>
<Trans>
The verification code you have provided is invalid. Please make
sure that you have used the correct verification link or request
a new one.
</Trans>
</Text>
</>
) : (
<>
<Text style={[a.font_bold, a.text_2xl]}>
<Trans>Email Resent</Trans>
</Text>
<Text style={[a.text_md, a.leading_tight]}>
<Trans>
We have sent another verification email to{' '}
<Text style={[a.text_md, a.font_bold]}>
{currentAccount?.email}
</Text>
.
</Trans>
</Text>
</>
)}
{status !== 'loading' ? (
<View style={[a.w_full, a.flex_row, a.gap_sm, {marginLeft: 'auto'}]}>
<Button
label={_(msg`Close`)}
onPress={() => control.close()}
variant="solid"
color={status === 'failure' ? 'secondary' : 'primary'}
size="medium"
style={{marginLeft: 'auto'}}>
<ButtonText>
<Trans>Close</Trans>
</ButtonText>
</Button>
{status === 'failure' ? (
<Button
label={_(msg`Resend Verification Email`)}
onPress={onPressResendEmail}
variant="solid"
color="primary"
size="medium"
disabled={sending}>
<ButtonText>
<Trans>Resend Email</Trans>
</ButtonText>
{sending ? <Loader size="sm" style={{color: 'white'}} /> : null}
</Button>
) : null}
</View>
) : null}
</View>
</Dialog.ScrollableInner>
)
}
+89
View File
@@ -0,0 +1,89 @@
import React from 'react'
import {ModerationUI} from '@atproto/api'
import {
ModerationCauseDescription,
useModerationCauseDescription,
} from '#/lib/moderation/useModerationCauseDescription'
import {
ModerationDetailsDialog,
useModerationDetailsDialogControl,
} from '#/components/moderation/ModerationDetailsDialog'
type Context = {
isContentVisible: boolean
setIsContentVisible: (show: boolean) => void
info: ModerationCauseDescription
showInfoDialog: () => void
meta: {
isNoPwi: boolean
allowOverride: boolean
}
}
const Context = React.createContext<Context>({} as Context)
export const useHider = () => React.useContext(Context)
export function Outer({
modui,
isContentVisibleInitialState,
allowOverride,
children,
}: React.PropsWithChildren<{
isContentVisibleInitialState?: boolean
allowOverride?: boolean
modui: ModerationUI | undefined
}>) {
const control = useModerationDetailsDialogControl()
const blur = modui?.blurs[0]
const [isContentVisible, setIsContentVisible] = React.useState(
isContentVisibleInitialState || !blur,
)
const info = useModerationCauseDescription(blur)
const meta = {
isNoPwi: Boolean(
modui?.blurs.find(
cause =>
cause.type === 'label' &&
cause.labelDef.identifier === '!no-unauthenticated',
),
),
allowOverride: allowOverride ?? !modui?.noOverride,
}
const showInfoDialog = () => {
control.open()
}
const onSetContentVisible = (show: boolean) => {
if (!meta.allowOverride) return
setIsContentVisible(show)
}
const ctx = {
isContentVisible,
setIsContentVisible: onSetContentVisible,
showInfoDialog,
info,
meta,
}
return (
<Context.Provider value={ctx}>
{children}
<ModerationDetailsDialog control={control} modcause={blur} />
</Context.Provider>
)
}
export function Content({children}: {children: React.ReactNode}) {
const ctx = useHider()
return ctx.isContentVisible ? children : null
}
export function Mask({children}: {children: React.ReactNode}) {
const ctx = useHider()
return ctx.isContentVisible ? null : children
}
+5 -6
View File
@@ -14,19 +14,18 @@ import {
} from '#/components/moderation/LabelsOnMeDialog'
export function LabelsOnMe({
details,
type,
labels,
size,
style,
}: {
details: {did: string} | {uri: string; cid: string}
type: 'account' | 'content'
labels: ComAtprotoLabelDefs.Label[] | undefined
size?: ButtonSize
style?: StyleProp<ViewStyle>
}) {
const {_} = useLingui()
const {currentAccount} = useSession()
const isAccount = 'did' in details
const control = useLabelsOnMeDialogControl()
if (!labels || !currentAccount) {
@@ -39,7 +38,7 @@ export function LabelsOnMe({
return (
<View style={[a.flex_row, style]}>
<LabelsOnMeDialog control={control} subject={details} labels={labels} />
<LabelsOnMeDialog control={control} labels={labels} type={type} />
<Button
variant="solid"
@@ -51,7 +50,7 @@ export function LabelsOnMe({
}}>
<ButtonIcon position="left" icon={CircleInfo} />
<ButtonText style={[a.leading_snug]}>
{isAccount ? (
{type === 'account' ? (
<Plural
value={labels.length}
one="# label has been placed on this account"
@@ -82,6 +81,6 @@ export function LabelsOnMyPost({
return null
}
return (
<LabelsOnMe details={post} labels={post.labels} size="tiny" style={style} />
<LabelsOnMe type="content" labels={post.labels} size="tiny" style={style} />
)
}
+6 -30
View File
@@ -5,9 +5,9 @@ import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useMutation} from '@tanstack/react-query'
import {useLabelSubject} from '#/lib/moderation'
import {useLabelInfo} from '#/lib/moderation/useLabelInfo'
import {makeProfileLink} from '#/lib/routes/links'
import {useGate} from '#/lib/statsig/statsig'
import {sanitizeHandle} from '#/lib/strings/handles'
import {logger} from '#/logger'
import {useAgent, useSession} from '#/state/session'
@@ -19,21 +19,13 @@ import {InlineLinkText} from '#/components/Link'
import {Text} from '#/components/Typography'
import {Divider} from '../Divider'
import {Loader} from '../Loader'
export {useDialogControl as useLabelsOnMeDialogControl} from '#/components/Dialog'
type Subject =
| {
uri: string
cid: string
}
| {
did: string
}
export {useDialogControl as useLabelsOnMeDialogControl} from '#/components/Dialog'
export interface LabelsOnMeDialogProps {
control: Dialog.DialogOuterProps['control']
subject: Subject
labels: ComAtprotoLabelDefs.Label[]
type: 'account' | 'content'
}
export function LabelsOnMeDialog(props: LabelsOnMeDialogProps) {
@@ -52,8 +44,8 @@ function LabelsOnMeDialogInner(props: LabelsOnMeDialogProps) {
const [appealingLabel, setAppealingLabel] = React.useState<
ComAtprotoLabelDefs.Label | undefined
>(undefined)
const {subject, labels} = props
const isAccount = 'did' in subject
const {labels} = props
const isAccount = props.type === 'account'
const containsSelfLabel = React.useMemo(
() => labels.some(l => l.src === currentAccount?.did),
[currentAccount?.did, labels],
@@ -69,7 +61,6 @@ function LabelsOnMeDialogInner(props: LabelsOnMeDialogProps) {
{appealingLabel ? (
<AppealForm
label={appealingLabel}
subject={subject}
control={props.control}
onPressBack={() => setAppealingLabel(undefined)}
/>
@@ -189,12 +180,10 @@ function Label({
function AppealForm({
label,
subject,
control,
onPressBack,
}: {
label: ComAtprotoLabelDefs.Label
subject: Subject
control: Dialog.DialogOuterProps['control']
onPressBack: () => void
}) {
@@ -202,9 +191,9 @@ function AppealForm({
const {labeler, strings} = useLabelInfo(label)
const {gtMobile} = useBreakpoints()
const [details, setDetails] = React.useState('')
const {subject} = useLabelSubject({label})
const isAccountReport = 'did' in subject
const agent = useAgent()
const gate = useGate()
const sourceName = labeler
? sanitizeHandle(labeler.creator.handle, '@')
: label.src
@@ -214,7 +203,6 @@ function AppealForm({
const $type = !isAccountReport
? 'com.atproto.repo.strongRef'
: 'com.atproto.admin.defs#repoRef'
if (gate('session_withproxy_fix')) {
await agent.createModerationReport(
{
reasonType: ComAtprotoModerationDefs.REASONAPPEAL,
@@ -231,18 +219,6 @@ function AppealForm({
},
},
)
} else {
await agent
.withProxy('atproto_labeler', label.src)
.createModerationReport({
reasonType: ComAtprotoModerationDefs.REASONAPPEAL,
subject: {
$type,
...subject,
},
reason: details,
})
}
},
onError: err => {
logger.error('Failed to submit label appeal', {message: err})
@@ -8,17 +8,19 @@ import {useModerationCauseDescription} from '#/lib/moderation/useModerationCause
import {makeProfileLink} from '#/lib/routes/links'
import {listUriToHref} from '#/lib/strings/url-helpers'
import {isNative} from '#/platform/detection'
import {useSession} from '#/state/session'
import {atoms as a, useTheme} from '#/alf'
import * as Dialog from '#/components/Dialog'
import {Divider} from '#/components/Divider'
import {InlineLinkText} from '#/components/Link'
import {AppModerationCause} from '#/components/Pills'
import {Text} from '#/components/Typography'
export {useDialogControl as useModerationDetailsDialogControl} from '#/components/Dialog'
export interface ModerationDetailsDialogProps {
control: Dialog.DialogOuterProps['control']
modcause: ModerationCause
modcause?: ModerationCause | AppModerationCause
}
export function ModerationDetailsDialog(props: ModerationDetailsDialogProps) {
@@ -39,6 +41,7 @@ function ModerationDetailsDialogInner({
const t = useTheme()
const {_} = useLingui()
const desc = useModerationCauseDescription(modcause)
const {currentAccount} = useSession()
let name
let description
@@ -105,6 +108,14 @@ function ModerationDetailsDialogInner({
} else if (modcause.type === 'hidden') {
name = _(msg`Post Hidden by You`)
description = _(msg`You have hidden this post.`)
} else if (modcause.type === 'reply-hidden') {
const isYou = currentAccount?.did === modcause.source.did
name = isYou
? _(msg`Reply Hidden by You`)
: _(msg`Reply Hidden by Thread Author`)
description = isYou
? _(msg`You hid this reply.`)
: _(msg`The author of this thread has hidden this reply.`)
} else if (modcause.type === 'label') {
name = desc.name
description = desc.description
@@ -119,12 +130,12 @@ function ModerationDetailsDialogInner({
<Text style={[t.atoms.text, a.text_2xl, a.font_bold, a.mb_sm]}>
{name}
</Text>
<Text style={[t.atoms.text, a.text_md, a.mb_lg, a.leading_snug]}>
<Text style={[t.atoms.text, a.text_md, a.leading_snug]}>
{description}
</Text>
{modcause.type === 'label' && (
<>
{modcause?.type === 'label' && (
<View style={[a.pt_lg]}>
<Divider />
<Text style={[t.atoms.text, a.text_md, a.leading_snug, a.mt_lg]}>
{modcause.source.type === 'user' ? (
@@ -143,7 +154,7 @@ function ModerationDetailsDialogInner({
</Trans>
)}
</Text>
</>
</View>
)}
{isNative && <View style={{height: 40}} />}
+12 -2
View File
@@ -1,6 +1,6 @@
import React from 'react'
import {StyleProp, ViewStyle} from 'react-native'
import {ModerationUI} from '@atproto/api'
import {ModerationCause, ModerationUI} from '@atproto/api'
import {getModerationCauseKey} from '#/lib/moderation'
import * as Pills from '#/components/Pills'
@@ -9,13 +9,15 @@ export function PostAlerts({
modui,
size = 'sm',
style,
additionalCauses,
}: {
modui: ModerationUI
size?: Pills.CommonProps['size']
includeMute?: boolean
style?: StyleProp<ViewStyle>
additionalCauses?: ModerationCause[] | Pills.AppModerationCause[]
}) {
if (!modui.alert && !modui.inform) {
if (!modui.alert && !modui.inform && !additionalCauses?.length) {
return null
}
@@ -37,6 +39,14 @@ export function PostAlerts({
noBg={size === 'sm'}
/>
))}
{additionalCauses?.map(cause => (
<Pills.Label
key={getModerationCauseKey(cause)}
cause={cause}
size={size}
noBg={size === 'sm'}
/>
))}
</Pills.Row>
)
}
+48
View File
@@ -0,0 +1,48 @@
import React from 'react'
import {View} from 'react-native'
import {atoms as a, useTheme} from '#/alf'
import {Play_Filled_Corner0_Rounded as PlayIcon} from '#/components/icons/Play'
export function PlayButtonIcon({size = 36}: {size?: number}) {
const t = useTheme()
const bg = t.name === 'light' ? t.palette.contrast_25 : t.palette.contrast_975
const fg = t.name === 'light' ? t.palette.contrast_975 : t.palette.contrast_25
return (
<View
style={[
a.rounded_full,
a.overflow_hidden,
a.align_center,
a.justify_center,
t.atoms.shadow_lg,
{
width: size + size / 1.5,
height: size + size / 1.5,
},
]}>
<View
style={[
a.absolute,
a.inset_0,
{
backgroundColor: bg,
opacity: 0.7,
},
]}
/>
<PlayIcon
width={size}
fill={fg}
style={[
a.relative,
a.z_10,
{
left: size / 50,
},
]}
/>
</View>
)
}
-85
View File
@@ -1,85 +0,0 @@
import RNFS from 'react-native-fs'
import {BskyAgent, jsonToLex, stringifyLex} from '@atproto/api'
const GET_TIMEOUT = 15e3 // 15s
const POST_TIMEOUT = 60e3 // 60s
export function doPolyfill() {
BskyAgent.configure({fetch: fetchHandler})
}
interface FetchHandlerResponse {
status: number
headers: Record<string, string>
body: any
}
async function fetchHandler(
reqUri: string,
reqMethod: string,
reqHeaders: Record<string, string>,
reqBody: any,
): Promise<FetchHandlerResponse> {
const reqMimeType = reqHeaders['Content-Type'] || reqHeaders['content-type']
if (reqMimeType && reqMimeType.startsWith('application/json')) {
reqBody = stringifyLex(reqBody)
} else if (
typeof reqBody === 'string' &&
(reqBody.startsWith('/') || reqBody.startsWith('file:'))
) {
if (reqBody.endsWith('.jpeg') || reqBody.endsWith('.jpg')) {
// HACK
// React native has a bug that inflates the size of jpegs on upload
// we get around that by renaming the file ext to .bin
// see https://github.com/facebook/react-native/issues/27099
// -prf
const newPath = reqBody.replace(/\.jpe?g$/, '.bin')
await RNFS.moveFile(reqBody, newPath)
reqBody = newPath
}
// NOTE
// React native treats bodies with {uri: string} as file uploads to pull from cache
// -prf
reqBody = {uri: reqBody}
}
const controller = new AbortController()
const to = setTimeout(
() => controller.abort(),
reqMethod === 'post' ? POST_TIMEOUT : GET_TIMEOUT,
)
const res = await fetch(reqUri, {
method: reqMethod,
headers: reqHeaders,
body: reqBody,
signal: controller.signal,
})
const resStatus = res.status
const resHeaders: Record<string, string> = {}
res.headers.forEach((value: string, key: string) => {
resHeaders[key] = value
})
const resMimeType = resHeaders['Content-Type'] || resHeaders['content-type']
let resBody
if (resMimeType) {
if (resMimeType.startsWith('application/json')) {
resBody = jsonToLex(await res.json())
} else if (resMimeType.startsWith('text/')) {
resBody = await res.text()
} else if (resMimeType === 'application/vnd.ipld.car') {
resBody = await res.arrayBuffer()
} else {
throw new Error('Non-supported mime type')
}
}
clearTimeout(to)
return {
status: resStatus,
headers: resHeaders,
body: resBody,
}
}
-3
View File
@@ -1,3 +0,0 @@
export function doPolyfill() {
// no polyfill is needed on web
}
+66 -24
View File
@@ -23,6 +23,7 @@ type FeedSliceItem = {
record: AppBskyFeedPost.Record
parentAuthor: AppBskyActorDefs.ProfileViewBasic | undefined
isParentBlocked: boolean
isParentNotFound: boolean
}
type AuthorContext = {
@@ -68,6 +69,7 @@ export class FeedViewPostsSlice {
}
const parent = reply?.parent
const isParentBlocked = AppBskyFeedDefs.isBlockedPost(parent)
const isParentNotFound = AppBskyFeedDefs.isNotFoundPost(parent)
let parentAuthor: AppBskyActorDefs.ProfileViewBasic | undefined
if (AppBskyFeedDefs.isPostView(parent)) {
parentAuthor = parent.author
@@ -77,8 +79,17 @@ export class FeedViewPostsSlice {
record: post.record,
parentAuthor,
isParentBlocked,
isParentNotFound,
})
if (!reply || reason) {
if (!reply) {
if (post.record.reply) {
// This reply wasn't properly hydrated by the AppView.
this.isOrphan = true
this.items[0].isParentNotFound = true
}
return
}
if (reason) {
return
}
if (
@@ -89,23 +100,40 @@ export class FeedViewPostsSlice {
this.isOrphan = true
return
}
const root = reply.root
const rootIsView =
AppBskyFeedDefs.isPostView(root) ||
AppBskyFeedDefs.isBlockedPost(root) ||
AppBskyFeedDefs.isNotFoundPost(root)
/*
* If the parent is also the root, we just so happen to have the data we
* need to compute if the parent's parent (grandparent) is blocked. This
* doesn't always happen, of course, but we can take advantage of it when
* it does.
*/
const grandparent =
rootIsView && parent.record.reply?.parent.uri === root.uri
? root
: undefined
const grandparentAuthor = reply.grandparentAuthor
const isGrandparentBlocked = Boolean(
grandparentAuthor?.viewer?.blockedBy ||
grandparentAuthor?.viewer?.blocking ||
grandparentAuthor?.viewer?.blockingByList,
grandparent && AppBskyFeedDefs.isBlockedPost(grandparent),
)
const isGrandparentNotFound = Boolean(
grandparent && AppBskyFeedDefs.isNotFoundPost(grandparent),
)
this.items.unshift({
post: parent,
record: parent.record,
parentAuthor: grandparentAuthor,
isParentBlocked: isGrandparentBlocked,
isParentNotFound: isGrandparentNotFound,
})
if (isGrandparentBlocked) {
this.isOrphan = true
// Keep going, it might still have a root.
// Keep going, it might still have a root, and we need this for thread
// de-deduping
}
const root = reply.root
if (
!AppBskyFeedDefs.isPostView(root) ||
!AppBskyFeedPost.isRecord(root.record) ||
@@ -121,6 +149,7 @@ export class FeedViewPostsSlice {
post: root,
record: root.record,
isParentBlocked: false,
isParentNotFound: false,
parentAuthor: undefined,
})
if (parent.record.reply?.parent.uri !== root.uri) {
@@ -242,10 +271,15 @@ export class FeedTuner {
}
} else {
if (!dryRun) {
// Reposting a reply elevates it to top-level, so its parent/root won't be displayed.
// Disable in-thread dedupe for this case since we don't want to miss them later.
const disableDedupe = slice.isReply && slice.isRepost
if (!disableDedupe) {
this.seenUris.add(item.post.uri)
}
}
}
}
if (!dryRun) {
this.seenKeys.add(slice._reactKey)
}
@@ -345,11 +379,7 @@ export class FeedTuner {
): FeedViewPostsSlice[] => {
for (let i = 0; i < slices.length; i++) {
const slice = slices[i]
if (
slice.isReply &&
!slice.isRepost &&
!shouldDisplayReplyInFollowing(slice.getAuthors(), userDid)
) {
if (slice.isReply && !shouldDisplayReplyInFollowing(slice, userDid)) {
slices.splice(i, 1)
i--
}
@@ -371,27 +401,20 @@ export class FeedTuner {
slices: FeedViewPostsSlice[],
_dryRun: boolean,
): FeedViewPostsSlice[] => {
const candidateSlices = slices.slice()
// early return if no languages have been specified
if (!preferredLangsCode2.length || preferredLangsCode2.length === 0) {
return slices
}
for (let i = 0; i < slices.length; i++) {
let hasPreferredLang = false
for (const item of slices[i].items) {
const candidateSlices = slices.filter(slice => {
for (const item of slice.items) {
if (isPostInLanguage(item.post, preferredLangsCode2)) {
hasPreferredLang = true
break
return true
}
}
// if item does not fit preferred language, remove it
if (!hasPreferredLang) {
candidateSlices.splice(i, 1)
}
}
return false
})
// if the language filter cleared out the entire page, return the original set
// so that something always shows
@@ -420,9 +443,13 @@ function areSameAuthor(authors: AuthorContext): boolean {
}
function shouldDisplayReplyInFollowing(
authors: AuthorContext,
slice: FeedViewPostsSlice,
userDid: string,
): boolean {
if (slice.isRepost) {
return true
}
const authors = slice.getAuthors()
const {author, parentAuthor, grandparentAuthor, rootAuthor} = authors
if (!isSelfOrFollowing(author, userDid)) {
// Only show replies from self or people you follow.
@@ -436,6 +463,21 @@ function shouldDisplayReplyInFollowing(
// Always show self-threads.
return true
}
if (
parentAuthor &&
parentAuthor.did !== author.did &&
rootAuthor &&
rootAuthor.did === author.did &&
slice.items.length > 2
) {
// If you follow A, show A -> someone[>0 likes] -> A chains too.
// This is different from cases below because you only know one person.
const parentPost = slice.items[1].post
const parentLikeCount = parentPost.likeCount ?? 0
if (parentLikeCount > 0) {
return true
}
}
// From this point on we need at least one more reason to show it.
if (
parentAuthor &&
+11 -14
View File
@@ -1,7 +1,6 @@
import {
AppBskyFeedDefs,
AppBskyFeedGetFeed as GetCustomFeed,
AtpAgent,
BskyAgent,
} from '@atproto/api'
@@ -51,7 +50,7 @@ export class CustomFeedAPI implements FeedAPI {
const agent = this.agent
const isBlueskyOwned = isBlueskyOwnedFeed(this.params.feed)
const res = agent.session
const res = agent.did
? await this.agent.app.bsky.feed.getFeed(
{
...this.params,
@@ -106,34 +105,32 @@ async function loggedOutFetch({
let contentLangs = getContentLanguages().join(',')
// manually construct fetch call so we can add the `lang` cache-busting param
let res = await AtpAgent.fetch!(
let res = await fetch(
`https://api.bsky.app/xrpc/app.bsky.feed.getFeed?feed=${feed}${
cursor ? `&cursor=${cursor}` : ''
}&limit=${limit}&lang=${contentLangs}`,
'GET',
{'Accept-Language': contentLangs},
undefined,
{method: 'GET', headers: {'Accept-Language': contentLangs}},
)
if (res.body?.feed?.length) {
let data = res.ok ? await res.json() : null
if (data?.feed?.length) {
return {
success: true,
data: res.body,
data,
}
}
// no data, try again with language headers removed
res = await AtpAgent.fetch!(
res = await fetch(
`https://api.bsky.app/xrpc/app.bsky.feed.getFeed?feed=${feed}${
cursor ? `&cursor=${cursor}` : ''
}&limit=${limit}`,
'GET',
{'Accept-Language': ''},
undefined,
{method: 'GET', headers: {'Accept-Language': ''}},
)
if (res.body?.feed?.length) {
data = res.ok ? await res.json() : null
if (data?.feed?.length) {
return {
success: true,
data: res.body,
data,
}
}
+104 -90
View File
@@ -1,24 +1,35 @@
import {
AppBskyEmbedDefs,
AppBskyEmbedExternal,
AppBskyEmbedImages,
AppBskyEmbedRecord,
AppBskyEmbedRecordWithMedia,
AppBskyFeedThreadgate,
AppBskyEmbedVideo,
AppBskyFeedPostgate,
AtUri,
BlobRef,
BskyAgent,
ComAtprotoLabelDefs,
ComAtprotoRepoUploadBlob,
RichText,
} from '@atproto/api'
import {AtUri} from '@atproto/api'
import {logger} from '#/logger'
import {ThreadgateSetting} from '#/state/queries/threadgate'
import {writePostgateRecord} from '#/state/queries/postgate'
import {
createThreadgateRecord,
ThreadgateAllowUISetting,
threadgateAllowUISettingToAllowRecordValue,
writeThreadgateRecord,
} from '#/state/queries/threadgate'
import {isNetworkError} from 'lib/strings/errors'
import {shortenLinks, stripInvalidMentions} from 'lib/strings/rich-text-manip'
import {isNative, isWeb} from 'platform/detection'
import {isNative} from 'platform/detection'
import {ImageModel} from 'state/models/media/image'
import {LinkMeta} from '../link-meta/link-meta'
import {safeDeleteAsync} from '../media/manip'
import {uploadBlob} from './upload-blob'
export {uploadBlob}
export interface ExternalEmbedDraft {
uri: string
@@ -28,25 +39,6 @@ export interface ExternalEmbedDraft {
localThumb?: ImageModel
}
export async function uploadBlob(
agent: BskyAgent,
blob: string,
encoding: string,
): Promise<ComAtprotoRepoUploadBlob.Response> {
if (isWeb) {
// `blob` should be a data uri
return agent.uploadBlob(convertDataURIToUint8Array(blob), {
encoding,
})
} else {
// `blob` should be a path to a file in the local FS
return agent.uploadBlob(
blob, // this will be special-cased by the fetch monkeypatch in /src/state/lib/api.ts
{encoding},
)
}
}
interface PostOpts {
rawText: string
replyTo?: string
@@ -55,13 +47,16 @@ interface PostOpts {
cid: string
}
video?: {
uri: string
cid: string
blobRef: BlobRef
altText: string
captions: {lang: string; file: File}[]
aspectRatio?: AppBskyEmbedDefs.AspectRatio
}
extLink?: ExternalEmbedDraft
images?: ImageModel[]
labels?: string[]
threadgate?: ThreadgateSetting[]
threadgate: ThreadgateAllowUISetting[]
postgate: AppBskyFeedPostgate.Record
onStateChange?: (state: string) => void
langs?: string[]
}
@@ -71,18 +66,16 @@ export async function post(agent: BskyAgent, opts: PostOpts) {
| AppBskyEmbedImages.Main
| AppBskyEmbedExternal.Main
| AppBskyEmbedRecord.Main
| AppBskyEmbedVideo.Main
| AppBskyEmbedRecordWithMedia.Main
| undefined
let reply
let rt = new RichText(
{text: opts.rawText.trimEnd()},
{
cleanNewlines: true,
},
)
let rt = new RichText({text: opts.rawText.trimEnd()}, {cleanNewlines: true})
opts.onStateChange?.('Processing...')
await rt.detectFacets(agent)
rt = shortenLinks(rt)
rt = stripInvalidMentions(rt)
@@ -139,6 +132,41 @@ export async function post(agent: BskyAgent, opts: PostOpts) {
}
}
// add video embed if present
if (opts.video) {
const captions = await Promise.all(
opts.video.captions
.filter(caption => caption.lang !== '')
.map(async caption => {
const {data} = await agent.uploadBlob(caption.file, {
encoding: 'text/vtt',
})
return {lang: caption.lang, file: data.blob}
}),
)
if (opts.quote) {
embed = {
$type: 'app.bsky.embed.recordWithMedia',
record: embed,
media: {
$type: 'app.bsky.embed.video',
video: opts.video.blobRef,
alt: opts.video.altText || undefined,
captions: captions.length === 0 ? undefined : captions,
aspectRatio: opts.video.aspectRatio,
} as AppBskyEmbedVideo.Main,
} as AppBskyEmbedRecordWithMedia.Main
} else {
embed = {
$type: 'app.bsky.embed.video',
video: opts.video.blobRef,
alt: opts.video.altText || undefined,
captions: captions.length === 0 ? undefined : captions,
aspectRatio: opts.video.aspectRatio,
} as AppBskyEmbedVideo.Main
}
}
// add external embed if present
if (opts.extLink && !opts.images?.length) {
if (opts.extLink.embed) {
@@ -249,7 +277,9 @@ export async function post(agent: BskyAgent, opts: PostOpts) {
labels,
})
} catch (e: any) {
console.error(`Failed to create post: ${e.toString()}`)
logger.error(`Failed to create post`, {
safeMessage: e.message,
})
if (isNetworkError(e)) {
throw new Error(
'Post failed to upload. Please check your Internet connection and try again.',
@@ -259,68 +289,52 @@ export async function post(agent: BskyAgent, opts: PostOpts) {
}
}
if (opts.threadgate.some(tg => tg.type !== 'everybody')) {
try {
// TODO: this needs to be batch-created with the post!
if (opts.threadgate?.length) {
await createThreadgate(agent, res.uri, opts.threadgate)
}
await writeThreadgateRecord({
agent,
postUri: res.uri,
threadgate: createThreadgateRecord({
post: res.uri,
allow: threadgateAllowUISettingToAllowRecordValue(opts.threadgate),
}),
})
} catch (e: any) {
console.error(`Failed to create threadgate: ${e.toString()}`)
logger.error(`Failed to create threadgate`, {
context: 'composer',
safeMessage: e.message,
})
throw new Error(
'Post reply-controls failed to be set. Your post was created but anyone can reply to it.',
'Failed to save post interaction settings. Your post was created but users may be able to interact with it.',
)
}
}
if (
opts.postgate.embeddingRules?.length ||
opts.postgate.detachedEmbeddingUris?.length
) {
try {
// TODO: this needs to be batch-created with the post!
await writePostgateRecord({
agent,
postUri: res.uri,
postgate: {
...opts.postgate,
post: res.uri,
},
})
} catch (e: any) {
logger.error(`Failed to create postgate`, {
context: 'composer',
safeMessage: e.message,
})
throw new Error(
'Failed to save post interaction settings. Your post was created but users may be able to interact with it.',
)
}
}
return res
}
export async function createThreadgate(
agent: BskyAgent,
postUri: string,
threadgate: ThreadgateSetting[],
) {
let allow: (
| AppBskyFeedThreadgate.MentionRule
| AppBskyFeedThreadgate.FollowingRule
| AppBskyFeedThreadgate.ListRule
)[] = []
if (!threadgate.find(v => v.type === 'nobody')) {
for (const rule of threadgate) {
if (rule.type === 'mention') {
allow.push({$type: 'app.bsky.feed.threadgate#mentionRule'})
} else if (rule.type === 'following') {
allow.push({$type: 'app.bsky.feed.threadgate#followingRule'})
} else if (rule.type === 'list') {
allow.push({
$type: 'app.bsky.feed.threadgate#listRule',
list: rule.list,
})
}
}
}
const postUrip = new AtUri(postUri)
await agent.api.com.atproto.repo.putRecord({
repo: agent.session!.did,
collection: 'app.bsky.feed.threadgate',
rkey: postUrip.rkey,
record: {
$type: 'app.bsky.feed.threadgate',
post: postUri,
allow,
createdAt: new Date().toISOString(),
},
})
}
// helpers
// =
function convertDataURIToUint8Array(uri: string): Uint8Array {
var raw = window.atob(uri.substring(uri.indexOf(';base64,') + 8))
var binary = new Uint8Array(new ArrayBuffer(raw.length))
for (let i = 0; i < raw.length; i++) {
binary[i] = raw.charCodeAt(i)
}
return binary
}
+82
View File
@@ -0,0 +1,82 @@
import RNFS from 'react-native-fs'
import {BskyAgent, ComAtprotoRepoUploadBlob} from '@atproto/api'
/**
* @param encoding Allows overriding the blob's type
*/
export async function uploadBlob(
agent: BskyAgent,
input: string | Blob,
encoding?: string,
): Promise<ComAtprotoRepoUploadBlob.Response> {
if (typeof input === 'string' && input.startsWith('file:')) {
const blob = await asBlob(input)
return agent.uploadBlob(blob, {encoding})
}
if (typeof input === 'string' && input.startsWith('/')) {
const blob = await asBlob(`file://${input}`)
return agent.uploadBlob(blob, {encoding})
}
if (typeof input === 'string' && input.startsWith('data:')) {
const blob = await fetch(input).then(r => r.blob())
return agent.uploadBlob(blob, {encoding})
}
if (input instanceof Blob) {
return agent.uploadBlob(input, {encoding})
}
throw new TypeError(`Invalid uploadBlob input: ${typeof input}`)
}
async function asBlob(uri: string): Promise<Blob> {
return withSafeFile(uri, async safeUri => {
// Note
// Android does not support `fetch()` on `file://` URIs. for this reason, we
// use XMLHttpRequest instead of simply calling:
// return fetch(safeUri.replace('file:///', 'file:/')).then(r => r.blob())
return await new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.onload = () => resolve(xhr.response)
xhr.onerror = () => reject(new Error('Failed to load blob'))
xhr.responseType = 'blob'
xhr.open('GET', safeUri, true)
xhr.send(null)
})
})
}
// HACK
// React native has a bug that inflates the size of jpegs on upload
// we get around that by renaming the file ext to .bin
// see https://github.com/facebook/react-native/issues/27099
// -prf
async function withSafeFile<T>(
uri: string,
fn: (path: string) => Promise<T>,
): Promise<T> {
if (uri.endsWith('.jpeg') || uri.endsWith('.jpg')) {
// Since we don't "own" the file, we should avoid renaming or modifying it.
// Instead, let's copy it to a temporary file and use that (then remove the
// temporary file).
const newPath = uri.replace(/\.jpe?g$/, '.bin')
try {
await RNFS.copyFile(uri, newPath)
} catch {
// Failed to copy the file, just use the original
return await fn(uri)
}
try {
return await fn(newPath)
} finally {
// Remove the temporary file
await RNFS.unlink(newPath)
}
} else {
return fn(uri)
}
}
+26
View File
@@ -0,0 +1,26 @@
import {BskyAgent, ComAtprotoRepoUploadBlob} from '@atproto/api'
/**
* @note It is recommended, on web, to use the `file` instance of the file
* selector input element, rather than a `data:` URL, to avoid
* loading the file into memory. `File` extends `Blob` "file" instances can
* be passed directly to this function.
*/
export async function uploadBlob(
agent: BskyAgent,
input: string | Blob,
encoding?: string,
): Promise<ComAtprotoRepoUploadBlob.Response> {
if (typeof input === 'string' && input.startsWith('data:')) {
const blob = await fetch(input).then(r => r.blob())
return agent.uploadBlob(blob, {encoding})
}
if (input instanceof Blob) {
return agent.uploadBlob(input, {
encoding,
})
}
throw new TypeError(`Invalid uploadBlob input: ${typeof input}`)
}
+20
View File
@@ -0,0 +1,20 @@
export function cancelable<A, T>(
f: (args: A) => Promise<T>,
signal: AbortSignal,
) {
return (args: A) => {
return new Promise<T>((resolve, reject) => {
signal.addEventListener('abort', () => {
reject(new AbortError())
})
f(args).then(resolve, reject)
})
}
}
export class AbortError extends Error {
constructor() {
super('Aborted')
this.name = 'AbortError'
}
}
+14 -1
View File
@@ -12,6 +12,7 @@ export const HELP_DESK_URL = `https://blueskyweb.zendesk.com/hc/${HELP_DESK_LANG
export const EMBED_SERVICE = 'https://embed.bsky.app'
export const EMBED_SCRIPT = `${EMBED_SERVICE}/static/embed.js`
export const BSKY_DOWNLOAD_URL = 'https://bsky.app/download'
export const STARTER_PACK_MAX_SIZE = 150
// HACK
// Yes, this is exactly what it looks like. It's a hard-coded constant
@@ -20,7 +21,7 @@ export const BSKY_DOWNLOAD_URL = 'https://bsky.app/download'
// code and update this number with each release until we can get the
// server route done.
// -prf
export const JOINED_THIS_WEEK = 21797 // as of Jul5 2024
export const JOINED_THIS_WEEK = 3060000 // estimate as of 9/6/24
const BASE_FEEDBACK_FORM_URL = `${HELP_DESK_URL}/requests/new`
export function FEEDBACK_FORM_URL({
@@ -135,3 +136,15 @@ export const GIF_FEATURED = (params: string) =>
`${GIF_SERVICE}/tenor/v2/featured?${params}`
export const MAX_LABELERS = 20
export const VIDEO_SERVICE = 'https://video.bsky.app'
export const VIDEO_SERVICE_DID = 'did:web:video.bsky.app'
export const SUPPORTED_MIME_TYPES = [
'video/mp4',
'video/mpeg',
'video/webm',
'video/quicktime',
] as const
export type SupportedMimeTypes = (typeof SUPPORTED_MIME_TYPES)[number]
+179
View File
@@ -0,0 +1,179 @@
import React from 'react'
import {View} from 'react-native'
import Animated, {
Easing,
LayoutAnimationConfig,
useReducedMotion,
withTiming,
} from 'react-native-reanimated'
import {i18n} from '@lingui/core'
import {decideShouldRoll} from 'lib/custom-animations/util'
import {s} from 'lib/styles'
import {formatCount} from 'view/com/util/numeric/format'
import {Text} from 'view/com/util/text/Text'
import {atoms as a, useTheme} from '#/alf'
const animationConfig = {
duration: 400,
easing: Easing.out(Easing.cubic),
}
function EnteringUp() {
'worklet'
const animations = {
opacity: withTiming(1, animationConfig),
transform: [{translateY: withTiming(0, animationConfig)}],
}
const initialValues = {
opacity: 0,
transform: [{translateY: 18}],
}
return {
animations,
initialValues,
}
}
function EnteringDown() {
'worklet'
const animations = {
opacity: withTiming(1, animationConfig),
transform: [{translateY: withTiming(0, animationConfig)}],
}
const initialValues = {
opacity: 0,
transform: [{translateY: -18}],
}
return {
animations,
initialValues,
}
}
function ExitingUp() {
'worklet'
const animations = {
opacity: withTiming(0, animationConfig),
transform: [
{
translateY: withTiming(-18, animationConfig),
},
],
}
const initialValues = {
opacity: 1,
transform: [{translateY: 0}],
}
return {
animations,
initialValues,
}
}
function ExitingDown() {
'worklet'
const animations = {
opacity: withTiming(0, animationConfig),
transform: [{translateY: withTiming(18, animationConfig)}],
}
const initialValues = {
opacity: 1,
transform: [{translateY: 0}],
}
return {
animations,
initialValues,
}
}
export function CountWheel({
likeCount,
big,
isLiked,
isToggle,
}: {
likeCount: number
big?: boolean
isLiked: boolean
isToggle: boolean
}) {
const t = useTheme()
const shouldAnimate = !useReducedMotion() && isToggle
const shouldRoll = decideShouldRoll(isLiked, likeCount)
// Incrementing the key will cause the `Animated.View` to re-render, with the newly selected entering/exiting
// animation
// The initial entering/exiting animations will get skipped, since these will happen on screen mounts and would
// be unnecessary
const [key, setKey] = React.useState(0)
const [prevCount, setPrevCount] = React.useState(likeCount)
const prevIsLiked = React.useRef(isLiked)
const formattedCount = formatCount(i18n, likeCount)
const formattedPrevCount = formatCount(i18n, prevCount)
React.useEffect(() => {
if (isLiked === prevIsLiked.current) {
return
}
const newPrevCount = isLiked ? likeCount - 1 : likeCount + 1
setKey(prev => prev + 1)
setPrevCount(newPrevCount)
prevIsLiked.current = isLiked
}, [isLiked, likeCount])
const enteringAnimation =
shouldAnimate && shouldRoll
? isLiked
? EnteringUp
: EnteringDown
: undefined
const exitingAnimation =
shouldAnimate && shouldRoll
? isLiked
? ExitingUp
: ExitingDown
: undefined
return (
<LayoutAnimationConfig skipEntering skipExiting>
{likeCount > 0 ? (
<View style={[a.justify_center]}>
<Animated.View entering={enteringAnimation} key={key}>
<Text
testID="likeCount"
style={[
big ? a.text_md : {fontSize: 15},
a.user_select_none,
isLiked
? [a.font_bold, s.likeColor]
: {color: t.palette.contrast_500},
]}>
{formattedCount}
</Text>
</Animated.View>
{shouldAnimate && (likeCount > 1 || !isLiked) ? (
<Animated.View
entering={exitingAnimation}
// Add 2 to the key so there are never duplicates
key={key + 2}
style={[a.absolute, {width: 50, opacity: 0}]}
aria-disabled={true}>
<Text
style={[
big ? a.text_md : {fontSize: 15},
a.user_select_none,
isLiked
? [a.font_bold, s.likeColor]
: {color: t.palette.contrast_500},
]}>
{formattedPrevCount}
</Text>
</Animated.View>
) : null}
</View>
) : null}
</LayoutAnimationConfig>
)
}
@@ -0,0 +1,122 @@
import React from 'react'
import {View} from 'react-native'
import {useReducedMotion} from 'react-native-reanimated'
import {i18n} from '@lingui/core'
import {decideShouldRoll} from 'lib/custom-animations/util'
import {s} from 'lib/styles'
import {formatCount} from 'view/com/util/numeric/format'
import {Text} from 'view/com/util/text/Text'
import {atoms as a, useTheme} from '#/alf'
const animationConfig = {
duration: 400,
easing: 'cubic-bezier(0.4, 0, 0.2, 1)',
fill: 'forwards' as FillMode,
}
const enteringUpKeyframe = [
{opacity: 0, transform: 'translateY(18px)'},
{opacity: 1, transform: 'translateY(0)'},
]
const enteringDownKeyframe = [
{opacity: 0, transform: 'translateY(-18px)'},
{opacity: 1, transform: 'translateY(0)'},
]
const exitingUpKeyframe = [
{opacity: 1, transform: 'translateY(0)'},
{opacity: 0, transform: 'translateY(-18px)'},
]
const exitingDownKeyframe = [
{opacity: 1, transform: 'translateY(0)'},
{opacity: 0, transform: 'translateY(18px)'},
]
export function CountWheel({
likeCount,
big,
isLiked,
isToggle,
}: {
likeCount: number
big?: boolean
isLiked: boolean
isToggle: boolean
}) {
const t = useTheme()
const shouldAnimate = !useReducedMotion() && isToggle
const shouldRoll = decideShouldRoll(isLiked, likeCount)
const countView = React.useRef<HTMLDivElement>(null)
const prevCountView = React.useRef<HTMLDivElement>(null)
const [prevCount, setPrevCount] = React.useState(likeCount)
const prevIsLiked = React.useRef(isLiked)
const formattedCount = formatCount(i18n, likeCount)
const formattedPrevCount = formatCount(i18n, prevCount)
React.useEffect(() => {
if (isLiked === prevIsLiked.current) {
return
}
const newPrevCount = isLiked ? likeCount - 1 : likeCount + 1
if (shouldAnimate && shouldRoll) {
countView.current?.animate?.(
isLiked ? enteringUpKeyframe : enteringDownKeyframe,
animationConfig,
)
prevCountView.current?.animate?.(
isLiked ? exitingUpKeyframe : exitingDownKeyframe,
animationConfig,
)
setPrevCount(newPrevCount)
}
prevIsLiked.current = isLiked
}, [isLiked, likeCount, shouldAnimate, shouldRoll])
if (likeCount < 1) {
return null
}
return (
<View>
<View
// @ts-expect-error is div
ref={countView}>
<Text
testID="likeCount"
style={[
big ? a.text_md : {fontSize: 15},
a.user_select_none,
isLiked
? [a.font_bold, s.likeColor]
: {color: t.palette.contrast_500},
]}>
{formattedCount}
</Text>
</View>
{shouldAnimate && (likeCount > 1 || !isLiked) ? (
<View
style={{position: 'absolute', opacity: 0}}
aria-disabled={true}
// @ts-expect-error is div
ref={prevCountView}>
<Text
style={[
big ? a.text_md : {fontSize: 15},
a.user_select_none,
isLiked
? [a.font_bold, s.likeColor]
: {color: t.palette.contrast_500},
]}>
{formattedPrevCount}
</Text>
</View>
) : null}
</View>
)
}
+137
View File
@@ -0,0 +1,137 @@
import React from 'react'
import {View} from 'react-native'
import Animated, {
Keyframe,
LayoutAnimationConfig,
useReducedMotion,
} from 'react-native-reanimated'
import {s} from 'lib/styles'
import {useTheme} from '#/alf'
import {
Heart2_Filled_Stroke2_Corner0_Rounded as HeartIconFilled,
Heart2_Stroke2_Corner0_Rounded as HeartIconOutline,
} from '#/components/icons/Heart2'
const keyframe = new Keyframe({
0: {
transform: [{scale: 1}],
},
10: {
transform: [{scale: 0.7}],
},
40: {
transform: [{scale: 1.2}],
},
100: {
transform: [{scale: 1}],
},
})
const circle1Keyframe = new Keyframe({
0: {
opacity: 0,
transform: [{scale: 0}],
},
10: {
opacity: 0.4,
},
40: {
transform: [{scale: 1.5}],
},
95: {
opacity: 0.4,
},
100: {
opacity: 0,
transform: [{scale: 1.5}],
},
})
const circle2Keyframe = new Keyframe({
0: {
opacity: 0,
transform: [{scale: 0}],
},
10: {
opacity: 1,
},
40: {
transform: [{scale: 0}],
},
95: {
opacity: 1,
},
100: {
opacity: 0,
transform: [{scale: 1.5}],
},
})
export function AnimatedLikeIcon({
isLiked,
big,
isToggle,
}: {
isLiked: boolean
big?: boolean
isToggle: boolean
}) {
const t = useTheme()
const size = big ? 22 : 18
const shouldAnimate = !useReducedMotion() && isToggle
return (
<View>
<LayoutAnimationConfig skipEntering>
{isLiked ? (
<Animated.View
entering={shouldAnimate ? keyframe.duration(300) : undefined}>
<HeartIconFilled style={s.likeColor} width={size} />
</Animated.View>
) : (
<HeartIconOutline
style={[{color: t.palette.contrast_500}, {pointerEvents: 'none'}]}
width={size}
/>
)}
{isLiked ? (
<>
<Animated.View
entering={
shouldAnimate ? circle1Keyframe.duration(300) : undefined
}
style={{
position: 'absolute',
backgroundColor: s.likeColor.color,
top: 0,
left: 0,
width: size,
height: size,
zIndex: -1,
pointerEvents: 'none',
borderRadius: size / 2,
}}
/>
<Animated.View
entering={
shouldAnimate ? circle2Keyframe.duration(300) : undefined
}
style={{
position: 'absolute',
backgroundColor: t.atoms.bg.backgroundColor,
top: 0,
left: 0,
width: size,
height: size,
zIndex: -1,
pointerEvents: 'none',
borderRadius: size / 2,
}}
/>
</>
) : null}
</LayoutAnimationConfig>
</View>
)
}
+119
View File
@@ -0,0 +1,119 @@
import React from 'react'
import {View} from 'react-native'
import {useReducedMotion} from 'react-native-reanimated'
import {s} from 'lib/styles'
import {useTheme} from '#/alf'
import {
Heart2_Filled_Stroke2_Corner0_Rounded as HeartIconFilled,
Heart2_Stroke2_Corner0_Rounded as HeartIconOutline,
} from '#/components/icons/Heart2'
const animationConfig = {
duration: 400,
easing: 'cubic-bezier(0.4, 0, 0.2, 1)',
fill: 'forwards' as FillMode,
}
const keyframe = [
{transform: 'scale(1)'},
{transform: 'scale(0.7)'},
{transform: 'scale(1.2)'},
{transform: 'scale(1)'},
]
const circle1Keyframe = [
{opacity: 0, transform: 'scale(0)'},
{opacity: 0.4},
{transform: 'scale(1.5)'},
{opacity: 0.4},
{opacity: 0, transform: 'scale(1.5)'},
]
const circle2Keyframe = [
{opacity: 0, transform: 'scale(0)'},
{opacity: 1},
{transform: 'scale(0)'},
{opacity: 1},
{opacity: 0, transform: 'scale(1.5)'},
]
export function AnimatedLikeIcon({
isLiked,
big,
isToggle,
}: {
isLiked: boolean
big?: boolean
isToggle: boolean
}) {
const t = useTheme()
const size = big ? 22 : 18
const shouldAnimate = !useReducedMotion() && isToggle
const prevIsLiked = React.useRef(isLiked)
const likeIconRef = React.useRef<HTMLDivElement>(null)
const circle1Ref = React.useRef<HTMLDivElement>(null)
const circle2Ref = React.useRef<HTMLDivElement>(null)
React.useEffect(() => {
if (prevIsLiked.current === isLiked) {
return
}
if (shouldAnimate && isLiked) {
likeIconRef.current?.animate?.(keyframe, animationConfig)
circle1Ref.current?.animate?.(circle1Keyframe, animationConfig)
circle2Ref.current?.animate?.(circle2Keyframe, animationConfig)
}
prevIsLiked.current = isLiked
}, [shouldAnimate, isLiked])
return (
<View>
{isLiked ? (
// @ts-expect-error is div
<View ref={likeIconRef}>
<HeartIconFilled style={s.likeColor} width={size} />
</View>
) : (
<HeartIconOutline
style={[{color: t.palette.contrast_500}, {pointerEvents: 'none'}]}
width={size}
/>
)}
<View
// @ts-expect-error is div
ref={circle1Ref}
style={{
position: 'absolute',
backgroundColor: s.likeColor.color,
top: 0,
left: 0,
width: size,
height: size,
zIndex: -1,
pointerEvents: 'none',
borderRadius: size / 2,
opacity: 0,
}}
/>
<View
// @ts-expect-error is div
ref={circle2Ref}
style={{
position: 'absolute',
backgroundColor: t.atoms.bg.backgroundColor,
top: 0,
left: 0,
width: size,
height: size,
zIndex: -1,
pointerEvents: 'none',
borderRadius: size / 2,
opacity: 0,
}}
/>
</View>
)
}
+21
View File
@@ -0,0 +1,21 @@
// It should roll when:
// - We're going from 1 to 0 (roll backwards)
// - The count is anywhere between 1 and 999
// - The count is going up and is a multiple of 100
// - The count is going down and is 1 less than a multiple of 100
export function decideShouldRoll(isSet: boolean, count: number) {
let shouldRoll = false
if (!isSet && count === 1) {
shouldRoll = true
} else if (count > 1 && count < 1000) {
shouldRoll = true
} else if (count > 0) {
const mod = count % 100
if (isSet && mod === 0) {
shouldRoll = true
} else if (!isSet && mod === 99) {
shouldRoll = true
}
}
return shouldRoll
}
+1 -2
View File
@@ -65,7 +65,6 @@ export function useGenerateStarterPackMutation({
}) {
const {_} = useLingui()
const agent = useAgent()
const starterPackString = _(msg`Starter Pack`)
return useMutation<{uri: string; cid: string}, Error, void>({
mutationFn: async () => {
@@ -106,7 +105,7 @@ export function useGenerateStarterPackMutation({
25,
true,
)
const starterPackName = `${displayName}'s ${starterPackString}`
const starterPackName = _(msg`${displayName}'s Starter Pack`)
const list = await createStarterPackList({
name: starterPackName,
+136 -25
View File
@@ -1,102 +1,213 @@
import {describe, expect, it} from '@jest/globals'
import {MessageDescriptor} from '@lingui/core'
import {addDays, subDays, subHours, subMinutes, subSeconds} from 'date-fns'
import {dateDiff} from '../useTimeAgo'
const lingui: any = (obj: MessageDescriptor) => obj.message
const base = new Date('2024-06-17T00:00:00Z')
describe('dateDiff', () => {
it(`works with numbers`, () => {
expect(dateDiff(subDays(base, 3), Number(base), {lingui})).toEqual('3d')
const earlier = subDays(base, 3)
expect(dateDiff(earlier, Number(base))).toEqual({
value: 3,
unit: 'day',
earlier,
later: base,
})
})
it(`works with strings`, () => {
expect(dateDiff(subDays(base, 3), base.toString(), {lingui})).toEqual('3d')
const earlier = subDays(base, 3)
expect(dateDiff(earlier, base.toString())).toEqual({
value: 3,
unit: 'day',
earlier,
later: base,
})
})
it(`works with dates`, () => {
expect(dateDiff(subDays(base, 3), base, {lingui})).toEqual('3d')
const earlier = subDays(base, 3)
expect(dateDiff(earlier, base)).toEqual({
value: 3,
unit: 'day',
earlier,
later: base,
})
})
it(`equal values return now`, () => {
expect(dateDiff(base, base, {lingui})).toEqual('now')
expect(dateDiff(base, base)).toEqual({
value: 0,
unit: 'now',
earlier: base,
later: base,
})
})
it(`future dates return now`, () => {
expect(dateDiff(addDays(base, 3), base, {lingui})).toEqual('now')
const earlier = addDays(base, 3)
expect(dateDiff(earlier, base)).toEqual({
value: 0,
unit: 'now',
earlier,
later: base,
})
})
it(`values < 5 seconds ago return now`, () => {
const then = subSeconds(base, 4)
expect(dateDiff(then, base, {lingui})).toEqual('now')
expect(dateDiff(then, base)).toEqual({
value: 0,
unit: 'now',
earlier: then,
later: base,
})
})
it(`values >= 5 seconds ago return seconds`, () => {
const then = subSeconds(base, 5)
expect(dateDiff(then, base, {lingui})).toEqual('5s')
expect(dateDiff(then, base)).toEqual({
value: 5,
unit: 'second',
earlier: then,
later: base,
})
})
it(`values < 1 min return seconds`, () => {
const then = subSeconds(base, 59)
expect(dateDiff(then, base, {lingui})).toEqual('59s')
expect(dateDiff(then, base)).toEqual({
value: 59,
unit: 'second',
earlier: then,
later: base,
})
})
it(`values >= 1 min return minutes`, () => {
const then = subSeconds(base, 60)
expect(dateDiff(then, base, {lingui})).toEqual('1m')
expect(dateDiff(then, base)).toEqual({
value: 1,
unit: 'minute',
earlier: then,
later: base,
})
})
it(`minutes round down`, () => {
const then = subSeconds(base, 119)
expect(dateDiff(then, base, {lingui})).toEqual('1m')
expect(dateDiff(then, base)).toEqual({
value: 1,
unit: 'minute',
earlier: then,
later: base,
})
})
it(`values < 1 hour return minutes`, () => {
const then = subMinutes(base, 59)
expect(dateDiff(then, base, {lingui})).toEqual('59m')
expect(dateDiff(then, base)).toEqual({
value: 59,
unit: 'minute',
earlier: then,
later: base,
})
})
it(`values >= 1 hour return hours`, () => {
const then = subMinutes(base, 60)
expect(dateDiff(then, base, {lingui})).toEqual('1h')
expect(dateDiff(then, base)).toEqual({
value: 1,
unit: 'hour',
earlier: then,
later: base,
})
})
it(`hours round down`, () => {
const then = subMinutes(base, 119)
expect(dateDiff(then, base, {lingui})).toEqual('1h')
expect(dateDiff(then, base)).toEqual({
value: 1,
unit: 'hour',
earlier: then,
later: base,
})
})
it(`values < 1 day return hours`, () => {
const then = subHours(base, 23)
expect(dateDiff(then, base, {lingui})).toEqual('23h')
expect(dateDiff(then, base)).toEqual({
value: 23,
unit: 'hour',
earlier: then,
later: base,
})
})
it(`values >= 1 day return days`, () => {
const then = subHours(base, 24)
expect(dateDiff(then, base, {lingui})).toEqual('1d')
expect(dateDiff(then, base)).toEqual({
value: 1,
unit: 'day',
earlier: then,
later: base,
})
})
it(`days round down`, () => {
const then = subHours(base, 47)
expect(dateDiff(then, base, {lingui})).toEqual('1d')
expect(dateDiff(then, base)).toEqual({
value: 1,
unit: 'day',
earlier: then,
later: base,
})
})
it(`values < 30 days return days`, () => {
const then = subDays(base, 29)
expect(dateDiff(then, base, {lingui})).toEqual('29d')
expect(dateDiff(then, base)).toEqual({
value: 29,
unit: 'day',
earlier: then,
later: base,
})
})
it(`values >= 30 days return months`, () => {
const then = subDays(base, 30)
expect(dateDiff(then, base, {lingui})).toEqual('1mo')
expect(dateDiff(then, base)).toEqual({
value: 1,
unit: 'month',
earlier: then,
later: base,
})
})
it(`months round down`, () => {
const then = subDays(base, 59)
expect(dateDiff(then, base, {lingui})).toEqual('1mo')
expect(dateDiff(then, base)).toEqual({
value: 1,
unit: 'month',
earlier: then,
later: base,
})
})
it(`values are rounded by increments of 30`, () => {
const then = subDays(base, 61)
expect(dateDiff(then, base, {lingui})).toEqual('2mo')
expect(dateDiff(then, base)).toEqual({
value: 2,
unit: 'month',
earlier: then,
later: base,
})
})
it(`values < 360 days return months`, () => {
const then = subDays(base, 359)
expect(dateDiff(then, base, {lingui})).toEqual('11mo')
expect(dateDiff(then, base)).toEqual({
value: 11,
unit: 'month',
earlier: then,
later: base,
})
})
it(`values >= 360 days return the earlier value`, () => {
const then = subDays(base, 360)
expect(dateDiff(then, base, {lingui})).toEqual(then.toLocaleDateString())
expect(dateDiff(then, base)).toEqual({
value: 12,
unit: 'month',
earlier: then,
later: base,
})
})
})

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