Compare commits
163 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 15e51c09bc | |||
| a1969faf8e | |||
| 64b50ba69a | |||
| b90cd68359 | |||
| 55468595d0 | |||
| 18133483fe | |||
| 6eabedd037 | |||
| 7eb4cd89c5 | |||
| 8a66883df8 | |||
| 2265fedd2a | |||
| 117926357d | |||
| 91c3dbd812 | |||
| 824206b95f | |||
| 93c171b403 | |||
| 6d8ed5c3c8 | |||
| 4e6b6740f7 | |||
| 428607d9a3 | |||
| 60b74f7ab8 | |||
| d846f5bbf0 | |||
| 2556698427 | |||
| 76f493c279 | |||
| 4d97a2aa16 | |||
| 86de0dda02 | |||
| 0ef17a464d | |||
| 5dcb52015c | |||
| fcf27f0512 | |||
| e8eaf2f4a7 | |||
| c36c47d49a | |||
| 6382a91fb0 | |||
| 5f5c14d044 | |||
| 82ca0b16b6 | |||
| 12b4a250d2 | |||
| 45bb2477d8 | |||
| d94ff2695d | |||
| dee28f378a | |||
| 21e48bb2d8 | |||
| 515f87ed24 | |||
| 3eef62d995 | |||
| e2a244b998 | |||
| 8860890a85 | |||
| 39f74ced5c | |||
| 3ee5ef32d9 | |||
| 0bd0146efb | |||
| ea4d8bc1ab | |||
| ddda612c11 | |||
| 5c2e9f09bf | |||
| 7f26ad508e | |||
| 5d300bec90 | |||
| 0014d4363f | |||
| bd42f770b8 | |||
| dde72b48e1 | |||
| 05e61346b8 | |||
| 0e1de19903 | |||
| f9d736653c | |||
| 0469ca6cb4 | |||
| f3f7dfc3e6 | |||
| 4abcd65ccf | |||
| 05ac76fc89 | |||
| 1225e84485 | |||
| eb868a042a | |||
| 17d82a64a6 | |||
| bf15fad240 | |||
| 2df22d8674 | |||
| bff6aedecf | |||
| 3d992f6bb5 | |||
| ed232e69f7 | |||
| c41f372b3c | |||
| ab260c3599 | |||
| 8647c8e9f5 | |||
| c70ec1ce1a | |||
| e7954e590b | |||
| c60e8d0772 | |||
| dbbbba1d32 | |||
| 46b7193a2b | |||
| 2ef2ae1c6c | |||
| 05b73d4d1e | |||
| 8cddce5f30 | |||
| 8651f31ebb | |||
| d5a7618374 | |||
| 497aacf113 | |||
| 69e896c221 | |||
| 91fe41670f | |||
| 0a75969475 | |||
| e51eb391df | |||
| 9ebc2785e4 | |||
| faf66f3178 | |||
| 69053b1b0a | |||
| e33b88ed7d | |||
| ea5ab99399 | |||
| 551c4a4f32 | |||
| d52d29621e | |||
| d92731b1eb | |||
| b136c44287 | |||
| 16d556c3c9 | |||
| 94d2180aaa | |||
| 5ae0d40a14 | |||
| 9aa2b2d14e | |||
| b69c40da33 | |||
| 9b534b968d | |||
| def9dda29c | |||
| fc5cc189b5 | |||
| b5ea87c981 | |||
| d646d590cc | |||
| 1d5e341ada | |||
| 632f71acc9 | |||
| 80320f9d7d | |||
| 3f98747925 | |||
| fa12bf5d87 | |||
| 1f657b3ac5 | |||
| 425dd5f27f | |||
| 5ec8761b29 | |||
| 990bf306c5 | |||
| b8dbb71781 | |||
| 2ae3ffcf78 | |||
| 27bb383268 | |||
| df5bf28e61 | |||
| 92989282ae | |||
| 9f1c411360 | |||
| 85d70fe3ef | |||
| d5c78b9183 | |||
| 61f0be705d | |||
| 6616a6467e | |||
| 56ab5e177f | |||
| ddb0b80017 | |||
| 5463e9718f | |||
| 723896a45f | |||
| e54298ec2c | |||
| f235be9819 | |||
| 3976d6738b | |||
| 2939ee7df7 | |||
| 40ab67fc4b | |||
| a5af24b53b | |||
| b6e515c664 | |||
| f3b57dd456 | |||
| 11061b628e | |||
| b9975697e2 | |||
| b6fa0d2d04 | |||
| 21e214c235 | |||
| 26d3777ecc | |||
| 630ebf523d | |||
| 57be2ea15b | |||
| 7e11b862e9 | |||
| 1fce7a793d | |||
| 3c04d9bd84 | |||
| 99d1a881f2 | |||
| 134fcd35d8 | |||
| 7df2327424 | |||
| ae883e2df7 | |||
| db7a744433 | |||
| 75c19b2dc2 | |||
| 88f879ffe9 | |||
| 6f450b4982 | |||
| 8367542138 | |||
| 65d6e561d4 | |||
| c2131bb039 | |||
| ab0da7c892 | |||
| 0a9782ac19 | |||
| 5bfe5aa503 | |||
| dd0d50a6f0 | |||
| 0f993a09c2 | |||
| cb574b7be3 | |||
| a4f0c9c753 | |||
| 4350dbc853 |
@@ -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',
|
||||
|
||||
@@ -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
@@ -191,7 +191,7 @@ module.exports = function (config) {
|
||||
'expo-build-properties',
|
||||
{
|
||||
ios: {
|
||||
deploymentTarget: '14.0',
|
||||
deploymentTarget: '15.1',
|
||||
newArchEnabled: false,
|
||||
},
|
||||
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 |
+3
-2
@@ -10,11 +10,12 @@
|
||||
],
|
||||
"rules": {
|
||||
"simple-import-sort/imports": "warn",
|
||||
"simple-import-sort/exports": "warn"
|
||||
"simple-import-sort/exports": "warn",
|
||||
'no-else-return': 'off'
|
||||
},
|
||||
"parserOptions": {
|
||||
"sourceType": "module",
|
||||
"ecmaVersion": "latest",
|
||||
"project": "./bskyembed/tsconfig.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 |
@@ -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 |
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
)
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -20,5 +20,5 @@
|
||||
"jsxFragmentFactory": "Fragment",
|
||||
"downlevelIteration": true
|
||||
},
|
||||
"include": ["src"]
|
||||
"include": ["src", "vite.config.ts"]
|
||||
}
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"strict": true,
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["snippet"],
|
||||
"include": ["snippet"]
|
||||
}
|
||||
|
||||
+50
-24
@@ -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==
|
||||
|
||||
@@ -255,6 +255,7 @@ 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)
|
||||
|
||||
@@ -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" %}
|
||||
|
||||
@@ -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
@@ -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
@@ -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,6 +1,7 @@
|
||||
import * as PlatformInfo from './src/PlatformInfo'
|
||||
import {AudioCategory} from './src/PlatformInfo/types'
|
||||
import * as Referrer from './src/Referrer'
|
||||
import * as SharedPrefs from './src/SharedPrefs'
|
||||
import VisibilityView from './src/VisibilityView'
|
||||
|
||||
export {PlatformInfo, Referrer, SharedPrefs, VisibilityView}
|
||||
export {AudioCategory, PlatformInfo, Referrer, SharedPrefs, VisibilityView}
|
||||
|
||||
@@ -1,11 +1,46 @@
|
||||
import ExpoModulesCore
|
||||
|
||||
public class ExpoPlatformInfoModule: Module {
|
||||
private var prevAudioActive: Bool?
|
||||
private var prevAudioCategory: AVAudioSession.Category?
|
||||
|
||||
public func definition() -> ModuleDefinition {
|
||||
Name("ExpoPlatformInfo")
|
||||
|
||||
Function("getIsReducedMotionEnabled") {
|
||||
return UIAccessibility.isReduceMotionEnabled
|
||||
}
|
||||
|
||||
Function("setAudioCategory") { (audioCategoryString: String) in
|
||||
let audioCategory = AVAudioSession.Category(rawValue: audioCategoryString)
|
||||
if audioCategory == self.prevAudioCategory {
|
||||
return
|
||||
}
|
||||
self.prevAudioCategory = audioCategory
|
||||
DispatchQueue.global(qos: .background).async {
|
||||
try? AVAudioSession.sharedInstance().setCategory(audioCategory)
|
||||
}
|
||||
}
|
||||
|
||||
Function("setAudioActive") { (active: Bool) in
|
||||
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]
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,20 @@
|
||||
import {Platform} from 'react-native'
|
||||
import {requireNativeModule} from 'expo-modules-core'
|
||||
|
||||
import {AudioCategory} from './types'
|
||||
|
||||
const NativeModule = requireNativeModule('ExpoPlatformInfo')
|
||||
|
||||
export function getIsReducedMotionEnabled(): boolean {
|
||||
return NativeModule.getIsReducedMotionEnabled()
|
||||
}
|
||||
|
||||
export function setAudioActive(active: boolean): void {
|
||||
if (Platform.OS !== 'ios') return
|
||||
NativeModule.setAudioActive(active)
|
||||
}
|
||||
|
||||
export function setAudioCategory(audioCategory: AudioCategory): void {
|
||||
if (Platform.OS !== 'ios') return
|
||||
NativeModule.setAudioCategory(audioCategory)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,25 @@
|
||||
import {NotImplementedError} from '../NotImplemented'
|
||||
import {AudioCategory} from './types'
|
||||
|
||||
export function getIsReducedMotionEnabled(): boolean {
|
||||
throw new NotImplementedError()
|
||||
}
|
||||
|
||||
/**
|
||||
* Set whether the app's audio should mix with other apps' audio. Will also resume background music playback when `false`
|
||||
* if it was previously playing.
|
||||
* @param mixWithOthers
|
||||
* @see https://developer.apple.com/documentation/avfaudio/avaudiosession/setactiveoptions/1616603-notifyothersondeactivation
|
||||
*/
|
||||
export function setAudioActive(active: boolean): void {
|
||||
throw new NotImplementedError({active})
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the audio category for the app.
|
||||
* @param audioCategory
|
||||
* @platform ios
|
||||
*/
|
||||
export function setAudioCategory(audioCategory: AudioCategory): void {
|
||||
throw new NotImplementedError({audioCategory})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
import {NotImplementedError} from '../NotImplemented'
|
||||
import {AudioCategory} from './types'
|
||||
|
||||
export function getIsReducedMotionEnabled(): boolean {
|
||||
if (typeof window === 'undefined') {
|
||||
return false
|
||||
}
|
||||
return window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
}
|
||||
|
||||
export function setAudioActive(active: boolean): void {
|
||||
throw new NotImplementedError({active})
|
||||
}
|
||||
|
||||
export function setAudioCategory(audioCategory: AudioCategory): void {
|
||||
throw new NotImplementedError({audioCategory})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Sets the audio session category on iOS. In general, we should only need to use this for the `playback` and `ambient`
|
||||
* categories. This enum however includes other categories that are available in the native API for clarity and
|
||||
* potential future use.
|
||||
* @see https://developer.apple.com/documentation/avfoundation/avaudiosession/category
|
||||
* @platform ios
|
||||
*/
|
||||
export enum AudioCategory {
|
||||
Ambient = 'AVAudioSessionCategoryAmbient',
|
||||
Playback = 'AVAudioSessionCategoryPlayback',
|
||||
_SoloAmbient = 'AVAudioSessionCategorySoloAmbient',
|
||||
_Record = 'AVAudioSessionCategoryRecord',
|
||||
_PlayAndRecord = 'AVAudioSessionCategoryPlayAndRecord',
|
||||
_MultiRoute = 'AVAudioSessionCategoryMultiRoute',
|
||||
}
|
||||
+6
-6
@@ -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.4",
|
||||
"@braintree/sanitize-url": "^6.0.2",
|
||||
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
|
||||
@@ -139,7 +139,7 @@
|
||||
"expo-system-ui": "~3.0.4",
|
||||
"expo-task-manager": "~11.8.1",
|
||||
"expo-updates": "~0.25.14",
|
||||
"expo-video": "^1.1.10",
|
||||
"expo-video": "https://github.com/bluesky-social/expo/raw/expo-video-1.2.4-patch/packages/expo-video/expo-video-v1.2.4-1.tgz",
|
||||
"expo-web-browser": "~13.0.3",
|
||||
"fast-text-encoding": "^1.0.6",
|
||||
"history": "^5.3.0",
|
||||
@@ -180,6 +180,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-mmkv": "^2.12.2",
|
||||
"react-native-pager-view": "6.2.3",
|
||||
"react-native-picker-select": "^9.1.3",
|
||||
"react-native-progress": "bluesky-social/react-native-progress",
|
||||
@@ -199,7 +200,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",
|
||||
@@ -208,7 +208,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",
|
||||
|
||||
@@ -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];
|
||||
@@ -13,7 +13,7 @@ index bb74e80..0aa0202 100644
|
||||
Map<String, Object> constants = new HashMap<>(3);
|
||||
constants.put(MODULES_CONSTANTS_KEY, new HashMap<>());
|
||||
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 @@
|
||||
@@ -24,3 +24,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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
--- 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 {
|
||||
@@ -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
@@ -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)
|
||||
|
||||
+27
-24
@@ -50,8 +50,9 @@ 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'
|
||||
@@ -59,8 +60,8 @@ import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
|
||||
import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
|
||||
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'
|
||||
|
||||
SplashScreen.preventAutoHideAsync()
|
||||
|
||||
@@ -121,10 +122,10 @@ function InnerApp() {
|
||||
<ModerationOptsProvider>
|
||||
<LoggedOutViewProvider>
|
||||
<SelectedFeedProvider>
|
||||
<UnreadNotifsProvider>
|
||||
<BackgroundNotificationPreferencesProvider>
|
||||
<MutedThreadsProvider>
|
||||
<TourProvider>
|
||||
<HiddenRepliesProvider>
|
||||
<UnreadNotifsProvider>
|
||||
<BackgroundNotificationPreferencesProvider>
|
||||
<MutedThreadsProvider>
|
||||
<ProgressGuideProvider>
|
||||
<GestureHandlerRootView
|
||||
style={s.h100pct}>
|
||||
@@ -132,10 +133,10 @@ function InnerApp() {
|
||||
<Shell />
|
||||
</GestureHandlerRootView>
|
||||
</ProgressGuideProvider>
|
||||
</TourProvider>
|
||||
</MutedThreadsProvider>
|
||||
</BackgroundNotificationPreferencesProvider>
|
||||
</UnreadNotifsProvider>
|
||||
</MutedThreadsProvider>
|
||||
</BackgroundNotificationPreferencesProvider>
|
||||
</UnreadNotifsProvider>
|
||||
</HiddenRepliesProvider>
|
||||
</SelectedFeedProvider>
|
||||
</LoggedOutViewProvider>
|
||||
</ModerationOptsProvider>
|
||||
@@ -157,6 +158,8 @@ function App() {
|
||||
const [isReady, setReady] = useState(false)
|
||||
|
||||
React.useEffect(() => {
|
||||
PlatformInfo.setAudioCategory(AudioCategory.Ambient)
|
||||
PlatformInfo.setAudioActive(false)
|
||||
initPersistedState().then(() => setReady(true))
|
||||
}, [])
|
||||
|
||||
@@ -172,25 +175,25 @@ 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 />
|
||||
</StarterPackProvider>
|
||||
</PortalProvider>
|
||||
</I18nProvider>
|
||||
</LightboxStateProvider>
|
||||
</DialogStateProvider>
|
||||
</ModalStateProvider>
|
||||
</InvitesStateProvider>
|
||||
</PrefsStateProvider>
|
||||
</ShellStateProvider>
|
||||
</LightboxStateProvider>
|
||||
</DialogStateProvider>
|
||||
</ModalStateProvider>
|
||||
</InvitesStateProvider>
|
||||
</ShellStateProvider>
|
||||
</I18nProvider>
|
||||
</PrefsStateProvider>
|
||||
</SessionProvider>
|
||||
</KeyboardProvider>
|
||||
</A11yProvider>
|
||||
|
||||
+26
-26
@@ -39,7 +39,8 @@ 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'
|
||||
@@ -47,7 +48,6 @@ import {ThemeProvider as Alf} from '#/alf'
|
||||
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
|
||||
import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
|
||||
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 +105,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 +153,25 @@ function App() {
|
||||
return (
|
||||
<A11yProvider>
|
||||
<SessionProvider>
|
||||
<ShellStateProvider>
|
||||
<PrefsStateProvider>
|
||||
<InvitesStateProvider>
|
||||
<ModalStateProvider>
|
||||
<DialogStateProvider>
|
||||
<LightboxStateProvider>
|
||||
<I18nProvider>
|
||||
<PrefsStateProvider>
|
||||
<I18nProvider>
|
||||
<ShellStateProvider>
|
||||
<InvitesStateProvider>
|
||||
<ModalStateProvider>
|
||||
<DialogStateProvider>
|
||||
<LightboxStateProvider>
|
||||
<PortalProvider>
|
||||
<StarterPackProvider>
|
||||
<InnerApp />
|
||||
</StarterPackProvider>
|
||||
</PortalProvider>
|
||||
</I18nProvider>
|
||||
</LightboxStateProvider>
|
||||
</DialogStateProvider>
|
||||
</ModalStateProvider>
|
||||
</InvitesStateProvider>
|
||||
</PrefsStateProvider>
|
||||
</ShellStateProvider>
|
||||
</LightboxStateProvider>
|
||||
</DialogStateProvider>
|
||||
</ModalStateProvider>
|
||||
</InvitesStateProvider>
|
||||
</ShellStateProvider>
|
||||
</I18nProvider>
|
||||
</PrefsStateProvider>
|
||||
</SessionProvider>
|
||||
</A11yProvider>
|
||||
)
|
||||
|
||||
+67
-58
@@ -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)}
|
||||
|
||||
+28
-1
@@ -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
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
+14
-14
@@ -186,19 +186,19 @@ export function createThemes({
|
||||
white: color.gray_0,
|
||||
black: color.trueBlack,
|
||||
|
||||
contrast_25: color.gray_1000,
|
||||
contrast_50: color.gray_975,
|
||||
contrast_100: color.gray_950,
|
||||
contrast_200: color.gray_900,
|
||||
contrast_300: color.gray_800,
|
||||
contrast_400: color.gray_700,
|
||||
contrast_500: color.gray_600,
|
||||
contrast_600: color.gray_500,
|
||||
contrast_700: color.gray_400,
|
||||
contrast_800: color.gray_300,
|
||||
contrast_900: color.gray_200,
|
||||
contrast_950: color.gray_100,
|
||||
contrast_975: color.gray_50,
|
||||
contrast_25: color.gray_975,
|
||||
contrast_50: color.gray_950,
|
||||
contrast_100: color.gray_900,
|
||||
contrast_200: color.gray_800,
|
||||
contrast_300: color.gray_700,
|
||||
contrast_400: color.gray_600,
|
||||
contrast_500: color.gray_500,
|
||||
contrast_600: color.gray_400,
|
||||
contrast_700: color.gray_300,
|
||||
contrast_800: color.gray_200,
|
||||
contrast_900: color.gray_100,
|
||||
contrast_950: color.gray_50,
|
||||
contrast_975: color.gray_25,
|
||||
|
||||
primary_25: color.primary_975,
|
||||
primary_50: color.primary_950,
|
||||
@@ -400,7 +400,7 @@ export function createThemes({
|
||||
color: darkPalette.contrast_400,
|
||||
},
|
||||
text_contrast_medium: {
|
||||
color: darkPalette.contrast_700,
|
||||
color: darkPalette.contrast_600,
|
||||
},
|
||||
text_contrast_high: {
|
||||
color: darkPalette.contrast_900,
|
||||
|
||||
+58
-46
@@ -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,
|
||||
@@ -202,28 +232,10 @@ export const Button = React.forwardRef<View, ButtonProps>(
|
||||
} else if (color === 'secondary') {
|
||||
if (variant === 'solid') {
|
||||
if (!disabled) {
|
||||
baseStyles.push({
|
||||
backgroundColor: select(t.name, {
|
||||
light: t.palette.contrast_25,
|
||||
dim: t.palette.contrast_100,
|
||||
dark: t.palette.contrast_100,
|
||||
}),
|
||||
})
|
||||
hoverStyles.push({
|
||||
backgroundColor: select(t.name, {
|
||||
light: t.palette.contrast_50,
|
||||
dim: t.palette.contrast_200,
|
||||
dark: t.palette.contrast_200,
|
||||
}),
|
||||
})
|
||||
baseStyles.push(t.atoms.bg_contrast_25)
|
||||
hoverStyles.push(t.atoms.bg_contrast_50)
|
||||
} else {
|
||||
baseStyles.push({
|
||||
backgroundColor: select(t.name, {
|
||||
light: t.palette.contrast_100,
|
||||
dim: t.palette.contrast_25,
|
||||
dark: t.palette.contrast_25,
|
||||
}),
|
||||
})
|
||||
baseStyles.push(t.atoms.bg_contrast_100)
|
||||
}
|
||||
} else if (variant === 'outline') {
|
||||
baseStyles.push(a.border, t.atoms.bg, {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
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'
|
||||
|
||||
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
|
||||
import {NavigationProp} from '#/lib/routes/types'
|
||||
import {useGate} from '#/lib/statsig/statsig'
|
||||
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 +176,63 @@ function useExperimentalSuggestedUsersQuery() {
|
||||
}
|
||||
}
|
||||
|
||||
export function SuggestedFollows() {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
export function SuggestedFollows({feed}: {feed: FeedDescriptor}) {
|
||||
const gate = useGate()
|
||||
const [feedType, feedUri] = feed.split('|')
|
||||
if (feedType === 'author') {
|
||||
if (gate('show_follow_suggestions_in_profile')) {
|
||||
return <SuggestedFollowsProfile did={feedUri} />
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
} 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()
|
||||
|
||||
@@ -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]}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
})
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -8,7 +8,10 @@ import {Button, ButtonColor, ButtonProps, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export {useDialogControl as usePromptControl} from '#/components/Dialog'
|
||||
export {
|
||||
type DialogControlProps as PromptControlProps,
|
||||
useDialogControl as usePromptControl,
|
||||
} from '#/components/Dialog'
|
||||
|
||||
const Context = React.createContext<{
|
||||
titleId: string
|
||||
@@ -23,7 +26,7 @@ export function Outer({
|
||||
control,
|
||||
testID,
|
||||
}: React.PropsWithChildren<{
|
||||
control: Dialog.DialogOuterProps['control']
|
||||
control: Dialog.DialogControlProps
|
||||
testID?: string
|
||||
}>) {
|
||||
const {gtMobile} = useBreakpoints()
|
||||
|
||||
@@ -6,7 +6,6 @@ import {useLingui} from '@lingui/react'
|
||||
|
||||
import {getLabelingServiceTitle} from '#/lib/moderation'
|
||||
import {ReportOption} from '#/lib/moderation/useReportOptions'
|
||||
import {useGate} from '#/lib/statsig/statsig'
|
||||
import {useAgent} from '#/state/session'
|
||||
import {CharProgress} from '#/view/com/composer/char-progress/CharProgress'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
@@ -37,7 +36,6 @@ export function SubmitView({
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const agent = useAgent()
|
||||
const gate = useGate()
|
||||
const [details, setDetails] = React.useState<string>('')
|
||||
const [submitting, setSubmitting] = React.useState<boolean>(false)
|
||||
const [selectedServices, setSelectedServices] = React.useState<string[]>([
|
||||
@@ -63,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(200)
|
||||
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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 '@discord/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
@@ -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,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}]}>
|
||||
|
||||
@@ -249,7 +249,9 @@ function DialogError({details}: {details?: string}) {
|
||||
const control = Dialog.useDialogContext()
|
||||
|
||||
return (
|
||||
<Dialog.ScrollableInner style={a.gap_md} label={_(msg`An error occured`)}>
|
||||
<Dialog.ScrollableInner
|
||||
style={a.gap_md}
|
||||
label={_(msg`An error has occurred`)}>
|
||||
<Dialog.Close />
|
||||
<ErrorScreen
|
||||
title={_(msg`Oh no!`)}
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
},
|
||||
[_],
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -193,6 +193,7 @@ export function createInput(Component: typeof TextInput) {
|
||||
lineHeight: a.text_md.fontSize * 1.1875,
|
||||
textAlignVertical: rest.multiline ? 'top' : undefined,
|
||||
minHeight: rest.multiline ? 80 : undefined,
|
||||
minWidth: 0,
|
||||
},
|
||||
// fix for autofill styles covering border
|
||||
web({
|
||||
|
||||
@@ -5,10 +5,10 @@ export function useInteractionState() {
|
||||
|
||||
const onIn = React.useCallback(() => {
|
||||
setState(true)
|
||||
}, [setState])
|
||||
}, [])
|
||||
const onOut = React.useCallback(() => {
|
||||
setState(false)
|
||||
}, [setState])
|
||||
}, [])
|
||||
|
||||
return React.useMemo(
|
||||
() => ({
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
@@ -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',
|
||||
})
|
||||
@@ -19,6 +19,7 @@ export const sizes = {
|
||||
md: 20,
|
||||
lg: 24,
|
||||
xl: 28,
|
||||
'2xl': 32,
|
||||
}
|
||||
|
||||
export function useCommonSVGProps(props: Props) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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} />
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}} />}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Play_Filled_Corner2_Rounded as PlayIcon} from '#/components/icons/Play'
|
||||
|
||||
export function PlayButtonIcon({size = 44}: {size?: number}) {
|
||||
const t = useTheme()
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.rounded_full,
|
||||
a.align_center,
|
||||
a.justify_center,
|
||||
{
|
||||
backgroundColor: t.palette.primary_500,
|
||||
width: size + 16,
|
||||
height: size + 16,
|
||||
},
|
||||
]}>
|
||||
<PlayIcon height={size} width={size} style={{color: 'white'}} />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export function doPolyfill() {
|
||||
// no polyfill is needed on web
|
||||
}
|
||||
+67
-25
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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}`)
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
+11
-1
@@ -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 = 50676 // as of Aug 17, 2024
|
||||
|
||||
const BASE_FEEDBACK_FORM_URL = `${HELP_DESK_URL}/requests/new`
|
||||
export function FEEDBACK_FORM_URL({
|
||||
@@ -135,3 +136,12 @@ export const GIF_FEATURED = (params: string) =>
|
||||
`${GIF_SERVICE}/tenor/v2/featured?${params}`
|
||||
|
||||
export const MAX_LABELERS = 20
|
||||
|
||||
export const SUPPORTED_MIME_TYPES = [
|
||||
'video/mp4',
|
||||
'video/mpeg',
|
||||
'video/webm',
|
||||
'video/quicktime',
|
||||
] as const
|
||||
|
||||
export type SupportedMimeTypes = (typeof SUPPORTED_MIME_TYPES)[number]
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import {StackActions, useNavigation} from '@react-navigation/native'
|
||||
|
||||
import {NavigationProp} from 'lib/routes/types'
|
||||
import {router} from '#/routes'
|
||||
|
||||
export function useGoBack(onGoBack?: () => unknown) {
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
return () => {
|
||||
onGoBack?.()
|
||||
if (navigation.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())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,24 @@
|
||||
import React from 'react'
|
||||
import {Dimensions} from 'react-native'
|
||||
import {useWindowDimensions} from 'react-native'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
|
||||
import {useBottomBarOffset} from 'lib/hooks/useBottomBarOffset'
|
||||
|
||||
const MIN_POST_HEIGHT = 100
|
||||
|
||||
export function useInitialNumToRender(minItemHeight: number = MIN_POST_HEIGHT) {
|
||||
return React.useMemo(() => {
|
||||
const screenHeight = Dimensions.get('window').height
|
||||
return Math.ceil(screenHeight / minItemHeight) + 1
|
||||
}, [minItemHeight])
|
||||
export function useInitialNumToRender({
|
||||
minItemHeight = MIN_POST_HEIGHT,
|
||||
screenHeightOffset = 0,
|
||||
}: {minItemHeight?: number; screenHeightOffset?: number} = {}) {
|
||||
const {height: screenHeight} = useWindowDimensions()
|
||||
const {top: topInset} = useSafeAreaInsets()
|
||||
const bottomBarHeight = useBottomBarOffset()
|
||||
|
||||
const finalHeight =
|
||||
screenHeight - screenHeightOffset - topInset - bottomBarHeight
|
||||
|
||||
const minItems = Math.floor(finalHeight / minItemHeight)
|
||||
if (minItems < 1) {
|
||||
return 1
|
||||
}
|
||||
return minItems
|
||||
}
|
||||
|
||||
@@ -6,16 +6,20 @@ import {useShellLayout} from '#/state/shell/shell-layout'
|
||||
// Keep these separated so that we only pay for useAnimatedStyle that gets used.
|
||||
|
||||
export function useMinimalShellHeaderTransform() {
|
||||
const mode = useMinimalShellMode()
|
||||
const {headerMode} = useMinimalShellMode()
|
||||
const {headerHeight} = useShellLayout()
|
||||
|
||||
const headerTransform = useAnimatedStyle(() => {
|
||||
return {
|
||||
pointerEvents: mode.value === 0 ? 'auto' : 'none',
|
||||
opacity: Math.pow(1 - mode.value, 2),
|
||||
pointerEvents: headerMode.value === 0 ? 'auto' : 'none',
|
||||
opacity: Math.pow(1 - headerMode.value, 2),
|
||||
transform: [
|
||||
{
|
||||
translateY: interpolate(mode.value, [0, 1], [0, -headerHeight.value]),
|
||||
translateY: interpolate(
|
||||
headerMode.value,
|
||||
[0, 1],
|
||||
[0, -headerHeight.value],
|
||||
),
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -25,31 +29,36 @@ export function useMinimalShellHeaderTransform() {
|
||||
}
|
||||
|
||||
export function useMinimalShellFooterTransform() {
|
||||
const mode = useMinimalShellMode()
|
||||
const {footerMode} = useMinimalShellMode()
|
||||
const {footerHeight} = useShellLayout()
|
||||
|
||||
const footerTransform = useAnimatedStyle(() => {
|
||||
return {
|
||||
pointerEvents: mode.value === 0 ? 'auto' : 'none',
|
||||
opacity: Math.pow(1 - mode.value, 2),
|
||||
pointerEvents: footerMode.value === 0 ? 'auto' : 'none',
|
||||
opacity: Math.pow(1 - footerMode.value, 2),
|
||||
transform: [
|
||||
{
|
||||
translateY: interpolate(mode.value, [0, 1], [0, footerHeight.value]),
|
||||
translateY: interpolate(
|
||||
footerMode.value,
|
||||
[0, 1],
|
||||
[0, footerHeight.value],
|
||||
),
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
return footerTransform
|
||||
}
|
||||
|
||||
export function useMinimalShellFabTransform() {
|
||||
const mode = useMinimalShellMode()
|
||||
const {footerMode} = useMinimalShellMode()
|
||||
|
||||
const fabTransform = useAnimatedStyle(() => {
|
||||
return {
|
||||
transform: [
|
||||
{
|
||||
translateY: interpolate(mode.value, [0, 1], [-44, 0]),
|
||||
translateY: interpolate(footerMode.value, [0, 1], [-44, 0]),
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
+153
-61
@@ -1,25 +1,16 @@
|
||||
import {useCallback} from 'react'
|
||||
import {msg, plural} from '@lingui/macro'
|
||||
import {I18nContext, useLingui} from '@lingui/react'
|
||||
import {I18n} from '@lingui/core'
|
||||
import {defineMessage, msg, plural} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {differenceInSeconds} from 'date-fns'
|
||||
|
||||
export type TimeAgoOptions = {
|
||||
lingui: I18nContext['_']
|
||||
format?: 'long' | 'short'
|
||||
}
|
||||
export type DateDiffFormat = 'long' | 'short'
|
||||
|
||||
export function useGetTimeAgo() {
|
||||
const {_} = useLingui()
|
||||
return useCallback(
|
||||
(
|
||||
earlier: number | string | Date,
|
||||
later: number | string | Date,
|
||||
options?: Omit<TimeAgoOptions, 'lingui'>,
|
||||
) => {
|
||||
return dateDiff(earlier, later, {lingui: _, format: options?.format})
|
||||
},
|
||||
[_],
|
||||
)
|
||||
type DateDiff = {
|
||||
value: number
|
||||
unit: 'now' | 'second' | 'minute' | 'hour' | 'day' | 'month'
|
||||
earlier: Date
|
||||
later: Date
|
||||
}
|
||||
|
||||
const NOW = 5
|
||||
@@ -28,59 +19,160 @@ const HOUR = MINUTE * 60
|
||||
const DAY = HOUR * 24
|
||||
const MONTH_30 = DAY * 30
|
||||
|
||||
export function useGetTimeAgo() {
|
||||
const {i18n} = useLingui()
|
||||
return useCallback(
|
||||
(
|
||||
earlier: number | string | Date,
|
||||
later: number | string | Date,
|
||||
options?: {format: DateDiffFormat},
|
||||
) => {
|
||||
const diff = dateDiff(earlier, later)
|
||||
return formatDateDiff({diff, i18n, format: options?.format})
|
||||
},
|
||||
[i18n],
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the difference between `earlier` and `later` dates, formatted as a
|
||||
* natural language string.
|
||||
* Returns the difference between `earlier` and `later` dates, based on
|
||||
* opinionated rules.
|
||||
*
|
||||
* - All month are considered exactly 30 days.
|
||||
* - Dates assume `earlier` <= `later`, and will otherwise return 'now'.
|
||||
* - All values round down
|
||||
*/
|
||||
export function dateDiff(
|
||||
earlier: number | string | Date,
|
||||
later: number | string | Date,
|
||||
): DateDiff {
|
||||
let diff = {
|
||||
value: 0,
|
||||
unit: 'now' as DateDiff['unit'],
|
||||
}
|
||||
const e = new Date(earlier)
|
||||
const l = new Date(later)
|
||||
const diffSeconds = differenceInSeconds(l, e)
|
||||
|
||||
if (diffSeconds < NOW) {
|
||||
diff = {
|
||||
value: 0,
|
||||
unit: 'now' as DateDiff['unit'],
|
||||
}
|
||||
} else if (diffSeconds < MINUTE) {
|
||||
diff = {
|
||||
value: diffSeconds,
|
||||
unit: 'second' as DateDiff['unit'],
|
||||
}
|
||||
} else if (diffSeconds < HOUR) {
|
||||
const value = Math.floor(diffSeconds / MINUTE)
|
||||
diff = {
|
||||
value,
|
||||
unit: 'minute' as DateDiff['unit'],
|
||||
}
|
||||
} else if (diffSeconds < DAY) {
|
||||
const value = Math.floor(diffSeconds / HOUR)
|
||||
diff = {
|
||||
value,
|
||||
unit: 'hour' as DateDiff['unit'],
|
||||
}
|
||||
} else if (diffSeconds < MONTH_30) {
|
||||
const value = Math.floor(diffSeconds / DAY)
|
||||
diff = {
|
||||
value,
|
||||
unit: 'day' as DateDiff['unit'],
|
||||
}
|
||||
} else {
|
||||
const value = Math.floor(diffSeconds / MONTH_30)
|
||||
diff = {
|
||||
value,
|
||||
unit: 'month' as DateDiff['unit'],
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...diff,
|
||||
earlier: e,
|
||||
later: l,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Accepts a `DateDiff` and teturns the difference between `earlier` and
|
||||
* `later` dates, formatted as a natural language string.
|
||||
*
|
||||
* - All month are considered exactly 30 days.
|
||||
* - Dates assume `earlier` <= `later`, and will otherwise return 'now'.
|
||||
* - Differences >= 360 days are returned as the "M/D/YYYY" string
|
||||
* - All values round down
|
||||
*/
|
||||
export function dateDiff(
|
||||
earlier: number | string | Date,
|
||||
later: number | string | Date,
|
||||
options: TimeAgoOptions,
|
||||
): string {
|
||||
const _ = options.lingui
|
||||
const format = options?.format || 'short'
|
||||
export function formatDateDiff({
|
||||
diff,
|
||||
format = 'short',
|
||||
i18n,
|
||||
}: {
|
||||
diff: DateDiff
|
||||
format?: DateDiffFormat
|
||||
i18n: I18n
|
||||
}): string {
|
||||
const long = format === 'long'
|
||||
const diffSeconds = differenceInSeconds(new Date(later), new Date(earlier))
|
||||
|
||||
if (diffSeconds < NOW) {
|
||||
return _(msg`now`)
|
||||
} else if (diffSeconds < MINUTE) {
|
||||
return `${diffSeconds}${
|
||||
long ? ` ${plural(diffSeconds, {one: 'second', other: 'seconds'})}` : 's'
|
||||
}`
|
||||
} else if (diffSeconds < HOUR) {
|
||||
const diff = Math.floor(diffSeconds / MINUTE)
|
||||
return `${diff}${
|
||||
long ? ` ${plural(diff, {one: 'minute', other: 'minutes'})}` : 'm'
|
||||
}`
|
||||
} else if (diffSeconds < DAY) {
|
||||
const diff = Math.floor(diffSeconds / HOUR)
|
||||
return `${diff}${
|
||||
long ? ` ${plural(diff, {one: 'hour', other: 'hours'})}` : 'h'
|
||||
}`
|
||||
} else if (diffSeconds < MONTH_30) {
|
||||
const diff = Math.floor(diffSeconds / DAY)
|
||||
return `${diff}${
|
||||
long ? ` ${plural(diff, {one: 'day', other: 'days'})}` : 'd'
|
||||
}`
|
||||
} else {
|
||||
const diff = Math.floor(diffSeconds / MONTH_30)
|
||||
if (diff < 12) {
|
||||
return `${diff}${
|
||||
long ? ` ${plural(diff, {one: 'month', other: 'months'})}` : 'mo'
|
||||
}`
|
||||
} else {
|
||||
const str = new Date(earlier).toLocaleDateString()
|
||||
|
||||
if (long) {
|
||||
return _(msg`on ${str}`)
|
||||
switch (diff.unit) {
|
||||
case 'now': {
|
||||
return i18n._(msg`now`)
|
||||
}
|
||||
case 'second': {
|
||||
return long
|
||||
? i18n._(plural(diff.value, {one: '# second', other: '# seconds'}))
|
||||
: i18n._(
|
||||
defineMessage({
|
||||
message: `${diff.value}s`,
|
||||
comment: `How many seconds have passed, displayed in a narrow form`,
|
||||
}),
|
||||
)
|
||||
}
|
||||
case 'minute': {
|
||||
return long
|
||||
? i18n._(plural(diff.value, {one: '# minute', other: '# minutes'}))
|
||||
: i18n._(
|
||||
defineMessage({
|
||||
message: `${diff.value}m`,
|
||||
comment: `How many minutes have passed, displayed in a narrow form`,
|
||||
}),
|
||||
)
|
||||
}
|
||||
case 'hour': {
|
||||
return long
|
||||
? i18n._(plural(diff.value, {one: '# hour', other: '# hours'}))
|
||||
: i18n._(
|
||||
defineMessage({
|
||||
message: `${diff.value}h`,
|
||||
comment: `How many hours have passed, displayed in a narrow form`,
|
||||
}),
|
||||
)
|
||||
}
|
||||
case 'day': {
|
||||
return long
|
||||
? i18n._(plural(diff.value, {one: '# day', other: '# days'}))
|
||||
: i18n._(
|
||||
defineMessage({
|
||||
message: `${diff.value}d`,
|
||||
comment: `How many days have passed, displayed in a narrow form`,
|
||||
}),
|
||||
)
|
||||
}
|
||||
case 'month': {
|
||||
if (diff.value < 12) {
|
||||
return long
|
||||
? i18n._(plural(diff.value, {one: '# month', other: '# months'}))
|
||||
: i18n._(
|
||||
defineMessage({
|
||||
message: `${diff.value}mo`,
|
||||
comment: `How many months have passed, displayed in a narrow form`,
|
||||
}),
|
||||
)
|
||||
}
|
||||
return str
|
||||
return i18n.date(new Date(diff.earlier))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,6 +107,11 @@ export async function extractBskyMeta(
|
||||
return meta
|
||||
}
|
||||
|
||||
export class EmbeddingDisabledError extends Error {
|
||||
constructor() {
|
||||
super('Embedding is disabled for this record')
|
||||
}
|
||||
}
|
||||
export async function getPostAsQuote(
|
||||
getPost: ReturnType<typeof useGetPost>,
|
||||
url: string,
|
||||
@@ -115,6 +120,9 @@ export async function getPostAsQuote(
|
||||
const [_0, user, _1, rkey] = url.split('/').filter(Boolean)
|
||||
const uri = makeRecordUri(user, 'app.bsky.feed.post', rkey)
|
||||
const post = await getPost({uri: uri})
|
||||
if (post.viewer?.embeddingDisabled) {
|
||||
throw new EmbeddingDisabledError()
|
||||
}
|
||||
return {
|
||||
uri: post.uri,
|
||||
cid: post.cid,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user