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,33 +13,34 @@ public class ExpoPlatformInfoModule: Module {
Function("setAudioCategory") { (audioCategoryString: String) in
let audioCategory = AVAudioSession.Category(rawValue: audioCategoryString)
try? AVAudioSession.sharedInstance().setCategory(audioCategory)
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 {
categoryOptions = [.mixWithOthers]
try? AVAudioSession.sharedInstance().setActive(true)
} else {
categoryOptions = [.duckOthers]
try? AVAudioSession
.sharedInstance()
.setActive(
false,
options: [.notifyOthersOnDeactivation]
)
if active == self.prevAudioActive {
return
}
self.prevAudioActive = active
if active {
DispatchQueue.global(qos: .background).async {
try? AVAudioSession.sharedInstance().setActive(true)
}
} else {
DispatchQueue.global(qos: .background).async {
try? AVAudioSession
.sharedInstance()
.setActive(
false,
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)
+62 -58
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,52 +106,50 @@ function InnerApp() {
}, [_])
return (
<SafeAreaProvider initialMetrics={initialWindowMetrics}>
<Alf theme={theme}>
<ThemeProvider theme={theme}>
<Splash isReady={isReady && hasCheckedReferrer}>
<ActiveVideoProvider>
<RootSiblingParent>
<React.Fragment
// Resets the entire tree below when it changes:
key={currentAccount?.did}>
<QueryProvider currentDid={currentAccount?.did}>
<StatsigProvider>
<MessagesProvider>
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
<LabelDefsProvider>
<ModerationOptsProvider>
<LoggedOutViewProvider>
<SelectedFeedProvider>
<Alf theme={theme}>
<ThemeProvider theme={theme}>
<Splash isReady={isReady && hasCheckedReferrer}>
<ActiveVideoProvider>
<RootSiblingParent>
<React.Fragment
// Resets the entire tree below when it changes:
key={currentAccount?.did}>
<QueryProvider currentDid={currentAccount?.did}>
<StatsigProvider>
<MessagesProvider>
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
<LabelDefsProvider>
<ModerationOptsProvider>
<LoggedOutViewProvider>
<SelectedFeedProvider>
<HiddenRepliesProvider>
<UnreadNotifsProvider>
<BackgroundNotificationPreferencesProvider>
<MutedThreadsProvider>
<TourProvider>
<ProgressGuideProvider>
<GestureHandlerRootView
style={s.h100pct}>
<TestCtrls />
<Shell />
</GestureHandlerRootView>
</ProgressGuideProvider>
</TourProvider>
<ProgressGuideProvider>
<GestureHandlerRootView
style={s.h100pct}>
<TestCtrls />
<Shell />
</GestureHandlerRootView>
</ProgressGuideProvider>
</MutedThreadsProvider>
</BackgroundNotificationPreferencesProvider>
</UnreadNotifsProvider>
</SelectedFeedProvider>
</LoggedOutViewProvider>
</ModerationOptsProvider>
</LabelDefsProvider>
</MessagesProvider>
</StatsigProvider>
</QueryProvider>
</React.Fragment>
</RootSiblingParent>
</ActiveVideoProvider>
</Splash>
</ThemeProvider>
</Alf>
</SafeAreaProvider>
</HiddenRepliesProvider>
</SelectedFeedProvider>
</LoggedOutViewProvider>
</ModerationOptsProvider>
</LabelDefsProvider>
</MessagesProvider>
</StatsigProvider>
</QueryProvider>
</React.Fragment>
</RootSiblingParent>
</ActiveVideoProvider>
</Splash>
</ThemeProvider>
</Alf>
)
}
@@ -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>
<InvitesStateProvider>
<ModalStateProvider>
<DialogStateProvider>
<LightboxStateProvider>
<I18nProvider>
<PrefsStateProvider>
<I18nProvider>
<ShellStateProvider>
<InvitesStateProvider>
<ModalStateProvider>
<DialogStateProvider>
<LightboxStateProvider>
<PortalProvider>
<StarterPackProvider>
<InnerApp />
<SafeAreaProvider
initialMetrics={initialWindowMetrics}>
<IntentDialogProvider>
<InnerApp />
</IntentDialogProvider>
</SafeAreaProvider>
</StarterPackProvider>
</PortalProvider>
</I18nProvider>
</LightboxStateProvider>
</DialogStateProvider>
</ModalStateProvider>
</InvitesStateProvider>
</PrefsStateProvider>
</ShellStateProvider>
</LightboxStateProvider>
</DialogStateProvider>
</ModalStateProvider>
</InvitesStateProvider>
</ShellStateProvider>
</I18nProvider>
</PrefsStateProvider>
</SessionProvider>
</KeyboardProvider>
</A11yProvider>
+30 -27
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>
<UnreadNotifsProvider>
<BackgroundNotificationPreferencesProvider>
<MutedThreadsProvider>
<SafeAreaProvider>
<TourProvider>
<HiddenRepliesProvider>
<UnreadNotifsProvider>
<BackgroundNotificationPreferencesProvider>
<MutedThreadsProvider>
<SafeAreaProvider>
<ProgressGuideProvider>
<Shell />
</ProgressGuideProvider>
</TourProvider>
</SafeAreaProvider>
</MutedThreadsProvider>
</BackgroundNotificationPreferencesProvider>
</UnreadNotifsProvider>
</SafeAreaProvider>
</MutedThreadsProvider>
</BackgroundNotificationPreferencesProvider>
</UnreadNotifsProvider>
</HiddenRepliesProvider>
</SelectedFeedProvider>
</LoggedOutViewProvider>
</ModerationOptsProvider>
@@ -153,25 +154,27 @@ function App() {
return (
<A11yProvider>
<SessionProvider>
<ShellStateProvider>
<PrefsStateProvider>
<InvitesStateProvider>
<ModalStateProvider>
<DialogStateProvider>
<LightboxStateProvider>
<I18nProvider>
<PrefsStateProvider>
<I18nProvider>
<ShellStateProvider>
<InvitesStateProvider>
<ModalStateProvider>
<DialogStateProvider>
<LightboxStateProvider>
<PortalProvider>
<StarterPackProvider>
<InnerApp />
<IntentDialogProvider>
<InnerApp />
</IntentDialogProvider>
</StarterPackProvider>
</PortalProvider>
</I18nProvider>
</LightboxStateProvider>
</DialogStateProvider>
</ModalStateProvider>
</InvitesStateProvider>
</PrefsStateProvider>
</ShellStateProvider>
</LightboxStateProvider>
</DialogStateProvider>
</ModalStateProvider>
</InvitesStateProvider>
</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,
}
}
+55 -25
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(() => {
setState(s => ({
...s,
pressed: true,
}))
}, [setState])
const onPressOut = React.useCallback(() => {
setState(s => ({
...s,
pressed: false,
}))
}, [setState])
const onHoverIn = React.useCallback(() => {
setState(s => ({
...s,
hovered: true,
}))
}, [setState])
const onHoverOut = React.useCallback(() => {
setState(s => ({
...s,
hovered: false,
}))
}, [setState])
const onPressInOuter = rest.onPressIn
const onPressIn = React.useCallback(
(e: GestureResponderEvent) => {
setState(s => ({
...s,
pressed: true,
}))
onPressInOuter?.(e)
},
[setState, onPressInOuter],
)
const onPressOutOuter = rest.onPressOut
const onPressOut = React.useCallback(
(e: GestureResponderEvent) => {
setState(s => ({
...s,
pressed: false,
}))
onPressOutOuter?.(e)
},
[setState, onPressOutOuter],
)
const onHoverInOuter = rest.onHoverIn
const onHoverIn = React.useCallback(
(e: MouseEvent) => {
setState(s => ({
...s,
hovered: true,
}))
onHoverInOuter?.(e)
},
[setState, onHoverInOuter],
)
const onHoverOutOuter = rest.onHoverOut
const onHoverOut = React.useCallback(
(e: MouseEvent) => {
setState(s => ({
...s,
hovered: false,
}))
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()
+42 -6
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}>
{title}
</Text>
<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,
})
+27 -7
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],
]}>
{children}
<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) {
+32 -14
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,18 +239,21 @@ export function Item({children, label, onPress, ...rest}: ItemProps) {
a.rounded_xs,
{minHeight: 32, paddingHorizontal: 10},
web({outline: 0}),
(hovered || focused) && [
web({outline: '0 !important'}),
t.name === 'light'
? t.atoms.bg_contrast_25
: t.atoms.bg_contrast_50,
],
(hovered || focused) &&
!rest.disabled && [
web({outline: '0 !important'}),
t.name === 'light'
? t.atoms.bg_contrast_25
: t.atoms.bg_contrast_50,
],
])}
{...web({
onMouseEnter,
onMouseLeave,
})}>
{children}
<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()
+11 -24
View File
@@ -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,27 +61,17 @@ export function SubmitView({
}
const results = await Promise.all(
selectedServices.map(did => {
if (gate('session_withproxy_fix')) {
return agent
.createModerationReport(report, {
encoding: 'application/json',
headers: {
'atproto-proxy': `${did}#atproto_labeler`,
},
})
.then(
_ => true,
_ => false,
)
} else {
return agent
.withProxy('atproto_labeler', did)
.createModerationReport(report)
.then(
_ => true,
_ => false,
)
}
return agent
.createModerationReport(report, {
encoding: 'application/json',
headers: {
'atproto-proxy': `${did}#atproto_labeler`,
},
})
.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)
+18 -14
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.font_bold,
a.text_center,
{color: 'white', fontSize: 18},
]}>
<Trans>on</Trans>
</Text>
<Logo width={26} fill="white" />
<View style={[{marginTop: 5, marginLeft: 2.5}]}>
<Logotype width={68} fill="white" />
</View>
</View>
<Text
style={[
a.flex,
a.flex_row,
a.align_center,
a.font_bold,
{color: 'white', fontSize: 18, gap: 6},
]}>
<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>
</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)
+129 -168
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>
<WhoCanReplyDialog
control={infoDialogControl}
post={post}
settings={settings}
/>
{isThreadAuthor && (
<ThreadgateEditorDialog
{isThreadAuthor ? (
<PostInteractionSettingsDialog
postUri={post.uri}
rootPostUri={rootUri}
control={editDialogControl}
threadgate={settings}
onConfirm={onEditConfirm}
initialThreadgateView={post.threadgate}
/>
) : (
<WhoCanReplyDialog
control={infoDialogControl}
post={post}
settings={settings}
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.ScrollableInner
label={_(msg`Who can reply dialog`)}
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>
<Rules post={post} settings={settings} />
</View>
</Dialog.ScrollableInner>
<Dialog.Outer control={control}>
<Dialog.Handle />
<Dialog.ScrollableInner
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, a.pb_sm]}>
<Trans>Who can interact with this post?</Trans>
</Text>
<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.flex_wrap,
t.atoms.text_contrast_medium,
]}>
{!settings.length ? (
<Trans>Everybody can reply</Trans>
) : settings[0].type === 'nobody' ? (
<Trans>Replies to this thread 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} />
</>
))}{' '}
can reply
</Trans>
<>
<Text
style={[
a.text_sm,
a.leading_snug,
a.flex_wrap,
t.atoms.text_contrast_medium,
]}>
{settings[0].type === 'everybody' ? (
<Trans>Everybody can reply to this post.</Trans>
) : settings[0].type === 'nobody' ? (
<Trans>Replies to this post are disabled.</Trans>
) : (
<Trans>
Only{' '}
{settings.map((rule, i) => (
<React.Fragment key={`rule-${i}`}>
<Rule rule={rule} post={post} lists={post.threadgate!.lists} />
<Separator i={i} length={settings.length} />
</React.Fragment>
))}{' '}
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>
)}
</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} />
)
}
+20 -44
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,35 +203,22 @@ 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,
subject: {
$type,
...subject,
},
reason: details,
await agent.createModerationReport(
{
reasonType: ComAtprotoModerationDefs.REASONAPPEAL,
subject: {
$type,
...subject,
},
{
encoding: 'application/json',
headers: {
'atproto-proxy': `${label.src}#atproto_labeler`,
},
reason: details,
},
{
encoding: 'application/json',
headers: {
'atproto-proxy': `${label.src}#atproto_labeler`,
},
)
} 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
}
+67 -25
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,7 +271,12 @@ export class FeedTuner {
}
} else {
if (!dryRun) {
this.seenUris.add(item.post.uri)
// 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)
}
}
}
}
@@ -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,
}
}
+108 -94
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) {
}
}
try {
// TODO: this needs to be batch-created with the post!
if (opts.threadgate?.length) {
await createThreadgate(agent, res.uri, opts.threadgate)
if (opts.threadgate.some(tg => tg.type !== 'everybody')) {
try {
// TODO: this needs to be batch-created with the post!
await writeThreadgateRecord({
agent,
postUri: res.uri,
threadgate: createThreadgateRecord({
post: res.uri,
allow: threadgateAllowUISettingToAllowRecordValue(opts.threadgate),
}),
})
} catch (e: any) {
logger.error(`Failed to create threadgate`, {
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.',
)
}
}
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.',
)
}
} catch (e: any) {
console.error(`Failed to create threadgate: ${e.toString()}`)
throw new Error(
'Post reply-controls failed to be set. Your post was created but anyone can reply to 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