Merge remote-tracking branch 'origin/main' into 0.74-fabric
This commit is contained in:
@@ -71,6 +71,19 @@ module.exports = {
|
|||||||
'simple-import-sort/exports': 'warn',
|
'simple-import-sort/exports': 'warn',
|
||||||
// TODO: Reenable when we figure out why it gets stuck on CI.
|
// TODO: Reenable when we figure out why it gets stuck on CI.
|
||||||
// 'react-compiler/react-compiler': 'error',
|
// '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: [
|
ignorePatterns: [
|
||||||
'**/__mocks__/*.ts',
|
'**/__mocks__/*.ts',
|
||||||
|
|||||||
@@ -10,8 +10,13 @@ appId: xyz.blueskyweb.app
|
|||||||
id: "e2eSignInAlice"
|
id: "e2eSignInAlice"
|
||||||
|
|
||||||
# Pin alice's feed
|
# Pin alice's feed
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
id: "viewHeaderDrawerBtn"
|
||||||
- tapOn:
|
- tapOn:
|
||||||
id: "bottomBarProfileBtn"
|
id: "viewHeaderDrawerBtn"
|
||||||
|
- tapOn:
|
||||||
|
id: "profileCardButton"
|
||||||
- swipe:
|
- swipe:
|
||||||
from:
|
from:
|
||||||
id: "profilePager-selector"
|
id: "profilePager-selector"
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ appId: xyz.blueskyweb.app
|
|||||||
- tapOn:
|
- tapOn:
|
||||||
id: "e2eSignInAlice"
|
id: "e2eSignInAlice"
|
||||||
|
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
text: "Feeds ✨"
|
||||||
- tapOn:
|
- tapOn:
|
||||||
label: "Can go to feeds page using feeds button in tab bar"
|
label: "Can go to feeds page using feeds button in tab bar"
|
||||||
text: "Feeds ✨"
|
text: "Feeds ✨"
|
||||||
@@ -34,26 +37,16 @@ appId: xyz.blueskyweb.app
|
|||||||
- tapOn:
|
- tapOn:
|
||||||
label: "Can like posts"
|
label: "Can like posts"
|
||||||
id: "likeBtn"
|
id: "likeBtn"
|
||||||
- assertVisible:
|
|
||||||
id: "likeCount"
|
|
||||||
text: "1"
|
|
||||||
- tapOn:
|
- tapOn:
|
||||||
id: "likeBtn"
|
id: "likeBtn"
|
||||||
- assertNotVisible:
|
|
||||||
id: "likeCount"
|
|
||||||
|
|
||||||
- tapOn:
|
- tapOn:
|
||||||
label: "Can repost posts"
|
label: "Can repost posts"
|
||||||
id: "repostBtn"
|
id: "repostBtn"
|
||||||
- tapOn: "Repost"
|
- tapOn: "Repost"
|
||||||
- assertVisible:
|
|
||||||
id: "repostCount"
|
|
||||||
text: "1"
|
|
||||||
- tapOn:
|
- tapOn:
|
||||||
id: "repostBtn"
|
id: "repostBtn"
|
||||||
- tapOn: "Remove repost"
|
- tapOn: "Remove repost"
|
||||||
- assertNotVisible:
|
|
||||||
id: "repostCount"
|
|
||||||
|
|
||||||
- tapOn:
|
- tapOn:
|
||||||
label: "Can delete posts"
|
label: "Can delete posts"
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ appId: xyz.blueskyweb.app
|
|||||||
|
|
||||||
|
|
||||||
# Navigate to my profile
|
# Navigate to my profile
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
id: "bottomBarSearchBtn"
|
||||||
- tapOn:
|
- tapOn:
|
||||||
id: "bottomBarProfileBtn"
|
id: "bottomBarProfileBtn"
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ appId: xyz.blueskyweb.app
|
|||||||
id: "e2eSignInAlice"
|
id: "e2eSignInAlice"
|
||||||
|
|
||||||
# Navigate to another user profile
|
# Navigate to another user profile
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
id: "bottomBarSearchBtn"
|
||||||
- tapOn:
|
- tapOn:
|
||||||
id: "bottomBarSearchBtn"
|
id: "bottomBarSearchBtn"
|
||||||
- tapOn:
|
- tapOn:
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ appId: xyz.blueskyweb.app
|
|||||||
|
|
||||||
|
|
||||||
# Navigate to thread
|
# Navigate to thread
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Thread root"
|
||||||
- tapOn: "Thread root"
|
- tapOn: "Thread root"
|
||||||
- assertVisible: "Thread reply"
|
- assertVisible: "Thread reply"
|
||||||
|
|
||||||
@@ -33,18 +35,10 @@ appId: xyz.blueskyweb.app
|
|||||||
id: "likeBtn"
|
id: "likeBtn"
|
||||||
childOf:
|
childOf:
|
||||||
id: "postThreadItem-by-carla.test"
|
id: "postThreadItem-by-carla.test"
|
||||||
- assertVisible:
|
|
||||||
id: "likeCount"
|
|
||||||
childOf:
|
|
||||||
id: "postThreadItem-by-carla.test"
|
|
||||||
- tapOn:
|
- tapOn:
|
||||||
id: "likeBtn"
|
id: "likeBtn"
|
||||||
childOf:
|
childOf:
|
||||||
id: "postThreadItem-by-carla.test"
|
id: "postThreadItem-by-carla.test"
|
||||||
- assertNotVisible:
|
|
||||||
id: "likeCount"
|
|
||||||
childOf:
|
|
||||||
id: "postThreadItem-by-carla.test"
|
|
||||||
|
|
||||||
# Can repost the root post
|
# Can repost the root post
|
||||||
- tapOn:
|
- tapOn:
|
||||||
|
|||||||
@@ -340,12 +340,14 @@ describe('parseEmbedPlayerFromUrl', () => {
|
|||||||
'https://youtube.com/watch?v=videoId',
|
'https://youtube.com/watch?v=videoId',
|
||||||
'https://youtube.com/watch?v=videoId&feature=share',
|
'https://youtube.com/watch?v=videoId&feature=share',
|
||||||
'https://youtube.com/shorts/videoId',
|
'https://youtube.com/shorts/videoId',
|
||||||
|
'https://youtube.com/live/videoId',
|
||||||
'https://m.youtube.com/watch?v=videoId',
|
'https://m.youtube.com/watch?v=videoId',
|
||||||
'https://music.youtube.com/watch?v=videoId',
|
'https://music.youtube.com/watch?v=videoId',
|
||||||
|
|
||||||
'https://youtube.com/shorts/',
|
'https://youtube.com/shorts/',
|
||||||
'https://youtube.com/',
|
'https://youtube.com/',
|
||||||
'https://youtube.com/random',
|
'https://youtube.com/random',
|
||||||
|
'https://youtube.com/live/',
|
||||||
|
|
||||||
'https://twitch.tv/channelName',
|
'https://twitch.tv/channelName',
|
||||||
'https://www.twitch.tv/channelName',
|
'https://www.twitch.tv/channelName',
|
||||||
@@ -475,10 +477,16 @@ describe('parseEmbedPlayerFromUrl', () => {
|
|||||||
source: 'youtube',
|
source: 'youtube',
|
||||||
playerUri: 'https://bsky.app/iframe/youtube.html?videoId=videoId&start=0',
|
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,
|
||||||
undefined,
|
undefined,
|
||||||
|
undefined,
|
||||||
|
|
||||||
{
|
{
|
||||||
type: 'twitch_video',
|
type: 'twitch_video',
|
||||||
|
|||||||
+1
-1
@@ -191,7 +191,7 @@ module.exports = function (config) {
|
|||||||
'expo-build-properties',
|
'expo-build-properties',
|
||||||
{
|
{
|
||||||
ios: {
|
ios: {
|
||||||
deploymentTarget: '14.0',
|
deploymentTarget: '15.1',
|
||||||
newArchEnabled: true,
|
newArchEnabled: true,
|
||||||
},
|
},
|
||||||
android: {
|
android: {
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M6 2a1 1 0 0 1 1 1v2h11a1 1 0 0 1 1 1v11h2a1 1 0 1 1 0 2h-2v2a1 1 0 1 1-2 0v-2H6a1 1 0 0 1-1-1V7H3a1 1 0 0 1 0-2h2V3a1 1 0 0 1 1-1Zm1 5v10h10V7H7Z" clip-rule="evenodd"/></svg>
|
||||||
|
After Width: | Height: | Size: 289 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M3.135 12C5.413 16.088 8.77 18 12 18s6.587-1.912 8.865-6C18.587 7.912 15.23 6 12 6c-3.228 0-6.587 1.912-8.865 6ZM12 4c4.24 0 8.339 2.611 10.888 7.54a1 1 0 0 1 0 .92C20.338 17.388 16.24 20 12 20c-4.24 0-8.339-2.611-10.888-7.54a1 1 0 0 1 0-.92C3.662 6.612 7.76 4 12 4Zm0 6a2 2 0 1 0 0 4 2 2 0 0 0 0-4Zm-4 2a4 4 0 1 1 8 0 4 4 0 0 1-8 0Z" clip-rule="evenodd"/></svg>
|
||||||
|
After Width: | Height: | Size: 476 B |
+2
-1
@@ -10,7 +10,8 @@
|
|||||||
],
|
],
|
||||||
"rules": {
|
"rules": {
|
||||||
"simple-import-sort/imports": "warn",
|
"simple-import-sort/imports": "warn",
|
||||||
"simple-import-sort/exports": "warn"
|
"simple-import-sort/exports": "warn",
|
||||||
|
'no-else-return': 'off'
|
||||||
},
|
},
|
||||||
"parserOptions": {
|
"parserOptions": {
|
||||||
"sourceType": "module",
|
"sourceType": "module",
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#fff" d="M9.576 2.534C7.578 1.299 5 2.737 5 5.086v13.828c0 2.35 2.578 3.787 4.576 2.552l11.194-6.914c1.899-1.172 1.899-3.932 0-5.104L9.576 2.534Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 239 B |
@@ -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"
|
"lint": "eslint --cache --ext .js,.jsx,.ts,.tsx src"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@atproto/api": "^0.12.2",
|
"@atproto/api": "0.13.6",
|
||||||
"@preact/preset-vite": "^2.8.2",
|
"@preact/preset-vite": "^2.8.2",
|
||||||
"@vitejs/plugin-legacy": "^5.3.2",
|
"@vitejs/plugin-legacy": "^5.3.2",
|
||||||
"preact": "^10.4.8",
|
"preact": "^10.4.8",
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
"eslint-plugin-simple-import-sort": "^12.0.0",
|
"eslint-plugin-simple-import-sort": "^12.0.0",
|
||||||
"postcss": "^8.4.38",
|
"postcss": "^8.4.38",
|
||||||
"tailwindcss": "^3.4.3",
|
"tailwindcss": "^3.4.3",
|
||||||
"typescript": "^4.0.5",
|
"typescript": "^5.5.4",
|
||||||
"vite": "^5.2.8",
|
"vite": "^5.2.8",
|
||||||
"vite-tsconfig-paths": "^4.3.2"
|
"vite-tsconfig-paths": "^4.3.2"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,15 +3,20 @@ import {
|
|||||||
AppBskyEmbedImages,
|
AppBskyEmbedImages,
|
||||||
AppBskyEmbedRecord,
|
AppBskyEmbedRecord,
|
||||||
AppBskyEmbedRecordWithMedia,
|
AppBskyEmbedRecordWithMedia,
|
||||||
|
AppBskyEmbedVideo,
|
||||||
AppBskyFeedDefs,
|
AppBskyFeedDefs,
|
||||||
AppBskyFeedPost,
|
AppBskyFeedPost,
|
||||||
AppBskyGraphDefs,
|
AppBskyGraphDefs,
|
||||||
|
AppBskyGraphStarterpack,
|
||||||
AppBskyLabelerDefs,
|
AppBskyLabelerDefs,
|
||||||
|
AtUri,
|
||||||
} from '@atproto/api'
|
} from '@atproto/api'
|
||||||
import {ComponentChildren, h} from 'preact'
|
import {ComponentChildren, h} from 'preact'
|
||||||
import {useMemo} from 'preact/hooks'
|
import {useMemo} from 'preact/hooks'
|
||||||
|
|
||||||
import infoIcon from '../../assets/circleInfo_stroke2_corner0_rounded.svg'
|
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 {CONTENT_LABELS, labelsToInfo} from '../labels'
|
||||||
import {getRkey} from '../utils'
|
import {getRkey} from '../utils'
|
||||||
import {Link} from './link'
|
import {Link} from './link'
|
||||||
@@ -105,7 +110,7 @@ export function Embed({
|
|||||||
// Case 3.2: List
|
// Case 3.2: List
|
||||||
if (AppBskyGraphDefs.isListView(record)) {
|
if (AppBskyGraphDefs.isListView(record)) {
|
||||||
return (
|
return (
|
||||||
<GenericWithImage
|
<GenericWithImageEmbed
|
||||||
image={record.avatar}
|
image={record.avatar}
|
||||||
title={record.name}
|
title={record.name}
|
||||||
href={`/profile/${record.creator.did}/lists/${getRkey(record)}`}
|
href={`/profile/${record.creator.did}/lists/${getRkey(record)}`}
|
||||||
@@ -122,7 +127,7 @@ export function Embed({
|
|||||||
// Case 3.3: Feed
|
// Case 3.3: Feed
|
||||||
if (AppBskyFeedDefs.isGeneratorView(record)) {
|
if (AppBskyFeedDefs.isGeneratorView(record)) {
|
||||||
return (
|
return (
|
||||||
<GenericWithImage
|
<GenericWithImageEmbed
|
||||||
image={record.avatar}
|
image={record.avatar}
|
||||||
title={record.displayName}
|
title={record.displayName}
|
||||||
href={`/profile/${record.creator.did}/feed/${getRkey(record)}`}
|
href={`/profile/${record.creator.did}/feed/${getRkey(record)}`}
|
||||||
@@ -134,31 +139,35 @@ export function Embed({
|
|||||||
|
|
||||||
// Case 3.4: Labeler
|
// Case 3.4: Labeler
|
||||||
if (AppBskyLabelerDefs.isLabelerView(record)) {
|
if (AppBskyLabelerDefs.isLabelerView(record)) {
|
||||||
return (
|
// Embed type does not exist in the app, so show nothing
|
||||||
<GenericWithImage
|
return null
|
||||||
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`}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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)) {
|
if (AppBskyEmbedRecord.isViewNotFound(record)) {
|
||||||
return <Info>Quoted post not found, it may have been deleted.</Info>
|
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)) {
|
if (AppBskyEmbedRecord.isViewBlocked(record)) {
|
||||||
return <Info>The quoted post is blocked.</Info>
|
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 (
|
if (
|
||||||
AppBskyEmbedRecordWithMedia.isView(content) &&
|
AppBskyEmbedRecordWithMedia.isView(content) &&
|
||||||
AppBskyEmbedRecord.isViewRecord(content.record.record)
|
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) {
|
} catch (err) {
|
||||||
return (
|
return (
|
||||||
<Info>{err instanceof Error ? err.message : 'An error occurred'}</Info>
|
<Info>{err instanceof Error ? err.message : 'An error occurred'}</Info>
|
||||||
@@ -314,7 +324,7 @@ function ExternalEmbed({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function GenericWithImage({
|
function GenericWithImageEmbed({
|
||||||
title,
|
title,
|
||||||
subtitle,
|
subtitle,
|
||||||
href,
|
href,
|
||||||
@@ -350,3 +360,89 @@ function GenericWithImage({
|
|||||||
</Link>
|
</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 logo from '../../assets/logo.svg'
|
||||||
import repostIcon from '../../assets/repost_stroke2_corner2_rounded.svg'
|
import repostIcon from '../../assets/repost_stroke2_corner2_rounded.svg'
|
||||||
import {CONTENT_LABELS} from '../labels'
|
import {CONTENT_LABELS} from '../labels'
|
||||||
import {getRkey, niceDate} from '../utils'
|
import {getRkey, niceDate, prettyNumber} from '../utils'
|
||||||
import {Container} from './container'
|
import {Container} from './container'
|
||||||
import {Embed} from './embed'
|
import {Embed} from './embed'
|
||||||
import {Link} from './link'
|
import {Link} from './link'
|
||||||
@@ -78,7 +78,7 @@ export function Post({thread}: Props) {
|
|||||||
<div className="flex items-center gap-2 cursor-pointer">
|
<div className="flex items-center gap-2 cursor-pointer">
|
||||||
<img src={likeIcon} className="w-5 h-5" />
|
<img src={likeIcon} className="w-5 h-5" />
|
||||||
<p className="font-bold text-neutral-500 mb-px">
|
<p className="font-bold text-neutral-500 mb-px">
|
||||||
{post.likeCount}
|
{prettyNumber(post.likeCount)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -86,7 +86,7 @@ export function Post({thread}: Props) {
|
|||||||
<div className="flex items-center gap-2 cursor-pointer">
|
<div className="flex items-center gap-2 cursor-pointer">
|
||||||
<img src={repostIcon} className="w-5 h-5" />
|
<img src={repostIcon} className="w-5 h-5" />
|
||||||
<p className="font-bold text-neutral-500 mb-px">
|
<p className="font-bold text-neutral-500 mb-px">
|
||||||
{post.repostCount}
|
{prettyNumber(post.repostCount)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -97,7 +97,7 @@ export function Post({thread}: Props) {
|
|||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
<p className="cursor-pointer text-brand font-bold hover:underline hidden min-[450px]:inline">
|
<p className="cursor-pointer text-brand font-bold hover:underline hidden min-[450px]:inline">
|
||||||
{post.replyCount
|
{post.replyCount
|
||||||
? `Read ${post.replyCount} ${
|
? `Read ${prettyNumber(post.replyCount)} ${
|
||||||
post.replyCount > 1 ? 'replies' : 'reply'
|
post.replyCount > 1 ? 'replies' : 'reply'
|
||||||
} on Bluesky`
|
} on Bluesky`
|
||||||
: `View on Bluesky`}
|
: `View on Bluesky`}
|
||||||
@@ -132,7 +132,10 @@ function PostContent({record}: {record: AppBskyFeedPost.Record | null}) {
|
|||||||
key={counter}
|
key={counter}
|
||||||
href={segment.link.uri}
|
href={segment.link.uri}
|
||||||
className="text-blue-400 hover:underline"
|
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}
|
{segment.text}
|
||||||
</Link>,
|
</Link>,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import '../index.css'
|
import '../index.css'
|
||||||
|
|
||||||
import {AppBskyFeedDefs, BskyAgent} from '@atproto/api'
|
import {AppBskyFeedDefs, AtpAgent} from '@atproto/api'
|
||||||
import {h, render} from 'preact'
|
import {h, render} from 'preact'
|
||||||
|
|
||||||
import logo from '../../assets/logo.svg'
|
import logo from '../../assets/logo.svg'
|
||||||
@@ -12,7 +12,7 @@ import {getRkey} from '../utils'
|
|||||||
const root = document.getElementById('app')
|
const root = document.getElementById('app')
|
||||||
if (!root) throw new Error('No root element')
|
if (!root) throw new Error('No root element')
|
||||||
|
|
||||||
const agent = new BskyAgent({
|
const agent = new AtpAgent({
|
||||||
service: 'https://public.api.bsky.app',
|
service: 'https://public.api.bsky.app',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -16,3 +16,13 @@ export function getRkey({uri}: {uri: string}): string {
|
|||||||
const at = new AtUri(uri)
|
const at = new AtUri(uri)
|
||||||
return at.rkey
|
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",
|
"jsxFragmentFactory": "Fragment",
|
||||||
"downlevelIteration": true
|
"downlevelIteration": true
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src", "vite.config.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,5 +6,5 @@
|
|||||||
"strict": true,
|
"strict": true,
|
||||||
"outDir": "dist"
|
"outDir": "dist"
|
||||||
},
|
},
|
||||||
"include": ["snippet"],
|
"include": ["snippet"]
|
||||||
}
|
}
|
||||||
|
|||||||
+50
-24
@@ -20,15 +20,16 @@
|
|||||||
"@jridgewell/gen-mapping" "^0.3.5"
|
"@jridgewell/gen-mapping" "^0.3.5"
|
||||||
"@jridgewell/trace-mapping" "^0.3.24"
|
"@jridgewell/trace-mapping" "^0.3.24"
|
||||||
|
|
||||||
"@atproto/api@^0.12.2":
|
"@atproto/api@0.13.6":
|
||||||
version "0.12.2"
|
version "0.13.6"
|
||||||
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.12.2.tgz#5df6d4f60dea0395c84fdebd9e81a7e853edf130"
|
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.13.6.tgz#2500e9d7143e6718089632300c42ce50149f8cd5"
|
||||||
integrity sha512-UVzCiDZH2j0wrr/O8nb1edD5cYLVqB5iujueXUCbHS3rAwIxgmyLtA3Hzm2QYsGPo/+xsIg1fNvpq9rNT6KWUA==
|
integrity sha512-58emFFZhqY8nVWD3xFWK0yYqAmJ2un+NaTtZxBbRo00mGq1rz9VXTpVmfoHFcuXL1hoDQN3WyJfsub8r6xGOgg==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@atproto/common-web" "^0.3.0"
|
"@atproto/common-web" "^0.3.0"
|
||||||
"@atproto/lexicon" "^0.4.0"
|
"@atproto/lexicon" "^0.4.1"
|
||||||
"@atproto/syntax" "^0.3.0"
|
"@atproto/syntax" "^0.3.0"
|
||||||
"@atproto/xrpc" "^0.5.0"
|
"@atproto/xrpc" "^0.6.1"
|
||||||
|
await-lock "^2.2.2"
|
||||||
multiformats "^9.9.0"
|
multiformats "^9.9.0"
|
||||||
tlds "^1.234.0"
|
tlds "^1.234.0"
|
||||||
|
|
||||||
@@ -42,29 +43,29 @@
|
|||||||
uint8arrays "3.0.0"
|
uint8arrays "3.0.0"
|
||||||
zod "^3.21.4"
|
zod "^3.21.4"
|
||||||
|
|
||||||
"@atproto/lexicon@^0.4.0":
|
"@atproto/lexicon@^0.4.1":
|
||||||
version "0.4.0"
|
version "0.4.1"
|
||||||
resolved "https://registry.yarnpkg.com/@atproto/lexicon/-/lexicon-0.4.0.tgz#63e8829945d80c25524882caa8ed27b1151cc576"
|
resolved "https://registry.yarnpkg.com/@atproto/lexicon/-/lexicon-0.4.1.tgz#19155210570a2fafbcc7d4f655d9b813948e72a0"
|
||||||
integrity sha512-RvCBKdSI4M8qWm5uTNz1z3R2yIvIhmOsMuleOj8YR6BwRD+QbtUBy3l+xQ7iXf4M5fdfJFxaUNa6Ty0iRwdKqQ==
|
integrity sha512-bzyr+/VHXLQWbumViX5L7h1NKQObfs8Z+XZJl43OUK8nYFUI4e/sW1IZKRNfw7Wvi5YVNK+J+yP3DWIBZhkCYA==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@atproto/common-web" "^0.3.0"
|
"@atproto/common-web" "^0.3.0"
|
||||||
"@atproto/syntax" "^0.3.0"
|
"@atproto/syntax" "^0.3.0"
|
||||||
iso-datestring-validator "^2.2.2"
|
iso-datestring-validator "^2.2.2"
|
||||||
multiformats "^9.9.0"
|
multiformats "^9.9.0"
|
||||||
zod "^3.21.4"
|
zod "^3.23.8"
|
||||||
|
|
||||||
"@atproto/syntax@^0.3.0":
|
"@atproto/syntax@^0.3.0":
|
||||||
version "0.3.0"
|
version "0.3.0"
|
||||||
resolved "https://registry.yarnpkg.com/@atproto/syntax/-/syntax-0.3.0.tgz#fafa2dbea9add37253005cb663e7373e05e618b3"
|
resolved "https://registry.yarnpkg.com/@atproto/syntax/-/syntax-0.3.0.tgz#fafa2dbea9add37253005cb663e7373e05e618b3"
|
||||||
integrity sha512-Weq0ZBxffGHDXHl9U7BQc2BFJi/e23AL+k+i5+D9hUq/bzT4yjGsrCejkjq0xt82xXDjmhhvQSZ0LqxyZ5woxA==
|
integrity sha512-Weq0ZBxffGHDXHl9U7BQc2BFJi/e23AL+k+i5+D9hUq/bzT4yjGsrCejkjq0xt82xXDjmhhvQSZ0LqxyZ5woxA==
|
||||||
|
|
||||||
"@atproto/xrpc@^0.5.0":
|
"@atproto/xrpc@^0.6.1":
|
||||||
version "0.5.0"
|
version "0.6.1"
|
||||||
resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.5.0.tgz#dacbfd8f7b13f0ab5bd56f8fdd4b460e132a6032"
|
resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.6.1.tgz#dcd1315c8c60eef5af2db7fa4e35a38ebc6d79d5"
|
||||||
integrity sha512-swu+wyOLvYW4l3n+VAuJbHcPcES+tin2Lsrp8Bw5aIXIICiuFn1YMFlwK9JwVUzTH21Py1s1nHEjr4CJeElJog==
|
integrity sha512-Zy5ydXEdk6sY7FDUZcEVfCL1jvbL4tXu5CcdPqbEaW6LQtk9GLds/DK1bCX9kswTGaBC88EMuqQMfkxOhp2t4A==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@atproto/lexicon" "^0.4.0"
|
"@atproto/lexicon" "^0.4.1"
|
||||||
zod "^3.21.4"
|
zod "^3.23.8"
|
||||||
|
|
||||||
"@babel/code-frame@^7.23.5", "@babel/code-frame@^7.24.1", "@babel/code-frame@^7.24.2":
|
"@babel/code-frame@^7.23.5", "@babel/code-frame@^7.24.1", "@babel/code-frame@^7.24.2":
|
||||||
version "7.24.2"
|
version "7.24.2"
|
||||||
@@ -1710,6 +1711,11 @@ available-typed-arrays@^1.0.7:
|
|||||||
dependencies:
|
dependencies:
|
||||||
possible-typed-array-names "^1.0.0"
|
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:
|
babel-plugin-polyfill-corejs2@^0.4.10:
|
||||||
version "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"
|
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"
|
resolved "https://registry.yarnpkg.com/stack-trace/-/stack-trace-1.0.0-pre2.tgz#46a83a79f1b287807e9aaafc6a5dd8bcde626f9c"
|
||||||
integrity sha512-2ztBJRek8IVofG9DBJqdy2N5kulaacX30Nz7xmkYF6ale9WBVmIy6mFBchvGX7Vx/MyjBhx+Rcxqrj+dbOnQ6A==
|
integrity sha512-2ztBJRek8IVofG9DBJqdy2N5kulaacX30Nz7xmkYF6ale9WBVmIy6mFBchvGX7Vx/MyjBhx+Rcxqrj+dbOnQ6A==
|
||||||
|
|
||||||
"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0:
|
"string-width-cjs@npm:string-width@^4.2.0":
|
||||||
name string-width-cjs
|
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"
|
version "4.2.3"
|
||||||
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
|
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
|
||||||
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
|
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
|
||||||
@@ -3795,7 +3809,14 @@ string.prototype.trimstart@^1.0.8:
|
|||||||
define-properties "^1.2.1"
|
define-properties "^1.2.1"
|
||||||
es-object-atoms "^1.0.0"
|
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"
|
version "6.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
|
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
|
||||||
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
|
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
|
||||||
@@ -4003,10 +4024,10 @@ typed-array-length@^1.0.6:
|
|||||||
is-typed-array "^1.1.13"
|
is-typed-array "^1.1.13"
|
||||||
possible-typed-array-names "^1.0.0"
|
possible-typed-array-names "^1.0.0"
|
||||||
|
|
||||||
typescript@^4.0.5:
|
typescript@^5.5.4:
|
||||||
version "4.9.5"
|
version "5.5.4"
|
||||||
resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.9.5.tgz#095979f9bcc0d09da324d58d03ce8f8374cbe65a"
|
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.5.4.tgz#d9852d6c82bad2d2eda4fd74a5762a8f5909e9ba"
|
||||||
integrity sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==
|
integrity sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==
|
||||||
|
|
||||||
uint8arrays@3.0.0:
|
uint8arrays@3.0.0:
|
||||||
version "3.0.0"
|
version "3.0.0"
|
||||||
@@ -4192,3 +4213,8 @@ zod@^3.21.4:
|
|||||||
version "3.22.4"
|
version "3.22.4"
|
||||||
resolved "https://registry.yarnpkg.com/zod/-/zod-3.22.4.tgz#f31c3a9386f61b1f228af56faa9255e845cf3fff"
|
resolved "https://registry.yarnpkg.com/zod/-/zod-3.22.4.tgz#f31c3a9386f61b1f228af56faa9255e845cf3fff"
|
||||||
integrity sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==
|
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==
|
||||||
|
|||||||
@@ -60,6 +60,12 @@ func run(args []string) {
|
|||||||
Value: "",
|
Value: "",
|
||||||
EnvVars: []string{"LINK_HOST"},
|
EnvVars: []string{"LINK_HOST"},
|
||||||
},
|
},
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "ipcc-host",
|
||||||
|
Usage: "scheme, hostname, and port of ipcc service",
|
||||||
|
Value: "https://localhost:8730",
|
||||||
|
EnvVars: []string{"IPCC_HOST"},
|
||||||
|
},
|
||||||
&cli.BoolFlag{
|
&cli.BoolFlag{
|
||||||
Name: "debug",
|
Name: "debug",
|
||||||
Usage: "Enable debug mode",
|
Usage: "Enable debug mode",
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"crypto/subtle"
|
"crypto/subtle"
|
||||||
|
"crypto/tls"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
@@ -41,6 +46,7 @@ type Config struct {
|
|||||||
appviewHost string
|
appviewHost string
|
||||||
ogcardHost string
|
ogcardHost string
|
||||||
linkHost string
|
linkHost string
|
||||||
|
ipccHost string
|
||||||
}
|
}
|
||||||
|
|
||||||
func serve(cctx *cli.Context) error {
|
func serve(cctx *cli.Context) error {
|
||||||
@@ -49,6 +55,7 @@ func serve(cctx *cli.Context) error {
|
|||||||
appviewHost := cctx.String("appview-host")
|
appviewHost := cctx.String("appview-host")
|
||||||
ogcardHost := cctx.String("ogcard-host")
|
ogcardHost := cctx.String("ogcard-host")
|
||||||
linkHost := cctx.String("link-host")
|
linkHost := cctx.String("link-host")
|
||||||
|
ipccHost := cctx.String("ipcc-host")
|
||||||
basicAuthPassword := cctx.String("basic-auth-password")
|
basicAuthPassword := cctx.String("basic-auth-password")
|
||||||
|
|
||||||
// Echo
|
// Echo
|
||||||
@@ -91,6 +98,7 @@ func serve(cctx *cli.Context) error {
|
|||||||
appviewHost: appviewHost,
|
appviewHost: appviewHost,
|
||||||
ogcardHost: ogcardHost,
|
ogcardHost: ogcardHost,
|
||||||
linkHost: linkHost,
|
linkHost: linkHost,
|
||||||
|
ipccHost: ipccHost,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,11 +263,15 @@ func serve(cctx *cli.Context) error {
|
|||||||
e.GET("/profile/:handleOrDID/post/:rkey", server.WebPost)
|
e.GET("/profile/:handleOrDID/post/:rkey", server.WebPost)
|
||||||
e.GET("/profile/:handleOrDID/post/:rkey/liked-by", server.WebGeneric)
|
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/reposted-by", server.WebGeneric)
|
||||||
|
e.GET("/profile/:handleOrDID/post/:rkey/quotes", server.WebGeneric)
|
||||||
|
|
||||||
// starter packs
|
// starter packs
|
||||||
e.GET("/starter-pack/:handleOrDID/:rkey", server.WebStarterPack)
|
e.GET("/starter-pack/:handleOrDID/:rkey", server.WebStarterPack)
|
||||||
e.GET("/start/:handleOrDID/:rkey", server.WebStarterPack)
|
e.GET("/start/:handleOrDID/:rkey", server.WebStarterPack)
|
||||||
|
|
||||||
|
// ipcc
|
||||||
|
e.GET("/ipcc", server.WebIpCC)
|
||||||
|
|
||||||
if linkHost != "" {
|
if linkHost != "" {
|
||||||
linkUrl, err := url.Parse(linkHost)
|
linkUrl, err := url.Parse(linkHost)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -519,3 +531,61 @@ func (srv *Server) WebProfile(c echo.Context) error {
|
|||||||
data["requestHost"] = req.Host
|
data["requestHost"] = req.Host
|
||||||
return c.Render(http.StatusOK, "profile.html", data)
|
return c.Render(http.StatusOK, "profile.html", data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type IPCCRequest struct {
|
||||||
|
IP string `json:"ip"`
|
||||||
|
}
|
||||||
|
type IPCCResponse struct {
|
||||||
|
CC string `json:"countryCode"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (srv *Server) WebIpCC(c echo.Context) error {
|
||||||
|
realIP := c.RealIP()
|
||||||
|
addr, err := netip.ParseAddr(realIP)
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf("could not parse IP %q %s", realIP, err)
|
||||||
|
return c.JSON(400, IPCCResponse{})
|
||||||
|
}
|
||||||
|
var request []byte
|
||||||
|
if addr.Is4() {
|
||||||
|
ip4 := addr.As4()
|
||||||
|
var dest [8]byte
|
||||||
|
base64.StdEncoding.Encode(dest[:], ip4[:])
|
||||||
|
request, _ = json.Marshal(IPCCRequest{IP: string(dest[:])})
|
||||||
|
} else if addr.Is6() {
|
||||||
|
ip6 := addr.As16()
|
||||||
|
var dest [24]byte
|
||||||
|
base64.StdEncoding.Encode(dest[:], ip6[:])
|
||||||
|
request, _ = json.Marshal(IPCCRequest{IP: string(dest[:])})
|
||||||
|
}
|
||||||
|
|
||||||
|
ipccUrlBuilder, err := url.Parse(srv.cfg.ipccHost)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("ipcc misconfigured bad url %s", err)
|
||||||
|
return c.JSON(500, IPCCResponse{})
|
||||||
|
}
|
||||||
|
ipccUrlBuilder.Path = "ipccdata.IpCcService/Lookup"
|
||||||
|
ipccUrl := ipccUrlBuilder.String()
|
||||||
|
cl := http.Client{
|
||||||
|
Transport: &http.Transport{
|
||||||
|
TLSClientConfig: &tls.Config{
|
||||||
|
InsecureSkipVerify: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
postBodyReader := bytes.NewReader(request)
|
||||||
|
response, err := cl.Post(ipccUrl, "application/json", postBodyReader)
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf("ipcc backend error %s", err)
|
||||||
|
return c.JSON(500, IPCCResponse{})
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
dec := json.NewDecoder(response.Body)
|
||||||
|
var outResponse IPCCResponse
|
||||||
|
err = dec.Decode(&outResponse)
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf("ipcc bad response %s", err)
|
||||||
|
return c.JSON(500, IPCCResponse{})
|
||||||
|
}
|
||||||
|
return c.JSON(200, outResponse)
|
||||||
|
}
|
||||||
|
|||||||
@@ -253,6 +253,11 @@
|
|||||||
from { opacity: 1; }
|
from { opacity: 1; }
|
||||||
to { opacity: 0; }
|
to { opacity: 0; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.force-no-clicks > *,
|
||||||
|
.force-no-clicks * {
|
||||||
|
pointer-events: none !important;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</style>
|
</style>
|
||||||
{% include "scripts.html" %}
|
{% include "scripts.html" %}
|
||||||
|
|||||||
@@ -1,14 +1,11 @@
|
|||||||
import 'react-native-gesture-handler' // must be first
|
import 'react-native-gesture-handler' // must be first
|
||||||
import {LogBox} from 'react-native'
|
|
||||||
|
|
||||||
import '#/platform/polyfills'
|
import '#/platform/polyfills'
|
||||||
import {IS_TEST} from '#/env'
|
|
||||||
|
import {LogBox} from 'react-native'
|
||||||
import {registerRootComponent} from 'expo'
|
import {registerRootComponent} from 'expo'
|
||||||
import {doPolyfill} from '#/lib/api/api-polyfill'
|
|
||||||
|
|
||||||
import App from '#/App'
|
import App from '#/App'
|
||||||
|
import {IS_TEST} from '#/env'
|
||||||
doPolyfill()
|
|
||||||
|
|
||||||
if (IS_TEST) {
|
if (IS_TEST) {
|
||||||
LogBox.ignoreAllLogs() // suppress all logs in tests
|
LogBox.ignoreAllLogs() // suppress all logs in tests
|
||||||
|
|||||||
+2
-3
@@ -1,9 +1,8 @@
|
|||||||
import '#/platform/markBundleStartTime'
|
import '#/platform/markBundleStartTime'
|
||||||
|
|
||||||
import '#/platform/polyfills'
|
import '#/platform/polyfills'
|
||||||
|
|
||||||
import {registerRootComponent} from 'expo'
|
import {registerRootComponent} from 'expo'
|
||||||
import {doPolyfill} from '#/lib/api/api-polyfill'
|
|
||||||
import App from '#/App'
|
import App from '#/App'
|
||||||
|
|
||||||
doPolyfill()
|
|
||||||
registerRootComponent(App)
|
registerRootComponent(App)
|
||||||
|
|||||||
+27
-26
@@ -79,32 +79,33 @@ export async function createServer(
|
|||||||
plc: {port: port2},
|
plc: {port: port2},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// DISABLED - looks like dev-env added this and now it conflicts
|
||||||
// add the test mod authority
|
// add the test mod authority
|
||||||
const agent = new BskyAgent({service: pdsUrl})
|
// const agent = new BskyAgent({service: pdsUrl})
|
||||||
const res = await agent.api.com.atproto.server.createAccount({
|
// const res = await agent.api.com.atproto.server.createAccount({
|
||||||
email: 'mod-authority@test.com',
|
// email: 'mod-authority@test.com',
|
||||||
handle: 'mod-authority.test',
|
// handle: 'mod-authority.test',
|
||||||
password: 'hunter2',
|
// password: 'hunter2',
|
||||||
})
|
// })
|
||||||
agent.api.setHeader('Authorization', `Bearer ${res.data.accessJwt}`)
|
// agent.api.setHeader('Authorization', `Bearer ${res.data.accessJwt}`)
|
||||||
await agent.api.app.bsky.actor.profile.create(
|
// await agent.api.app.bsky.actor.profile.create(
|
||||||
{repo: res.data.did},
|
// {repo: res.data.did},
|
||||||
{
|
// {
|
||||||
displayName: 'Dev-env Moderation',
|
// displayName: 'Dev-env Moderation',
|
||||||
description: `The pretend version of mod.bsky.app`,
|
// description: `The pretend version of mod.bsky.app`,
|
||||||
},
|
// },
|
||||||
)
|
// )
|
||||||
|
|
||||||
await agent.api.app.bsky.labeler.service.create(
|
// await agent.api.app.bsky.labeler.service.create(
|
||||||
{repo: res.data.did, rkey: 'self'},
|
// {repo: res.data.did, rkey: 'self'},
|
||||||
{
|
// {
|
||||||
policies: {
|
// policies: {
|
||||||
labelValues: ['!hide', '!warn'],
|
// labelValues: ['!hide', '!warn'],
|
||||||
labelValueDefinitions: [],
|
// labelValueDefinitions: [],
|
||||||
},
|
// },
|
||||||
createdAt: new Date().toISOString(),
|
// createdAt: new Date().toISOString(),
|
||||||
},
|
// },
|
||||||
)
|
// )
|
||||||
|
|
||||||
const pic = fs.readFileSync(
|
const pic = fs.readFileSync(
|
||||||
path.join(__dirname, '..', 'assets', 'default-avatar.png'),
|
path.join(__dirname, '..', 'assets', 'default-avatar.png'),
|
||||||
@@ -156,7 +157,7 @@ class Mocker {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async createUser(name: string) {
|
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(
|
const inviteRes = await agent.api.com.atproto.server.createInviteCode(
|
||||||
{useCount: 1},
|
{useCount: 1},
|
||||||
@@ -332,7 +333,7 @@ class Mocker {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async createInvite(forAccount: string) {
|
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(
|
await agent.api.com.atproto.server.createInviteCode(
|
||||||
{useCount: 1, forAccount},
|
{useCount: 1, forAccount},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ module.exports = {
|
|||||||
'ja',
|
'ja',
|
||||||
'ko',
|
'ko',
|
||||||
'pt-BR',
|
'pt-BR',
|
||||||
|
'ru',
|
||||||
'tr',
|
'tr',
|
||||||
'uk',
|
'uk',
|
||||||
'zh-CN',
|
'zh-CN',
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import ExpoModulesCore
|
import ExpoModulesCore
|
||||||
|
|
||||||
public class ExpoPlatformInfoModule: Module {
|
public class ExpoPlatformInfoModule: Module {
|
||||||
|
private var prevAudioActive: Bool?
|
||||||
|
private var prevAudioCategory: AVAudioSession.Category?
|
||||||
|
|
||||||
public func definition() -> ModuleDefinition {
|
public func definition() -> ModuleDefinition {
|
||||||
Name("ExpoPlatformInfo")
|
Name("ExpoPlatformInfo")
|
||||||
|
|
||||||
@@ -10,18 +13,26 @@ public class ExpoPlatformInfoModule: Module {
|
|||||||
|
|
||||||
Function("setAudioCategory") { (audioCategoryString: String) in
|
Function("setAudioCategory") { (audioCategoryString: String) in
|
||||||
let audioCategory = AVAudioSession.Category(rawValue: audioCategoryString)
|
let audioCategory = AVAudioSession.Category(rawValue: audioCategoryString)
|
||||||
|
if audioCategory == self.prevAudioCategory {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.prevAudioCategory = audioCategory
|
||||||
|
DispatchQueue.global(qos: .background).async {
|
||||||
try? AVAudioSession.sharedInstance().setCategory(audioCategory)
|
try? AVAudioSession.sharedInstance().setCategory(audioCategory)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Function("setAudioActive") { (active: Bool) in
|
Function("setAudioActive") { (active: Bool) in
|
||||||
var categoryOptions: AVAudioSession.CategoryOptions
|
if active == self.prevAudioActive {
|
||||||
let currentCategory = AVAudioSession.sharedInstance().category
|
return
|
||||||
|
}
|
||||||
|
self.prevAudioActive = active
|
||||||
if active {
|
if active {
|
||||||
categoryOptions = [.mixWithOthers]
|
DispatchQueue.global(qos: .background).async {
|
||||||
try? AVAudioSession.sharedInstance().setActive(true)
|
try? AVAudioSession.sharedInstance().setActive(true)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
categoryOptions = [.duckOthers]
|
DispatchQueue.global(qos: .background).async {
|
||||||
try? AVAudioSession
|
try? AVAudioSession
|
||||||
.sharedInstance()
|
.sharedInstance()
|
||||||
.setActive(
|
.setActive(
|
||||||
@@ -29,14 +40,7 @@ public class ExpoPlatformInfoModule: Module {
|
|||||||
options: [.notifyOthersOnDeactivation]
|
options: [.notifyOthersOnDeactivation]
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
try? AVAudioSession
|
|
||||||
.sharedInstance()
|
|
||||||
.setCategory(
|
|
||||||
currentCategory,
|
|
||||||
mode: .default,
|
|
||||||
options: categoryOptions
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-9
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "bsky.app",
|
"name": "bsky.app",
|
||||||
"version": "1.90.0",
|
"version": "1.91.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
"lint-native": "swiftlint ./modules && ktlint ./modules",
|
"lint-native": "swiftlint ./modules && ktlint ./modules",
|
||||||
"lint-native:fix": "swiftlint --fix ./modules && ktlint --format ./modules",
|
"lint-native:fix": "swiftlint --fix ./modules && ktlint --format ./modules",
|
||||||
"typecheck": "tsc --project ./tsconfig.check.json",
|
"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": "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:metro-android": "EXPO_PUBLIC_ENV=e2e NODE_ENV=test RN_SRC_EXT=e2e.ts,e2e.tsx expo run:android",
|
||||||
"e2e:run": "maestro test __e2e__",
|
"e2e:run": "maestro test __e2e__",
|
||||||
@@ -52,7 +52,7 @@
|
|||||||
"open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web"
|
"open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@atproto/api": "0.12.29",
|
"@atproto/api": "0.13.5",
|
||||||
"@bam.tech/react-native-image-resizer": "^3.0.10",
|
"@bam.tech/react-native-image-resizer": "^3.0.10",
|
||||||
"@braintree/sanitize-url": "^6.0.2",
|
"@braintree/sanitize-url": "^6.0.2",
|
||||||
"@gorhom/bottom-sheet": "5.0.0-alpha.11",
|
"@gorhom/bottom-sheet": "5.0.0-alpha.11",
|
||||||
@@ -136,8 +136,8 @@
|
|||||||
"expo-splash-screen": "~0.27.4",
|
"expo-splash-screen": "~0.27.4",
|
||||||
"expo-status-bar": "~1.12.1",
|
"expo-status-bar": "~1.12.1",
|
||||||
"expo-system-ui": "~3.0.4",
|
"expo-system-ui": "~3.0.4",
|
||||||
"expo-task-manager": "~11.8.2",
|
"expo-task-manager": "~11.8.1",
|
||||||
"expo-updates": "~0.25.21",
|
"expo-updates": "~0.25.14",
|
||||||
"expo-video": "^1.2.4",
|
"expo-video": "^1.2.4",
|
||||||
"expo-web-browser": "~13.0.3",
|
"expo-web-browser": "~13.0.3",
|
||||||
"fast-text-encoding": "^1.0.6",
|
"fast-text-encoding": "^1.0.6",
|
||||||
@@ -176,8 +176,9 @@
|
|||||||
"react-native-fs": "^2.20.0",
|
"react-native-fs": "^2.20.0",
|
||||||
"react-native-gesture-handler": "~2.16.2",
|
"react-native-gesture-handler": "~2.16.2",
|
||||||
"react-native-get-random-values": "~1.11.0",
|
"react-native-get-random-values": "~1.11.0",
|
||||||
"react-native-image-crop-picker": "0.41.2",
|
"react-native-image-crop-picker": "0.40.3",
|
||||||
"react-native-keyboard-controller": "^1.12.7",
|
"react-native-ios-context-menu": "^1.15.3",
|
||||||
|
"react-native-keyboard-controller": "^1.12.1",
|
||||||
"react-native-pager-view": "6.2.3",
|
"react-native-pager-view": "6.2.3",
|
||||||
"react-native-picker-select": "^9.1.3",
|
"react-native-picker-select": "^9.1.3",
|
||||||
"react-native-progress": "bluesky-social/react-native-progress",
|
"react-native-progress": "bluesky-social/react-native-progress",
|
||||||
@@ -197,7 +198,6 @@
|
|||||||
"react-responsive": "^9.0.2",
|
"react-responsive": "^9.0.2",
|
||||||
"react-textarea-autosize": "^8.5.3",
|
"react-textarea-autosize": "^8.5.3",
|
||||||
"rn-fetch-blob": "^0.12.0",
|
"rn-fetch-blob": "^0.12.0",
|
||||||
"rn-tourguide": "bluesky-social/rn-tourguide",
|
|
||||||
"sentry-expo": "~7.0.1",
|
"sentry-expo": "~7.0.1",
|
||||||
"statsig-react-native-expo": "^4.6.1",
|
"statsig-react-native-expo": "^4.6.1",
|
||||||
"tippy.js": "^6.3.7",
|
"tippy.js": "^6.3.7",
|
||||||
@@ -206,7 +206,7 @@
|
|||||||
"zod": "^3.20.2"
|
"zod": "^3.20.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@atproto/dev-env": "^0.3.5",
|
"@atproto/dev-env": "^0.3.39",
|
||||||
"@babel/core": "^7.23.2",
|
"@babel/core": "^7.23.2",
|
||||||
"@babel/preset-env": "^7.20.0",
|
"@babel/preset-env": "^7.20.0",
|
||||||
"@babel/runtime": "^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];
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
diff --git a/node_modules/expo-modules-core/build/uuid/uuid.js b/node_modules/expo-modules-core/build/uuid/uuid.js
|
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
|
--- a/node_modules/expo-modules-core/build/uuid/uuid.js
|
||||||
+++ b/node_modules/expo-modules-core/build/uuid/uuid.js
|
+++ b/node_modules/expo-modules-core/build/uuid/uuid.js
|
||||||
@@ -1,5 +1,7 @@
|
@@ -1,5 +1,7 @@
|
||||||
@@ -10,3 +10,16 @@ index 109d3fe..c7fce9e 100644
|
|||||||
const nativeUuidv4 = globalThis?.expo?.uuidv4;
|
const nativeUuidv4 = globalThis?.expo?.uuidv4;
|
||||||
const nativeUuidv5 = globalThis?.expo?.uuidv5;
|
const nativeUuidv5 = globalThis?.expo?.uuidv5;
|
||||||
function uuidv4() {
|
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,135 +0,0 @@
|
|||||||
diff --git a/node_modules/expo-video/android/src/main/java/expo/modules/video/PlayerViewExtension.kt b/node_modules/expo-video/android/src/main/java/expo/modules/video/PlayerViewExtension.kt
|
|
||||||
index 9905e13..47342ff 100644
|
|
||||||
--- a/node_modules/expo-video/android/src/main/java/expo/modules/video/PlayerViewExtension.kt
|
|
||||||
+++ b/node_modules/expo-video/android/src/main/java/expo/modules/video/PlayerViewExtension.kt
|
|
||||||
@@ -11,6 +11,7 @@ internal fun PlayerView.applyRequiresLinearPlayback(requireLinearPlayback: Boole
|
|
||||||
setShowPreviousButton(!requireLinearPlayback)
|
|
||||||
setShowNextButton(!requireLinearPlayback)
|
|
||||||
setTimeBarInteractive(requireLinearPlayback)
|
|
||||||
+ setShowSubtitleButton(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
|
|
||||||
@@ -27,7 +28,8 @@ internal fun PlayerView.setTimeBarInteractive(interactive: Boolean) {
|
|
||||||
|
|
||||||
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
|
|
||||||
internal fun PlayerView.setFullscreenButtonVisibility(visible: Boolean) {
|
|
||||||
- val fullscreenButton = findViewById<android.widget.ImageButton>(androidx.media3.ui.R.id.exo_fullscreen)
|
|
||||||
+ val fullscreenButton =
|
|
||||||
+ findViewById<android.widget.ImageButton>(androidx.media3.ui.R.id.exo_fullscreen)
|
|
||||||
fullscreenButton?.visibility = if (visible) {
|
|
||||||
android.view.View.VISIBLE
|
|
||||||
} else {
|
|
||||||
diff --git a/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoModule.kt b/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoModule.kt
|
|
||||||
index ec3da2a..5a1397a 100644
|
|
||||||
--- a/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoModule.kt
|
|
||||||
+++ b/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoModule.kt
|
|
||||||
@@ -43,7 +43,9 @@ class VideoModule : Module() {
|
|
||||||
View(VideoView::class) {
|
|
||||||
Events(
|
|
||||||
"onPictureInPictureStart",
|
|
||||||
- "onPictureInPictureStop"
|
|
||||||
+ "onPictureInPictureStop",
|
|
||||||
+ "onEnterFullscreen",
|
|
||||||
+ "onExitFullscreen"
|
|
||||||
)
|
|
||||||
|
|
||||||
Prop("player") { view: VideoView, player: VideoPlayer ->
|
|
||||||
diff --git a/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoView.kt b/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoView.kt
|
|
||||||
index a951d80..3932535 100644
|
|
||||||
--- a/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoView.kt
|
|
||||||
+++ b/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoView.kt
|
|
||||||
@@ -36,6 +36,8 @@ class VideoView(context: Context, appContext: AppContext) : ExpoView(context, ap
|
|
||||||
val playerView: PlayerView = PlayerView(context.applicationContext)
|
|
||||||
val onPictureInPictureStart by EventDispatcher<Unit>()
|
|
||||||
val onPictureInPictureStop by EventDispatcher<Unit>()
|
|
||||||
+ val onEnterFullscreen by EventDispatcher()
|
|
||||||
+ val onExitFullscreen by EventDispatcher()
|
|
||||||
|
|
||||||
var willEnterPiP: Boolean = false
|
|
||||||
var isInFullscreen: Boolean = false
|
|
||||||
@@ -154,6 +156,7 @@ class VideoView(context: Context, appContext: AppContext) : ExpoView(context, ap
|
|
||||||
@Suppress("DEPRECATION")
|
|
||||||
currentActivity.overridePendingTransition(0, 0)
|
|
||||||
}
|
|
||||||
+ onEnterFullscreen(mapOf())
|
|
||||||
isInFullscreen = true
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -162,6 +165,7 @@ class VideoView(context: Context, appContext: AppContext) : ExpoView(context, ap
|
|
||||||
val fullScreenButton: ImageButton = playerView.findViewById(androidx.media3.ui.R.id.exo_fullscreen)
|
|
||||||
fullScreenButton.setImageResource(androidx.media3.ui.R.drawable.exo_icon_fullscreen_enter)
|
|
||||||
videoPlayer?.changePlayerView(playerView)
|
|
||||||
+ this.onExitFullscreen(mapOf())
|
|
||||||
isInFullscreen = false
|
|
||||||
}
|
|
||||||
|
|
||||||
diff --git a/node_modules/expo-video/build/VideoView.types.d.ts b/node_modules/expo-video/build/VideoView.types.d.ts
|
|
||||||
index cb9ca6d..60e9f4e 100644
|
|
||||||
--- a/node_modules/expo-video/build/VideoView.types.d.ts
|
|
||||||
+++ b/node_modules/expo-video/build/VideoView.types.d.ts
|
|
||||||
@@ -89,5 +89,8 @@ export interface VideoViewProps extends ViewProps {
|
|
||||||
* @platform ios 16.0+
|
|
||||||
*/
|
|
||||||
allowsVideoFrameAnalysis?: boolean;
|
|
||||||
+
|
|
||||||
+ onEnterFullscreen?: () => void;
|
|
||||||
+ onExitFullscreen?: () => void;
|
|
||||||
}
|
|
||||||
//# sourceMappingURL=VideoView.types.d.ts.map
|
|
||||||
diff --git a/node_modules/expo-video/ios/VideoModule.swift b/node_modules/expo-video/ios/VideoModule.swift
|
|
||||||
index c537a12..e4a918f 100644
|
|
||||||
--- a/node_modules/expo-video/ios/VideoModule.swift
|
|
||||||
+++ b/node_modules/expo-video/ios/VideoModule.swift
|
|
||||||
@@ -16,7 +16,9 @@ public final class VideoModule: Module {
|
|
||||||
View(VideoView.self) {
|
|
||||||
Events(
|
|
||||||
"onPictureInPictureStart",
|
|
||||||
- "onPictureInPictureStop"
|
|
||||||
+ "onPictureInPictureStop",
|
|
||||||
+ "onEnterFullscreen",
|
|
||||||
+ "onExitFullscreen"
|
|
||||||
)
|
|
||||||
|
|
||||||
Prop("player") { (view, player: VideoPlayer?) in
|
|
||||||
diff --git a/node_modules/expo-video/ios/VideoView.swift b/node_modules/expo-video/ios/VideoView.swift
|
|
||||||
index f4579e4..10c5908 100644
|
|
||||||
--- a/node_modules/expo-video/ios/VideoView.swift
|
|
||||||
+++ b/node_modules/expo-video/ios/VideoView.swift
|
|
||||||
@@ -41,6 +41,8 @@ public final class VideoView: ExpoView, AVPlayerViewControllerDelegate {
|
|
||||||
|
|
||||||
let onPictureInPictureStart = EventDispatcher()
|
|
||||||
let onPictureInPictureStop = EventDispatcher()
|
|
||||||
+ let onEnterFullscreen = EventDispatcher()
|
|
||||||
+ let onExitFullscreen = EventDispatcher()
|
|
||||||
|
|
||||||
public override var bounds: CGRect {
|
|
||||||
didSet {
|
|
||||||
@@ -163,6 +165,7 @@ public final class VideoView: ExpoView, AVPlayerViewControllerDelegate {
|
|
||||||
_ playerViewController: AVPlayerViewController,
|
|
||||||
willBeginFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator
|
|
||||||
) {
|
|
||||||
+ onEnterFullscreen()
|
|
||||||
isFullscreen = true
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -179,6 +182,7 @@ public final class VideoView: ExpoView, AVPlayerViewControllerDelegate {
|
|
||||||
if wasPlaying {
|
|
||||||
self.player?.pointer.play()
|
|
||||||
}
|
|
||||||
+ self.onExitFullscreen()
|
|
||||||
self.isFullscreen = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
diff --git a/node_modules/expo-video/src/VideoView.types.ts b/node_modules/expo-video/src/VideoView.types.ts
|
|
||||||
index 29fe5db..e1fbf59 100644
|
|
||||||
--- a/node_modules/expo-video/src/VideoView.types.ts
|
|
||||||
+++ b/node_modules/expo-video/src/VideoView.types.ts
|
|
||||||
@@ -100,4 +100,7 @@ export interface VideoViewProps extends ViewProps {
|
|
||||||
* @platform ios 16.0+
|
|
||||||
*/
|
|
||||||
allowsVideoFrameAnalysis?: boolean;
|
|
||||||
+
|
|
||||||
+ onEnterFullscreen?: () => void;
|
|
||||||
+ onExitFullscreen?: () => void;
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
## uwu woad beawing, do not wemove
|
|
||||||
|
|
||||||
## `expo-video` Patch
|
|
||||||
|
|
||||||
This patch adds two props to `VideoView`: `onEnterFullscreen` and `onExitFullscreen` which do exactly what they say on
|
|
||||||
the tin.
|
|
||||||
@@ -57,7 +57,7 @@ const withXcodeTarget = (config, {targetName}) => {
|
|||||||
buildSettingsObj.SWIFT_VERSION = '5.0'
|
buildSettingsObj.SWIFT_VERSION = '5.0'
|
||||||
buildSettingsObj.TARGETED_DEVICE_FAMILY = `"1"`
|
buildSettingsObj.TARGETED_DEVICE_FAMILY = `"1"`
|
||||||
buildSettingsObj.DEVELOPMENT_TEAM = 'B3LX46C5HS'
|
buildSettingsObj.DEVELOPMENT_TEAM = 'B3LX46C5HS'
|
||||||
buildSettingsObj.IPHONEOS_DEPLOYMENT_TARGET = '14.0'
|
buildSettingsObj.IPHONEOS_DEPLOYMENT_TARGET = '15.1'
|
||||||
buildSettingsObj.ASSETCATALOG_COMPILER_APPICON_NAME = 'AppIcon'
|
buildSettingsObj.ASSETCATALOG_COMPILER_APPICON_NAME = 'AppIcon'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-11
@@ -2,7 +2,6 @@ const path = require('path')
|
|||||||
const fs = require('fs')
|
const fs = require('fs')
|
||||||
|
|
||||||
const projectRoot = path.join(__dirname, '..')
|
const projectRoot = path.join(__dirname, '..')
|
||||||
const webBuildJs = path.join(projectRoot, 'web-build', 'static', 'js')
|
|
||||||
const templateFile = path.join(
|
const templateFile = path.join(
|
||||||
projectRoot,
|
projectRoot,
|
||||||
'bskyweb',
|
'bskyweb',
|
||||||
@@ -10,18 +9,18 @@ const templateFile = path.join(
|
|||||||
'scripts.html',
|
'scripts.html',
|
||||||
)
|
)
|
||||||
|
|
||||||
const jsFiles = fs.readdirSync(webBuildJs).filter(name => name.endsWith('.js'))
|
const {entrypoints} = require(path.join(
|
||||||
jsFiles.sort((a, b) => {
|
projectRoot,
|
||||||
// make sure main is written last
|
'web-build/asset-manifest.json',
|
||||||
if (a.startsWith('main')) return 1
|
))
|
||||||
if (b.startsWith('main')) return -1
|
|
||||||
return a.localeCompare(b)
|
|
||||||
})
|
|
||||||
|
|
||||||
console.log(`Found ${jsFiles.length} js files in web-build`)
|
console.log(`Found ${entrypoints.length} entrypoints`)
|
||||||
console.log(`Writing ${templateFile}`)
|
console.log(`Writing ${templateFile}`)
|
||||||
|
|
||||||
const outputFile = jsFiles
|
const outputFile = entrypoints
|
||||||
.map(name => `<script defer="defer" src="/static/js/${name}"></script>`)
|
.map(name => {
|
||||||
|
const file = path.basename(name)
|
||||||
|
return `<script defer="defer" src="/static/js/${file}"></script>`
|
||||||
|
})
|
||||||
.join('\n')
|
.join('\n')
|
||||||
fs.writeFileSync(templateFile, outputFile)
|
fs.writeFileSync(templateFile, outputFile)
|
||||||
|
|||||||
+15
-11
@@ -50,16 +50,17 @@ import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out'
|
|||||||
import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide'
|
import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide'
|
||||||
import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
|
import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
|
||||||
import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
|
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 {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 * as Toast from '#/view/com/util/Toast'
|
||||||
import {Shell} from '#/view/shell'
|
import {Shell} from '#/view/shell'
|
||||||
import {ThemeProvider as Alf} from '#/alf'
|
import {ThemeProvider as Alf} from '#/alf'
|
||||||
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
|
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
|
||||||
import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
|
import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
|
||||||
|
import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialogs'
|
||||||
import {Provider as PortalProvider} from '#/components/Portal'
|
import {Provider as PortalProvider} from '#/components/Portal'
|
||||||
import {Splash} from '#/Splash'
|
import {Splash} from '#/Splash'
|
||||||
import {Provider as TourProvider} from '#/tours'
|
|
||||||
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
|
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
|
||||||
import {AudioCategory, PlatformInfo} from '../modules/expo-bluesky-swiss-army'
|
import {AudioCategory, PlatformInfo} from '../modules/expo-bluesky-swiss-army'
|
||||||
|
|
||||||
@@ -105,7 +106,6 @@ function InnerApp() {
|
|||||||
}, [_])
|
}, [_])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaProvider initialMetrics={initialWindowMetrics}>
|
|
||||||
<Alf theme={theme}>
|
<Alf theme={theme}>
|
||||||
<ThemeProvider theme={theme}>
|
<ThemeProvider theme={theme}>
|
||||||
<Splash isReady={isReady && hasCheckedReferrer}>
|
<Splash isReady={isReady && hasCheckedReferrer}>
|
||||||
@@ -122,10 +122,10 @@ function InnerApp() {
|
|||||||
<ModerationOptsProvider>
|
<ModerationOptsProvider>
|
||||||
<LoggedOutViewProvider>
|
<LoggedOutViewProvider>
|
||||||
<SelectedFeedProvider>
|
<SelectedFeedProvider>
|
||||||
|
<HiddenRepliesProvider>
|
||||||
<UnreadNotifsProvider>
|
<UnreadNotifsProvider>
|
||||||
<BackgroundNotificationPreferencesProvider>
|
<BackgroundNotificationPreferencesProvider>
|
||||||
<MutedThreadsProvider>
|
<MutedThreadsProvider>
|
||||||
<TourProvider>
|
|
||||||
<ProgressGuideProvider>
|
<ProgressGuideProvider>
|
||||||
<GestureHandlerRootView
|
<GestureHandlerRootView
|
||||||
style={s.h100pct}>
|
style={s.h100pct}>
|
||||||
@@ -133,10 +133,10 @@ function InnerApp() {
|
|||||||
<Shell />
|
<Shell />
|
||||||
</GestureHandlerRootView>
|
</GestureHandlerRootView>
|
||||||
</ProgressGuideProvider>
|
</ProgressGuideProvider>
|
||||||
</TourProvider>
|
|
||||||
</MutedThreadsProvider>
|
</MutedThreadsProvider>
|
||||||
</BackgroundNotificationPreferencesProvider>
|
</BackgroundNotificationPreferencesProvider>
|
||||||
</UnreadNotifsProvider>
|
</UnreadNotifsProvider>
|
||||||
|
</HiddenRepliesProvider>
|
||||||
</SelectedFeedProvider>
|
</SelectedFeedProvider>
|
||||||
</LoggedOutViewProvider>
|
</LoggedOutViewProvider>
|
||||||
</ModerationOptsProvider>
|
</ModerationOptsProvider>
|
||||||
@@ -150,7 +150,6 @@ function InnerApp() {
|
|||||||
</Splash>
|
</Splash>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</Alf>
|
</Alf>
|
||||||
</SafeAreaProvider>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,7 +158,7 @@ function App() {
|
|||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
PlatformInfo.setAudioCategory(AudioCategory.Ambient)
|
PlatformInfo.setAudioCategory(AudioCategory.Ambient)
|
||||||
PlatformInfo.setAudioActive(true)
|
PlatformInfo.setAudioActive(false)
|
||||||
initPersistedState().then(() => setReady(true))
|
initPersistedState().then(() => setReady(true))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
@@ -175,25 +174,30 @@ function App() {
|
|||||||
<A11yProvider>
|
<A11yProvider>
|
||||||
<KeyboardProvider enabled={false} statusBarTranslucent={true}>
|
<KeyboardProvider enabled={false} statusBarTranslucent={true}>
|
||||||
<SessionProvider>
|
<SessionProvider>
|
||||||
<ShellStateProvider>
|
|
||||||
<PrefsStateProvider>
|
<PrefsStateProvider>
|
||||||
|
<I18nProvider>
|
||||||
|
<ShellStateProvider>
|
||||||
<InvitesStateProvider>
|
<InvitesStateProvider>
|
||||||
<ModalStateProvider>
|
<ModalStateProvider>
|
||||||
<DialogStateProvider>
|
<DialogStateProvider>
|
||||||
<LightboxStateProvider>
|
<LightboxStateProvider>
|
||||||
<I18nProvider>
|
|
||||||
<PortalProvider>
|
<PortalProvider>
|
||||||
<StarterPackProvider>
|
<StarterPackProvider>
|
||||||
|
<SafeAreaProvider
|
||||||
|
initialMetrics={initialWindowMetrics}>
|
||||||
|
<IntentDialogProvider>
|
||||||
<InnerApp />
|
<InnerApp />
|
||||||
|
</IntentDialogProvider>
|
||||||
|
</SafeAreaProvider>
|
||||||
</StarterPackProvider>
|
</StarterPackProvider>
|
||||||
</PortalProvider>
|
</PortalProvider>
|
||||||
</I18nProvider>
|
|
||||||
</LightboxStateProvider>
|
</LightboxStateProvider>
|
||||||
</DialogStateProvider>
|
</DialogStateProvider>
|
||||||
</ModalStateProvider>
|
</ModalStateProvider>
|
||||||
</InvitesStateProvider>
|
</InvitesStateProvider>
|
||||||
</PrefsStateProvider>
|
|
||||||
</ShellStateProvider>
|
</ShellStateProvider>
|
||||||
|
</I18nProvider>
|
||||||
|
</PrefsStateProvider>
|
||||||
</SessionProvider>
|
</SessionProvider>
|
||||||
</KeyboardProvider>
|
</KeyboardProvider>
|
||||||
</A11yProvider>
|
</A11yProvider>
|
||||||
|
|||||||
+11
-8
@@ -39,15 +39,16 @@ import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out'
|
|||||||
import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide'
|
import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide'
|
||||||
import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
|
import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
|
||||||
import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
|
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 * as Toast from '#/view/com/util/Toast'
|
||||||
import {ToastContainer} from '#/view/com/util/Toast.web'
|
import {ToastContainer} from '#/view/com/util/Toast.web'
|
||||||
import {Shell} from '#/view/shell/index'
|
import {Shell} from '#/view/shell/index'
|
||||||
import {ThemeProvider as Alf} from '#/alf'
|
import {ThemeProvider as Alf} from '#/alf'
|
||||||
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
|
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
|
||||||
import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
|
import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
|
||||||
|
import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialogs'
|
||||||
import {Provider as PortalProvider} from '#/components/Portal'
|
import {Provider as PortalProvider} from '#/components/Portal'
|
||||||
import {Provider as TourProvider} from '#/tours'
|
|
||||||
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
|
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
|
||||||
|
|
||||||
function InnerApp() {
|
function InnerApp() {
|
||||||
@@ -105,19 +106,19 @@ function InnerApp() {
|
|||||||
<ModerationOptsProvider>
|
<ModerationOptsProvider>
|
||||||
<LoggedOutViewProvider>
|
<LoggedOutViewProvider>
|
||||||
<SelectedFeedProvider>
|
<SelectedFeedProvider>
|
||||||
|
<HiddenRepliesProvider>
|
||||||
<UnreadNotifsProvider>
|
<UnreadNotifsProvider>
|
||||||
<BackgroundNotificationPreferencesProvider>
|
<BackgroundNotificationPreferencesProvider>
|
||||||
<MutedThreadsProvider>
|
<MutedThreadsProvider>
|
||||||
<SafeAreaProvider>
|
<SafeAreaProvider>
|
||||||
<TourProvider>
|
|
||||||
<ProgressGuideProvider>
|
<ProgressGuideProvider>
|
||||||
<Shell />
|
<Shell />
|
||||||
</ProgressGuideProvider>
|
</ProgressGuideProvider>
|
||||||
</TourProvider>
|
|
||||||
</SafeAreaProvider>
|
</SafeAreaProvider>
|
||||||
</MutedThreadsProvider>
|
</MutedThreadsProvider>
|
||||||
</BackgroundNotificationPreferencesProvider>
|
</BackgroundNotificationPreferencesProvider>
|
||||||
</UnreadNotifsProvider>
|
</UnreadNotifsProvider>
|
||||||
|
</HiddenRepliesProvider>
|
||||||
</SelectedFeedProvider>
|
</SelectedFeedProvider>
|
||||||
</LoggedOutViewProvider>
|
</LoggedOutViewProvider>
|
||||||
</ModerationOptsProvider>
|
</ModerationOptsProvider>
|
||||||
@@ -153,25 +154,27 @@ function App() {
|
|||||||
return (
|
return (
|
||||||
<A11yProvider>
|
<A11yProvider>
|
||||||
<SessionProvider>
|
<SessionProvider>
|
||||||
<ShellStateProvider>
|
|
||||||
<PrefsStateProvider>
|
<PrefsStateProvider>
|
||||||
|
<I18nProvider>
|
||||||
|
<ShellStateProvider>
|
||||||
<InvitesStateProvider>
|
<InvitesStateProvider>
|
||||||
<ModalStateProvider>
|
<ModalStateProvider>
|
||||||
<DialogStateProvider>
|
<DialogStateProvider>
|
||||||
<LightboxStateProvider>
|
<LightboxStateProvider>
|
||||||
<I18nProvider>
|
|
||||||
<PortalProvider>
|
<PortalProvider>
|
||||||
<StarterPackProvider>
|
<StarterPackProvider>
|
||||||
|
<IntentDialogProvider>
|
||||||
<InnerApp />
|
<InnerApp />
|
||||||
|
</IntentDialogProvider>
|
||||||
</StarterPackProvider>
|
</StarterPackProvider>
|
||||||
</PortalProvider>
|
</PortalProvider>
|
||||||
</I18nProvider>
|
|
||||||
</LightboxStateProvider>
|
</LightboxStateProvider>
|
||||||
</DialogStateProvider>
|
</DialogStateProvider>
|
||||||
</ModalStateProvider>
|
</ModalStateProvider>
|
||||||
</InvitesStateProvider>
|
</InvitesStateProvider>
|
||||||
</PrefsStateProvider>
|
|
||||||
</ShellStateProvider>
|
</ShellStateProvider>
|
||||||
|
</I18nProvider>
|
||||||
|
</PrefsStateProvider>
|
||||||
</SessionProvider>
|
</SessionProvider>
|
||||||
</A11yProvider>
|
</A11yProvider>
|
||||||
)
|
)
|
||||||
|
|||||||
+71
-63
@@ -15,10 +15,12 @@ import {
|
|||||||
StackActions,
|
StackActions,
|
||||||
} from '@react-navigation/native'
|
} from '@react-navigation/native'
|
||||||
|
|
||||||
import {timeout} from 'lib/async/timeout'
|
import {init as initAnalytics} from '#/lib/analytics/analytics'
|
||||||
import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle'
|
import {timeout} from '#/lib/async/timeout'
|
||||||
import {usePalette} from 'lib/hooks/usePalette'
|
import {useColorSchemeStyle} from '#/lib/hooks/useColorSchemeStyle'
|
||||||
import {buildStateObject} from 'lib/routes/helpers'
|
import {usePalette} from '#/lib/hooks/usePalette'
|
||||||
|
import {useWebScrollRestoration} from '#/lib/hooks/useWebScrollRestoration'
|
||||||
|
import {buildStateObject} from '#/lib/routes/helpers'
|
||||||
import {
|
import {
|
||||||
AllNavigatorParams,
|
AllNavigatorParams,
|
||||||
BottomTabNavigatorParams,
|
BottomTabNavigatorParams,
|
||||||
@@ -28,20 +30,62 @@ import {
|
|||||||
MyProfileTabNavigatorParams,
|
MyProfileTabNavigatorParams,
|
||||||
NotificationsTabNavigatorParams,
|
NotificationsTabNavigatorParams,
|
||||||
SearchTabNavigatorParams,
|
SearchTabNavigatorParams,
|
||||||
} from 'lib/routes/types'
|
} from '#/lib/routes/types'
|
||||||
import {RouteParams, State} from 'lib/routes/types'
|
import {RouteParams, State} from '#/lib/routes/types'
|
||||||
import {bskyTitle} from 'lib/strings/headings'
|
import {attachRouteToLogEvents, logEvent} from '#/lib/statsig/statsig'
|
||||||
import {isAndroid, isNative, isWeb} from 'platform/detection'
|
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 {PreferencesExternalEmbeds} from '#/view/screens/PreferencesExternalEmbeds'
|
||||||
import {AppPasswords} from 'view/screens/AppPasswords'
|
import {PreferencesFollowingFeed} from '#/view/screens/PreferencesFollowingFeed'
|
||||||
import {ModerationBlockedAccounts} from 'view/screens/ModerationBlockedAccounts'
|
import {PreferencesThreads} from '#/view/screens/PreferencesThreads'
|
||||||
import {ModerationMutedAccounts} from 'view/screens/ModerationMutedAccounts'
|
import {PrivacyPolicyScreen} from '#/view/screens/PrivacyPolicy'
|
||||||
import {PreferencesFollowingFeed} from 'view/screens/PreferencesFollowingFeed'
|
import {ProfileScreen} from '#/view/screens/Profile'
|
||||||
import {PreferencesThreads} from 'view/screens/PreferencesThreads'
|
import {ProfileFeedScreen} from '#/view/screens/ProfileFeed'
|
||||||
import {SavedFeeds} from 'view/screens/SavedFeeds'
|
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 {SharedPreferencesTesterScreen} from '#/screens/E2E/SharedPreferencesTesterScreen'
|
||||||
import HashtagScreen from '#/screens/Hashtag'
|
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 {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 {ProfileKnownFollowersScreen} from '#/screens/Profile/KnownFollowers'
|
||||||
import {ProfileLabelerLikedByScreen} from '#/screens/Profile/ProfileLabelerLikedBy'
|
import {ProfileLabelerLikedByScreen} from '#/screens/Profile/ProfileLabelerLikedBy'
|
||||||
import {AppearanceSettingsScreen} from '#/screens/Settings/AppearanceSettings'
|
import {AppearanceSettingsScreen} from '#/screens/Settings/AppearanceSettings'
|
||||||
@@ -50,51 +94,8 @@ import {
|
|||||||
StarterPackScreenShort,
|
StarterPackScreenShort,
|
||||||
} from '#/screens/StarterPack/StarterPackScreen'
|
} from '#/screens/StarterPack/StarterPackScreen'
|
||||||
import {Wizard} from '#/screens/StarterPack/Wizard'
|
import {Wizard} from '#/screens/StarterPack/Wizard'
|
||||||
|
import {router} from '#/routes'
|
||||||
import {Referrer} from '../modules/expo-bluesky-swiss-army'
|
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>()
|
const navigationRef = createNavigationContainerRef<AllNavigatorParams>()
|
||||||
|
|
||||||
@@ -212,6 +213,13 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
|
|||||||
title: title(msg`Post by @${route.params.name}`),
|
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
|
<Stack.Screen
|
||||||
name="ProfileFeed"
|
name="ProfileFeed"
|
||||||
getComponent={() => ProfileFeedScreen}
|
getComponent={() => ProfileFeedScreen}
|
||||||
@@ -482,6 +490,7 @@ function MyProfileTabNavigator() {
|
|||||||
getComponent={() => ProfileScreen}
|
getComponent={() => ProfileScreen}
|
||||||
initialParams={{
|
initialParams={{
|
||||||
name: 'me',
|
name: 'me',
|
||||||
|
hideBackButton: true,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{commonScreens(MyProfileTab as typeof HomeTab)}
|
{commonScreens(MyProfileTab as typeof HomeTab)}
|
||||||
@@ -652,16 +661,15 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
|
|||||||
linking={LINKING}
|
linking={LINKING}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
onStateChange={() => {
|
onStateChange={() => {
|
||||||
logEvent('router:navigate:sampled', {
|
const routeName = getCurrentRouteName()
|
||||||
from: prevLoggedRouteName.current,
|
if (routeName === 'Notifications') {
|
||||||
})
|
logEvent('router:navigate:notifications:sampled', {})
|
||||||
prevLoggedRouteName.current = getCurrentRouteName()
|
}
|
||||||
}}
|
}}
|
||||||
onReady={() => {
|
onReady={() => {
|
||||||
attachRouteToLogEvents(getCurrentRouteName)
|
attachRouteToLogEvents(getCurrentRouteName)
|
||||||
logModuleInitTime()
|
logModuleInitTime()
|
||||||
onReady()
|
onReady()
|
||||||
logEvent('router:navigate:sampled', {})
|
|
||||||
}}>
|
}}>
|
||||||
{children}
|
{children}
|
||||||
</NavigationContainer>
|
</NavigationContainer>
|
||||||
|
|||||||
+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 * as tokens from '#/alf/tokens'
|
||||||
import {native, web} from '#/alf/util/platform'
|
import {native, web} from '#/alf/util/platform'
|
||||||
|
|
||||||
export const atoms = {
|
export const atoms = {
|
||||||
|
debug: {
|
||||||
|
borderColor: 'red',
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Positioning
|
* Positioning
|
||||||
*/
|
*/
|
||||||
@@ -55,6 +60,19 @@ export const atoms = {
|
|||||||
height: '100vh',
|
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
|
* Theme-independent bg colors
|
||||||
*/
|
*/
|
||||||
@@ -853,6 +871,7 @@ export const atoms = {
|
|||||||
mr_auto: {
|
mr_auto: {
|
||||||
marginRight: 'auto',
|
marginRight: 'auto',
|
||||||
},
|
},
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Pointer events & user select
|
* Pointer events & user select
|
||||||
*/
|
*/
|
||||||
@@ -871,6 +890,7 @@ export const atoms = {
|
|||||||
user_select_all: {
|
user_select_all: {
|
||||||
userSelect: 'all',
|
userSelect: 'all',
|
||||||
},
|
},
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Text decoration
|
* Text decoration
|
||||||
*/
|
*/
|
||||||
@@ -880,4 +900,11 @@ export const atoms = {
|
|||||||
strike_through: {
|
strike_through: {
|
||||||
textDecorationLine: 'line-through',
|
textDecorationLine: 'line-through',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Display
|
||||||
|
*/
|
||||||
|
hidden: {
|
||||||
|
display: 'none',
|
||||||
|
},
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
+10
-53
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {Dimensions} from 'react-native'
|
import {useMediaQuery} from 'react-responsive'
|
||||||
|
|
||||||
import {createThemes, defaultTheme} from '#/alf/themes'
|
import {createThemes, defaultTheme} from '#/alf/themes'
|
||||||
import {Theme, ThemeName} from '#/alf/types'
|
import {Theme, ThemeName} from '#/alf/types'
|
||||||
@@ -12,52 +12,15 @@ export * from '#/alf/util/flatten'
|
|||||||
export * from '#/alf/util/platform'
|
export * from '#/alf/util/platform'
|
||||||
export * from '#/alf/util/themeSelector'
|
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
|
* Context
|
||||||
*/
|
*/
|
||||||
export const Context = React.createContext<{
|
export const Context = React.createContext<{
|
||||||
themeName: ThemeName
|
themeName: ThemeName
|
||||||
theme: Theme
|
theme: Theme
|
||||||
breakpoints: {
|
|
||||||
active: BreakpointName | undefined
|
|
||||||
gtPhone: boolean
|
|
||||||
gtMobile: boolean
|
|
||||||
gtTablet: boolean
|
|
||||||
}
|
|
||||||
}>({
|
}>({
|
||||||
themeName: 'light',
|
themeName: 'light',
|
||||||
theme: defaultTheme,
|
theme: defaultTheme,
|
||||||
breakpoints: {
|
|
||||||
active: undefined,
|
|
||||||
gtPhone: false,
|
|
||||||
gtMobile: false,
|
|
||||||
gtTablet: false,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
export function ThemeProvider({
|
export function ThemeProvider({
|
||||||
@@ -74,18 +37,6 @@ export function ThemeProvider({
|
|||||||
})
|
})
|
||||||
}, [])
|
}, [])
|
||||||
const theme = themes[themeName]
|
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 (
|
return (
|
||||||
<Context.Provider
|
<Context.Provider
|
||||||
@@ -93,9 +44,8 @@ export function ThemeProvider({
|
|||||||
() => ({
|
() => ({
|
||||||
themeName: themeName,
|
themeName: themeName,
|
||||||
theme: theme,
|
theme: theme,
|
||||||
breakpoints,
|
|
||||||
}),
|
}),
|
||||||
[theme, themeName, breakpoints],
|
[theme, themeName],
|
||||||
)}>
|
)}>
|
||||||
{children}
|
{children}
|
||||||
</Context.Provider>
|
</Context.Provider>
|
||||||
@@ -107,5 +57,12 @@ export function useTheme() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useBreakpoints() {
|
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,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {
|
import {
|
||||||
AccessibilityProps,
|
AccessibilityProps,
|
||||||
|
GestureResponderEvent,
|
||||||
|
MouseEvent,
|
||||||
Pressable,
|
Pressable,
|
||||||
PressableProps,
|
PressableProps,
|
||||||
StyleProp,
|
StyleProp,
|
||||||
@@ -65,7 +67,15 @@ type NonTextElements =
|
|||||||
|
|
||||||
export type ButtonProps = Pick<
|
export type ButtonProps = Pick<
|
||||||
PressableProps,
|
PressableProps,
|
||||||
'disabled' | 'onPress' | 'testID' | 'onLongPress' | 'hitSlop'
|
| 'disabled'
|
||||||
|
| 'onPress'
|
||||||
|
| 'testID'
|
||||||
|
| 'onLongPress'
|
||||||
|
| 'hitSlop'
|
||||||
|
| 'onHoverIn'
|
||||||
|
| 'onHoverOut'
|
||||||
|
| 'onPressIn'
|
||||||
|
| 'onPressOut'
|
||||||
> &
|
> &
|
||||||
AccessibilityProps &
|
AccessibilityProps &
|
||||||
VariantProps & {
|
VariantProps & {
|
||||||
@@ -115,30 +125,50 @@ export const Button = React.forwardRef<View, ButtonProps>(
|
|||||||
focused: false,
|
focused: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
const onPressIn = React.useCallback(() => {
|
const onPressInOuter = rest.onPressIn
|
||||||
|
const onPressIn = React.useCallback(
|
||||||
|
(e: GestureResponderEvent) => {
|
||||||
setState(s => ({
|
setState(s => ({
|
||||||
...s,
|
...s,
|
||||||
pressed: true,
|
pressed: true,
|
||||||
}))
|
}))
|
||||||
}, [setState])
|
onPressInOuter?.(e)
|
||||||
const onPressOut = React.useCallback(() => {
|
},
|
||||||
|
[setState, onPressInOuter],
|
||||||
|
)
|
||||||
|
const onPressOutOuter = rest.onPressOut
|
||||||
|
const onPressOut = React.useCallback(
|
||||||
|
(e: GestureResponderEvent) => {
|
||||||
setState(s => ({
|
setState(s => ({
|
||||||
...s,
|
...s,
|
||||||
pressed: false,
|
pressed: false,
|
||||||
}))
|
}))
|
||||||
}, [setState])
|
onPressOutOuter?.(e)
|
||||||
const onHoverIn = React.useCallback(() => {
|
},
|
||||||
|
[setState, onPressOutOuter],
|
||||||
|
)
|
||||||
|
const onHoverInOuter = rest.onHoverIn
|
||||||
|
const onHoverIn = React.useCallback(
|
||||||
|
(e: MouseEvent) => {
|
||||||
setState(s => ({
|
setState(s => ({
|
||||||
...s,
|
...s,
|
||||||
hovered: true,
|
hovered: true,
|
||||||
}))
|
}))
|
||||||
}, [setState])
|
onHoverInOuter?.(e)
|
||||||
const onHoverOut = React.useCallback(() => {
|
},
|
||||||
|
[setState, onHoverInOuter],
|
||||||
|
)
|
||||||
|
const onHoverOutOuter = rest.onHoverOut
|
||||||
|
const onHoverOut = React.useCallback(
|
||||||
|
(e: MouseEvent) => {
|
||||||
setState(s => ({
|
setState(s => ({
|
||||||
...s,
|
...s,
|
||||||
hovered: false,
|
hovered: false,
|
||||||
}))
|
}))
|
||||||
}, [setState])
|
onHoverOutOuter?.(e)
|
||||||
|
},
|
||||||
|
[setState, onHoverOutOuter],
|
||||||
|
)
|
||||||
const onFocus = React.useCallback(() => {
|
const onFocus = React.useCallback(() => {
|
||||||
setState(s => ({
|
setState(s => ({
|
||||||
...s,
|
...s,
|
||||||
|
|||||||
@@ -2,21 +2,18 @@ import React from 'react'
|
|||||||
import {View} from 'react-native'
|
import {View} from 'react-native'
|
||||||
import {msg, Trans} from '@lingui/macro'
|
import {msg, Trans} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
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 {CenteredView} from 'view/com/util/Views'
|
||||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||||
import {Button, ButtonText} from '#/components/Button'
|
import {Button, ButtonText} from '#/components/Button'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
import {router} from '#/routes'
|
|
||||||
|
|
||||||
export function Error({
|
export function Error({
|
||||||
title,
|
title,
|
||||||
message,
|
message,
|
||||||
onRetry,
|
onRetry,
|
||||||
onGoBack: onGoBackProp,
|
onGoBack,
|
||||||
hideBackButton,
|
hideBackButton,
|
||||||
sideBorders = true,
|
sideBorders = true,
|
||||||
}: {
|
}: {
|
||||||
@@ -27,31 +24,10 @@ export function Error({
|
|||||||
hideBackButton?: boolean
|
hideBackButton?: boolean
|
||||||
sideBorders?: boolean
|
sideBorders?: boolean
|
||||||
}) {
|
}) {
|
||||||
const navigation = useNavigation<NavigationProp>()
|
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {gtMobile} = useBreakpoints()
|
const {gtMobile} = useBreakpoints()
|
||||||
|
const goBack = useGoBack(onGoBack)
|
||||||
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])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CenteredView
|
<CenteredView
|
||||||
@@ -96,7 +72,7 @@ export function Error({
|
|||||||
variant="solid"
|
variant="solid"
|
||||||
color={onRetry ? 'secondary' : 'primary'}
|
color={onRetry ? 'secondary' : 'primary'}
|
||||||
label={_(msg`Return to previous page`)}
|
label={_(msg`Return to previous page`)}
|
||||||
onPress={onGoBack}
|
onPress={goBack}
|
||||||
size="large"
|
size="large"
|
||||||
style={[a.rounded_sm, a.overflow_hidden, {paddingVertical: 10}]}>
|
style={[a.rounded_sm, a.overflow_hidden, {paddingVertical: 10}]}>
|
||||||
<ButtonText>
|
<ButtonText>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {View} from 'react-native'
|
import {View} from 'react-native'
|
||||||
import {ScrollView} from 'react-native-gesture-handler'
|
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 {msg, Trans} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
import {useNavigation} from '@react-navigation/native'
|
import {useNavigation} from '@react-navigation/native'
|
||||||
@@ -12,7 +12,9 @@ import {logEvent} from '#/lib/statsig/statsig'
|
|||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||||
import {useGetPopularFeedsQuery} from '#/state/queries/feed'
|
import {useGetPopularFeedsQuery} from '#/state/queries/feed'
|
||||||
|
import {FeedDescriptor} from '#/state/queries/post-feed'
|
||||||
import {useProfilesQuery} from '#/state/queries/profile'
|
import {useProfilesQuery} from '#/state/queries/profile'
|
||||||
|
import {useSuggestedFollowsByActorQuery} from '#/state/queries/suggested-follows'
|
||||||
import {useSession} from '#/state/session'
|
import {useSession} from '#/state/session'
|
||||||
import {useProgressGuide} from '#/state/shell/progress-guide'
|
import {useProgressGuide} from '#/state/shell/progress-guide'
|
||||||
import * as userActionHistory from '#/state/userActionHistory'
|
import * as userActionHistory from '#/state/userActionHistory'
|
||||||
@@ -173,14 +175,58 @@ function useExperimentalSuggestedUsersQuery() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SuggestedFollows() {
|
export function SuggestedFollows({feed}: {feed: FeedDescriptor}) {
|
||||||
const t = useTheme()
|
const [feedType, feedUri] = feed.split('|')
|
||||||
const {_} = useLingui()
|
if (feedType === 'author') {
|
||||||
|
return <SuggestedFollowsProfile did={feedUri} />
|
||||||
|
} else {
|
||||||
|
return <SuggestedFollowsHome />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SuggestedFollowsProfile({did}: {did: string}) {
|
||||||
|
const {
|
||||||
|
isLoading: isSuggestionsLoading,
|
||||||
|
data,
|
||||||
|
error,
|
||||||
|
} = useSuggestedFollowsByActorQuery({
|
||||||
|
did,
|
||||||
|
})
|
||||||
|
return (
|
||||||
|
<ProfileGrid
|
||||||
|
isSuggestionsLoading={isSuggestionsLoading}
|
||||||
|
profiles={data?.suggestions ?? []}
|
||||||
|
error={error}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SuggestedFollowsHome() {
|
||||||
const {
|
const {
|
||||||
isLoading: isSuggestionsLoading,
|
isLoading: isSuggestionsLoading,
|
||||||
profiles,
|
profiles,
|
||||||
error,
|
error,
|
||||||
} = useExperimentalSuggestedUsersQuery()
|
} = 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 moderationOpts = useModerationOpts()
|
||||||
const navigation = useNavigation<NavigationProp>()
|
const navigation = useNavigation<NavigationProp>()
|
||||||
const {gtMobile} = useBreakpoints()
|
const {gtMobile} = useBreakpoints()
|
||||||
|
|||||||
@@ -1,13 +1,20 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {View} from 'react-native'
|
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 {Trans} from '@lingui/macro'
|
||||||
import {useQueryClient} from '@tanstack/react-query'
|
import {useQueryClient} from '@tanstack/react-query'
|
||||||
|
|
||||||
import {sanitizeHandle} from 'lib/strings/handles'
|
import {sanitizeHandle} from 'lib/strings/handles'
|
||||||
|
import {useModerationOpts} from 'state/preferences/moderation-opts'
|
||||||
import {precacheList} from 'state/queries/feed'
|
import {precacheList} from 'state/queries/feed'
|
||||||
import {useTheme} from '#/alf'
|
import {useSession} from 'state/session'
|
||||||
import {atoms as a} from '#/alf'
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
Description,
|
Description,
|
||||||
@@ -16,6 +23,7 @@ import {
|
|||||||
SaveButton,
|
SaveButton,
|
||||||
} from '#/components/FeedCard'
|
} from '#/components/FeedCard'
|
||||||
import {Link as InternalLink, LinkProps} from '#/components/Link'
|
import {Link as InternalLink, LinkProps} from '#/components/Link'
|
||||||
|
import * as Hider from '#/components/moderation/Hider'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -43,6 +51,11 @@ type Props = {
|
|||||||
|
|
||||||
export function Default(props: Props) {
|
export function Default(props: Props) {
|
||||||
const {view, showPinButton} = props
|
const {view, showPinButton} = props
|
||||||
|
const moderationOpts = useModerationOpts()
|
||||||
|
const moderation = moderationOpts
|
||||||
|
? moderateUserList(view, moderationOpts)
|
||||||
|
: undefined
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link {...props}>
|
<Link {...props}>
|
||||||
<Outer>
|
<Outer>
|
||||||
@@ -52,6 +65,7 @@ export function Default(props: Props) {
|
|||||||
title={view.name}
|
title={view.name}
|
||||||
creator={view.creator}
|
creator={view.creator}
|
||||||
purpose={view.purpose}
|
purpose={view.purpose}
|
||||||
|
modUi={moderation?.ui('contentView')}
|
||||||
/>
|
/>
|
||||||
{showPinButton && view.purpose === CURATELIST && (
|
{showPinButton && view.purpose === CURATELIST && (
|
||||||
<SaveButton view={view} pin />
|
<SaveButton view={view} pin />
|
||||||
@@ -89,18 +103,40 @@ export function TitleAndByline({
|
|||||||
title,
|
title,
|
||||||
creator,
|
creator,
|
||||||
purpose = CURATELIST,
|
purpose = CURATELIST,
|
||||||
|
modUi,
|
||||||
}: {
|
}: {
|
||||||
title: string
|
title: string
|
||||||
creator?: AppBskyActorDefs.ProfileViewBasic
|
creator?: AppBskyActorDefs.ProfileViewBasic
|
||||||
purpose?: AppBskyGraphDefs.ListView['purpose']
|
purpose?: AppBskyGraphDefs.ListView['purpose']
|
||||||
|
modUi?: ModerationUI
|
||||||
}) {
|
}) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
|
const {currentAccount} = useSession()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[a.flex_1]}>
|
<View style={[a.flex_1]}>
|
||||||
<Text style={[a.text_md, a.font_bold, a.leading_snug]} numberOfLines={1}>
|
<Hider.Outer
|
||||||
|
modui={modUi}
|
||||||
|
isContentVisibleInitialState={
|
||||||
|
creator && currentAccount?.did === creator.did
|
||||||
|
}
|
||||||
|
allowOverride={creator && currentAccount?.did === creator.did}>
|
||||||
|
<Hider.Mask>
|
||||||
|
<Text
|
||||||
|
style={[a.text_md, a.font_bold, a.leading_snug, a.italic]}
|
||||||
|
numberOfLines={1}>
|
||||||
|
<Trans>Hidden list</Trans>
|
||||||
|
</Text>
|
||||||
|
</Hider.Mask>
|
||||||
|
<Hider.Content>
|
||||||
|
<Text
|
||||||
|
style={[a.text_md, a.font_bold, a.leading_snug]}
|
||||||
|
numberOfLines={1}>
|
||||||
{title}
|
{title}
|
||||||
</Text>
|
</Text>
|
||||||
|
</Hider.Content>
|
||||||
|
</Hider.Outer>
|
||||||
|
|
||||||
{creator && (
|
{creator && (
|
||||||
<Text
|
<Text
|
||||||
style={[a.leading_snug, t.atoms.text_contrast_medium]}
|
style={[a.leading_snug, t.atoms.text_contrast_medium]}
|
||||||
|
|||||||
@@ -178,7 +178,7 @@ let ListMaybePlaceholder = ({
|
|||||||
return (
|
return (
|
||||||
<CenteredView
|
<CenteredView
|
||||||
style={[
|
style={[
|
||||||
a.flex_1,
|
a.h_full_vh,
|
||||||
a.align_center,
|
a.align_center,
|
||||||
!gtMobile ? a.justify_between : a.gap_5xl,
|
!gtMobile ? a.justify_between : a.gap_5xl,
|
||||||
t.atoms.border_contrast_low,
|
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 React from 'react'
|
||||||
|
|
||||||
import type {ContextType} from '#/components/Menu/types'
|
import type {ContextType, ItemContextType} from '#/components/Menu/types'
|
||||||
|
|
||||||
export const Context = React.createContext<ContextType>({
|
export const Context = React.createContext<ContextType>({
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
control: null,
|
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 {Button, ButtonText} from '#/components/Button'
|
||||||
import * as Dialog from '#/components/Dialog'
|
import * as Dialog from '#/components/Dialog'
|
||||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||||
import {Context} from '#/components/Menu/context'
|
import {Context, ItemContext} from '#/components/Menu/context'
|
||||||
import {
|
import {
|
||||||
ContextType,
|
ContextType,
|
||||||
GroupProps,
|
GroupProps,
|
||||||
@@ -125,8 +125,14 @@ export function Item({children, label, style, onPress, ...rest}: ItemProps) {
|
|||||||
}}
|
}}
|
||||||
onFocus={onFocus}
|
onFocus={onFocus}
|
||||||
onBlur={onBlur}
|
onBlur={onBlur}
|
||||||
onPressIn={onPressIn}
|
onPressIn={e => {
|
||||||
onPressOut={onPressOut}
|
onPressIn()
|
||||||
|
rest.onPressIn?.(e)
|
||||||
|
}}
|
||||||
|
onPressOut={e => {
|
||||||
|
onPressOut()
|
||||||
|
rest.onPressOut?.(e)
|
||||||
|
}}
|
||||||
style={[
|
style={[
|
||||||
a.flex_row,
|
a.flex_row,
|
||||||
a.align_center,
|
a.align_center,
|
||||||
@@ -138,15 +144,18 @@ export function Item({children, label, style, onPress, ...rest}: ItemProps) {
|
|||||||
t.atoms.border_contrast_low,
|
t.atoms.border_contrast_low,
|
||||||
{minHeight: 44, paddingVertical: 10},
|
{minHeight: 44, paddingVertical: 10},
|
||||||
style,
|
style,
|
||||||
(focused || pressed) && [t.atoms.bg_contrast_50],
|
(focused || pressed) && !rest.disabled && [t.atoms.bg_contrast_50],
|
||||||
]}>
|
]}>
|
||||||
|
<ItemContext.Provider value={{disabled: Boolean(rest.disabled)}}>
|
||||||
{children}
|
{children}
|
||||||
|
</ItemContext.Provider>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ItemText({children, style}: ItemTextProps) {
|
export function ItemText({children, style}: ItemTextProps) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
|
const {disabled} = React.useContext(ItemContext)
|
||||||
return (
|
return (
|
||||||
<Text
|
<Text
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
@@ -155,9 +164,10 @@ export function ItemText({children, style}: ItemTextProps) {
|
|||||||
a.flex_1,
|
a.flex_1,
|
||||||
a.text_md,
|
a.text_md,
|
||||||
a.font_bold,
|
a.font_bold,
|
||||||
t.atoms.text_contrast_medium,
|
t.atoms.text_contrast_high,
|
||||||
{paddingTop: 3},
|
{paddingTop: 3},
|
||||||
style,
|
style,
|
||||||
|
disabled && t.atoms.text_contrast_low,
|
||||||
]}>
|
]}>
|
||||||
{children}
|
{children}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -166,7 +176,17 @@ export function ItemText({children, style}: ItemTextProps) {
|
|||||||
|
|
||||||
export function ItemIcon({icon: Comp}: ItemIconProps) {
|
export function ItemIcon({icon: Comp}: ItemIconProps) {
|
||||||
const t = useTheme()
|
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) {
|
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 {atoms as a, flatten, useTheme, web} from '#/alf'
|
||||||
import * as Dialog from '#/components/Dialog'
|
import * as Dialog from '#/components/Dialog'
|
||||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||||
import {Context} from '#/components/Menu/context'
|
import {Context, ItemContext} from '#/components/Menu/context'
|
||||||
import {
|
import {
|
||||||
ContextType,
|
ContextType,
|
||||||
GroupProps,
|
GroupProps,
|
||||||
@@ -239,7 +239,8 @@ export function Item({children, label, onPress, ...rest}: ItemProps) {
|
|||||||
a.rounded_xs,
|
a.rounded_xs,
|
||||||
{minHeight: 32, paddingHorizontal: 10},
|
{minHeight: 32, paddingHorizontal: 10},
|
||||||
web({outline: 0}),
|
web({outline: 0}),
|
||||||
(hovered || focused) && [
|
(hovered || focused) &&
|
||||||
|
!rest.disabled && [
|
||||||
web({outline: '0 !important'}),
|
web({outline: '0 !important'}),
|
||||||
t.name === 'light'
|
t.name === 'light'
|
||||||
? t.atoms.bg_contrast_25
|
? t.atoms.bg_contrast_25
|
||||||
@@ -250,7 +251,9 @@ export function Item({children, label, onPress, ...rest}: ItemProps) {
|
|||||||
onMouseEnter,
|
onMouseEnter,
|
||||||
onMouseLeave,
|
onMouseLeave,
|
||||||
})}>
|
})}>
|
||||||
|
<ItemContext.Provider value={{disabled: Boolean(rest.disabled)}}>
|
||||||
{children}
|
{children}
|
||||||
|
</ItemContext.Provider>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
)
|
)
|
||||||
@@ -258,8 +261,16 @@ export function Item({children, label, onPress, ...rest}: ItemProps) {
|
|||||||
|
|
||||||
export function ItemText({children, style}: ItemTextProps) {
|
export function ItemText({children, style}: ItemTextProps) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
|
const {disabled} = React.useContext(ItemContext)
|
||||||
return (
|
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}
|
{children}
|
||||||
</Text>
|
</Text>
|
||||||
)
|
)
|
||||||
@@ -267,10 +278,9 @@ export function ItemText({children, style}: ItemTextProps) {
|
|||||||
|
|
||||||
export function ItemIcon({icon: Comp, position = 'left'}: ItemIconProps) {
|
export function ItemIcon({icon: Comp, position = 'left'}: ItemIconProps) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
|
const {disabled} = React.useContext(ItemContext)
|
||||||
return (
|
return (
|
||||||
<Comp
|
<View
|
||||||
size="md"
|
|
||||||
fill={t.atoms.text_contrast_medium.color}
|
|
||||||
style={[
|
style={[
|
||||||
position === 'left' && {
|
position === 'left' && {
|
||||||
marginLeft: -2,
|
marginLeft: -2,
|
||||||
@@ -279,8 +289,16 @@ export function ItemIcon({icon: Comp, position = 'left'}: ItemIconProps) {
|
|||||||
marginRight: -2,
|
marginRight: -2,
|
||||||
marginLeft: 12,
|
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 React from 'react'
|
||||||
import {
|
import {
|
||||||
|
AccessibilityProps,
|
||||||
GestureResponderEvent,
|
GestureResponderEvent,
|
||||||
PressableProps,
|
PressableProps,
|
||||||
AccessibilityProps,
|
|
||||||
} from 'react-native'
|
} from 'react-native'
|
||||||
|
|
||||||
import {Props as SVGIconProps} from '#/components/icons/common'
|
|
||||||
import * as Dialog from '#/components/Dialog'
|
|
||||||
import {TextStyleProp, ViewStyleProp} from '#/alf'
|
import {TextStyleProp, ViewStyleProp} from '#/alf'
|
||||||
|
import * as Dialog from '#/components/Dialog'
|
||||||
|
import {Props as SVGIconProps} from '#/components/icons/common'
|
||||||
|
|
||||||
export type ContextType = {
|
export type ContextType = {
|
||||||
control: Dialog.DialogOuterProps['control']
|
control: Dialog.DialogOuterProps['control']
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ItemContextType = {
|
||||||
|
disabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export type RadixPassThroughTriggerProps = {
|
export type RadixPassThroughTriggerProps = {
|
||||||
id: string
|
id: string
|
||||||
type: 'button'
|
type: 'button'
|
||||||
|
|||||||
@@ -13,6 +13,15 @@ import {
|
|||||||
} from '#/components/moderation/ModerationDetailsDialog'
|
} from '#/components/moderation/ModerationDetailsDialog'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
|
export type AppModerationCause =
|
||||||
|
| ModerationCause
|
||||||
|
| {
|
||||||
|
type: 'reply-hidden'
|
||||||
|
source: {type: 'user'; did: string}
|
||||||
|
priority: 6
|
||||||
|
downgraded?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export type CommonProps = {
|
export type CommonProps = {
|
||||||
size?: 'sm' | 'lg'
|
size?: 'sm' | 'lg'
|
||||||
}
|
}
|
||||||
@@ -40,7 +49,7 @@ export function Row({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type LabelProps = {
|
export type LabelProps = {
|
||||||
cause: ModerationCause
|
cause: AppModerationCause
|
||||||
disableDetailsDialog?: boolean
|
disableDetailsDialog?: boolean
|
||||||
noBg?: boolean
|
noBg?: boolean
|
||||||
} & CommonProps
|
} & CommonProps
|
||||||
|
|||||||
@@ -276,8 +276,8 @@ export function DescriptionPlaceholder() {
|
|||||||
export type FollowButtonProps = {
|
export type FollowButtonProps = {
|
||||||
profile: AppBskyActorDefs.ProfileViewBasic
|
profile: AppBskyActorDefs.ProfileViewBasic
|
||||||
moderationOpts: ModerationOpts
|
moderationOpts: ModerationOpts
|
||||||
logContext: LogEvents['profile:follow']['logContext'] &
|
logContext: LogEvents['profile:follow:sampled']['logContext'] &
|
||||||
LogEvents['profile:unfollow']['logContext']
|
LogEvents['profile:unfollow:sampled']['logContext']
|
||||||
} & Partial<ButtonProps>
|
} & Partial<ButtonProps>
|
||||||
|
|
||||||
export function FollowButton(props: FollowButtonProps) {
|
export function FollowButton(props: FollowButtonProps) {
|
||||||
|
|||||||
@@ -377,7 +377,7 @@ function Inner({
|
|||||||
hide: () => void
|
hide: () => void
|
||||||
}) {
|
}) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {_} = useLingui()
|
const {_, i18n} = useLingui()
|
||||||
const {currentAccount} = useSession()
|
const {currentAccount} = useSession()
|
||||||
const moderation = React.useMemo(
|
const moderation = React.useMemo(
|
||||||
() => moderateProfile(profile, moderationOpts),
|
() => moderateProfile(profile, moderationOpts),
|
||||||
@@ -393,8 +393,8 @@ function Inner({
|
|||||||
profile.viewer?.blocking ||
|
profile.viewer?.blocking ||
|
||||||
profile.viewer?.blockedBy ||
|
profile.viewer?.blockedBy ||
|
||||||
profile.viewer?.blockingByList
|
profile.viewer?.blockingByList
|
||||||
const following = formatCount(profile.followsCount || 0)
|
const following = formatCount(i18n, profile.followsCount || 0)
|
||||||
const followers = formatCount(profile.followersCount || 0)
|
const followers = formatCount(i18n, profile.followersCount || 0)
|
||||||
const pluralizedFollowers = plural(profile.followersCount || 0, {
|
const pluralizedFollowers = plural(profile.followersCount || 0, {
|
||||||
one: 'follower',
|
one: 'follower',
|
||||||
other: 'followers',
|
other: 'followers',
|
||||||
|
|||||||
@@ -8,7 +8,10 @@ import {Button, ButtonColor, ButtonProps, ButtonText} from '#/components/Button'
|
|||||||
import * as Dialog from '#/components/Dialog'
|
import * as Dialog from '#/components/Dialog'
|
||||||
import {Text} from '#/components/Typography'
|
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<{
|
const Context = React.createContext<{
|
||||||
titleId: string
|
titleId: string
|
||||||
@@ -23,7 +26,7 @@ export function Outer({
|
|||||||
control,
|
control,
|
||||||
testID,
|
testID,
|
||||||
}: React.PropsWithChildren<{
|
}: React.PropsWithChildren<{
|
||||||
control: Dialog.DialogOuterProps['control']
|
control: Dialog.DialogControlProps
|
||||||
testID?: string
|
testID?: string
|
||||||
}>) {
|
}>) {
|
||||||
const {gtMobile} = useBreakpoints()
|
const {gtMobile} = useBreakpoints()
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {useLingui} from '@lingui/react'
|
|||||||
|
|
||||||
import {getLabelingServiceTitle} from '#/lib/moderation'
|
import {getLabelingServiceTitle} from '#/lib/moderation'
|
||||||
import {ReportOption} from '#/lib/moderation/useReportOptions'
|
import {ReportOption} from '#/lib/moderation/useReportOptions'
|
||||||
import {useGate} from '#/lib/statsig/statsig'
|
|
||||||
import {useAgent} from '#/state/session'
|
import {useAgent} from '#/state/session'
|
||||||
import {CharProgress} from '#/view/com/composer/char-progress/CharProgress'
|
import {CharProgress} from '#/view/com/composer/char-progress/CharProgress'
|
||||||
import * as Toast from '#/view/com/util/Toast'
|
import * as Toast from '#/view/com/util/Toast'
|
||||||
@@ -37,7 +36,6 @@ export function SubmitView({
|
|||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const agent = useAgent()
|
const agent = useAgent()
|
||||||
const gate = useGate()
|
|
||||||
const [details, setDetails] = React.useState<string>('')
|
const [details, setDetails] = React.useState<string>('')
|
||||||
const [submitting, setSubmitting] = React.useState<boolean>(false)
|
const [submitting, setSubmitting] = React.useState<boolean>(false)
|
||||||
const [selectedServices, setSelectedServices] = React.useState<string[]>([
|
const [selectedServices, setSelectedServices] = React.useState<string[]>([
|
||||||
@@ -63,7 +61,6 @@ export function SubmitView({
|
|||||||
}
|
}
|
||||||
const results = await Promise.all(
|
const results = await Promise.all(
|
||||||
selectedServices.map(did => {
|
selectedServices.map(did => {
|
||||||
if (gate('session_withproxy_fix')) {
|
|
||||||
return agent
|
return agent
|
||||||
.createModerationReport(report, {
|
.createModerationReport(report, {
|
||||||
encoding: 'application/json',
|
encoding: 'application/json',
|
||||||
@@ -75,15 +72,6 @@ export function SubmitView({
|
|||||||
_ => true,
|
_ => true,
|
||||||
_ => false,
|
_ => false,
|
||||||
)
|
)
|
||||||
} else {
|
|
||||||
return agent
|
|
||||||
.withProxy('atproto_labeler', did)
|
|
||||||
.createModerationReport(report)
|
|
||||||
.then(
|
|
||||||
_ => true,
|
|
||||||
_ => false,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -108,7 +96,6 @@ export function SubmitView({
|
|||||||
onSubmitComplete,
|
onSubmitComplete,
|
||||||
setError,
|
setError,
|
||||||
agent,
|
agent,
|
||||||
gate,
|
|
||||||
])
|
])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -9,14 +9,15 @@ import {
|
|||||||
import {InfiniteData, UseInfiniteQueryResult} from '@tanstack/react-query'
|
import {InfiniteData, UseInfiniteQueryResult} from '@tanstack/react-query'
|
||||||
|
|
||||||
import {useBottomBarOffset} from 'lib/hooks/useBottomBarOffset'
|
import {useBottomBarOffset} from 'lib/hooks/useBottomBarOffset'
|
||||||
|
import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender'
|
||||||
import {isBlockedOrBlocking} from 'lib/moderation/blocked-and-muted'
|
import {isBlockedOrBlocking} from 'lib/moderation/blocked-and-muted'
|
||||||
import {isNative, isWeb} from 'platform/detection'
|
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 {useSession} from 'state/session'
|
||||||
import {List, ListRef} from 'view/com/util/List'
|
import {List, ListRef} from 'view/com/util/List'
|
||||||
import {SectionRef} from '#/screens/Profile/Sections/types'
|
import {SectionRef} from '#/screens/Profile/Sections/types'
|
||||||
import {atoms as a, useTheme} from '#/alf'
|
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'
|
import {Default as ProfileCard} from '#/components/ProfileCard'
|
||||||
|
|
||||||
function keyExtractor(item: AppBskyActorDefs.ProfileViewBasic, index: number) {
|
function keyExtractor(item: AppBskyActorDefs.ProfileViewBasic, index: number) {
|
||||||
@@ -39,17 +40,20 @@ export const ProfilesList = React.forwardRef<SectionRef, ProfilesListProps>(
|
|||||||
ref,
|
ref,
|
||||||
) {
|
) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const [initialHeaderHeight] = React.useState(headerHeight)
|
const bottomBarOffset = useBottomBarOffset(300)
|
||||||
const bottomBarOffset = useBottomBarOffset(20)
|
const initialNumToRender = useInitialNumToRender()
|
||||||
const {currentAccount} = useSession()
|
const {currentAccount} = useSession()
|
||||||
const {data, refetch, isError} = useListMembersQuery(listUri, 50)
|
const {data, refetch, isError} = useAllListMembersQuery(listUri)
|
||||||
|
|
||||||
const [isPTRing, setIsPTRing] = React.useState(false)
|
const [isPTRing, setIsPTRing] = React.useState(false)
|
||||||
|
|
||||||
// The server returns these sorted by descending creation date, so we want to invert
|
// 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))
|
const profiles = data
|
||||||
.filter(p => !isBlockedOrBlocking(p) && !p.associated?.labeler)
|
?.filter(
|
||||||
|
p => !isBlockedOrBlocking(p.subject) && !p.subject.associated?.labeler,
|
||||||
|
)
|
||||||
|
.map(p => p.subject)
|
||||||
.reverse()
|
.reverse()
|
||||||
const isOwn = new AtUri(listUri).host === currentAccount?.did
|
const isOwn = new AtUri(listUri).host === currentAccount?.did
|
||||||
|
|
||||||
@@ -99,7 +103,11 @@ export const ProfilesList = React.forwardRef<SectionRef, ProfilesListProps>(
|
|||||||
|
|
||||||
if (!data) {
|
if (!data) {
|
||||||
return (
|
return (
|
||||||
<View style={{marginTop: headerHeight, marginBottom: bottomBarOffset}}>
|
<View
|
||||||
|
style={[
|
||||||
|
a.h_full_vh,
|
||||||
|
{marginTop: headerHeight, marginBottom: bottomBarOffset},
|
||||||
|
]}>
|
||||||
<ListMaybePlaceholder
|
<ListMaybePlaceholder
|
||||||
isLoading={true}
|
isLoading={true}
|
||||||
isError={isError}
|
isError={isError}
|
||||||
@@ -118,10 +126,13 @@ export const ProfilesList = React.forwardRef<SectionRef, ProfilesListProps>(
|
|||||||
ref={scrollElRef}
|
ref={scrollElRef}
|
||||||
headerOffset={headerHeight}
|
headerOffset={headerHeight}
|
||||||
ListFooterComponent={
|
ListFooterComponent={
|
||||||
<View style={[{height: initialHeaderHeight + bottomBarOffset}]} />
|
<ListFooter
|
||||||
|
style={{paddingBottom: bottomBarOffset, borderTopWidth: 0}}
|
||||||
|
/>
|
||||||
}
|
}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
desktopFixedHeight
|
desktopFixedHeight
|
||||||
|
initialNumToRender={initialNumToRender}
|
||||||
refreshing={isPTRing}
|
refreshing={isPTRing}
|
||||||
onRefresh={async () => {
|
onRefresh={async () => {
|
||||||
setIsPTRing(true)
|
setIsPTRing(true)
|
||||||
|
|||||||
@@ -59,20 +59,24 @@ export const QrCode = React.forwardRef<ViewShot, Props>(function QrCode(
|
|||||||
<QrCodeInner link={link} />
|
<QrCodeInner link={link} />
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={[a.flex_row, a.align_center, {gap: 5}]}>
|
|
||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
|
a.flex,
|
||||||
|
a.flex_row,
|
||||||
|
a.align_center,
|
||||||
a.font_bold,
|
a.font_bold,
|
||||||
a.text_center,
|
{color: 'white', fontSize: 18, gap: 6},
|
||||||
{color: 'white', fontSize: 18},
|
|
||||||
]}>
|
]}>
|
||||||
<Trans>on</Trans>
|
<Trans>
|
||||||
|
on
|
||||||
|
<View style={[a.flex_row, a.align_center, {gap: 6}]}>
|
||||||
|
<Logo width={25} fill="white" />
|
||||||
|
<View style={[{marginTop: 3.5}]}>
|
||||||
|
<Logotype width={72} fill="white" />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
<Logo width={26} fill="white" />
|
|
||||||
<View style={[{marginTop: 5, marginLeft: 2.5}]}>
|
|
||||||
<Logotype width={68} fill="white" />
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
</LinearGradientBackground>
|
</LinearGradientBackground>
|
||||||
</ViewShot>
|
</ViewShot>
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {View} from 'react-native'
|
import {View} from 'react-native'
|
||||||
import {Image} from 'expo-image'
|
import {Image} from 'expo-image'
|
||||||
import {AppBskyGraphStarterpack, AtUri} from '@atproto/api'
|
import {AppBskyGraphDefs, AppBskyGraphStarterpack, AtUri} from '@atproto/api'
|
||||||
import {StarterPackViewBasic} from '@atproto/api/dist/client/types/app/bsky/graph/defs'
|
|
||||||
import {msg, Trans} from '@lingui/macro'
|
import {msg, Trans} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
import {useQueryClient} from '@tanstack/react-query'
|
import {useQueryClient} from '@tanstack/react-query'
|
||||||
@@ -17,7 +16,11 @@ import {StarterPack} from '#/components/icons/StarterPack'
|
|||||||
import {BaseLink} from '#/components/Link'
|
import {BaseLink} from '#/components/Link'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
export function Default({starterPack}: {starterPack?: StarterPackViewBasic}) {
|
export function Default({
|
||||||
|
starterPack,
|
||||||
|
}: {
|
||||||
|
starterPack?: AppBskyGraphDefs.StarterPackViewBasic
|
||||||
|
}) {
|
||||||
if (!starterPack) return null
|
if (!starterPack) return null
|
||||||
return (
|
return (
|
||||||
<Link starterPack={starterPack}>
|
<Link starterPack={starterPack}>
|
||||||
@@ -29,7 +32,7 @@ export function Default({starterPack}: {starterPack?: StarterPackViewBasic}) {
|
|||||||
export function Notification({
|
export function Notification({
|
||||||
starterPack,
|
starterPack,
|
||||||
}: {
|
}: {
|
||||||
starterPack?: StarterPackViewBasic
|
starterPack?: AppBskyGraphDefs.StarterPackViewBasic
|
||||||
}) {
|
}) {
|
||||||
if (!starterPack) return null
|
if (!starterPack) return null
|
||||||
return (
|
return (
|
||||||
@@ -44,7 +47,7 @@ export function Card({
|
|||||||
noIcon,
|
noIcon,
|
||||||
noDescription,
|
noDescription,
|
||||||
}: {
|
}: {
|
||||||
starterPack: StarterPackViewBasic
|
starterPack: AppBskyGraphDefs.StarterPackViewBasic
|
||||||
noIcon?: boolean
|
noIcon?: boolean
|
||||||
noDescription?: boolean
|
noDescription?: boolean
|
||||||
}) {
|
}) {
|
||||||
@@ -94,7 +97,7 @@ export function Link({
|
|||||||
starterPack,
|
starterPack,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
starterPack: StarterPackViewBasic
|
starterPack: AppBskyGraphDefs.StarterPackViewBasic
|
||||||
onPress?: () => void
|
onPress?: () => void
|
||||||
children: React.ReactNode
|
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 t = useTheme()
|
||||||
const imageUri = getStarterPackOgCard(starterPack)
|
const imageUri = getStarterPackOgCard(starterPack)
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {BottomSheetFlatListMethods} from '@gorhom/bottom-sheet'
|
|||||||
import {msg, Trans} from '@lingui/macro'
|
import {msg, Trans} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
|
import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender'
|
||||||
import {isWeb} from 'platform/detection'
|
import {isWeb} from 'platform/detection'
|
||||||
import {useSession} from 'state/session'
|
import {useSession} from 'state/session'
|
||||||
import {WizardAction, WizardState} from '#/screens/StarterPack/Wizard/State'
|
import {WizardAction, WizardState} from '#/screens/StarterPack/Wizard/State'
|
||||||
@@ -42,6 +43,7 @@ export function WizardEditListDialog({
|
|||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {currentAccount} = useSession()
|
const {currentAccount} = useSession()
|
||||||
|
const initialNumToRender = useInitialNumToRender()
|
||||||
|
|
||||||
const listRef = useRef<BottomSheetFlatListMethods>(null)
|
const listRef = useRef<BottomSheetFlatListMethods>(null)
|
||||||
|
|
||||||
@@ -148,6 +150,7 @@ export function WizardEditListDialog({
|
|||||||
webInnerStyle={[a.py_0, {maxWidth: 500, minWidth: 200}]}
|
webInnerStyle={[a.py_0, {maxWidth: 500, minWidth: 200}]}
|
||||||
keyboardDismissMode="on-drag"
|
keyboardDismissMode="on-drag"
|
||||||
removeClippedSubviews={true}
|
removeClippedSubviews={true}
|
||||||
|
initialNumToRender={initialNumToRender}
|
||||||
/>
|
/>
|
||||||
</Dialog.Outer>
|
</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 {msg, Trans} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
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 {sanitizeDisplayName} from 'lib/strings/display-names'
|
||||||
import {sanitizeHandle} from 'lib/strings/handles'
|
import {sanitizeHandle} from 'lib/strings/handles'
|
||||||
import {useSession} from 'state/session'
|
import {useSession} from 'state/session'
|
||||||
@@ -130,7 +130,8 @@ export function WizardProfileCard({
|
|||||||
|
|
||||||
const isMe = profile.did === currentAccount?.did
|
const isMe = profile.did === currentAccount?.did
|
||||||
const included = isMe || state.profiles.some(p => p.did === profile.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 moderationUi = moderateProfile(profile, moderationOpts).ui('avatar')
|
||||||
const displayName = profile.displayName
|
const displayName = profile.displayName
|
||||||
? sanitizeDisplayName(profile.displayName)
|
? sanitizeDisplayName(profile.displayName)
|
||||||
|
|||||||
+108
-147
@@ -1,39 +1,34 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {Keyboard, StyleProp, View, ViewStyle} from 'react-native'
|
import {Keyboard, Platform, StyleProp, View, ViewStyle} from 'react-native'
|
||||||
import {
|
import {
|
||||||
AppBskyFeedDefs,
|
AppBskyFeedDefs,
|
||||||
AppBskyFeedGetPostThread,
|
AppBskyFeedPost,
|
||||||
AppBskyGraphDefs,
|
AppBskyGraphDefs,
|
||||||
AtUri,
|
AtUri,
|
||||||
BskyAgent,
|
|
||||||
} from '@atproto/api'
|
} from '@atproto/api'
|
||||||
import {msg, Trans} from '@lingui/macro'
|
import {msg, Trans} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
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 {HITSLOP_10} from '#/lib/constants'
|
||||||
import {makeListLink, makeProfileLink} from '#/lib/routes/links'
|
import {makeListLink, makeProfileLink} from '#/lib/routes/links'
|
||||||
import {logger} from '#/logger'
|
|
||||||
import {isNative} from '#/platform/detection'
|
import {isNative} from '#/platform/detection'
|
||||||
import {RQKEY_ROOT as POST_THREAD_RQKEY_ROOT} from '#/state/queries/post-thread'
|
|
||||||
import {
|
import {
|
||||||
ThreadgateSetting,
|
ThreadgateAllowUISetting,
|
||||||
threadgateViewToSettings,
|
threadgateViewToAllowUISetting,
|
||||||
} from '#/state/queries/threadgate'
|
} 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 {atoms as a, useTheme} from '#/alf'
|
||||||
import {Button} from '#/components/Button'
|
import {Button} from '#/components/Button'
|
||||||
import * as Dialog from '#/components/Dialog'
|
import * as Dialog from '#/components/Dialog'
|
||||||
import {useDialogControl} 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 {CircleBanSign_Stroke2_Corner0_Rounded as CircleBanSign} from '#/components/icons/CircleBanSign'
|
||||||
import {Earth_Stroke2_Corner0_Rounded as Earth} from '#/components/icons/Globe'
|
import {Earth_Stroke2_Corner0_Rounded as Earth} from '#/components/icons/Globe'
|
||||||
import {Group3_Stroke2_Corner0_Rounded as Group} from '#/components/icons/Group'
|
import {Group3_Stroke2_Corner0_Rounded as Group} from '#/components/icons/Group'
|
||||||
|
import {InlineLinkText} from '#/components/Link'
|
||||||
import {Text} from '#/components/Typography'
|
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'
|
import {PencilLine_Stroke2_Corner0_Rounded as PencilLine} from './icons/Pencil'
|
||||||
|
|
||||||
interface WhoCanReplyProps {
|
interface WhoCanReplyProps {
|
||||||
@@ -47,31 +42,34 @@ export function WhoCanReply({post, isThreadAuthor, style}: WhoCanReplyProps) {
|
|||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const infoDialogControl = useDialogControl()
|
const infoDialogControl = useDialogControl()
|
||||||
const editDialogControl = useDialogControl()
|
const editDialogControl = useDialogControl()
|
||||||
const agent = useAgent()
|
|
||||||
const queryClient = useQueryClient()
|
|
||||||
|
|
||||||
const settings = React.useMemo(
|
/*
|
||||||
() => threadgateViewToSettings(post.threadgate),
|
* `WhoCanReply` is only used for root posts atm, in case this changes
|
||||||
[post],
|
* unexpectedly, we should check to make sure it's for sure the root URI.
|
||||||
)
|
*/
|
||||||
const isRootPost = !('reply' in post.record)
|
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) {
|
const prefetchPostInteractionSettings = usePrefetchPostInteractionSettings({
|
||||||
return null
|
postUri: post.uri,
|
||||||
}
|
rootPostUri: rootUri,
|
||||||
if (!settings.length && !isThreadAuthor) {
|
})
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const isEverybody = settings.length === 0
|
const anyoneCanReply =
|
||||||
const isNobody = !!settings.find(gate => gate.type === 'nobody')
|
settings.length === 1 && settings[0].type === 'everybody'
|
||||||
const description = isEverybody
|
const noOneCanReply = settings.length === 1 && settings[0].type === 'nobody'
|
||||||
|
const description = anyoneCanReply
|
||||||
? _(msg`Everybody can reply`)
|
? _(msg`Everybody can reply`)
|
||||||
: isNobody
|
: noOneCanReply
|
||||||
? _(msg`Replies disabled`)
|
? _(msg`Replies disabled`)
|
||||||
: _(msg`Some people can reply`)
|
: _(msg`Some people can reply`)
|
||||||
|
|
||||||
const onPressEdit = () => {
|
const onPressOpen = () => {
|
||||||
if (isNative && Keyboard.isVisible()) {
|
if (isNative && Keyboard.isVisible()) {
|
||||||
Keyboard.dismiss()
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
label={
|
label={
|
||||||
isThreadAuthor ? _(msg`Edit who can reply`) : _(msg`Who can reply`)
|
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}>
|
hitSlop={HITSLOP_10}>
|
||||||
{({hovered}) => (
|
{({hovered}) => (
|
||||||
<View style={[a.flex_row, a.align_center, a.gap_xs, style]}>
|
<View style={[a.flex_row, a.align_center, a.gap_xs, style]}>
|
||||||
@@ -145,22 +114,27 @@ export function WhoCanReply({post, isThreadAuthor, style}: WhoCanReplyProps) {
|
|||||||
]}>
|
]}>
|
||||||
{description}
|
{description}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{isThreadAuthor && (
|
{isThreadAuthor && (
|
||||||
<PencilLine width={12} fill={t.palette.primary_500} />
|
<PencilLine width={12} fill={t.palette.primary_500} />
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
{isThreadAuthor ? (
|
||||||
|
<PostInteractionSettingsDialog
|
||||||
|
postUri={post.uri}
|
||||||
|
rootPostUri={rootUri}
|
||||||
|
control={editDialogControl}
|
||||||
|
initialThreadgateView={post.threadgate}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<WhoCanReplyDialog
|
<WhoCanReplyDialog
|
||||||
control={infoDialogControl}
|
control={infoDialogControl}
|
||||||
post={post}
|
post={post}
|
||||||
settings={settings}
|
settings={settings}
|
||||||
/>
|
embeddingDisabled={Boolean(post.viewer?.embeddingDisabled)}
|
||||||
{isThreadAuthor && (
|
|
||||||
<ThreadgateEditorDialog
|
|
||||||
control={editDialogControl}
|
|
||||||
threadgate={settings}
|
|
||||||
onConfirm={onEditConfirm}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -174,7 +148,7 @@ function Icon({
|
|||||||
}: {
|
}: {
|
||||||
color: string
|
color: string
|
||||||
width?: number
|
width?: number
|
||||||
settings: ThreadgateSetting[]
|
settings: ThreadgateAllowUISetting[]
|
||||||
}) {
|
}) {
|
||||||
const isEverybody = settings.length === 0
|
const isEverybody = settings.length === 0
|
||||||
const isNobody = !!settings.find(gate => gate.type === 'nobody')
|
const isNobody = !!settings.find(gate => gate.type === 'nobody')
|
||||||
@@ -186,79 +160,84 @@ function WhoCanReplyDialog({
|
|||||||
control,
|
control,
|
||||||
post,
|
post,
|
||||||
settings,
|
settings,
|
||||||
|
embeddingDisabled,
|
||||||
}: {
|
}: {
|
||||||
control: Dialog.DialogControlProps
|
control: Dialog.DialogControlProps
|
||||||
post: AppBskyFeedDefs.PostView
|
post: AppBskyFeedDefs.PostView
|
||||||
settings: ThreadgateSetting[]
|
settings: ThreadgateAllowUISetting[]
|
||||||
}) {
|
embeddingDisabled: boolean
|
||||||
return (
|
|
||||||
<Dialog.Outer control={control}>
|
|
||||||
<Dialog.Handle />
|
|
||||||
<WhoCanReplyDialogInner post={post} settings={settings} />
|
|
||||||
</Dialog.Outer>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function WhoCanReplyDialogInner({
|
|
||||||
post,
|
|
||||||
settings,
|
|
||||||
}: {
|
|
||||||
post: AppBskyFeedDefs.PostView
|
|
||||||
settings: ThreadgateSetting[]
|
|
||||||
}) {
|
}) {
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
return (
|
return (
|
||||||
|
<Dialog.Outer control={control}>
|
||||||
|
<Dialog.Handle />
|
||||||
<Dialog.ScrollableInner
|
<Dialog.ScrollableInner
|
||||||
label={_(msg`Who can reply dialog`)}
|
label={_(msg`Dialog: adjust who can interact with this post`)}
|
||||||
style={[{width: 'auto', maxWidth: 400, minWidth: 200}]}>
|
style={[{width: 'auto', maxWidth: 400, minWidth: 200}]}>
|
||||||
<View style={[a.gap_sm]}>
|
<View style={[a.gap_sm]}>
|
||||||
<Text style={[a.font_bold, a.text_xl]}>
|
<Text style={[a.font_bold, a.text_xl, a.pb_sm]}>
|
||||||
<Trans>Who can reply?</Trans>
|
<Trans>Who can interact with this post?</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
<Rules post={post} settings={settings} />
|
<Rules
|
||||||
|
post={post}
|
||||||
|
settings={settings}
|
||||||
|
embeddingDisabled={embeddingDisabled}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
</Dialog.ScrollableInner>
|
</Dialog.ScrollableInner>
|
||||||
|
</Dialog.Outer>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function Rules({
|
function Rules({
|
||||||
post,
|
post,
|
||||||
settings,
|
settings,
|
||||||
|
embeddingDisabled,
|
||||||
}: {
|
}: {
|
||||||
post: AppBskyFeedDefs.PostView
|
post: AppBskyFeedDefs.PostView
|
||||||
settings: ThreadgateSetting[]
|
settings: ThreadgateAllowUISetting[]
|
||||||
|
embeddingDisabled: boolean
|
||||||
}) {
|
}) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
a.text_md,
|
a.text_sm,
|
||||||
a.leading_tight,
|
a.leading_snug,
|
||||||
a.flex_wrap,
|
a.flex_wrap,
|
||||||
t.atoms.text_contrast_medium,
|
t.atoms.text_contrast_medium,
|
||||||
]}>
|
]}>
|
||||||
{!settings.length ? (
|
{settings[0].type === 'everybody' ? (
|
||||||
<Trans>Everybody can reply</Trans>
|
<Trans>Everybody can reply to this post.</Trans>
|
||||||
) : settings[0].type === 'nobody' ? (
|
) : settings[0].type === 'nobody' ? (
|
||||||
<Trans>Replies to this thread are disabled</Trans>
|
<Trans>Replies to this post are disabled.</Trans>
|
||||||
) : (
|
) : (
|
||||||
<Trans>
|
<Trans>
|
||||||
Only{' '}
|
Only{' '}
|
||||||
{settings.map((rule, i) => (
|
{settings.map((rule, i) => (
|
||||||
<>
|
<React.Fragment key={`rule-${i}`}>
|
||||||
<Rule
|
<Rule rule={rule} post={post} lists={post.threadgate!.lists} />
|
||||||
key={`rule-${i}`}
|
<Separator i={i} length={settings.length} />
|
||||||
rule={rule}
|
</React.Fragment>
|
||||||
post={post}
|
|
||||||
lists={post.threadgate!.lists}
|
|
||||||
/>
|
|
||||||
<Separator key={`sep-${i}`} i={i} length={settings.length} />
|
|
||||||
</>
|
|
||||||
))}{' '}
|
))}{' '}
|
||||||
can reply
|
can reply.
|
||||||
</Trans>
|
</Trans>
|
||||||
)}
|
)}{' '}
|
||||||
</Text>
|
</Text>
|
||||||
|
{embeddingDisabled && (
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.text_sm,
|
||||||
|
a.leading_snug,
|
||||||
|
a.flex_wrap,
|
||||||
|
t.atoms.text_contrast_medium,
|
||||||
|
]}>
|
||||||
|
<Trans>No one but the author can quote this post.</Trans>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,11 +246,10 @@ function Rule({
|
|||||||
post,
|
post,
|
||||||
lists,
|
lists,
|
||||||
}: {
|
}: {
|
||||||
rule: ThreadgateSetting
|
rule: ThreadgateAllowUISetting
|
||||||
post: AppBskyFeedDefs.PostView
|
post: AppBskyFeedDefs.PostView
|
||||||
lists: AppBskyGraphDefs.ListViewBasic[] | undefined
|
lists: AppBskyGraphDefs.ListViewBasic[] | undefined
|
||||||
}) {
|
}) {
|
||||||
const t = useTheme()
|
|
||||||
if (rule.type === 'mention') {
|
if (rule.type === 'mention') {
|
||||||
return <Trans>mentioned users</Trans>
|
return <Trans>mentioned users</Trans>
|
||||||
}
|
}
|
||||||
@@ -279,12 +257,12 @@ function Rule({
|
|||||||
return (
|
return (
|
||||||
<Trans>
|
<Trans>
|
||||||
users followed by{' '}
|
users followed by{' '}
|
||||||
<TextLink
|
<InlineLinkText
|
||||||
type="sm"
|
label={`@${post.author.handle}`}
|
||||||
href={makeProfileLink(post.author)}
|
to={makeProfileLink(post.author)}
|
||||||
text={`@${post.author.handle}`}
|
style={[a.text_sm, a.leading_snug]}>
|
||||||
style={{color: t.palette.primary_500}}
|
@{post.author.handle}
|
||||||
/>
|
</InlineLinkText>
|
||||||
</Trans>
|
</Trans>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -294,12 +272,12 @@ function Rule({
|
|||||||
const listUrip = new AtUri(list.uri)
|
const listUrip = new AtUri(list.uri)
|
||||||
return (
|
return (
|
||||||
<Trans>
|
<Trans>
|
||||||
<TextLink
|
<InlineLinkText
|
||||||
type="sm"
|
label={list.name}
|
||||||
href={makeListLink(listUrip.hostname, listUrip.rkey)}
|
to={makeListLink(listUrip.hostname, listUrip.rkey)}
|
||||||
text={list.name}
|
style={[a.text_sm, a.leading_snug]}>
|
||||||
style={{color: t.palette.primary_500}}
|
{list.name}
|
||||||
/>{' '}
|
</InlineLinkText>{' '}
|
||||||
members
|
members
|
||||||
</Trans>
|
</Trans>
|
||||||
)
|
)
|
||||||
@@ -320,20 +298,3 @@ function Separator({i, length}: {i: number; length: number}) {
|
|||||||
}
|
}
|
||||||
return <>, </>
|
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,
|
timestamp,
|
||||||
}: Omit<EmbedDialogProps, 'control'>) {
|
}: Omit<EmbedDialogProps, 'control'>) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {_} = useLingui()
|
const {_, i18n} = useLingui()
|
||||||
const ref = useRef<TextInput>(null)
|
const ref = useRef<TextInput>(null)
|
||||||
const [copied, setCopied] = useState(false)
|
const [copied, setCopied] = useState(false)
|
||||||
|
|
||||||
@@ -86,9 +86,9 @@ function EmbedDialogInner({
|
|||||||
)} (<a href="${escapeHtml(profileHref)}">@${escapeHtml(
|
)} (<a href="${escapeHtml(profileHref)}">@${escapeHtml(
|
||||||
postAuthor.handle,
|
postAuthor.handle,
|
||||||
)}</a>) <a href="${escapeHtml(href)}">${escapeHtml(
|
)}</a>) <a href="${escapeHtml(href)}">${escapeHtml(
|
||||||
niceDate(timestamp),
|
niceDate(i18n, timestamp),
|
||||||
)}</a></blockquote><script async src="${EMBED_SCRIPT}" charset="utf-8"></script>`
|
)}</a></blockquote><script async src="${EMBED_SCRIPT}" charset="utf-8"></script>`
|
||||||
}, [postUri, postCid, record, timestamp, postAuthor])
|
}, [i18n, postUri, postCid, record, timestamp, postAuthor])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog.Inner label="Embed post" style={[a.gap_md, {maxWidth: 500}]}>
|
<Dialog.Inner label="Embed post" style={[a.gap_md, {maxWidth: 500}]}>
|
||||||
|
|||||||
@@ -0,0 +1,538 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import {StyleProp, View, ViewStyle} from 'react-native'
|
||||||
|
import {AppBskyFeedDefs, AppBskyFeedPostgate, AtUri} from '@atproto/api'
|
||||||
|
import {msg, Trans} from '@lingui/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
import {useQueryClient} from '@tanstack/react-query'
|
||||||
|
import isEqual from 'lodash.isequal'
|
||||||
|
|
||||||
|
import {logger} from '#/logger'
|
||||||
|
import {STALE} from '#/state/queries'
|
||||||
|
import {useMyListsQuery} from '#/state/queries/my-lists'
|
||||||
|
import {
|
||||||
|
createPostgateQueryKey,
|
||||||
|
getPostgateRecord,
|
||||||
|
usePostgateQuery,
|
||||||
|
useWritePostgateMutation,
|
||||||
|
} from '#/state/queries/postgate'
|
||||||
|
import {
|
||||||
|
createPostgateRecord,
|
||||||
|
embeddingRules,
|
||||||
|
} from '#/state/queries/postgate/util'
|
||||||
|
import {
|
||||||
|
createThreadgateViewQueryKey,
|
||||||
|
getThreadgateView,
|
||||||
|
ThreadgateAllowUISetting,
|
||||||
|
threadgateViewToAllowUISetting,
|
||||||
|
useSetThreadgateAllowMutation,
|
||||||
|
useThreadgateViewQuery,
|
||||||
|
} from '#/state/queries/threadgate'
|
||||||
|
import {useAgent, useSession} from '#/state/session'
|
||||||
|
import * as Toast from '#/view/com/util/Toast'
|
||||||
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
|
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||||
|
import * as Dialog from '#/components/Dialog'
|
||||||
|
import {Divider} from '#/components/Divider'
|
||||||
|
import * as Toggle from '#/components/forms/Toggle'
|
||||||
|
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
|
||||||
|
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
|
||||||
|
import {Loader} from '#/components/Loader'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
|
export type PostInteractionSettingsFormProps = {
|
||||||
|
onSave: () => void
|
||||||
|
isSaving?: boolean
|
||||||
|
|
||||||
|
postgate: AppBskyFeedPostgate.Record
|
||||||
|
onChangePostgate: (v: AppBskyFeedPostgate.Record) => void
|
||||||
|
|
||||||
|
threadgateAllowUISettings: ThreadgateAllowUISetting[]
|
||||||
|
onChangeThreadgateAllowUISettings: (v: ThreadgateAllowUISetting[]) => void
|
||||||
|
|
||||||
|
replySettingsDisabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PostInteractionSettingsControlledDialog({
|
||||||
|
control,
|
||||||
|
...rest
|
||||||
|
}: PostInteractionSettingsFormProps & {
|
||||||
|
control: Dialog.DialogControlProps
|
||||||
|
}) {
|
||||||
|
const {_} = useLingui()
|
||||||
|
return (
|
||||||
|
<Dialog.Outer control={control}>
|
||||||
|
<Dialog.Handle />
|
||||||
|
<Dialog.ScrollableInner
|
||||||
|
label={_(msg`Edit post interaction settings`)}
|
||||||
|
style={[{maxWidth: 500}, a.w_full]}>
|
||||||
|
<PostInteractionSettingsForm {...rest} />
|
||||||
|
<Dialog.Close />
|
||||||
|
</Dialog.ScrollableInner>
|
||||||
|
</Dialog.Outer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PostInteractionSettingsDialogProps = {
|
||||||
|
control: Dialog.DialogControlProps
|
||||||
|
/**
|
||||||
|
* URI of the post to edit the interaction settings for. Could be a root post
|
||||||
|
* or could be a reply.
|
||||||
|
*/
|
||||||
|
postUri: string
|
||||||
|
/**
|
||||||
|
* The URI of the root post in the thread. Used to determine if the viewer
|
||||||
|
* owns the threadgate record and can therefore edit it.
|
||||||
|
*/
|
||||||
|
rootPostUri: string
|
||||||
|
/**
|
||||||
|
* Optional initial {@link AppBskyFeedDefs.ThreadgateView} to use if we
|
||||||
|
* happen to have one before opening the settings dialog.
|
||||||
|
*/
|
||||||
|
initialThreadgateView?: AppBskyFeedDefs.ThreadgateView
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PostInteractionSettingsDialog(
|
||||||
|
props: PostInteractionSettingsDialogProps,
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<Dialog.Outer control={props.control}>
|
||||||
|
<Dialog.Handle />
|
||||||
|
<PostInteractionSettingsDialogControlledInner {...props} />
|
||||||
|
</Dialog.Outer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PostInteractionSettingsDialogControlledInner(
|
||||||
|
props: PostInteractionSettingsDialogProps,
|
||||||
|
) {
|
||||||
|
const {_} = useLingui()
|
||||||
|
const {currentAccount} = useSession()
|
||||||
|
const [isSaving, setIsSaving] = React.useState(false)
|
||||||
|
|
||||||
|
const {data: threadgateViewLoaded, isLoading: isLoadingThreadgate} =
|
||||||
|
useThreadgateViewQuery({postUri: props.rootPostUri})
|
||||||
|
const {data: postgate, isLoading: isLoadingPostgate} = usePostgateQuery({
|
||||||
|
postUri: props.postUri,
|
||||||
|
})
|
||||||
|
|
||||||
|
const {mutateAsync: writePostgateRecord} = useWritePostgateMutation()
|
||||||
|
const {mutateAsync: setThreadgateAllow} = useSetThreadgateAllowMutation()
|
||||||
|
|
||||||
|
const [editedPostgate, setEditedPostgate] =
|
||||||
|
React.useState<AppBskyFeedPostgate.Record>()
|
||||||
|
const [editedAllowUISettings, setEditedAllowUISettings] =
|
||||||
|
React.useState<ThreadgateAllowUISetting[]>()
|
||||||
|
|
||||||
|
const isLoading = isLoadingThreadgate || isLoadingPostgate
|
||||||
|
const threadgateView = threadgateViewLoaded || props.initialThreadgateView
|
||||||
|
const isThreadgateOwnedByViewer = React.useMemo(() => {
|
||||||
|
return currentAccount?.did === new AtUri(props.rootPostUri).host
|
||||||
|
}, [props.rootPostUri, currentAccount?.did])
|
||||||
|
|
||||||
|
const postgateValue = React.useMemo(() => {
|
||||||
|
return (
|
||||||
|
editedPostgate || postgate || createPostgateRecord({post: props.postUri})
|
||||||
|
)
|
||||||
|
}, [postgate, editedPostgate, props.postUri])
|
||||||
|
const allowUIValue = React.useMemo(() => {
|
||||||
|
return (
|
||||||
|
editedAllowUISettings || threadgateViewToAllowUISetting(threadgateView)
|
||||||
|
)
|
||||||
|
}, [threadgateView, editedAllowUISettings])
|
||||||
|
|
||||||
|
const onSave = React.useCallback(async () => {
|
||||||
|
if (!editedPostgate && !editedAllowUISettings) {
|
||||||
|
props.control.close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSaving(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const requests = []
|
||||||
|
|
||||||
|
if (editedPostgate) {
|
||||||
|
requests.push(
|
||||||
|
writePostgateRecord({
|
||||||
|
postUri: props.postUri,
|
||||||
|
postgate: editedPostgate,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editedAllowUISettings && isThreadgateOwnedByViewer) {
|
||||||
|
requests.push(
|
||||||
|
setThreadgateAllow({
|
||||||
|
postUri: props.rootPostUri,
|
||||||
|
allow: editedAllowUISettings,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(requests)
|
||||||
|
|
||||||
|
props.control.close()
|
||||||
|
} catch (e: any) {
|
||||||
|
logger.error(`Failed to save post interaction settings`, {
|
||||||
|
context: 'PostInteractionSettingsDialogControlledInner',
|
||||||
|
safeMessage: e.message,
|
||||||
|
})
|
||||||
|
Toast.show(
|
||||||
|
_(
|
||||||
|
msg`There was an issue. Please check your internet connection and try again.`,
|
||||||
|
),
|
||||||
|
'xmark',
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false)
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
_,
|
||||||
|
props.postUri,
|
||||||
|
props.rootPostUri,
|
||||||
|
props.control,
|
||||||
|
editedPostgate,
|
||||||
|
editedAllowUISettings,
|
||||||
|
setIsSaving,
|
||||||
|
writePostgateRecord,
|
||||||
|
setThreadgateAllow,
|
||||||
|
isThreadgateOwnedByViewer,
|
||||||
|
])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog.ScrollableInner
|
||||||
|
label={_(msg`Edit post interaction settings`)}
|
||||||
|
style={[{maxWidth: 500}, a.w_full]}>
|
||||||
|
{isLoading ? (
|
||||||
|
<Loader size="xl" />
|
||||||
|
) : (
|
||||||
|
<PostInteractionSettingsForm
|
||||||
|
replySettingsDisabled={!isThreadgateOwnedByViewer}
|
||||||
|
isSaving={isSaving}
|
||||||
|
onSave={onSave}
|
||||||
|
postgate={postgateValue}
|
||||||
|
onChangePostgate={setEditedPostgate}
|
||||||
|
threadgateAllowUISettings={allowUIValue}
|
||||||
|
onChangeThreadgateAllowUISettings={setEditedAllowUISettings}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Dialog.ScrollableInner>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PostInteractionSettingsForm({
|
||||||
|
onSave,
|
||||||
|
isSaving,
|
||||||
|
postgate,
|
||||||
|
onChangePostgate,
|
||||||
|
threadgateAllowUISettings,
|
||||||
|
onChangeThreadgateAllowUISettings,
|
||||||
|
replySettingsDisabled,
|
||||||
|
}: PostInteractionSettingsFormProps) {
|
||||||
|
const t = useTheme()
|
||||||
|
const {_} = useLingui()
|
||||||
|
const control = Dialog.useDialogContext()
|
||||||
|
const {data: lists} = useMyListsQuery('curate')
|
||||||
|
const [quotesEnabled, setQuotesEnabled] = React.useState(
|
||||||
|
!(
|
||||||
|
postgate.embeddingRules &&
|
||||||
|
postgate.embeddingRules.find(
|
||||||
|
v => v.$type === embeddingRules.disableRule.$type,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const onPressAudience = (setting: ThreadgateAllowUISetting) => {
|
||||||
|
// remove boolean values
|
||||||
|
let newSelected: ThreadgateAllowUISetting[] =
|
||||||
|
threadgateAllowUISettings.filter(
|
||||||
|
v => v.type !== 'nobody' && v.type !== 'everybody',
|
||||||
|
)
|
||||||
|
// toggle
|
||||||
|
const i = newSelected.findIndex(v => isEqual(v, setting))
|
||||||
|
if (i === -1) {
|
||||||
|
newSelected.push(setting)
|
||||||
|
} else {
|
||||||
|
newSelected.splice(i, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
onChangeThreadgateAllowUISettings(newSelected)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onChangeQuotesEnabled = React.useCallback(
|
||||||
|
(enabled: boolean) => {
|
||||||
|
setQuotesEnabled(enabled)
|
||||||
|
onChangePostgate(
|
||||||
|
createPostgateRecord({
|
||||||
|
...postgate,
|
||||||
|
embeddingRules: enabled ? [] : [embeddingRules.disableRule],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
[setQuotesEnabled, postgate, onChangePostgate],
|
||||||
|
)
|
||||||
|
|
||||||
|
const noOneCanReply = !!threadgateAllowUISettings.find(
|
||||||
|
v => v.type === 'nobody',
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<View style={[a.flex_1, a.gap_md]}>
|
||||||
|
<Text style={[a.text_2xl, a.font_bold]}>
|
||||||
|
<Trans>Post interaction settings</Trans>
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View style={[a.gap_lg]}>
|
||||||
|
<Text style={[a.text_md]}>
|
||||||
|
<Trans>Customize who can interact with this post.</Trans>
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<View style={[a.gap_sm]}>
|
||||||
|
<Text style={[a.font_bold, a.text_lg]}>
|
||||||
|
<Trans>Quote settings</Trans>
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Toggle.Item
|
||||||
|
name="quoteposts"
|
||||||
|
type="checkbox"
|
||||||
|
label={
|
||||||
|
quotesEnabled
|
||||||
|
? _(msg`Click to disable quote posts of this post.`)
|
||||||
|
: _(msg`Click to enable quote posts of this post.`)
|
||||||
|
}
|
||||||
|
value={quotesEnabled}
|
||||||
|
onChange={onChangeQuotesEnabled}
|
||||||
|
style={[, a.justify_between, a.pt_xs]}>
|
||||||
|
<Text style={[t.atoms.text_contrast_medium]}>
|
||||||
|
{quotesEnabled ? (
|
||||||
|
<Trans>Quote posts enabled</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>Quote posts disabled</Trans>
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
<Toggle.Switch />
|
||||||
|
</Toggle.Item>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
{replySettingsDisabled && (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.px_md,
|
||||||
|
a.py_sm,
|
||||||
|
a.rounded_sm,
|
||||||
|
a.flex_row,
|
||||||
|
a.align_center,
|
||||||
|
a.gap_sm,
|
||||||
|
t.atoms.bg_contrast_25,
|
||||||
|
]}>
|
||||||
|
<CircleInfo fill={t.atoms.text_contrast_low.color} />
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.flex_1,
|
||||||
|
a.leading_snug,
|
||||||
|
t.atoms.text_contrast_medium,
|
||||||
|
]}>
|
||||||
|
<Trans>
|
||||||
|
Reply settings are chosen by the author of the thread
|
||||||
|
</Trans>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.gap_sm,
|
||||||
|
{
|
||||||
|
opacity: replySettingsDisabled ? 0.3 : 1,
|
||||||
|
},
|
||||||
|
]}>
|
||||||
|
<Text style={[a.font_bold, a.text_lg]}>
|
||||||
|
<Trans>Reply settings</Trans>
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text style={[a.pt_sm, t.atoms.text_contrast_medium]}>
|
||||||
|
<Trans>Allow replies from:</Trans>
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View style={[a.flex_row, a.gap_sm]}>
|
||||||
|
<Selectable
|
||||||
|
label={_(msg`Everybody`)}
|
||||||
|
isSelected={
|
||||||
|
!!threadgateAllowUISettings.find(v => v.type === 'everybody')
|
||||||
|
}
|
||||||
|
onPress={() =>
|
||||||
|
onChangeThreadgateAllowUISettings([{type: 'everybody'}])
|
||||||
|
}
|
||||||
|
style={{flex: 1}}
|
||||||
|
disabled={replySettingsDisabled}
|
||||||
|
/>
|
||||||
|
<Selectable
|
||||||
|
label={_(msg`Nobody`)}
|
||||||
|
isSelected={noOneCanReply}
|
||||||
|
onPress={() =>
|
||||||
|
onChangeThreadgateAllowUISettings([{type: 'nobody'}])
|
||||||
|
}
|
||||||
|
style={{flex: 1}}
|
||||||
|
disabled={replySettingsDisabled}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{!noOneCanReply && (
|
||||||
|
<>
|
||||||
|
<Text style={[a.pt_sm, t.atoms.text_contrast_medium]}>
|
||||||
|
<Trans>Or combine these options:</Trans>
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View style={[a.gap_sm]}>
|
||||||
|
<Selectable
|
||||||
|
label={_(msg`Mentioned users`)}
|
||||||
|
isSelected={
|
||||||
|
!!threadgateAllowUISettings.find(
|
||||||
|
v => v.type === 'mention',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onPress={() => onPressAudience({type: 'mention'})}
|
||||||
|
disabled={replySettingsDisabled}
|
||||||
|
/>
|
||||||
|
<Selectable
|
||||||
|
label={_(msg`Followed users`)}
|
||||||
|
isSelected={
|
||||||
|
!!threadgateAllowUISettings.find(
|
||||||
|
v => v.type === 'following',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onPress={() => onPressAudience({type: 'following'})}
|
||||||
|
disabled={replySettingsDisabled}
|
||||||
|
/>
|
||||||
|
{lists && lists.length > 0
|
||||||
|
? lists.map(list => (
|
||||||
|
<Selectable
|
||||||
|
key={list.uri}
|
||||||
|
label={_(msg`Users in "${list.name}"`)}
|
||||||
|
isSelected={
|
||||||
|
!!threadgateAllowUISettings.find(
|
||||||
|
v => v.type === 'list' && v.list === list.uri,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onPress={() =>
|
||||||
|
onPressAudience({type: 'list', list: list.uri})
|
||||||
|
}
|
||||||
|
disabled={replySettingsDisabled}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
: // No loading states to avoid jumps for the common case (no lists)
|
||||||
|
null}
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
label={_(msg`Save`)}
|
||||||
|
onPress={onSave}
|
||||||
|
onAccessibilityEscape={control.close}
|
||||||
|
color="primary"
|
||||||
|
size="medium"
|
||||||
|
variant="solid"
|
||||||
|
style={a.mt_xl}>
|
||||||
|
<ButtonText>{_(msg`Save`)}</ButtonText>
|
||||||
|
{isSaving && <ButtonIcon icon={Loader} position="right" />}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Selectable({
|
||||||
|
label,
|
||||||
|
isSelected,
|
||||||
|
onPress,
|
||||||
|
style,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
isSelected: boolean
|
||||||
|
onPress: () => void
|
||||||
|
style?: StyleProp<ViewStyle>
|
||||||
|
disabled?: boolean
|
||||||
|
}) {
|
||||||
|
const t = useTheme()
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
disabled={disabled}
|
||||||
|
onPress={onPress}
|
||||||
|
label={label}
|
||||||
|
accessibilityRole="checkbox"
|
||||||
|
aria-checked={isSelected}
|
||||||
|
accessibilityState={{
|
||||||
|
checked: isSelected,
|
||||||
|
}}
|
||||||
|
style={a.flex_1}>
|
||||||
|
{({hovered, focused}) => (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.flex_1,
|
||||||
|
a.flex_row,
|
||||||
|
a.align_center,
|
||||||
|
a.justify_between,
|
||||||
|
a.rounded_sm,
|
||||||
|
a.p_md,
|
||||||
|
{height: 40}, // for consistency with checkmark icon visible or not
|
||||||
|
t.atoms.bg_contrast_50,
|
||||||
|
(hovered || focused) && t.atoms.bg_contrast_100,
|
||||||
|
isSelected && {
|
||||||
|
backgroundColor: t.palette.primary_100,
|
||||||
|
},
|
||||||
|
style,
|
||||||
|
]}>
|
||||||
|
<Text style={[a.text_sm, isSelected && a.font_semibold]}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
{isSelected ? (
|
||||||
|
<Check size="sm" fill={t.palette.primary_500} />
|
||||||
|
) : (
|
||||||
|
<View />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePrefetchPostInteractionSettings({
|
||||||
|
postUri,
|
||||||
|
rootPostUri,
|
||||||
|
}: {
|
||||||
|
postUri: string
|
||||||
|
rootPostUri: string
|
||||||
|
}) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const agent = useAgent()
|
||||||
|
|
||||||
|
return React.useCallback(async () => {
|
||||||
|
try {
|
||||||
|
await Promise.all([
|
||||||
|
queryClient.prefetchQuery({
|
||||||
|
queryKey: createPostgateQueryKey(postUri),
|
||||||
|
queryFn: () => getPostgateRecord({agent, postUri}),
|
||||||
|
staleTime: STALE.SECONDS.THIRTY,
|
||||||
|
}),
|
||||||
|
queryClient.prefetchQuery({
|
||||||
|
queryKey: createThreadgateViewQueryKey(rootPostUri),
|
||||||
|
queryFn: () => getThreadgateView({agent, postUri: rootPostUri}),
|
||||||
|
staleTime: STALE.SECONDS.THIRTY,
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
} catch (e: any) {
|
||||||
|
logger.error(`Failed to prefetch post interaction settings`, {
|
||||||
|
safeMessage: e.message,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [queryClient, agent, postUri, rootPostUri])
|
||||||
|
}
|
||||||
@@ -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,
|
ChatBskyConvoDefs,
|
||||||
RichText as RichTextAPI,
|
RichText as RichTextAPI,
|
||||||
} from '@atproto/api'
|
} from '@atproto/api'
|
||||||
|
import {I18n} from '@lingui/core'
|
||||||
import {msg} from '@lingui/macro'
|
import {msg} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
@@ -153,14 +154,14 @@ let MessageItemMetadata = ({
|
|||||||
)
|
)
|
||||||
|
|
||||||
const relativeTimestamp = useCallback(
|
const relativeTimestamp = useCallback(
|
||||||
(timestamp: string) => {
|
(i18n: I18n, timestamp: string) => {
|
||||||
const date = new Date(timestamp)
|
const date = new Date(timestamp)
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
|
|
||||||
const time = new Intl.DateTimeFormat(undefined, {
|
const time = i18n.date(date, {
|
||||||
hour: 'numeric',
|
hour: 'numeric',
|
||||||
minute: 'numeric',
|
minute: 'numeric',
|
||||||
}).format(date)
|
})
|
||||||
|
|
||||||
const diff = now.getTime() - date.getTime()
|
const diff = now.getTime() - date.getTime()
|
||||||
|
|
||||||
@@ -182,13 +183,13 @@ let MessageItemMetadata = ({
|
|||||||
return _(msg`Yesterday, ${time}`)
|
return _(msg`Yesterday, ${time}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Intl.DateTimeFormat(undefined, {
|
return i18n.date(date, {
|
||||||
hour: 'numeric',
|
hour: 'numeric',
|
||||||
minute: 'numeric',
|
minute: 'numeric',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
month: 'numeric',
|
month: 'numeric',
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
}).format(date)
|
})
|
||||||
},
|
},
|
||||||
[_],
|
[_],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React from 'react'
|
|||||||
import {View} from 'react-native'
|
import {View} from 'react-native'
|
||||||
import {AppBskyEmbedRecord} from '@atproto/api'
|
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'
|
import {atoms as a, native, useTheme} from '#/alf'
|
||||||
|
|
||||||
let MessageItemEmbed = ({
|
let MessageItemEmbed = ({
|
||||||
@@ -14,7 +14,11 @@ let MessageItemEmbed = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[a.my_xs, t.atoms.bg, native({flexBasis: 0})]}>
|
<View style={[a.my_xs, t.atoms.bg, native({flexBasis: 0})]}>
|
||||||
<PostEmbeds embed={embed} allowNestedQuotes />
|
<PostEmbeds
|
||||||
|
embed={embed}
|
||||||
|
allowNestedQuotes
|
||||||
|
viewContext={PostEmbedViewContext.Feed}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {Pressable, View} from 'react-native'
|
import {Pressable, View} from 'react-native'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
import {android, atoms as a, useTheme, web} from '#/alf'
|
import {android, atoms as a, useTheme, web} from '#/alf'
|
||||||
import * as TextField from '#/components/forms/TextField'
|
import * as TextField from '#/components/forms/TextField'
|
||||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||||
import {CalendarDays_Stroke2_Corner0_Rounded as CalendarDays} from '#/components/icons/CalendarDays'
|
import {CalendarDays_Stroke2_Corner0_Rounded as CalendarDays} from '#/components/icons/CalendarDays'
|
||||||
import {Text} from '#/components/Typography'
|
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
|
// 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
|
// iOS: open a dialog with an inline date picker
|
||||||
@@ -25,6 +25,7 @@ export function DateFieldButton({
|
|||||||
isInvalid?: boolean
|
isInvalid?: boolean
|
||||||
accessibilityHint?: string
|
accessibilityHint?: string
|
||||||
}) {
|
}) {
|
||||||
|
const {i18n} = useLingui()
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -91,7 +92,7 @@ export function DateFieldButton({
|
|||||||
t.atoms.text,
|
t.atoms.text,
|
||||||
{lineHeight: a.text_md.fontSize * 1.1875},
|
{lineHeight: a.text_md.fontSize * 1.1875},
|
||||||
]}>
|
]}>
|
||||||
{localizeDate(value)}
|
{i18n.date(value, {timeZone: 'UTC'})}
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</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
|
// we need the date in the form yyyy-MM-dd to pass to the input
|
||||||
export function toSimpleDateString(date: Date | string): string {
|
export function toSimpleDateString(date: Date | string): string {
|
||||||
const _date = typeof date === 'string' ? new Date(date) : date
|
const _date = typeof date === 'string' ? new Date(date) : date
|
||||||
return _date.toISOString().split('T')[0]
|
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)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
ja,
|
ja,
|
||||||
ko,
|
ko,
|
||||||
ptBR,
|
ptBR,
|
||||||
|
ru,
|
||||||
tr,
|
tr,
|
||||||
uk,
|
uk,
|
||||||
zhCN,
|
zhCN,
|
||||||
@@ -47,6 +48,7 @@ const locales: Record<AppLanguage, Locale | undefined> = {
|
|||||||
ja,
|
ja,
|
||||||
ko,
|
ko,
|
||||||
['pt-BR']: ptBR,
|
['pt-BR']: ptBR,
|
||||||
|
ru,
|
||||||
tr,
|
tr,
|
||||||
uk,
|
uk,
|
||||||
['zh-CN']: zhCN,
|
['zh-CN']: zhCN,
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ export function useFollowMethods({
|
|||||||
logContext,
|
logContext,
|
||||||
}: {
|
}: {
|
||||||
profile: Shadow<AppBskyActorDefs.ProfileViewBasic>
|
profile: Shadow<AppBskyActorDefs.ProfileViewBasic>
|
||||||
logContext: LogEvents['profile:follow']['logContext'] &
|
logContext: LogEvents['profile:follow:sampled']['logContext'] &
|
||||||
LogEvents['profile:unfollow']['logContext']
|
LogEvents['profile:unfollow:sampled']['logContext']
|
||||||
}) {
|
}) {
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const requireAuth = useRequireAuth()
|
const requireAuth = useRequireAuth()
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ export function useInteractionState() {
|
|||||||
|
|
||||||
const onIn = React.useCallback(() => {
|
const onIn = React.useCallback(() => {
|
||||||
setState(true)
|
setState(true)
|
||||||
}, [setState])
|
}, [])
|
||||||
const onOut = React.useCallback(() => {
|
const onOut = React.useCallback(() => {
|
||||||
setState(false)
|
setState(false)
|
||||||
}, [setState])
|
}, [])
|
||||||
|
|
||||||
return React.useMemo(
|
return React.useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import {useEffect, useRef, useState} from 'react'
|
|||||||
|
|
||||||
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
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 pendingValueRef = useRef(value)
|
||||||
const [throttledValue, setThrottledValue] = useState(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,
|
md: 20,
|
||||||
lg: 24,
|
lg: 24,
|
||||||
xl: 28,
|
xl: 28,
|
||||||
|
'2xl': 32,
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useCommonSVGProps(props: Props) {
|
export function useCommonSVGProps(props: Props) {
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
import * as Dialog from '#/components/Dialog'
|
||||||
|
import {DialogControlProps} from '#/components/Dialog'
|
||||||
|
import {VerifyEmailIntentDialog} from '#/components/intents/VerifyEmailIntentDialog'
|
||||||
|
|
||||||
|
interface Context {
|
||||||
|
verifyEmailDialogControl: DialogControlProps
|
||||||
|
verifyEmailState: {code: string} | undefined
|
||||||
|
setVerifyEmailState: (state: {code: string} | undefined) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const Context = React.createContext({} as Context)
|
||||||
|
export const useIntentDialogs = () => React.useContext(Context)
|
||||||
|
|
||||||
|
export function Provider({children}: {children: React.ReactNode}) {
|
||||||
|
const verifyEmailDialogControl = Dialog.useDialogControl()
|
||||||
|
const [verifyEmailState, setVerifyEmailState] = React.useState<
|
||||||
|
{code: string} | undefined
|
||||||
|
>()
|
||||||
|
|
||||||
|
const value = React.useMemo(
|
||||||
|
() => ({
|
||||||
|
verifyEmailDialogControl,
|
||||||
|
verifyEmailState,
|
||||||
|
setVerifyEmailState,
|
||||||
|
}),
|
||||||
|
[verifyEmailDialogControl, verifyEmailState, setVerifyEmailState],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Context.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
<VerifyEmailIntentDialog />
|
||||||
|
</Context.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import {View} from 'react-native'
|
||||||
|
import {msg, Trans} from '@lingui/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
|
import {useAgent, useSession} from 'state/session'
|
||||||
|
import {atoms as a} from '#/alf'
|
||||||
|
import {Button, ButtonText} from '#/components/Button'
|
||||||
|
import * as Dialog from '#/components/Dialog'
|
||||||
|
import {DialogControlProps} from '#/components/Dialog'
|
||||||
|
import {useIntentDialogs} from '#/components/intents/IntentDialogs'
|
||||||
|
import {Loader} from '#/components/Loader'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
|
export function VerifyEmailIntentDialog() {
|
||||||
|
const {verifyEmailDialogControl: control} = useIntentDialogs()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog.Outer control={control}>
|
||||||
|
<Dialog.Handle />
|
||||||
|
<Inner control={control} />
|
||||||
|
</Dialog.Outer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Inner({control}: {control: DialogControlProps}) {
|
||||||
|
const {_} = useLingui()
|
||||||
|
const {verifyEmailState: state} = useIntentDialogs()
|
||||||
|
const [status, setStatus] = React.useState<
|
||||||
|
'loading' | 'success' | 'failure' | 'resent'
|
||||||
|
>('loading')
|
||||||
|
const [sending, setSending] = React.useState(false)
|
||||||
|
const agent = useAgent()
|
||||||
|
const {currentAccount} = useSession()
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
;(async () => {
|
||||||
|
if (!state?.code) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await agent.com.atproto.server.confirmEmail({
|
||||||
|
email: (currentAccount?.email || '').trim(),
|
||||||
|
token: state.code.trim(),
|
||||||
|
})
|
||||||
|
setStatus('success')
|
||||||
|
} catch (e) {
|
||||||
|
setStatus('failure')
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
}, [agent.com.atproto.server, currentAccount?.email, state?.code])
|
||||||
|
|
||||||
|
const onPressResendEmail = async () => {
|
||||||
|
setSending(true)
|
||||||
|
await agent.com.atproto.server.requestEmailConfirmation()
|
||||||
|
setSending(false)
|
||||||
|
setStatus('resent')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog.ScrollableInner label={_(msg`Verify email dialog`)}>
|
||||||
|
<Dialog.Close />
|
||||||
|
<View style={[a.gap_xl]}>
|
||||||
|
{status === 'loading' ? (
|
||||||
|
<View style={[a.py_2xl, a.align_center, a.justify_center]}>
|
||||||
|
<Loader size="xl" />
|
||||||
|
</View>
|
||||||
|
) : status === 'success' ? (
|
||||||
|
<>
|
||||||
|
<Text style={[a.font_bold, a.text_2xl]}>
|
||||||
|
<Trans>Email Verified</Trans>
|
||||||
|
</Text>
|
||||||
|
<Text style={[a.text_md, a.leading_tight]}>
|
||||||
|
<Trans>
|
||||||
|
Thanks, you have successfully verified your email address.
|
||||||
|
</Trans>
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
) : status === 'failure' ? (
|
||||||
|
<>
|
||||||
|
<Text style={[a.font_bold, a.text_2xl]}>
|
||||||
|
<Trans>Invalid Verification Code</Trans>
|
||||||
|
</Text>
|
||||||
|
<Text style={[a.text_md, a.leading_tight]}>
|
||||||
|
<Trans>
|
||||||
|
The verification code you have provided is invalid. Please make
|
||||||
|
sure that you have used the correct verification link or request
|
||||||
|
a new one.
|
||||||
|
</Trans>
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Text style={[a.font_bold, a.text_2xl]}>
|
||||||
|
<Trans>Email Resent</Trans>
|
||||||
|
</Text>
|
||||||
|
<Text style={[a.text_md, a.leading_tight]}>
|
||||||
|
<Trans>
|
||||||
|
We have sent another verification email to{' '}
|
||||||
|
<Text style={[a.text_md, a.font_bold]}>
|
||||||
|
{currentAccount?.email}
|
||||||
|
</Text>
|
||||||
|
.
|
||||||
|
</Trans>
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{status !== 'loading' ? (
|
||||||
|
<View style={[a.w_full, a.flex_row, a.gap_sm, {marginLeft: 'auto'}]}>
|
||||||
|
<Button
|
||||||
|
label={_(msg`Close`)}
|
||||||
|
onPress={() => control.close()}
|
||||||
|
variant="solid"
|
||||||
|
color={status === 'failure' ? 'secondary' : 'primary'}
|
||||||
|
size="medium"
|
||||||
|
style={{marginLeft: 'auto'}}>
|
||||||
|
<ButtonText>
|
||||||
|
<Trans>Close</Trans>
|
||||||
|
</ButtonText>
|
||||||
|
</Button>
|
||||||
|
{status === 'failure' ? (
|
||||||
|
<Button
|
||||||
|
label={_(msg`Resend Verification Email`)}
|
||||||
|
onPress={onPressResendEmail}
|
||||||
|
variant="solid"
|
||||||
|
color="primary"
|
||||||
|
size="medium"
|
||||||
|
disabled={sending}>
|
||||||
|
<ButtonText>
|
||||||
|
<Trans>Resend Email</Trans>
|
||||||
|
</ButtonText>
|
||||||
|
{sending ? <Loader size="sm" style={{color: 'white'}} /> : null}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</Dialog.ScrollableInner>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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'
|
} from '#/components/moderation/LabelsOnMeDialog'
|
||||||
|
|
||||||
export function LabelsOnMe({
|
export function LabelsOnMe({
|
||||||
details,
|
type,
|
||||||
labels,
|
labels,
|
||||||
size,
|
size,
|
||||||
style,
|
style,
|
||||||
}: {
|
}: {
|
||||||
details: {did: string} | {uri: string; cid: string}
|
type: 'account' | 'content'
|
||||||
labels: ComAtprotoLabelDefs.Label[] | undefined
|
labels: ComAtprotoLabelDefs.Label[] | undefined
|
||||||
size?: ButtonSize
|
size?: ButtonSize
|
||||||
style?: StyleProp<ViewStyle>
|
style?: StyleProp<ViewStyle>
|
||||||
}) {
|
}) {
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const {currentAccount} = useSession()
|
const {currentAccount} = useSession()
|
||||||
const isAccount = 'did' in details
|
|
||||||
const control = useLabelsOnMeDialogControl()
|
const control = useLabelsOnMeDialogControl()
|
||||||
|
|
||||||
if (!labels || !currentAccount) {
|
if (!labels || !currentAccount) {
|
||||||
@@ -39,7 +38,7 @@ export function LabelsOnMe({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[a.flex_row, style]}>
|
<View style={[a.flex_row, style]}>
|
||||||
<LabelsOnMeDialog control={control} subject={details} labels={labels} />
|
<LabelsOnMeDialog control={control} labels={labels} type={type} />
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="solid"
|
variant="solid"
|
||||||
@@ -51,7 +50,7 @@ export function LabelsOnMe({
|
|||||||
}}>
|
}}>
|
||||||
<ButtonIcon position="left" icon={CircleInfo} />
|
<ButtonIcon position="left" icon={CircleInfo} />
|
||||||
<ButtonText style={[a.leading_snug]}>
|
<ButtonText style={[a.leading_snug]}>
|
||||||
{isAccount ? (
|
{type === 'account' ? (
|
||||||
<Plural
|
<Plural
|
||||||
value={labels.length}
|
value={labels.length}
|
||||||
one="# label has been placed on this account"
|
one="# label has been placed on this account"
|
||||||
@@ -82,6 +81,6 @@ export function LabelsOnMyPost({
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
return (
|
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 {useLingui} from '@lingui/react'
|
||||||
import {useMutation} from '@tanstack/react-query'
|
import {useMutation} from '@tanstack/react-query'
|
||||||
|
|
||||||
|
import {useLabelSubject} from '#/lib/moderation'
|
||||||
import {useLabelInfo} from '#/lib/moderation/useLabelInfo'
|
import {useLabelInfo} from '#/lib/moderation/useLabelInfo'
|
||||||
import {makeProfileLink} from '#/lib/routes/links'
|
import {makeProfileLink} from '#/lib/routes/links'
|
||||||
import {useGate} from '#/lib/statsig/statsig'
|
|
||||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {useAgent, useSession} from '#/state/session'
|
import {useAgent, useSession} from '#/state/session'
|
||||||
@@ -19,21 +19,13 @@ import {InlineLinkText} from '#/components/Link'
|
|||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
import {Divider} from '../Divider'
|
import {Divider} from '../Divider'
|
||||||
import {Loader} from '../Loader'
|
import {Loader} from '../Loader'
|
||||||
export {useDialogControl as useLabelsOnMeDialogControl} from '#/components/Dialog'
|
|
||||||
|
|
||||||
type Subject =
|
export {useDialogControl as useLabelsOnMeDialogControl} from '#/components/Dialog'
|
||||||
| {
|
|
||||||
uri: string
|
|
||||||
cid: string
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
did: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LabelsOnMeDialogProps {
|
export interface LabelsOnMeDialogProps {
|
||||||
control: Dialog.DialogOuterProps['control']
|
control: Dialog.DialogOuterProps['control']
|
||||||
subject: Subject
|
|
||||||
labels: ComAtprotoLabelDefs.Label[]
|
labels: ComAtprotoLabelDefs.Label[]
|
||||||
|
type: 'account' | 'content'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LabelsOnMeDialog(props: LabelsOnMeDialogProps) {
|
export function LabelsOnMeDialog(props: LabelsOnMeDialogProps) {
|
||||||
@@ -52,8 +44,8 @@ function LabelsOnMeDialogInner(props: LabelsOnMeDialogProps) {
|
|||||||
const [appealingLabel, setAppealingLabel] = React.useState<
|
const [appealingLabel, setAppealingLabel] = React.useState<
|
||||||
ComAtprotoLabelDefs.Label | undefined
|
ComAtprotoLabelDefs.Label | undefined
|
||||||
>(undefined)
|
>(undefined)
|
||||||
const {subject, labels} = props
|
const {labels} = props
|
||||||
const isAccount = 'did' in subject
|
const isAccount = props.type === 'account'
|
||||||
const containsSelfLabel = React.useMemo(
|
const containsSelfLabel = React.useMemo(
|
||||||
() => labels.some(l => l.src === currentAccount?.did),
|
() => labels.some(l => l.src === currentAccount?.did),
|
||||||
[currentAccount?.did, labels],
|
[currentAccount?.did, labels],
|
||||||
@@ -69,7 +61,6 @@ function LabelsOnMeDialogInner(props: LabelsOnMeDialogProps) {
|
|||||||
{appealingLabel ? (
|
{appealingLabel ? (
|
||||||
<AppealForm
|
<AppealForm
|
||||||
label={appealingLabel}
|
label={appealingLabel}
|
||||||
subject={subject}
|
|
||||||
control={props.control}
|
control={props.control}
|
||||||
onPressBack={() => setAppealingLabel(undefined)}
|
onPressBack={() => setAppealingLabel(undefined)}
|
||||||
/>
|
/>
|
||||||
@@ -189,12 +180,10 @@ function Label({
|
|||||||
|
|
||||||
function AppealForm({
|
function AppealForm({
|
||||||
label,
|
label,
|
||||||
subject,
|
|
||||||
control,
|
control,
|
||||||
onPressBack,
|
onPressBack,
|
||||||
}: {
|
}: {
|
||||||
label: ComAtprotoLabelDefs.Label
|
label: ComAtprotoLabelDefs.Label
|
||||||
subject: Subject
|
|
||||||
control: Dialog.DialogOuterProps['control']
|
control: Dialog.DialogOuterProps['control']
|
||||||
onPressBack: () => void
|
onPressBack: () => void
|
||||||
}) {
|
}) {
|
||||||
@@ -202,9 +191,9 @@ function AppealForm({
|
|||||||
const {labeler, strings} = useLabelInfo(label)
|
const {labeler, strings} = useLabelInfo(label)
|
||||||
const {gtMobile} = useBreakpoints()
|
const {gtMobile} = useBreakpoints()
|
||||||
const [details, setDetails] = React.useState('')
|
const [details, setDetails] = React.useState('')
|
||||||
|
const {subject} = useLabelSubject({label})
|
||||||
const isAccountReport = 'did' in subject
|
const isAccountReport = 'did' in subject
|
||||||
const agent = useAgent()
|
const agent = useAgent()
|
||||||
const gate = useGate()
|
|
||||||
const sourceName = labeler
|
const sourceName = labeler
|
||||||
? sanitizeHandle(labeler.creator.handle, '@')
|
? sanitizeHandle(labeler.creator.handle, '@')
|
||||||
: label.src
|
: label.src
|
||||||
@@ -214,7 +203,6 @@ function AppealForm({
|
|||||||
const $type = !isAccountReport
|
const $type = !isAccountReport
|
||||||
? 'com.atproto.repo.strongRef'
|
? 'com.atproto.repo.strongRef'
|
||||||
: 'com.atproto.admin.defs#repoRef'
|
: 'com.atproto.admin.defs#repoRef'
|
||||||
if (gate('session_withproxy_fix')) {
|
|
||||||
await agent.createModerationReport(
|
await agent.createModerationReport(
|
||||||
{
|
{
|
||||||
reasonType: ComAtprotoModerationDefs.REASONAPPEAL,
|
reasonType: ComAtprotoModerationDefs.REASONAPPEAL,
|
||||||
@@ -231,18 +219,6 @@ function AppealForm({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
} else {
|
|
||||||
await agent
|
|
||||||
.withProxy('atproto_labeler', label.src)
|
|
||||||
.createModerationReport({
|
|
||||||
reasonType: ComAtprotoModerationDefs.REASONAPPEAL,
|
|
||||||
subject: {
|
|
||||||
$type,
|
|
||||||
...subject,
|
|
||||||
},
|
|
||||||
reason: details,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
onError: err => {
|
onError: err => {
|
||||||
logger.error('Failed to submit label appeal', {message: 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 {makeProfileLink} from '#/lib/routes/links'
|
||||||
import {listUriToHref} from '#/lib/strings/url-helpers'
|
import {listUriToHref} from '#/lib/strings/url-helpers'
|
||||||
import {isNative} from '#/platform/detection'
|
import {isNative} from '#/platform/detection'
|
||||||
|
import {useSession} from '#/state/session'
|
||||||
import {atoms as a, useTheme} from '#/alf'
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
import * as Dialog from '#/components/Dialog'
|
import * as Dialog from '#/components/Dialog'
|
||||||
import {Divider} from '#/components/Divider'
|
import {Divider} from '#/components/Divider'
|
||||||
import {InlineLinkText} from '#/components/Link'
|
import {InlineLinkText} from '#/components/Link'
|
||||||
|
import {AppModerationCause} from '#/components/Pills'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
export {useDialogControl as useModerationDetailsDialogControl} from '#/components/Dialog'
|
export {useDialogControl as useModerationDetailsDialogControl} from '#/components/Dialog'
|
||||||
|
|
||||||
export interface ModerationDetailsDialogProps {
|
export interface ModerationDetailsDialogProps {
|
||||||
control: Dialog.DialogOuterProps['control']
|
control: Dialog.DialogOuterProps['control']
|
||||||
modcause: ModerationCause
|
modcause?: ModerationCause | AppModerationCause
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ModerationDetailsDialog(props: ModerationDetailsDialogProps) {
|
export function ModerationDetailsDialog(props: ModerationDetailsDialogProps) {
|
||||||
@@ -39,6 +41,7 @@ function ModerationDetailsDialogInner({
|
|||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const desc = useModerationCauseDescription(modcause)
|
const desc = useModerationCauseDescription(modcause)
|
||||||
|
const {currentAccount} = useSession()
|
||||||
|
|
||||||
let name
|
let name
|
||||||
let description
|
let description
|
||||||
@@ -105,6 +108,14 @@ function ModerationDetailsDialogInner({
|
|||||||
} else if (modcause.type === 'hidden') {
|
} else if (modcause.type === 'hidden') {
|
||||||
name = _(msg`Post Hidden by You`)
|
name = _(msg`Post Hidden by You`)
|
||||||
description = _(msg`You have hidden this post.`)
|
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') {
|
} else if (modcause.type === 'label') {
|
||||||
name = desc.name
|
name = desc.name
|
||||||
description = desc.description
|
description = desc.description
|
||||||
@@ -119,12 +130,12 @@ function ModerationDetailsDialogInner({
|
|||||||
<Text style={[t.atoms.text, a.text_2xl, a.font_bold, a.mb_sm]}>
|
<Text style={[t.atoms.text, a.text_2xl, a.font_bold, a.mb_sm]}>
|
||||||
{name}
|
{name}
|
||||||
</Text>
|
</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}
|
{description}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{modcause.type === 'label' && (
|
{modcause?.type === 'label' && (
|
||||||
<>
|
<View style={[a.pt_lg]}>
|
||||||
<Divider />
|
<Divider />
|
||||||
<Text style={[t.atoms.text, a.text_md, a.leading_snug, a.mt_lg]}>
|
<Text style={[t.atoms.text, a.text_md, a.leading_snug, a.mt_lg]}>
|
||||||
{modcause.source.type === 'user' ? (
|
{modcause.source.type === 'user' ? (
|
||||||
@@ -143,7 +154,7 @@ function ModerationDetailsDialogInner({
|
|||||||
</Trans>
|
</Trans>
|
||||||
)}
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
</>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isNative && <View style={{height: 40}} />}
|
{isNative && <View style={{height: 40}} />}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {StyleProp, ViewStyle} from 'react-native'
|
import {StyleProp, ViewStyle} from 'react-native'
|
||||||
import {ModerationUI} from '@atproto/api'
|
import {ModerationCause, ModerationUI} from '@atproto/api'
|
||||||
|
|
||||||
import {getModerationCauseKey} from '#/lib/moderation'
|
import {getModerationCauseKey} from '#/lib/moderation'
|
||||||
import * as Pills from '#/components/Pills'
|
import * as Pills from '#/components/Pills'
|
||||||
@@ -9,13 +9,15 @@ export function PostAlerts({
|
|||||||
modui,
|
modui,
|
||||||
size = 'sm',
|
size = 'sm',
|
||||||
style,
|
style,
|
||||||
|
additionalCauses,
|
||||||
}: {
|
}: {
|
||||||
modui: ModerationUI
|
modui: ModerationUI
|
||||||
size?: Pills.CommonProps['size']
|
size?: Pills.CommonProps['size']
|
||||||
includeMute?: boolean
|
includeMute?: boolean
|
||||||
style?: StyleProp<ViewStyle>
|
style?: StyleProp<ViewStyle>
|
||||||
|
additionalCauses?: ModerationCause[] | Pills.AppModerationCause[]
|
||||||
}) {
|
}) {
|
||||||
if (!modui.alert && !modui.inform) {
|
if (!modui.alert && !modui.inform && !additionalCauses?.length) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,6 +39,14 @@ export function PostAlerts({
|
|||||||
noBg={size === 'sm'}
|
noBg={size === 'sm'}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
{additionalCauses?.map(cause => (
|
||||||
|
<Pills.Label
|
||||||
|
key={getModerationCauseKey(cause)}
|
||||||
|
cause={cause}
|
||||||
|
size={size}
|
||||||
|
noBg={size === 'sm'}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</Pills.Row>
|
</Pills.Row>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import {View} from 'react-native'
|
||||||
|
|
||||||
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
|
import {Play_Filled_Corner0_Rounded as PlayIcon} from '#/components/icons/Play'
|
||||||
|
|
||||||
|
export function PlayButtonIcon({size = 36}: {size?: number}) {
|
||||||
|
const t = useTheme()
|
||||||
|
const bg = t.name === 'light' ? t.palette.contrast_25 : t.palette.contrast_975
|
||||||
|
const fg = t.name === 'light' ? t.palette.contrast_975 : t.palette.contrast_25
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.rounded_full,
|
||||||
|
a.overflow_hidden,
|
||||||
|
a.align_center,
|
||||||
|
a.justify_center,
|
||||||
|
t.atoms.shadow_lg,
|
||||||
|
{
|
||||||
|
width: size + size / 1.5,
|
||||||
|
height: size + size / 1.5,
|
||||||
|
},
|
||||||
|
]}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.absolute,
|
||||||
|
a.inset_0,
|
||||||
|
{
|
||||||
|
backgroundColor: bg,
|
||||||
|
opacity: 0.7,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<PlayIcon
|
||||||
|
width={size}
|
||||||
|
fill={fg}
|
||||||
|
style={[
|
||||||
|
a.relative,
|
||||||
|
a.z_10,
|
||||||
|
{
|
||||||
|
left: size / 50,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
+66
-24
@@ -23,6 +23,7 @@ type FeedSliceItem = {
|
|||||||
record: AppBskyFeedPost.Record
|
record: AppBskyFeedPost.Record
|
||||||
parentAuthor: AppBskyActorDefs.ProfileViewBasic | undefined
|
parentAuthor: AppBskyActorDefs.ProfileViewBasic | undefined
|
||||||
isParentBlocked: boolean
|
isParentBlocked: boolean
|
||||||
|
isParentNotFound: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type AuthorContext = {
|
type AuthorContext = {
|
||||||
@@ -68,6 +69,7 @@ export class FeedViewPostsSlice {
|
|||||||
}
|
}
|
||||||
const parent = reply?.parent
|
const parent = reply?.parent
|
||||||
const isParentBlocked = AppBskyFeedDefs.isBlockedPost(parent)
|
const isParentBlocked = AppBskyFeedDefs.isBlockedPost(parent)
|
||||||
|
const isParentNotFound = AppBskyFeedDefs.isNotFoundPost(parent)
|
||||||
let parentAuthor: AppBskyActorDefs.ProfileViewBasic | undefined
|
let parentAuthor: AppBskyActorDefs.ProfileViewBasic | undefined
|
||||||
if (AppBskyFeedDefs.isPostView(parent)) {
|
if (AppBskyFeedDefs.isPostView(parent)) {
|
||||||
parentAuthor = parent.author
|
parentAuthor = parent.author
|
||||||
@@ -77,8 +79,17 @@ export class FeedViewPostsSlice {
|
|||||||
record: post.record,
|
record: post.record,
|
||||||
parentAuthor,
|
parentAuthor,
|
||||||
isParentBlocked,
|
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
|
return
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
@@ -89,23 +100,40 @@ export class FeedViewPostsSlice {
|
|||||||
this.isOrphan = true
|
this.isOrphan = true
|
||||||
return
|
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 grandparentAuthor = reply.grandparentAuthor
|
||||||
const isGrandparentBlocked = Boolean(
|
const isGrandparentBlocked = Boolean(
|
||||||
grandparentAuthor?.viewer?.blockedBy ||
|
grandparent && AppBskyFeedDefs.isBlockedPost(grandparent),
|
||||||
grandparentAuthor?.viewer?.blocking ||
|
)
|
||||||
grandparentAuthor?.viewer?.blockingByList,
|
const isGrandparentNotFound = Boolean(
|
||||||
|
grandparent && AppBskyFeedDefs.isNotFoundPost(grandparent),
|
||||||
)
|
)
|
||||||
this.items.unshift({
|
this.items.unshift({
|
||||||
post: parent,
|
post: parent,
|
||||||
record: parent.record,
|
record: parent.record,
|
||||||
parentAuthor: grandparentAuthor,
|
parentAuthor: grandparentAuthor,
|
||||||
isParentBlocked: isGrandparentBlocked,
|
isParentBlocked: isGrandparentBlocked,
|
||||||
|
isParentNotFound: isGrandparentNotFound,
|
||||||
})
|
})
|
||||||
if (isGrandparentBlocked) {
|
if (isGrandparentBlocked) {
|
||||||
this.isOrphan = true
|
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 (
|
if (
|
||||||
!AppBskyFeedDefs.isPostView(root) ||
|
!AppBskyFeedDefs.isPostView(root) ||
|
||||||
!AppBskyFeedPost.isRecord(root.record) ||
|
!AppBskyFeedPost.isRecord(root.record) ||
|
||||||
@@ -121,6 +149,7 @@ export class FeedViewPostsSlice {
|
|||||||
post: root,
|
post: root,
|
||||||
record: root.record,
|
record: root.record,
|
||||||
isParentBlocked: false,
|
isParentBlocked: false,
|
||||||
|
isParentNotFound: false,
|
||||||
parentAuthor: undefined,
|
parentAuthor: undefined,
|
||||||
})
|
})
|
||||||
if (parent.record.reply?.parent.uri !== root.uri) {
|
if (parent.record.reply?.parent.uri !== root.uri) {
|
||||||
@@ -242,10 +271,15 @@ export class FeedTuner {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (!dryRun) {
|
if (!dryRun) {
|
||||||
|
// Reposting a reply elevates it to top-level, so its parent/root won't be displayed.
|
||||||
|
// Disable in-thread dedupe for this case since we don't want to miss them later.
|
||||||
|
const disableDedupe = slice.isReply && slice.isRepost
|
||||||
|
if (!disableDedupe) {
|
||||||
this.seenUris.add(item.post.uri)
|
this.seenUris.add(item.post.uri)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if (!dryRun) {
|
if (!dryRun) {
|
||||||
this.seenKeys.add(slice._reactKey)
|
this.seenKeys.add(slice._reactKey)
|
||||||
}
|
}
|
||||||
@@ -345,11 +379,7 @@ export class FeedTuner {
|
|||||||
): FeedViewPostsSlice[] => {
|
): FeedViewPostsSlice[] => {
|
||||||
for (let i = 0; i < slices.length; i++) {
|
for (let i = 0; i < slices.length; i++) {
|
||||||
const slice = slices[i]
|
const slice = slices[i]
|
||||||
if (
|
if (slice.isReply && !shouldDisplayReplyInFollowing(slice, userDid)) {
|
||||||
slice.isReply &&
|
|
||||||
!slice.isRepost &&
|
|
||||||
!shouldDisplayReplyInFollowing(slice.getAuthors(), userDid)
|
|
||||||
) {
|
|
||||||
slices.splice(i, 1)
|
slices.splice(i, 1)
|
||||||
i--
|
i--
|
||||||
}
|
}
|
||||||
@@ -371,27 +401,20 @@ export class FeedTuner {
|
|||||||
slices: FeedViewPostsSlice[],
|
slices: FeedViewPostsSlice[],
|
||||||
_dryRun: boolean,
|
_dryRun: boolean,
|
||||||
): FeedViewPostsSlice[] => {
|
): FeedViewPostsSlice[] => {
|
||||||
const candidateSlices = slices.slice()
|
|
||||||
|
|
||||||
// early return if no languages have been specified
|
// early return if no languages have been specified
|
||||||
if (!preferredLangsCode2.length || preferredLangsCode2.length === 0) {
|
if (!preferredLangsCode2.length || preferredLangsCode2.length === 0) {
|
||||||
return slices
|
return slices
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let i = 0; i < slices.length; i++) {
|
const candidateSlices = slices.filter(slice => {
|
||||||
let hasPreferredLang = false
|
for (const item of slice.items) {
|
||||||
for (const item of slices[i].items) {
|
|
||||||
if (isPostInLanguage(item.post, preferredLangsCode2)) {
|
if (isPostInLanguage(item.post, preferredLangsCode2)) {
|
||||||
hasPreferredLang = true
|
return true
|
||||||
break
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// if item does not fit preferred language, remove it
|
// if item does not fit preferred language, remove it
|
||||||
if (!hasPreferredLang) {
|
return false
|
||||||
candidateSlices.splice(i, 1)
|
})
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// if the language filter cleared out the entire page, return the original set
|
// if the language filter cleared out the entire page, return the original set
|
||||||
// so that something always shows
|
// so that something always shows
|
||||||
@@ -420,9 +443,13 @@ function areSameAuthor(authors: AuthorContext): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function shouldDisplayReplyInFollowing(
|
function shouldDisplayReplyInFollowing(
|
||||||
authors: AuthorContext,
|
slice: FeedViewPostsSlice,
|
||||||
userDid: string,
|
userDid: string,
|
||||||
): boolean {
|
): boolean {
|
||||||
|
if (slice.isRepost) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
const authors = slice.getAuthors()
|
||||||
const {author, parentAuthor, grandparentAuthor, rootAuthor} = authors
|
const {author, parentAuthor, grandparentAuthor, rootAuthor} = authors
|
||||||
if (!isSelfOrFollowing(author, userDid)) {
|
if (!isSelfOrFollowing(author, userDid)) {
|
||||||
// Only show replies from self or people you follow.
|
// Only show replies from self or people you follow.
|
||||||
@@ -436,6 +463,21 @@ function shouldDisplayReplyInFollowing(
|
|||||||
// Always show self-threads.
|
// Always show self-threads.
|
||||||
return true
|
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.
|
// From this point on we need at least one more reason to show it.
|
||||||
if (
|
if (
|
||||||
parentAuthor &&
|
parentAuthor &&
|
||||||
|
|||||||
+11
-14
@@ -1,7 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
AppBskyFeedDefs,
|
AppBskyFeedDefs,
|
||||||
AppBskyFeedGetFeed as GetCustomFeed,
|
AppBskyFeedGetFeed as GetCustomFeed,
|
||||||
AtpAgent,
|
|
||||||
BskyAgent,
|
BskyAgent,
|
||||||
} from '@atproto/api'
|
} from '@atproto/api'
|
||||||
|
|
||||||
@@ -51,7 +50,7 @@ export class CustomFeedAPI implements FeedAPI {
|
|||||||
const agent = this.agent
|
const agent = this.agent
|
||||||
const isBlueskyOwned = isBlueskyOwnedFeed(this.params.feed)
|
const isBlueskyOwned = isBlueskyOwnedFeed(this.params.feed)
|
||||||
|
|
||||||
const res = agent.session
|
const res = agent.did
|
||||||
? await this.agent.app.bsky.feed.getFeed(
|
? await this.agent.app.bsky.feed.getFeed(
|
||||||
{
|
{
|
||||||
...this.params,
|
...this.params,
|
||||||
@@ -106,34 +105,32 @@ async function loggedOutFetch({
|
|||||||
let contentLangs = getContentLanguages().join(',')
|
let contentLangs = getContentLanguages().join(',')
|
||||||
|
|
||||||
// manually construct fetch call so we can add the `lang` cache-busting param
|
// 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}${
|
`https://api.bsky.app/xrpc/app.bsky.feed.getFeed?feed=${feed}${
|
||||||
cursor ? `&cursor=${cursor}` : ''
|
cursor ? `&cursor=${cursor}` : ''
|
||||||
}&limit=${limit}&lang=${contentLangs}`,
|
}&limit=${limit}&lang=${contentLangs}`,
|
||||||
'GET',
|
{method: 'GET', headers: {'Accept-Language': contentLangs}},
|
||||||
{'Accept-Language': contentLangs},
|
|
||||||
undefined,
|
|
||||||
)
|
)
|
||||||
if (res.body?.feed?.length) {
|
let data = res.ok ? await res.json() : null
|
||||||
|
if (data?.feed?.length) {
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: res.body,
|
data,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// no data, try again with language headers removed
|
// 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}${
|
`https://api.bsky.app/xrpc/app.bsky.feed.getFeed?feed=${feed}${
|
||||||
cursor ? `&cursor=${cursor}` : ''
|
cursor ? `&cursor=${cursor}` : ''
|
||||||
}&limit=${limit}`,
|
}&limit=${limit}`,
|
||||||
'GET',
|
{method: 'GET', headers: {'Accept-Language': ''}},
|
||||||
{'Accept-Language': ''},
|
|
||||||
undefined,
|
|
||||||
)
|
)
|
||||||
if (res.body?.feed?.length) {
|
data = res.ok ? await res.json() : null
|
||||||
|
if (data?.feed?.length) {
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: res.body,
|
data,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+104
-90
@@ -1,24 +1,35 @@
|
|||||||
import {
|
import {
|
||||||
|
AppBskyEmbedDefs,
|
||||||
AppBskyEmbedExternal,
|
AppBskyEmbedExternal,
|
||||||
AppBskyEmbedImages,
|
AppBskyEmbedImages,
|
||||||
AppBskyEmbedRecord,
|
AppBskyEmbedRecord,
|
||||||
AppBskyEmbedRecordWithMedia,
|
AppBskyEmbedRecordWithMedia,
|
||||||
AppBskyFeedThreadgate,
|
AppBskyEmbedVideo,
|
||||||
|
AppBskyFeedPostgate,
|
||||||
|
AtUri,
|
||||||
|
BlobRef,
|
||||||
BskyAgent,
|
BskyAgent,
|
||||||
ComAtprotoLabelDefs,
|
ComAtprotoLabelDefs,
|
||||||
ComAtprotoRepoUploadBlob,
|
|
||||||
RichText,
|
RichText,
|
||||||
} from '@atproto/api'
|
} from '@atproto/api'
|
||||||
import {AtUri} from '@atproto/api'
|
|
||||||
|
|
||||||
import {logger} from '#/logger'
|
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 {isNetworkError} from 'lib/strings/errors'
|
||||||
import {shortenLinks, stripInvalidMentions} from 'lib/strings/rich-text-manip'
|
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 {ImageModel} from 'state/models/media/image'
|
||||||
import {LinkMeta} from '../link-meta/link-meta'
|
import {LinkMeta} from '../link-meta/link-meta'
|
||||||
import {safeDeleteAsync} from '../media/manip'
|
import {safeDeleteAsync} from '../media/manip'
|
||||||
|
import {uploadBlob} from './upload-blob'
|
||||||
|
|
||||||
|
export {uploadBlob}
|
||||||
|
|
||||||
export interface ExternalEmbedDraft {
|
export interface ExternalEmbedDraft {
|
||||||
uri: string
|
uri: string
|
||||||
@@ -28,25 +39,6 @@ export interface ExternalEmbedDraft {
|
|||||||
localThumb?: ImageModel
|
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 {
|
interface PostOpts {
|
||||||
rawText: string
|
rawText: string
|
||||||
replyTo?: string
|
replyTo?: string
|
||||||
@@ -55,13 +47,16 @@ interface PostOpts {
|
|||||||
cid: string
|
cid: string
|
||||||
}
|
}
|
||||||
video?: {
|
video?: {
|
||||||
uri: string
|
blobRef: BlobRef
|
||||||
cid: string
|
altText: string
|
||||||
|
captions: {lang: string; file: File}[]
|
||||||
|
aspectRatio?: AppBskyEmbedDefs.AspectRatio
|
||||||
}
|
}
|
||||||
extLink?: ExternalEmbedDraft
|
extLink?: ExternalEmbedDraft
|
||||||
images?: ImageModel[]
|
images?: ImageModel[]
|
||||||
labels?: string[]
|
labels?: string[]
|
||||||
threadgate?: ThreadgateSetting[]
|
threadgate: ThreadgateAllowUISetting[]
|
||||||
|
postgate: AppBskyFeedPostgate.Record
|
||||||
onStateChange?: (state: string) => void
|
onStateChange?: (state: string) => void
|
||||||
langs?: string[]
|
langs?: string[]
|
||||||
}
|
}
|
||||||
@@ -71,18 +66,16 @@ export async function post(agent: BskyAgent, opts: PostOpts) {
|
|||||||
| AppBskyEmbedImages.Main
|
| AppBskyEmbedImages.Main
|
||||||
| AppBskyEmbedExternal.Main
|
| AppBskyEmbedExternal.Main
|
||||||
| AppBskyEmbedRecord.Main
|
| AppBskyEmbedRecord.Main
|
||||||
|
| AppBskyEmbedVideo.Main
|
||||||
| AppBskyEmbedRecordWithMedia.Main
|
| AppBskyEmbedRecordWithMedia.Main
|
||||||
| undefined
|
| undefined
|
||||||
let reply
|
let reply
|
||||||
let rt = new RichText(
|
let rt = new RichText({text: opts.rawText.trimEnd()}, {cleanNewlines: true})
|
||||||
{text: opts.rawText.trimEnd()},
|
|
||||||
{
|
|
||||||
cleanNewlines: true,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
opts.onStateChange?.('Processing...')
|
opts.onStateChange?.('Processing...')
|
||||||
|
|
||||||
await rt.detectFacets(agent)
|
await rt.detectFacets(agent)
|
||||||
|
|
||||||
rt = shortenLinks(rt)
|
rt = shortenLinks(rt)
|
||||||
rt = stripInvalidMentions(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
|
// add external embed if present
|
||||||
if (opts.extLink && !opts.images?.length) {
|
if (opts.extLink && !opts.images?.length) {
|
||||||
if (opts.extLink.embed) {
|
if (opts.extLink.embed) {
|
||||||
@@ -249,7 +277,9 @@ export async function post(agent: BskyAgent, opts: PostOpts) {
|
|||||||
labels,
|
labels,
|
||||||
})
|
})
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.error(`Failed to create post: ${e.toString()}`)
|
logger.error(`Failed to create post`, {
|
||||||
|
safeMessage: e.message,
|
||||||
|
})
|
||||||
if (isNetworkError(e)) {
|
if (isNetworkError(e)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'Post failed to upload. Please check your Internet connection and try again.',
|
'Post failed to upload. Please check your Internet connection and try again.',
|
||||||
@@ -259,68 +289,52 @@ export async function post(agent: BskyAgent, opts: PostOpts) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (opts.threadgate.some(tg => tg.type !== 'everybody')) {
|
||||||
try {
|
try {
|
||||||
// TODO: this needs to be batch-created with the post!
|
// TODO: this needs to be batch-created with the post!
|
||||||
if (opts.threadgate?.length) {
|
await writeThreadgateRecord({
|
||||||
await createThreadgate(agent, res.uri, opts.threadgate)
|
agent,
|
||||||
}
|
postUri: res.uri,
|
||||||
|
threadgate: createThreadgateRecord({
|
||||||
|
post: res.uri,
|
||||||
|
allow: threadgateAllowUISettingToAllowRecordValue(opts.threadgate),
|
||||||
|
}),
|
||||||
|
})
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.error(`Failed to create threadgate: ${e.toString()}`)
|
logger.error(`Failed to create threadgate`, {
|
||||||
|
context: 'composer',
|
||||||
|
safeMessage: e.message,
|
||||||
|
})
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'Post reply-controls failed to be set. Your post was created but anyone can reply to it.',
|
'Failed to save post interaction settings. Your post was created but users may be able to interact with it.',
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
opts.postgate.embeddingRules?.length ||
|
||||||
|
opts.postgate.detachedEmbeddingUris?.length
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
// TODO: this needs to be batch-created with the post!
|
||||||
|
await writePostgateRecord({
|
||||||
|
agent,
|
||||||
|
postUri: res.uri,
|
||||||
|
postgate: {
|
||||||
|
...opts.postgate,
|
||||||
|
post: res.uri,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (e: any) {
|
||||||
|
logger.error(`Failed to create postgate`, {
|
||||||
|
context: 'composer',
|
||||||
|
safeMessage: e.message,
|
||||||
|
})
|
||||||
|
throw new Error(
|
||||||
|
'Failed to save post interaction settings. Your post was created but users may be able to interact with it.',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return res
|
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'
|
||||||
|
}
|
||||||
|
}
|
||||||
+14
-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_SERVICE = 'https://embed.bsky.app'
|
||||||
export const EMBED_SCRIPT = `${EMBED_SERVICE}/static/embed.js`
|
export const EMBED_SCRIPT = `${EMBED_SERVICE}/static/embed.js`
|
||||||
export const BSKY_DOWNLOAD_URL = 'https://bsky.app/download'
|
export const BSKY_DOWNLOAD_URL = 'https://bsky.app/download'
|
||||||
|
export const STARTER_PACK_MAX_SIZE = 150
|
||||||
|
|
||||||
// HACK
|
// HACK
|
||||||
// Yes, this is exactly what it looks like. It's a hard-coded constant
|
// 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
|
// code and update this number with each release until we can get the
|
||||||
// server route done.
|
// server route done.
|
||||||
// -prf
|
// -prf
|
||||||
export const JOINED_THIS_WEEK = 21797 // as of Jul5 2024
|
export const JOINED_THIS_WEEK = 3060000 // estimate as of 9/6/24
|
||||||
|
|
||||||
const BASE_FEEDBACK_FORM_URL = `${HELP_DESK_URL}/requests/new`
|
const BASE_FEEDBACK_FORM_URL = `${HELP_DESK_URL}/requests/new`
|
||||||
export function FEEDBACK_FORM_URL({
|
export function FEEDBACK_FORM_URL({
|
||||||
@@ -135,3 +136,15 @@ export const GIF_FEATURED = (params: string) =>
|
|||||||
`${GIF_SERVICE}/tenor/v2/featured?${params}`
|
`${GIF_SERVICE}/tenor/v2/featured?${params}`
|
||||||
|
|
||||||
export const MAX_LABELERS = 20
|
export const MAX_LABELERS = 20
|
||||||
|
|
||||||
|
export const VIDEO_SERVICE = 'https://video.bsky.app'
|
||||||
|
export const VIDEO_SERVICE_DID = 'did:web:video.bsky.app'
|
||||||
|
|
||||||
|
export const SUPPORTED_MIME_TYPES = [
|
||||||
|
'video/mp4',
|
||||||
|
'video/mpeg',
|
||||||
|
'video/webm',
|
||||||
|
'video/quicktime',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export type SupportedMimeTypes = (typeof SUPPORTED_MIME_TYPES)[number]
|
||||||
|
|||||||
@@ -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 {_} = useLingui()
|
||||||
const agent = useAgent()
|
const agent = useAgent()
|
||||||
const starterPackString = _(msg`Starter Pack`)
|
|
||||||
|
|
||||||
return useMutation<{uri: string; cid: string}, Error, void>({
|
return useMutation<{uri: string; cid: string}, Error, void>({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
@@ -106,7 +105,7 @@ export function useGenerateStarterPackMutation({
|
|||||||
25,
|
25,
|
||||||
true,
|
true,
|
||||||
)
|
)
|
||||||
const starterPackName = `${displayName}'s ${starterPackString}`
|
const starterPackName = _(msg`${displayName}'s Starter Pack`)
|
||||||
|
|
||||||
const list = await createStarterPackList({
|
const list = await createStarterPackList({
|
||||||
name: starterPackName,
|
name: starterPackName,
|
||||||
|
|||||||
@@ -1,102 +1,213 @@
|
|||||||
import {describe, expect, it} from '@jest/globals'
|
import {describe, expect, it} from '@jest/globals'
|
||||||
import {MessageDescriptor} from '@lingui/core'
|
|
||||||
import {addDays, subDays, subHours, subMinutes, subSeconds} from 'date-fns'
|
import {addDays, subDays, subHours, subMinutes, subSeconds} from 'date-fns'
|
||||||
|
|
||||||
import {dateDiff} from '../useTimeAgo'
|
import {dateDiff} from '../useTimeAgo'
|
||||||
|
|
||||||
const lingui: any = (obj: MessageDescriptor) => obj.message
|
|
||||||
|
|
||||||
const base = new Date('2024-06-17T00:00:00Z')
|
const base = new Date('2024-06-17T00:00:00Z')
|
||||||
|
|
||||||
describe('dateDiff', () => {
|
describe('dateDiff', () => {
|
||||||
it(`works with numbers`, () => {
|
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`, () => {
|
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`, () => {
|
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`, () => {
|
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`, () => {
|
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`, () => {
|
it(`values < 5 seconds ago return now`, () => {
|
||||||
const then = subSeconds(base, 4)
|
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`, () => {
|
it(`values >= 5 seconds ago return seconds`, () => {
|
||||||
const then = subSeconds(base, 5)
|
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`, () => {
|
it(`values < 1 min return seconds`, () => {
|
||||||
const then = subSeconds(base, 59)
|
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`, () => {
|
it(`values >= 1 min return minutes`, () => {
|
||||||
const then = subSeconds(base, 60)
|
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`, () => {
|
it(`minutes round down`, () => {
|
||||||
const then = subSeconds(base, 119)
|
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`, () => {
|
it(`values < 1 hour return minutes`, () => {
|
||||||
const then = subMinutes(base, 59)
|
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`, () => {
|
it(`values >= 1 hour return hours`, () => {
|
||||||
const then = subMinutes(base, 60)
|
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`, () => {
|
it(`hours round down`, () => {
|
||||||
const then = subMinutes(base, 119)
|
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`, () => {
|
it(`values < 1 day return hours`, () => {
|
||||||
const then = subHours(base, 23)
|
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`, () => {
|
it(`values >= 1 day return days`, () => {
|
||||||
const then = subHours(base, 24)
|
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`, () => {
|
it(`days round down`, () => {
|
||||||
const then = subHours(base, 47)
|
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`, () => {
|
it(`values < 30 days return days`, () => {
|
||||||
const then = subDays(base, 29)
|
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`, () => {
|
it(`values >= 30 days return months`, () => {
|
||||||
const then = subDays(base, 30)
|
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`, () => {
|
it(`months round down`, () => {
|
||||||
const then = subDays(base, 59)
|
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`, () => {
|
it(`values are rounded by increments of 30`, () => {
|
||||||
const then = subDays(base, 61)
|
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`, () => {
|
it(`values < 360 days return months`, () => {
|
||||||
const then = subDays(base, 359)
|
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`, () => {
|
it(`values >= 360 days return the earlier value`, () => {
|
||||||
const then = subDays(base, 360)
|
const then = subDays(base, 360)
|
||||||
expect(dateDiff(then, base, {lingui})).toEqual(then.toLocaleDateString())
|
expect(dateDiff(then, base)).toEqual({
|
||||||
|
value: 12,
|
||||||
|
unit: 'month',
|
||||||
|
earlier: then,
|
||||||
|
later: base,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user