Merge remote-tracking branch 'origin/main' into samuel/show-quotes
@@ -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',
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg width="24" height="24" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M20.957 3.043a1 1 0 0 1 0 1.414L16.414 9H20a1 1 0 1 1 0 2h-6a1 1 0 0 1-1-1V4a1 1 0 1 1 2 0v3.586l4.543-4.543a1 1 0 0 1 1.414 0ZM3 14a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v6a1 1 0 1 1-2 0v-3.586l-4.543 4.543a1 1 0 0 1-1.414-1.414L7.586 15H4a1 1 0 0 1-1-1Z" fill="#000"/></svg>
|
||||||
|
After Width: | Height: | Size: 391 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg width="24" height="24" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M20.957 3.043a1 1 0 0 1 0 1.414L16.414 9H20a1 1 0 1 1 0 2h-5a2 2 0 0 1-2-2V4a1 1 0 1 1 2 0v3.586l4.543-4.543a1 1 0 0 1 1.414 0ZM3 14a1 1 0 0 1 1-1h5a2 2 0 0 1 2 2v5a1 1 0 1 1-2 0v-3.586l-4.543 4.543a1 1 0 0 1-1.414-1.414L7.586 15H4a1 1 0 0 1-1-1Z" fill="#000"/></svg>
|
||||||
|
After Width: | Height: | Size: 392 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg width="24" height="24" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M14 5a1 1 0 1 1 0-2h6a1 1 0 0 1 1 1v6a1 1 0 1 1-2 0V6.414l-4.293 4.293a1 1 0 0 1-1.414-1.414L17.586 5H14ZM4 13a1 1 0 0 1 1 1v3.586l4.293-4.293a1 1 0 0 1 1.414 1.414L6.414 19H10a1 1 0 1 1 0 2H4a1 1 0 0 1-1-1v-6a1 1 0 0 1 1-1Z" fill="#000"/></svg>
|
||||||
|
After Width: | Height: | Size: 369 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg width="24" height="24" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M13 4a1 1 0 0 1 1-1h5a2 2 0 0 1 2 2v5a1 1 0 1 1-2 0V6.414l-4.293 4.293a1 1 0 0 1-1.414-1.414L17.586 5H14a1 1 0 0 1-1-1Zm-9 9a1 1 0 0 1 1 1v3.586l4.293-4.293a1 1 0 0 1 1.414 1.414L6.414 19H10a1 1 0 1 1 0 2H5a2 2 0 0 1-2-2v-5a1 1 0 0 1 1-1Z" fill="#000"/></svg>
|
||||||
|
After Width: | Height: | Size: 384 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg width="24" height="24" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M3 4a1 1 0 0 1 1-1h16a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4Zm11.543 7.293a1 1 0 0 1 1.414 0 1 1 0 0 0 1.414-1.414 3 3 0 1 0 0 4.242 1 1 0 0 0-1.414-1.414 1 1 0 0 1-1.414-1.414Zm-6 0a1 1 0 0 1 1.414 0 1 1 0 0 0 1.414-1.414 3 3 0 1 0 0 4.243 1 1 0 0 0-1.414-1.415 1 1 0 0 1-1.414-1.414Z" fill="#000"/></svg>
|
||||||
|
After Width: | Height: | Size: 440 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg width="24" height="24" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M3 4a1 1 0 0 1 1-1h16a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4Zm2 1v14h14V5H5Zm10.957 6.293a1 1 0 1 0 0 1.414 1 1 0 0 1 1.414 1.414 3 3 0 1 1 0-4.242 1 1 0 0 1-1.414 1.414Zm-6.331-.22a1 1 0 1 0 .331 1.634 1 1 0 0 1 1.414 1.414 3 3 0 1 1 0-4.242 1 1 0 0 1-1.414 1.414.994.994 0 0 0-.331-.22Z" fill="#000"/></svg>
|
||||||
|
After Width: | Height: | Size: 443 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg width="24" height="24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M4 4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V4ZM14 4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1h-4a1 1 0 0 1-1-1V4Z" fill="#000"/></svg>
|
||||||
|
After Width: | Height: | Size: 247 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg width="24" height="24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M4 6a3 3 0 0 1 6 0v12a3 3 0 1 1-6 0V6ZM14 6a3 3 0 1 1 6 0v12a3 3 0 1 1-6 0V6Z" fill="#000"/></svg>
|
||||||
|
After Width: | Height: | Size: 183 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg width="24" height="24" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M4 4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V4Zm2 1v14h2V5H6Zm8-1a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1h-4a1 1 0 0 1-1-1V4Zm2 1v14h2V5h-2Z" fill="#000"/></svg>
|
||||||
|
After Width: | Height: | Size: 315 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg width="24" height="24" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M4 6a3 3 0 0 1 6 0v12a3 3 0 1 1-6 0V6Zm3-1a1 1 0 0 0-1 1v12a1 1 0 1 0 2 0V6a1 1 0 0 0-1-1Zm7 1a3 3 0 1 1 6 0v12a3 3 0 1 1-6 0V6Zm3-1a1 1 0 0 0-1 1v12a1 1 0 1 0 2 0V6a1 1 0 0 0-1-1Z" fill="#000"/></svg>
|
||||||
|
After Width: | Height: | Size: 326 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg width="24" height="24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M6.514 2.143A1 1 0 0 0 5 3v18a1 1 0 0 0 1.514.858l15-9a1 1 0 0 0 0-1.716l-15-9Z" fill="#000"/></svg>
|
||||||
|
After Width: | Height: | Size: 184 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg width="24" height="24" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M5.507 2.13a1 1 0 0 1 1.008.013l15 9a1 1 0 0 1 0 1.714l-15 9A1 1 0 0 1 5 21V3a1 1 0 0 1 .507-.87ZM7 4.766v14.468L19.056 12 7 4.766Z" fill="#000"/></svg>
|
||||||
|
After Width: | Height: | Size: 276 B |
@@ -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"><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.1",
|
||||||
"@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",
|
||||||
|
|||||||
@@ -6,12 +6,15 @@ import {
|
|||||||
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 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 +108,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 +125,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,28 +137,27 @@ 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: Record with media
|
||||||
@@ -182,7 +184,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 +317,7 @@ function ExternalEmbed({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function GenericWithImage({
|
function GenericWithImageEmbed({
|
||||||
title,
|
title,
|
||||||
subtitle,
|
subtitle,
|
||||||
href,
|
href,
|
||||||
@@ -350,3 +353,60 @@ function GenericWithImage({
|
|||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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}`
|
||||||
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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.1":
|
||||||
version "0.12.2"
|
version "0.13.1"
|
||||||
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.12.2.tgz#5df6d4f60dea0395c84fdebd9e81a7e853edf130"
|
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.13.1.tgz#fbf4306e4465d5467aaf031308c1b47dcc8039d0"
|
||||||
integrity sha512-UVzCiDZH2j0wrr/O8nb1edD5cYLVqB5iujueXUCbHS3rAwIxgmyLtA3Hzm2QYsGPo/+xsIg1fNvpq9rNT6KWUA==
|
integrity sha512-DL3iBfavn8Nnl48FmnAreQB0k0cIkW531DJ5JAHUCQZo10Nq0ZLk2/WFxcs0KuBG5wuLnGUdo+Y6/GQPVq8dYw==
|
||||||
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.0"
|
||||||
|
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.0":
|
||||||
version "0.5.0"
|
version "0.6.0"
|
||||||
resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.5.0.tgz#dacbfd8f7b13f0ab5bd56f8fdd4b460e132a6032"
|
resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.6.0.tgz#668c3262e67e2afa65951ea79a03bfe3720ddf5c"
|
||||||
integrity sha512-swu+wyOLvYW4l3n+VAuJbHcPcES+tin2Lsrp8Bw5aIXIICiuFn1YMFlwK9JwVUzTH21Py1s1nHEjr4CJeElJog==
|
integrity sha512-5BbhBTv5j6MC3iIQ4+vYxQE7nLy2dDGQ+LYJrH8PptOCUdq0Pwg6aRccQ3y52kUZlhE/mzOTZ8Ngiy9pSAyfVQ==
|
||||||
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==
|
||||||
@@ -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==
|
||||||
|
|||||||
@@ -67,6 +67,13 @@ func run(args []string) {
|
|||||||
Required: false,
|
Required: false,
|
||||||
EnvVars: []string{"DEBUG"},
|
EnvVars: []string{"DEBUG"},
|
||||||
},
|
},
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "basic-auth-password",
|
||||||
|
Usage: "optional password to restrict access to web interface",
|
||||||
|
Required: false,
|
||||||
|
Value: "",
|
||||||
|
EnvVars: []string{"BASIC_AUTH_PASSWORD"},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/subtle"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
@@ -48,6 +49,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")
|
||||||
|
basicAuthPassword := cctx.String("basic-auth-password")
|
||||||
|
|
||||||
// Echo
|
// Echo
|
||||||
e := echo.New()
|
e := echo.New()
|
||||||
@@ -140,6 +142,18 @@ func serve(cctx *cli.Context) error {
|
|||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
// optional password gating of entire web interface
|
||||||
|
if basicAuthPassword != "" {
|
||||||
|
e.Use(middleware.BasicAuth(func(username, password string, c echo.Context) (bool, error) {
|
||||||
|
// Be careful to use constant time comparison to prevent timing attacks
|
||||||
|
if subtle.ConstantTimeCompare([]byte(username), []byte("admin")) == 1 &&
|
||||||
|
subtle.ConstantTimeCompare([]byte(password), []byte(basicAuthPassword)) == 1 {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
// redirect trailing slash to non-trailing slash.
|
// redirect trailing slash to non-trailing slash.
|
||||||
// all of our current endpoints have no trailing slash.
|
// all of our current endpoints have no trailing slash.
|
||||||
e.Use(middleware.RemoveTrailingSlashWithConfig(middleware.TrailingSlashConfig{
|
e.Use(middleware.RemoveTrailingSlashWithConfig(middleware.TrailingSlashConfig{
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -95,3 +95,16 @@ jest.mock('expo-application', () => ({
|
|||||||
nativeApplicationVersion: '1.0.0',
|
nativeApplicationVersion: '1.0.0',
|
||||||
nativeBuildVersion: '1',
|
nativeBuildVersion: '1',
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
jest.mock('expo-modules-core', () => ({
|
||||||
|
requireNativeModule: jest.fn().mockImplementation(moduleName => {
|
||||||
|
if (moduleName === 'ExpoPlatformInfo') {
|
||||||
|
return {
|
||||||
|
getIsReducedMotionEnabled: () => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
requireNativeViewManager: jest.fn().mockImplementation(moduleName => {
|
||||||
|
return () => null
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|||||||
@@ -156,7 +156,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 +332,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},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package expo.modules.blueskyswissarmy.platforminfo
|
||||||
|
|
||||||
|
import android.provider.Settings
|
||||||
|
import expo.modules.kotlin.modules.Module
|
||||||
|
import expo.modules.kotlin.modules.ModuleDefinition
|
||||||
|
|
||||||
|
class ExpoPlatformInfoModule : Module() {
|
||||||
|
override fun definition() =
|
||||||
|
ModuleDefinition {
|
||||||
|
Name("ExpoPlatformInfo")
|
||||||
|
|
||||||
|
// See https://github.com/software-mansion/react-native-reanimated/blob/7df5fd57d608fe25724608835461cd925ff5151d/packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/nativeProxy/NativeProxyCommon.java#L242
|
||||||
|
Function("getIsReducedMotionEnabled") {
|
||||||
|
val resolver = appContext.reactContext?.contentResolver ?: return@Function false
|
||||||
|
val scale = Settings.Global.getString(resolver, Settings.Global.TRANSITION_ANIMATION_SCALE) ?: return@Function false
|
||||||
|
|
||||||
|
try {
|
||||||
|
return@Function scale.toFloat() == 0f
|
||||||
|
} catch (_: Error) {
|
||||||
|
return@Function false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package expo.modules.blueskyswissarmy.visibilityview
|
||||||
|
|
||||||
|
import expo.modules.kotlin.modules.Module
|
||||||
|
import expo.modules.kotlin.modules.ModuleDefinition
|
||||||
|
|
||||||
|
class ExpoBlueskyVisibilityViewModule : Module() {
|
||||||
|
override fun definition() =
|
||||||
|
ModuleDefinition {
|
||||||
|
Name("ExpoBlueskyVisibilityView")
|
||||||
|
|
||||||
|
AsyncFunction("updateActiveViewAsync") {
|
||||||
|
VisibilityViewManager.updateActiveView()
|
||||||
|
}
|
||||||
|
|
||||||
|
View(VisibilityView::class) {
|
||||||
|
Events(arrayOf("onChangeStatus"))
|
||||||
|
|
||||||
|
Prop("enabled") { view: VisibilityView, prop: Boolean ->
|
||||||
|
view.isViewEnabled = prop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package expo.modules.blueskyswissarmy.visibilityview
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Rect
|
||||||
|
import expo.modules.kotlin.AppContext
|
||||||
|
import expo.modules.kotlin.viewevent.EventDispatcher
|
||||||
|
import expo.modules.kotlin.views.ExpoView
|
||||||
|
|
||||||
|
class VisibilityView(
|
||||||
|
context: Context,
|
||||||
|
appContext: AppContext,
|
||||||
|
) : ExpoView(context, appContext) {
|
||||||
|
var isViewEnabled: Boolean = false
|
||||||
|
|
||||||
|
private val onChangeStatus by EventDispatcher()
|
||||||
|
|
||||||
|
private var isCurrentlyActive = false
|
||||||
|
|
||||||
|
override fun onAttachedToWindow() {
|
||||||
|
super.onAttachedToWindow()
|
||||||
|
VisibilityViewManager.addView(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDetachedFromWindow() {
|
||||||
|
super.onDetachedFromWindow()
|
||||||
|
VisibilityViewManager.removeView(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setIsCurrentlyActive(isActive: Boolean) {
|
||||||
|
if (isCurrentlyActive == isActive) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isCurrentlyActive = isActive
|
||||||
|
this.onChangeStatus(
|
||||||
|
mapOf(
|
||||||
|
"isActive" to isActive,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getPositionOnScreen(): Rect? {
|
||||||
|
if (!this.isShown) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
val screenPosition = intArrayOf(0, 0)
|
||||||
|
this.getLocationInWindow(screenPosition)
|
||||||
|
return Rect(
|
||||||
|
screenPosition[0],
|
||||||
|
screenPosition[1],
|
||||||
|
screenPosition[0] + this.width,
|
||||||
|
screenPosition[1] + this.height,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isViewableEnough(): Boolean {
|
||||||
|
val positionOnScreen = this.getPositionOnScreen() ?: return false
|
||||||
|
val visibleArea = positionOnScreen.width() * positionOnScreen.height()
|
||||||
|
val totalArea = this.width * this.height
|
||||||
|
return visibleArea >= 0.5 * totalArea
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package expo.modules.blueskyswissarmy.visibilityview
|
||||||
|
|
||||||
|
import android.graphics.Rect
|
||||||
|
|
||||||
|
class VisibilityViewManager {
|
||||||
|
companion object {
|
||||||
|
private val views = HashMap<Int, VisibilityView>()
|
||||||
|
private var currentlyActiveView: VisibilityView? = null
|
||||||
|
private var prevCount = 0
|
||||||
|
|
||||||
|
fun addView(view: VisibilityView) {
|
||||||
|
this.views[view.id] = view
|
||||||
|
|
||||||
|
if (this.prevCount == 0) {
|
||||||
|
this.updateActiveView()
|
||||||
|
}
|
||||||
|
this.prevCount = this.views.count()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeView(view: VisibilityView) {
|
||||||
|
this.views.remove(view.id)
|
||||||
|
this.prevCount = this.views.count()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateActiveView() {
|
||||||
|
var activeView: VisibilityView? = null
|
||||||
|
val count = this.views.count()
|
||||||
|
|
||||||
|
if (count == 1) {
|
||||||
|
val view = this.views.values.first()
|
||||||
|
if (view.isViewableEnough()) {
|
||||||
|
activeView = view
|
||||||
|
}
|
||||||
|
} else if (count > 1) {
|
||||||
|
val views = this.views.values
|
||||||
|
var mostVisibleView: VisibilityView? = null
|
||||||
|
var mostVisiblePosition: Rect? = null
|
||||||
|
|
||||||
|
views.forEach { view ->
|
||||||
|
if (!view.isViewableEnough()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val position = view.getPositionOnScreen() ?: return@forEach
|
||||||
|
val topY = position.centerY() - (position.height() / 2)
|
||||||
|
|
||||||
|
if (topY >= 150) {
|
||||||
|
if (mostVisiblePosition == null) {
|
||||||
|
mostVisiblePosition = position
|
||||||
|
}
|
||||||
|
|
||||||
|
if (position.centerY() <= mostVisiblePosition!!.centerY()) {
|
||||||
|
mostVisibleView = view
|
||||||
|
mostVisiblePosition = position
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
activeView = mostVisibleView
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeView == this.currentlyActiveView) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.clearActiveView()
|
||||||
|
if (activeView != null) {
|
||||||
|
this.setActiveView(activeView)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun clearActiveView() {
|
||||||
|
this.currentlyActiveView?.setIsCurrentlyActive(false)
|
||||||
|
this.currentlyActiveView = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setActiveView(view: VisibilityView) {
|
||||||
|
view.setIsCurrentlyActive(true)
|
||||||
|
this.currentlyActiveView = view
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,19 @@
|
|||||||
{
|
{
|
||||||
"platforms": ["ios", "tvos", "android", "web"],
|
"platforms": ["ios", "tvos", "android", "web"],
|
||||||
"ios": {
|
"ios": {
|
||||||
"modules": ["ExpoBlueskySharedPrefsModule", "ExpoBlueskyReferrerModule"]
|
"modules": [
|
||||||
|
"ExpoBlueskySharedPrefsModule",
|
||||||
|
"ExpoBlueskyReferrerModule",
|
||||||
|
"ExpoBlueskyVisibilityViewModule",
|
||||||
|
"ExpoPlatformInfoModule"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"modules": [
|
"modules": [
|
||||||
"expo.modules.blueskyswissarmy.sharedprefs.ExpoBlueskySharedPrefsModule",
|
"expo.modules.blueskyswissarmy.sharedprefs.ExpoBlueskySharedPrefsModule",
|
||||||
"expo.modules.blueskyswissarmy.referrer.ExpoBlueskyReferrerModule"
|
"expo.modules.blueskyswissarmy.referrer.ExpoBlueskyReferrerModule",
|
||||||
|
"expo.modules.blueskyswissarmy.visibilityview.ExpoBlueskyVisibilityViewModule",
|
||||||
|
"expo.modules.blueskyswissarmy.platforminfo.ExpoPlatformInfoModule"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
|
import * as PlatformInfo from './src/PlatformInfo'
|
||||||
|
import {AudioCategory} from './src/PlatformInfo/types'
|
||||||
import * as Referrer from './src/Referrer'
|
import * as Referrer from './src/Referrer'
|
||||||
import * as SharedPrefs from './src/SharedPrefs'
|
import * as SharedPrefs from './src/SharedPrefs'
|
||||||
|
import VisibilityView from './src/VisibilityView'
|
||||||
|
|
||||||
export {Referrer, SharedPrefs}
|
export {AudioCategory, PlatformInfo, Referrer, SharedPrefs, VisibilityView}
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import ExpoModulesCore
|
||||||
|
|
||||||
|
public class ExpoPlatformInfoModule: Module {
|
||||||
|
private var prevAudioActive: Bool?
|
||||||
|
private var prevAudioCategory: AVAudioSession.Category?
|
||||||
|
|
||||||
|
public func definition() -> ModuleDefinition {
|
||||||
|
Name("ExpoPlatformInfo")
|
||||||
|
|
||||||
|
Function("getIsReducedMotionEnabled") {
|
||||||
|
return UIAccessibility.isReduceMotionEnabled
|
||||||
|
}
|
||||||
|
|
||||||
|
Function("setAudioCategory") { (audioCategoryString: String) in
|
||||||
|
let audioCategory = AVAudioSession.Category(rawValue: audioCategoryString)
|
||||||
|
if audioCategory == self.prevAudioCategory {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.prevAudioCategory = audioCategory
|
||||||
|
DispatchQueue.global(qos: .background).async {
|
||||||
|
try? AVAudioSession.sharedInstance().setCategory(audioCategory)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Function("setAudioActive") { (active: Bool) in
|
||||||
|
if active == self.prevAudioActive {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.prevAudioActive = active
|
||||||
|
if active {
|
||||||
|
DispatchQueue.global(qos: .background).async {
|
||||||
|
try? AVAudioSession.sharedInstance().setActive(true)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
DispatchQueue.global(qos: .background).async {
|
||||||
|
try? AVAudioSession
|
||||||
|
.sharedInstance()
|
||||||
|
.setActive(
|
||||||
|
false,
|
||||||
|
options: [.notifyOthersOnDeactivation]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import ExpoModulesCore
|
||||||
|
|
||||||
|
public class ExpoBlueskyVisibilityViewModule: Module {
|
||||||
|
public func definition() -> ModuleDefinition {
|
||||||
|
Name("ExpoBlueskyVisibilityView")
|
||||||
|
|
||||||
|
AsyncFunction("updateActiveViewAsync") {
|
||||||
|
VisibilityViewManager.shared.updateActiveView()
|
||||||
|
}
|
||||||
|
|
||||||
|
View(VisibilityView.self) {
|
||||||
|
Events([
|
||||||
|
"onChangeStatus"
|
||||||
|
])
|
||||||
|
|
||||||
|
Prop("enabled") { (view: VisibilityView, prop: Bool) in
|
||||||
|
view.enabled = prop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
class VisibilityViewManager {
|
||||||
|
static let shared = VisibilityViewManager()
|
||||||
|
|
||||||
|
private let views = NSHashTable<VisibilityView>(options: .weakMemory)
|
||||||
|
private var currentlyActiveView: VisibilityView?
|
||||||
|
private var screenHeight: CGFloat = UIScreen.main.bounds.height
|
||||||
|
private var prevCount = 0
|
||||||
|
|
||||||
|
func addView(_ view: VisibilityView) {
|
||||||
|
self.views.add(view)
|
||||||
|
|
||||||
|
if self.prevCount == 0 {
|
||||||
|
self.updateActiveView()
|
||||||
|
}
|
||||||
|
self.prevCount = self.views.count
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeView(_ view: VisibilityView) {
|
||||||
|
self.views.remove(view)
|
||||||
|
self.prevCount = self.views.count
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateActiveView() {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
var activeView: VisibilityView?
|
||||||
|
|
||||||
|
if self.views.count == 1 {
|
||||||
|
let view = self.views.allObjects[0]
|
||||||
|
if view.isViewableEnough() {
|
||||||
|
activeView = view
|
||||||
|
}
|
||||||
|
} else if self.views.count > 1 {
|
||||||
|
let views = self.views.allObjects
|
||||||
|
var mostVisibleView: VisibilityView?
|
||||||
|
var mostVisiblePosition: CGRect?
|
||||||
|
|
||||||
|
views.forEach { view in
|
||||||
|
if !view.isViewableEnough() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let position = view.getPositionOnScreen() else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if position.minY >= 150 {
|
||||||
|
if mostVisiblePosition == nil {
|
||||||
|
mostVisiblePosition = position
|
||||||
|
}
|
||||||
|
|
||||||
|
if let unwrapped = mostVisiblePosition,
|
||||||
|
position.minY <= unwrapped.minY {
|
||||||
|
mostVisibleView = view
|
||||||
|
mostVisiblePosition = position
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
activeView = mostVisibleView
|
||||||
|
}
|
||||||
|
|
||||||
|
if activeView == self.currentlyActiveView {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
self.clearActiveView()
|
||||||
|
if let view = activeView {
|
||||||
|
self.setActiveView(view)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func clearActiveView() {
|
||||||
|
if let currentlyActiveView = self.currentlyActiveView {
|
||||||
|
currentlyActiveView.setIsCurrentlyActive(isActive: false)
|
||||||
|
self.currentlyActiveView = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setActiveView(_ view: VisibilityView) {
|
||||||
|
view.setIsCurrentlyActive(isActive: true)
|
||||||
|
self.currentlyActiveView = view
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import ExpoModulesCore
|
||||||
|
|
||||||
|
class VisibilityView: ExpoView {
|
||||||
|
var enabled = false {
|
||||||
|
didSet {
|
||||||
|
if enabled {
|
||||||
|
VisibilityViewManager.shared.removeView(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private let onChangeStatus = EventDispatcher()
|
||||||
|
private var isCurrentlyActiveView = false
|
||||||
|
|
||||||
|
required init(appContext: AppContext? = nil) {
|
||||||
|
super.init(appContext: appContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
public override func willMove(toWindow newWindow: UIWindow?) {
|
||||||
|
super.willMove(toWindow: newWindow)
|
||||||
|
|
||||||
|
if !self.enabled {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if newWindow == nil {
|
||||||
|
VisibilityViewManager.shared.removeView(self)
|
||||||
|
} else {
|
||||||
|
VisibilityViewManager.shared.addView(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setIsCurrentlyActive(isActive: Bool) {
|
||||||
|
if isCurrentlyActiveView == isActive {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.isCurrentlyActiveView = isActive
|
||||||
|
self.onChangeStatus([
|
||||||
|
"isActive": isActive
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🚨 DANGER 🚨
|
||||||
|
// These functions need to be called from the main thread. Xcode will warn you if you call one of them
|
||||||
|
// off the main thread, so pay attention!
|
||||||
|
extension UIView {
|
||||||
|
func getPositionOnScreen() -> CGRect? {
|
||||||
|
if let window = self.window {
|
||||||
|
return self.convert(self.bounds, to: window)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isViewableEnough() -> Bool {
|
||||||
|
guard let window = self.window else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
let viewFrameOnScreen = self.convert(self.bounds, to: window)
|
||||||
|
let screenBounds = window.bounds
|
||||||
|
let intersection = viewFrameOnScreen.intersection(screenBounds)
|
||||||
|
|
||||||
|
let viewHeight = viewFrameOnScreen.height
|
||||||
|
let intersectionHeight = intersection.height
|
||||||
|
|
||||||
|
return intersectionHeight >= 0.5 * viewHeight
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import {Platform} from 'react-native'
|
||||||
|
import {requireNativeModule} from 'expo-modules-core'
|
||||||
|
|
||||||
|
import {AudioCategory} from './types'
|
||||||
|
|
||||||
|
const NativeModule = requireNativeModule('ExpoPlatformInfo')
|
||||||
|
|
||||||
|
export function getIsReducedMotionEnabled(): boolean {
|
||||||
|
return NativeModule.getIsReducedMotionEnabled()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setAudioActive(active: boolean): void {
|
||||||
|
if (Platform.OS !== 'ios') return
|
||||||
|
NativeModule.setAudioActive(active)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setAudioCategory(audioCategory: AudioCategory): void {
|
||||||
|
if (Platform.OS !== 'ios') return
|
||||||
|
NativeModule.setAudioCategory(audioCategory)
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import {NotImplementedError} from '../NotImplemented'
|
||||||
|
import {AudioCategory} from './types'
|
||||||
|
|
||||||
|
export function getIsReducedMotionEnabled(): boolean {
|
||||||
|
throw new NotImplementedError()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set whether the app's audio should mix with other apps' audio. Will also resume background music playback when `false`
|
||||||
|
* if it was previously playing.
|
||||||
|
* @param mixWithOthers
|
||||||
|
* @see https://developer.apple.com/documentation/avfaudio/avaudiosession/setactiveoptions/1616603-notifyothersondeactivation
|
||||||
|
*/
|
||||||
|
export function setAudioActive(active: boolean): void {
|
||||||
|
throw new NotImplementedError({active})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the audio category for the app.
|
||||||
|
* @param audioCategory
|
||||||
|
* @platform ios
|
||||||
|
*/
|
||||||
|
export function setAudioCategory(audioCategory: AudioCategory): void {
|
||||||
|
throw new NotImplementedError({audioCategory})
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import {NotImplementedError} from '../NotImplemented'
|
||||||
|
import {AudioCategory} from './types'
|
||||||
|
|
||||||
|
export function getIsReducedMotionEnabled(): boolean {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setAudioActive(active: boolean): void {
|
||||||
|
throw new NotImplementedError({active})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setAudioCategory(audioCategory: AudioCategory): void {
|
||||||
|
throw new NotImplementedError({audioCategory})
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* Sets the audio session category on iOS. In general, we should only need to use this for the `playback` and `ambient`
|
||||||
|
* categories. This enum however includes other categories that are available in the native API for clarity and
|
||||||
|
* potential future use.
|
||||||
|
* @see https://developer.apple.com/documentation/avfoundation/avaudiosession/category
|
||||||
|
* @platform ios
|
||||||
|
*/
|
||||||
|
export enum AudioCategory {
|
||||||
|
Ambient = 'AVAudioSessionCategoryAmbient',
|
||||||
|
Playback = 'AVAudioSessionCategoryPlayback',
|
||||||
|
_SoloAmbient = 'AVAudioSessionCategorySoloAmbient',
|
||||||
|
_Record = 'AVAudioSessionCategoryRecord',
|
||||||
|
_PlayAndRecord = 'AVAudioSessionCategoryPlayAndRecord',
|
||||||
|
_MultiRoute = 'AVAudioSessionCategoryMultiRoute',
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import {StyleProp, ViewStyle} from 'react-native'
|
||||||
|
import {requireNativeModule, requireNativeViewManager} from 'expo-modules-core'
|
||||||
|
|
||||||
|
import {VisibilityViewProps} from './types'
|
||||||
|
const NativeView: React.ComponentType<{
|
||||||
|
onChangeStatus: (e: {nativeEvent: {isActive: boolean}}) => void
|
||||||
|
children: React.ReactNode
|
||||||
|
enabled: Boolean
|
||||||
|
style: StyleProp<ViewStyle>
|
||||||
|
}> = requireNativeViewManager('ExpoBlueskyVisibilityView')
|
||||||
|
|
||||||
|
const NativeModule = requireNativeModule('ExpoBlueskyVisibilityView')
|
||||||
|
|
||||||
|
export async function updateActiveViewAsync() {
|
||||||
|
await NativeModule.updateActiveViewAsync()
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VisibilityView({
|
||||||
|
children,
|
||||||
|
onChangeStatus: onChangeStatusOuter,
|
||||||
|
enabled,
|
||||||
|
}: VisibilityViewProps) {
|
||||||
|
const onChangeStatus = React.useCallback(
|
||||||
|
(e: {nativeEvent: {isActive: boolean}}) => {
|
||||||
|
onChangeStatusOuter(e.nativeEvent.isActive)
|
||||||
|
},
|
||||||
|
[onChangeStatusOuter],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NativeView
|
||||||
|
onChangeStatus={onChangeStatus}
|
||||||
|
enabled={enabled}
|
||||||
|
style={{flex: 1}}>
|
||||||
|
{children}
|
||||||
|
</NativeView>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import {NotImplementedError} from '../NotImplemented'
|
||||||
|
import {VisibilityViewProps} from './types'
|
||||||
|
|
||||||
|
export async function updateActiveViewAsync() {
|
||||||
|
throw new NotImplementedError()
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VisibilityView({children}: VisibilityViewProps) {
|
||||||
|
return children
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import React from 'react'
|
||||||
|
export interface VisibilityViewProps {
|
||||||
|
children: React.ReactNode
|
||||||
|
onChangeStatus: (isActive: boolean) => void
|
||||||
|
enabled: boolean
|
||||||
|
}
|
||||||
@@ -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.26",
|
"@atproto/api": "0.13.0",
|
||||||
"@bam.tech/react-native-image-resizer": "^3.0.4",
|
"@bam.tech/react-native-image-resizer": "^3.0.4",
|
||||||
"@braintree/sanitize-url": "^6.0.2",
|
"@braintree/sanitize-url": "^6.0.2",
|
||||||
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
|
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
|
||||||
@@ -139,7 +139,7 @@
|
|||||||
"expo-system-ui": "~3.0.4",
|
"expo-system-ui": "~3.0.4",
|
||||||
"expo-task-manager": "~11.8.1",
|
"expo-task-manager": "~11.8.1",
|
||||||
"expo-updates": "~0.25.14",
|
"expo-updates": "~0.25.14",
|
||||||
"expo-video": "^1.1.10",
|
"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",
|
||||||
"history": "^5.3.0",
|
"history": "^5.3.0",
|
||||||
@@ -208,7 +208,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];
|
|
||||||
@@ -13,7 +13,7 @@ index bb74e80..0aa0202 100644
|
|||||||
Map<String, Object> constants = new HashMap<>(3);
|
Map<String, Object> constants = new HashMap<>(3);
|
||||||
constants.put(MODULES_CONSTANTS_KEY, new HashMap<>());
|
constants.put(MODULES_CONSTANTS_KEY, new HashMap<>());
|
||||||
diff --git a/node_modules/expo-modules-core/build/uuid/uuid.js b/node_modules/expo-modules-core/build/uuid/uuid.js
|
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 @@
|
||||||
@@ -24,3 +24,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,20 +0,0 @@
|
|||||||
--- a/node_modules/expo-video/android/src/main/java/expo/modules/video/PlayerViewExtension.kt
|
|
||||||
+++ b/node_modules/expo-video/android/src/main/java/expo/modules/video/PlayerViewExtension.kt
|
|
||||||
@@ -11,6 +11,7 @@ internal fun PlayerView.applyRequiresLinearPlayback(requireLinearPlayback: Boole
|
|
||||||
setShowPreviousButton(!requireLinearPlayback)
|
|
||||||
setShowNextButton(!requireLinearPlayback)
|
|
||||||
setTimeBarInteractive(requireLinearPlayback)
|
|
||||||
+ setShowSubtitleButton(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
|
|
||||||
@@ -27,7 +28,8 @@ internal fun PlayerView.setTimeBarInteractive(interactive: Boolean) {
|
|
||||||
|
|
||||||
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
|
|
||||||
internal fun PlayerView.setFullscreenButtonVisibility(visible: Boolean) {
|
|
||||||
- val fullscreenButton = findViewById<android.widget.ImageButton>(androidx.media3.ui.R.id.exo_fullscreen)
|
|
||||||
+ val fullscreenButton =
|
|
||||||
+ findViewById<android.widget.ImageButton>(androidx.media3.ui.R.id.exo_fullscreen)
|
|
||||||
fullscreenButton?.visibility = if (visible) {
|
|
||||||
android.view.View.VISIBLE
|
|
||||||
} else {
|
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
## 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.
|
||||||
@@ -207,31 +207,3 @@ index 88b3fdf..2488ebc 100644
|
|||||||
|
|
||||||
const { layout, entering, exiting, sharedTransitionTag } = this.props;
|
const { layout, entering, exiting, sharedTransitionTag } = this.props;
|
||||||
if (
|
if (
|
||||||
diff --git a/node_modules/react-native-reanimated/lib/module/reanimated2/index.js b/node_modules/react-native-reanimated/lib/module/reanimated2/index.js
|
|
||||||
index ac9be5d..86d4605 100644
|
|
||||||
--- a/node_modules/react-native-reanimated/lib/module/reanimated2/index.js
|
|
||||||
+++ b/node_modules/react-native-reanimated/lib/module/reanimated2/index.js
|
|
||||||
@@ -47,4 +47,5 @@ export { LayoutAnimationConfig } from './component/LayoutAnimationConfig';
|
|
||||||
export { PerformanceMonitor } from './component/PerformanceMonitor';
|
|
||||||
export { startMapper, stopMapper } from './mappers';
|
|
||||||
export { startScreenTransition, finishScreenTransition, ScreenTransition } from './screenTransition';
|
|
||||||
+export { isReducedMotion } from './PlatformChecker';
|
|
||||||
//# sourceMappingURL=index.js.map
|
|
||||||
diff --git a/node_modules/react-native-reanimated/lib/typescript/reanimated2/index.d.ts b/node_modules/react-native-reanimated/lib/typescript/reanimated2/index.d.ts
|
|
||||||
index f01dc57..161ef22 100644
|
|
||||||
--- a/node_modules/react-native-reanimated/lib/typescript/reanimated2/index.d.ts
|
|
||||||
+++ b/node_modules/react-native-reanimated/lib/typescript/reanimated2/index.d.ts
|
|
||||||
@@ -36,3 +36,4 @@ export type { FlatListPropsWithLayout } from './component/FlatList';
|
|
||||||
export { startMapper, stopMapper } from './mappers';
|
|
||||||
export { startScreenTransition, finishScreenTransition, ScreenTransition, } from './screenTransition';
|
|
||||||
export type { AnimatedScreenTransition, GoBackGesture, ScreenTransitionConfig, } from './screenTransition';
|
|
||||||
+export { isReducedMotion } from './PlatformChecker';
|
|
||||||
diff --git a/node_modules/react-native-reanimated/src/reanimated2/index.ts b/node_modules/react-native-reanimated/src/reanimated2/index.ts
|
|
||||||
index 5885fa1..a3c693f 100644
|
|
||||||
--- a/node_modules/react-native-reanimated/src/reanimated2/index.ts
|
|
||||||
+++ b/node_modules/react-native-reanimated/src/reanimated2/index.ts
|
|
||||||
@@ -284,3 +284,4 @@ export type {
|
|
||||||
GoBackGesture,
|
|
||||||
ScreenTransitionConfig,
|
|
||||||
} from './screenTransition';
|
|
||||||
+export { isReducedMotion } from './PlatformChecker';
|
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ import {Provider as PortalProvider} from '#/components/Portal'
|
|||||||
import {Splash} from '#/Splash'
|
import {Splash} from '#/Splash'
|
||||||
import {Provider as TourProvider} from '#/tours'
|
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'
|
||||||
|
|
||||||
SplashScreen.preventAutoHideAsync()
|
SplashScreen.preventAutoHideAsync()
|
||||||
|
|
||||||
@@ -157,6 +158,8 @@ function App() {
|
|||||||
const [isReady, setReady] = useState(false)
|
const [isReady, setReady] = useState(false)
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
PlatformInfo.setAudioCategory(AudioCategory.Ambient)
|
||||||
|
PlatformInfo.setAudioActive(false)
|
||||||
initPersistedState().then(() => setReady(true))
|
initPersistedState().then(() => setReady(true))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
|||||||
@@ -186,19 +186,19 @@ export function createThemes({
|
|||||||
white: color.gray_0,
|
white: color.gray_0,
|
||||||
black: color.trueBlack,
|
black: color.trueBlack,
|
||||||
|
|
||||||
contrast_25: color.gray_1000,
|
contrast_25: color.gray_975,
|
||||||
contrast_50: color.gray_975,
|
contrast_50: color.gray_950,
|
||||||
contrast_100: color.gray_950,
|
contrast_100: color.gray_900,
|
||||||
contrast_200: color.gray_900,
|
contrast_200: color.gray_800,
|
||||||
contrast_300: color.gray_800,
|
contrast_300: color.gray_700,
|
||||||
contrast_400: color.gray_700,
|
contrast_400: color.gray_600,
|
||||||
contrast_500: color.gray_600,
|
contrast_500: color.gray_500,
|
||||||
contrast_600: color.gray_500,
|
contrast_600: color.gray_400,
|
||||||
contrast_700: color.gray_400,
|
contrast_700: color.gray_300,
|
||||||
contrast_800: color.gray_300,
|
contrast_800: color.gray_200,
|
||||||
contrast_900: color.gray_200,
|
contrast_900: color.gray_100,
|
||||||
contrast_950: color.gray_100,
|
contrast_950: color.gray_50,
|
||||||
contrast_975: color.gray_50,
|
contrast_975: color.gray_25,
|
||||||
|
|
||||||
primary_25: color.primary_975,
|
primary_25: color.primary_975,
|
||||||
primary_50: color.primary_950,
|
primary_50: color.primary_950,
|
||||||
@@ -400,7 +400,7 @@ export function createThemes({
|
|||||||
color: darkPalette.contrast_400,
|
color: darkPalette.contrast_400,
|
||||||
},
|
},
|
||||||
text_contrast_medium: {
|
text_contrast_medium: {
|
||||||
color: darkPalette.contrast_700,
|
color: darkPalette.contrast_600,
|
||||||
},
|
},
|
||||||
text_contrast_high: {
|
text_contrast_high: {
|
||||||
color: darkPalette.contrast_900,
|
color: darkPalette.contrast_900,
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -202,28 +232,10 @@ export const Button = React.forwardRef<View, ButtonProps>(
|
|||||||
} else if (color === 'secondary') {
|
} else if (color === 'secondary') {
|
||||||
if (variant === 'solid') {
|
if (variant === 'solid') {
|
||||||
if (!disabled) {
|
if (!disabled) {
|
||||||
baseStyles.push({
|
baseStyles.push(t.atoms.bg_contrast_25)
|
||||||
backgroundColor: select(t.name, {
|
hoverStyles.push(t.atoms.bg_contrast_50)
|
||||||
light: t.palette.contrast_25,
|
|
||||||
dim: t.palette.contrast_100,
|
|
||||||
dark: t.palette.contrast_100,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
hoverStyles.push({
|
|
||||||
backgroundColor: select(t.name, {
|
|
||||||
light: t.palette.contrast_50,
|
|
||||||
dim: t.palette.contrast_200,
|
|
||||||
dark: t.palette.contrast_200,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
} else {
|
} else {
|
||||||
baseStyles.push({
|
baseStyles.push(t.atoms.bg_contrast_100)
|
||||||
backgroundColor: select(t.name, {
|
|
||||||
light: t.palette.contrast_100,
|
|
||||||
dim: t.palette.contrast_25,
|
|
||||||
dark: t.palette.contrast_25,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
} else if (variant === 'outline') {
|
} else if (variant === 'outline') {
|
||||||
baseStyles.push(a.border, t.atoms.bg, {
|
baseStyles.push(a.border, t.atoms.bg, {
|
||||||
|
|||||||
@@ -92,14 +92,16 @@ function getRank(seenPost: SeenPost): string {
|
|||||||
tier = 'a'
|
tier = 'a'
|
||||||
} else if (seenPost.feedContext?.startsWith('cluster')) {
|
} else if (seenPost.feedContext?.startsWith('cluster')) {
|
||||||
tier = 'b'
|
tier = 'b'
|
||||||
} else if (seenPost.feedContext?.startsWith('ntpc')) {
|
} else if (seenPost.feedContext === 'popcluster') {
|
||||||
tier = 'c'
|
tier = 'c'
|
||||||
} else if (seenPost.feedContext?.startsWith('t-')) {
|
} else if (seenPost.feedContext?.startsWith('ntpc')) {
|
||||||
tier = 'd'
|
tier = 'd'
|
||||||
} else if (seenPost.feedContext === 'nettop') {
|
} else if (seenPost.feedContext?.startsWith('t-')) {
|
||||||
tier = 'e'
|
tier = 'e'
|
||||||
} else {
|
} else if (seenPost.feedContext === 'nettop') {
|
||||||
tier = 'f'
|
tier = 'f'
|
||||||
|
} else {
|
||||||
|
tier = 'g'
|
||||||
}
|
}
|
||||||
let score = Math.round(
|
let score = Math.round(
|
||||||
Math.log(
|
Math.log(
|
||||||
@@ -131,16 +133,26 @@ function useExperimentalSuggestedUsersQuery() {
|
|||||||
const {currentAccount} = useSession()
|
const {currentAccount} = useSession()
|
||||||
const userActionSnapshot = userActionHistory.useActionHistorySnapshot()
|
const userActionSnapshot = userActionHistory.useActionHistorySnapshot()
|
||||||
const dids = React.useMemo(() => {
|
const dids = React.useMemo(() => {
|
||||||
const {likes, follows, seen} = userActionSnapshot
|
const {likes, follows, followSuggestions, seen} = userActionSnapshot
|
||||||
const likeDids = likes
|
const likeDids = likes
|
||||||
.map(l => new AtUri(l))
|
.map(l => new AtUri(l))
|
||||||
.map(uri => uri.host)
|
.map(uri => uri.host)
|
||||||
.filter(did => !follows.includes(did))
|
.filter(did => !follows.includes(did))
|
||||||
|
let suggestedDids: string[] = []
|
||||||
|
if (followSuggestions.length > 0) {
|
||||||
|
suggestedDids = [
|
||||||
|
// It's ok if these will pick the same item (weighed by its frequency)
|
||||||
|
followSuggestions[Math.floor(Math.random() * followSuggestions.length)],
|
||||||
|
followSuggestions[Math.floor(Math.random() * followSuggestions.length)],
|
||||||
|
followSuggestions[Math.floor(Math.random() * followSuggestions.length)],
|
||||||
|
followSuggestions[Math.floor(Math.random() * followSuggestions.length)],
|
||||||
|
]
|
||||||
|
}
|
||||||
const seenDids = seen
|
const seenDids = seen
|
||||||
.sort(sortSeenPosts)
|
.sort(sortSeenPosts)
|
||||||
.map(l => new AtUri(l.uri))
|
.map(l => new AtUri(l.uri))
|
||||||
.map(uri => uri.host)
|
.map(uri => uri.host)
|
||||||
return [...new Set([...likeDids, ...seenDids])].filter(
|
return [...new Set([...suggestedDids, ...likeDids, ...seenDids])].filter(
|
||||||
did => did !== currentAccount?.did,
|
did => did !== currentAccount?.did,
|
||||||
)
|
)
|
||||||
}, [userActionSnapshot, currentAccount])
|
}, [userActionSnapshot, currentAccount])
|
||||||
|
|||||||
@@ -122,8 +122,16 @@ export function ListHeaderDesktop({
|
|||||||
if (!gtTablet) return null
|
if (!gtTablet) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[a.w_full, a.py_lg, a.px_xl, a.gap_xs]}>
|
<View
|
||||||
<Text style={[a.text_3xl, a.font_bold]}>{title}</Text>
|
style={[
|
||||||
|
a.w_full,
|
||||||
|
a.py_sm,
|
||||||
|
a.px_xl,
|
||||||
|
a.gap_xs,
|
||||||
|
a.justify_center,
|
||||||
|
{minHeight: 50},
|
||||||
|
]}>
|
||||||
|
<Text style={[a.text_2xl, a.font_bold]}>{title}</Text>
|
||||||
{subtitle ? (
|
{subtitle ? (
|
||||||
<Text style={[a.text_md, t.atoms.text_contrast_medium]}>
|
<Text style={[a.text_md, t.atoms.text_contrast_medium]}>
|
||||||
{subtitle}
|
{subtitle}
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -249,7 +249,9 @@ function DialogError({details}: {details?: string}) {
|
|||||||
const control = Dialog.useDialogContext()
|
const control = Dialog.useDialogContext()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog.ScrollableInner style={a.gap_md} label={_(msg`An error occured`)}>
|
<Dialog.ScrollableInner
|
||||||
|
style={a.gap_md}
|
||||||
|
label={_(msg`An error has occurred`)}>
|
||||||
<Dialog.Close />
|
<Dialog.Close />
|
||||||
<ErrorScreen
|
<ErrorScreen
|
||||||
title={_(msg`Oh no!`)}
|
title={_(msg`Oh no!`)}
|
||||||
|
|||||||
@@ -193,6 +193,7 @@ export function createInput(Component: typeof TextInput) {
|
|||||||
lineHeight: a.text_md.fontSize * 1.1875,
|
lineHeight: a.text_md.fontSize * 1.1875,
|
||||||
textAlignVertical: rest.multiline ? 'top' : undefined,
|
textAlignVertical: rest.multiline ? 'top' : undefined,
|
||||||
minHeight: rest.multiline ? 80 : undefined,
|
minHeight: rest.multiline ? 80 : undefined,
|
||||||
|
minWidth: 0,
|
||||||
},
|
},
|
||||||
// fix for autofill styles covering border
|
// fix for autofill styles covering border
|
||||||
web({
|
web({
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import {createSinglePathSVG} from './TEMPLATE'
|
||||||
|
|
||||||
|
export const ArrowsDiagonalOut_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||||
|
path: 'M14 5a1 1 0 1 1 0-2h6a1 1 0 0 1 1 1v6a1 1 0 1 1-2 0V6.414l-4.293 4.293a1 1 0 0 1-1.414-1.414L17.586 5H14ZM4 13a1 1 0 0 1 1 1v3.586l4.293-4.293a1 1 0 0 1 1.414 1.414L6.414 19H10a1 1 0 1 1 0 2H4a1 1 0 0 1-1-1v-6a1 1 0 0 1 1-1Z',
|
||||||
|
})
|
||||||
|
|
||||||
|
export const ArrowsDiagonalIn_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||||
|
path: 'M20.957 3.043a1 1 0 0 1 0 1.414L16.414 9H20a1 1 0 1 1 0 2h-6a1 1 0 0 1-1-1V4a1 1 0 1 1 2 0v3.586l4.543-4.543a1 1 0 0 1 1.414 0ZM3 14a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v6a1 1 0 1 1-2 0v-3.586l-4.543 4.543a1 1 0 0 1-1.414-1.414L7.586 15H4a1 1 0 0 1-1-1Z',
|
||||||
|
})
|
||||||
|
|
||||||
|
export const ArrowsDiagonalOut_Stroke2_Corner2_Rounded = createSinglePathSVG({
|
||||||
|
path: 'M13 4a1 1 0 0 1 1-1h5a2 2 0 0 1 2 2v5a1 1 0 1 1-2 0V6.414l-4.293 4.293a1 1 0 0 1-1.414-1.414L17.586 5H14a1 1 0 0 1-1-1Zm-9 9a1 1 0 0 1 1 1v3.586l4.293-4.293a1 1 0 0 1 1.414 1.414L6.414 19H10a1 1 0 1 1 0 2H5a2 2 0 0 1-2-2v-5a1 1 0 0 1 1-1Z',
|
||||||
|
})
|
||||||
|
|
||||||
|
export const ArrowsDiagonalIn_Stroke2_Corner2_Rounded = createSinglePathSVG({
|
||||||
|
path: 'M20.957 3.043a1 1 0 0 1 0 1.414L16.414 9H20a1 1 0 1 1 0 2h-5a2 2 0 0 1-2-2V4a1 1 0 1 1 2 0v3.586l4.543-4.543a1 1 0 0 1 1.414 0ZM3 14a1 1 0 0 1 1-1h5a2 2 0 0 1 2 2v5a1 1 0 1 1-2 0v-3.586l-4.543 4.543a1 1 0 0 1-1.414-1.414L7.586 15H4a1 1 0 0 1-1-1Z',
|
||||||
|
})
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import {createSinglePathSVG} from './TEMPLATE'
|
||||||
|
|
||||||
|
export const CC_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||||
|
path: 'M3 4a1 1 0 0 1 1-1h16a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4Zm2 1v14h14V5H5Zm10.957 6.293a1 1 0 1 0 0 1.414 1 1 0 0 1 1.414 1.414 3 3 0 1 1 0-4.242 1 1 0 0 1-1.414 1.414Zm-6.331-.22a1 1 0 1 0 .331 1.634 1 1 0 0 1 1.414 1.414 3 3 0 1 1 0-4.242 1 1 0 0 1-1.414 1.414.994.994 0 0 0-.331-.22Z',
|
||||||
|
})
|
||||||
|
|
||||||
|
export const CC_Filled_Corner0_Rounded = createSinglePathSVG({
|
||||||
|
path: 'M3 4a1 1 0 0 1 1-1h16a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4Zm11.543 7.293a1 1 0 0 1 1.414 0 1 1 0 0 0 1.414-1.414 3 3 0 1 0 0 4.242 1 1 0 0 0-1.414-1.414 1 1 0 0 1-1.414-1.414Zm-6 0a1 1 0 0 1 1.414 0 1 1 0 0 0 1.414-1.414 3 3 0 1 0 0 4.243 1 1 0 0 0-1.414-1.415 1 1 0 0 1-1.414-1.414Z',
|
||||||
|
})
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import {createSinglePathSVG} from './TEMPLATE'
|
||||||
|
|
||||||
|
export const Pause_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||||
|
path: 'M4 4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V4Zm2 1v14h2V5H6Zm8-1a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1h-4a1 1 0 0 1-1-1V4Zm2 1v14h2V5h-2Z',
|
||||||
|
})
|
||||||
|
|
||||||
|
export const Pause_Filled_Corner0_Rounded = createSinglePathSVG({
|
||||||
|
path: 'M4 4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V4ZM14 4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1h-4a1 1 0 0 1-1-1V4Z',
|
||||||
|
})
|
||||||
|
|
||||||
|
export const Pause_Stroke2_Corner2_Rounded = createSinglePathSVG({
|
||||||
|
path: 'M4 6a3 3 0 0 1 6 0v12a3 3 0 1 1-6 0V6Zm3-1a1 1 0 0 0-1 1v12a1 1 0 1 0 2 0V6a1 1 0 0 0-1-1Zm7 1a3 3 0 1 1 6 0v12a3 3 0 1 1-6 0V6Zm3-1a1 1 0 0 0-1 1v12a1 1 0 1 0 2 0V6a1 1 0 0 0-1-1Z',
|
||||||
|
})
|
||||||
|
|
||||||
|
export const Pause_Filled_Corner2_Rounded = createSinglePathSVG({
|
||||||
|
path: 'M4 6a3 3 0 0 1 6 0v12a3 3 0 1 1-6 0V6ZM14 6a3 3 0 1 1 6 0v12a3 3 0 1 1-6 0V6Z',
|
||||||
|
})
|
||||||
@@ -1,5 +1,13 @@
|
|||||||
import {createSinglePathSVG} from './TEMPLATE'
|
import {createSinglePathSVG} from './TEMPLATE'
|
||||||
|
|
||||||
|
export const Play_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||||
|
path: 'M5.507 2.13a1 1 0 0 1 1.008.013l15 9a1 1 0 0 1 0 1.714l-15 9A1 1 0 0 1 5 21V3a1 1 0 0 1 .507-.87ZM7 4.766v14.468L19.056 12 7 4.766Z',
|
||||||
|
})
|
||||||
|
|
||||||
|
export const Play_Filled_Corner0_Rounded = createSinglePathSVG({
|
||||||
|
path: 'M6.514 2.143A1 1 0 0 0 5 3v18a1 1 0 0 0 1.514.858l15-9a1 1 0 0 0 0-1.716l-15-9Z',
|
||||||
|
})
|
||||||
|
|
||||||
export const Play_Stroke2_Corner2_Rounded = createSinglePathSVG({
|
export const Play_Stroke2_Corner2_Rounded = createSinglePathSVG({
|
||||||
path: 'M5 5.086C5 2.736 7.578 1.3 9.576 2.534L20.77 9.448c1.899 1.172 1.899 3.932 0 5.104L9.576 21.466C7.578 22.701 5 21.263 5 18.914V5.086Zm3.525-.85A1 1 0 0 0 7 5.085v13.828a1 1 0 0 0 1.525.85l11.194-6.913a1 1 0 0 0 0-1.702L8.525 4.235Z',
|
path: 'M5 5.086C5 2.736 7.578 1.3 9.576 2.534L20.77 9.448c1.899 1.172 1.899 3.932 0 5.104L9.576 21.466C7.578 22.701 5 21.263 5 18.914V5.086Zm3.525-.85A1 1 0 0 0 7 5.085v13.828a1 1 0 0 0 1.525.85l11.194-6.913a1 1 0 0 0 0-1.702L8.525 4.235Z',
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
|
import {StyleProp, View, ViewStyle} from 'react-native'
|
||||||
import {ModerationUI} from '@atproto/api'
|
import {ModerationUI} 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'
|
||||||
@@ -40,7 +40,7 @@ export function ContentHider({
|
|||||||
|
|
||||||
if (!blur || (ignoreMute && isJustAMute(modui))) {
|
if (!blur || (ignoreMute && isJustAMute(modui))) {
|
||||||
return (
|
return (
|
||||||
<View testID={testID} style={[styles.outer, style]}>
|
<View testID={testID} style={style}>
|
||||||
{children}
|
{children}
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
@@ -163,21 +163,3 @@ export function ContentHider({
|
|||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
outer: {},
|
|
||||||
cover: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 6,
|
|
||||||
borderRadius: 8,
|
|
||||||
marginTop: 4,
|
|
||||||
paddingVertical: 14,
|
|
||||||
paddingLeft: 14,
|
|
||||||
paddingRight: 18,
|
|
||||||
},
|
|
||||||
showBtn: {
|
|
||||||
marginLeft: 'auto',
|
|
||||||
alignSelf: 'center',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import {useMutation} from '@tanstack/react-query'
|
|||||||
|
|
||||||
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'
|
||||||
@@ -204,7 +203,6 @@ function AppealForm({
|
|||||||
const [details, setDetails] = React.useState('')
|
const [details, setDetails] = React.useState('')
|
||||||
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 +212,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 +228,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})
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
AppBskyActorDefs,
|
||||||
AppBskyEmbedRecord,
|
AppBskyEmbedRecord,
|
||||||
AppBskyEmbedRecordWithMedia,
|
AppBskyEmbedRecordWithMedia,
|
||||||
AppBskyFeedDefs,
|
AppBskyFeedDefs,
|
||||||
@@ -6,50 +7,146 @@ import {
|
|||||||
} from '@atproto/api'
|
} from '@atproto/api'
|
||||||
|
|
||||||
import {isPostInLanguage} from '../../locale/helpers'
|
import {isPostInLanguage} from '../../locale/helpers'
|
||||||
|
import {FALLBACK_MARKER_POST} from './feed/home'
|
||||||
import {ReasonFeedSource} from './feed/types'
|
import {ReasonFeedSource} from './feed/types'
|
||||||
|
|
||||||
type FeedViewPost = AppBskyFeedDefs.FeedViewPost
|
type FeedViewPost = AppBskyFeedDefs.FeedViewPost
|
||||||
|
|
||||||
export type FeedTunerFn = (
|
export type FeedTunerFn = (
|
||||||
tuner: FeedTuner,
|
tuner: FeedTuner,
|
||||||
slices: FeedViewPostsSlice[],
|
slices: FeedViewPostsSlice[],
|
||||||
|
dryRun: boolean,
|
||||||
) => FeedViewPostsSlice[]
|
) => FeedViewPostsSlice[]
|
||||||
|
|
||||||
type FeedSliceItem = {
|
type FeedSliceItem = {
|
||||||
post: AppBskyFeedDefs.PostView
|
post: AppBskyFeedDefs.PostView
|
||||||
reply?: AppBskyFeedDefs.ReplyRef
|
record: AppBskyFeedPost.Record
|
||||||
|
parentAuthor: AppBskyActorDefs.ProfileViewBasic | undefined
|
||||||
|
isParentBlocked: boolean
|
||||||
|
isParentNotFound: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function toSliceItem(feedViewPost: FeedViewPost): FeedSliceItem {
|
type AuthorContext = {
|
||||||
return {
|
author: AppBskyActorDefs.ProfileViewBasic
|
||||||
post: feedViewPost.post,
|
parentAuthor: AppBskyActorDefs.ProfileViewBasic | undefined
|
||||||
reply: feedViewPost.reply,
|
grandparentAuthor: AppBskyActorDefs.ProfileViewBasic | undefined
|
||||||
}
|
rootAuthor: AppBskyActorDefs.ProfileViewBasic | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
export class FeedViewPostsSlice {
|
export class FeedViewPostsSlice {
|
||||||
_reactKey: string
|
_reactKey: string
|
||||||
_feedPost: FeedViewPost
|
_feedPost: FeedViewPost
|
||||||
items: FeedSliceItem[]
|
items: FeedSliceItem[]
|
||||||
|
isIncompleteThread: boolean
|
||||||
|
isFallbackMarker: boolean
|
||||||
|
isOrphan: boolean
|
||||||
|
rootUri: string
|
||||||
|
|
||||||
constructor(feedPost: FeedViewPost) {
|
constructor(feedPost: FeedViewPost) {
|
||||||
|
const {post, reply, reason} = feedPost
|
||||||
|
this.items = []
|
||||||
|
this.isIncompleteThread = false
|
||||||
|
this.isFallbackMarker = false
|
||||||
|
this.isOrphan = false
|
||||||
|
if (AppBskyFeedDefs.isPostView(reply?.root)) {
|
||||||
|
this.rootUri = reply.root.uri
|
||||||
|
} else {
|
||||||
|
this.rootUri = post.uri
|
||||||
|
}
|
||||||
this._feedPost = feedPost
|
this._feedPost = feedPost
|
||||||
this._reactKey = `slice-${feedPost.post.uri}-${
|
this._reactKey = `slice-${post.uri}-${
|
||||||
feedPost.reason?.indexedAt || feedPost.post.indexedAt
|
feedPost.reason?.indexedAt || post.indexedAt
|
||||||
}`
|
}`
|
||||||
this.items = [toSliceItem(feedPost)]
|
if (feedPost.post.uri === FALLBACK_MARKER_POST.post.uri) {
|
||||||
|
this.isFallbackMarker = true
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
get uri() {
|
!AppBskyFeedPost.isRecord(post.record) ||
|
||||||
return this._feedPost.post.uri
|
!AppBskyFeedPost.validateRecord(post.record).success
|
||||||
|
) {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
const parent = reply?.parent
|
||||||
get isThread() {
|
const isParentBlocked = AppBskyFeedDefs.isBlockedPost(parent)
|
||||||
return (
|
const isParentNotFound = AppBskyFeedDefs.isNotFoundPost(parent)
|
||||||
this.items.length > 1 &&
|
let parentAuthor: AppBskyActorDefs.ProfileViewBasic | undefined
|
||||||
this.items.every(
|
if (AppBskyFeedDefs.isPostView(parent)) {
|
||||||
item => item.post.author.did === this.items[0].post.author.did,
|
parentAuthor = parent.author
|
||||||
|
}
|
||||||
|
this.items.push({
|
||||||
|
post,
|
||||||
|
record: post.record,
|
||||||
|
parentAuthor,
|
||||||
|
isParentBlocked,
|
||||||
|
isParentNotFound,
|
||||||
|
})
|
||||||
|
if (!reply || reason) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!AppBskyFeedDefs.isPostView(parent) ||
|
||||||
|
!AppBskyFeedPost.isRecord(parent.record) ||
|
||||||
|
!AppBskyFeedPost.validateRecord(parent.record).success
|
||||||
|
) {
|
||||||
|
this.isOrphan = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const root = reply.root
|
||||||
|
const rootIsView =
|
||||||
|
AppBskyFeedDefs.isPostView(root) ||
|
||||||
|
AppBskyFeedDefs.isBlockedPost(root) ||
|
||||||
|
AppBskyFeedDefs.isNotFoundPost(root)
|
||||||
|
/*
|
||||||
|
* If the parent is also the root, we just so happen to have the data we
|
||||||
|
* need to compute if the parent's parent (grandparent) is blocked. This
|
||||||
|
* doesn't always happen, of course, but we can take advantage of it when
|
||||||
|
* it does.
|
||||||
|
*/
|
||||||
|
const grandparent =
|
||||||
|
rootIsView && parent.record.reply?.parent.uri === root.uri
|
||||||
|
? root
|
||||||
|
: undefined
|
||||||
|
const grandparentAuthor = reply.grandparentAuthor
|
||||||
|
const isGrandparentBlocked = Boolean(
|
||||||
|
grandparent && AppBskyFeedDefs.isBlockedPost(grandparent),
|
||||||
)
|
)
|
||||||
|
const isGrandparentNotFound = Boolean(
|
||||||
|
grandparent && AppBskyFeedDefs.isNotFoundPost(grandparent),
|
||||||
)
|
)
|
||||||
|
this.items.unshift({
|
||||||
|
post: parent,
|
||||||
|
record: parent.record,
|
||||||
|
parentAuthor: grandparentAuthor,
|
||||||
|
isParentBlocked: isGrandparentBlocked,
|
||||||
|
isParentNotFound: isGrandparentNotFound,
|
||||||
|
})
|
||||||
|
if (isGrandparentBlocked) {
|
||||||
|
this.isOrphan = true
|
||||||
|
// Keep going, it might still have a root, and we need this for thread
|
||||||
|
// de-deduping
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!AppBskyFeedDefs.isPostView(root) ||
|
||||||
|
!AppBskyFeedPost.isRecord(root.record) ||
|
||||||
|
!AppBskyFeedPost.validateRecord(root.record).success
|
||||||
|
) {
|
||||||
|
this.isOrphan = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (root.uri === parent.uri) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.items.unshift({
|
||||||
|
post: root,
|
||||||
|
record: root.record,
|
||||||
|
isParentBlocked: false,
|
||||||
|
isParentNotFound: false,
|
||||||
|
parentAuthor: undefined,
|
||||||
|
})
|
||||||
|
if (parent.record.reply?.parent.uri !== root.uri) {
|
||||||
|
this.isIncompleteThread = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
get isQuotePost() {
|
get isQuotePost() {
|
||||||
@@ -90,204 +187,171 @@ export class FeedViewPostsSlice {
|
|||||||
return !!this.items.find(item => item.post.uri === uri)
|
return !!this.items.find(item => item.post.uri === uri)
|
||||||
}
|
}
|
||||||
|
|
||||||
isNextInThread(uri: string) {
|
getAuthors(): AuthorContext {
|
||||||
return this.items[this.items.length - 1].post.uri === uri
|
|
||||||
}
|
|
||||||
|
|
||||||
insert(item: FeedViewPost) {
|
|
||||||
const selfReplyUri = getSelfReplyUri(item)
|
|
||||||
const i = this.items.findIndex(item2 => item2.post.uri === selfReplyUri)
|
|
||||||
if (i !== -1) {
|
|
||||||
this.items.splice(i + 1, 0, item)
|
|
||||||
} else {
|
|
||||||
this.items.push(item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
flattenReplyParent() {
|
|
||||||
if (this.items[0].reply) {
|
|
||||||
const reply = this.items[0].reply
|
|
||||||
if (AppBskyFeedDefs.isPostView(reply.parent)) {
|
|
||||||
this.items.splice(0, 0, {post: reply.parent})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
isFollowingAllAuthors(userDid: string) {
|
|
||||||
const feedPost = this._feedPost
|
const feedPost = this._feedPost
|
||||||
const authors = [feedPost.post.author]
|
let author: AppBskyActorDefs.ProfileViewBasic = feedPost.post.author
|
||||||
|
let parentAuthor: AppBskyActorDefs.ProfileViewBasic | undefined
|
||||||
|
let grandparentAuthor: AppBskyActorDefs.ProfileViewBasic | undefined
|
||||||
|
let rootAuthor: AppBskyActorDefs.ProfileViewBasic | undefined
|
||||||
if (feedPost.reply) {
|
if (feedPost.reply) {
|
||||||
if (AppBskyFeedDefs.isPostView(feedPost.reply.parent)) {
|
if (AppBskyFeedDefs.isPostView(feedPost.reply.parent)) {
|
||||||
authors.push(feedPost.reply.parent.author)
|
parentAuthor = feedPost.reply.parent.author
|
||||||
}
|
}
|
||||||
if (feedPost.reply.grandparentAuthor) {
|
if (feedPost.reply.grandparentAuthor) {
|
||||||
authors.push(feedPost.reply.grandparentAuthor)
|
grandparentAuthor = feedPost.reply.grandparentAuthor
|
||||||
}
|
}
|
||||||
if (AppBskyFeedDefs.isPostView(feedPost.reply.root)) {
|
if (AppBskyFeedDefs.isPostView(feedPost.reply.root)) {
|
||||||
authors.push(feedPost.reply.root.author)
|
rootAuthor = feedPost.reply.root.author
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return authors.every(a => a.did === userDid || a.viewer?.following)
|
return {
|
||||||
|
author,
|
||||||
|
parentAuthor,
|
||||||
|
grandparentAuthor,
|
||||||
|
rootAuthor,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class FeedTuner {
|
export class FeedTuner {
|
||||||
seenKeys: Set<string> = new Set()
|
seenKeys: Set<string> = new Set()
|
||||||
seenUris: Set<string> = new Set()
|
seenUris: Set<string> = new Set()
|
||||||
|
seenRootUris: Set<string> = new Set()
|
||||||
|
|
||||||
constructor(public tunerFns: FeedTunerFn[]) {}
|
constructor(public tunerFns: FeedTunerFn[]) {}
|
||||||
|
|
||||||
reset() {
|
|
||||||
this.seenKeys.clear()
|
|
||||||
this.seenUris.clear()
|
|
||||||
}
|
|
||||||
|
|
||||||
tune(
|
tune(
|
||||||
feed: FeedViewPost[],
|
feed: FeedViewPost[],
|
||||||
{dryRun, maintainOrder}: {dryRun: boolean; maintainOrder: boolean} = {
|
{dryRun}: {dryRun: boolean} = {
|
||||||
dryRun: false,
|
dryRun: false,
|
||||||
maintainOrder: false,
|
|
||||||
},
|
},
|
||||||
): FeedViewPostsSlice[] {
|
): FeedViewPostsSlice[] {
|
||||||
let slices: FeedViewPostsSlice[] = []
|
let slices: FeedViewPostsSlice[] = feed
|
||||||
|
.map(item => new FeedViewPostsSlice(item))
|
||||||
// remove posts that are replies, but which don't have the parent
|
.filter(s => s.items.length > 0 || s.isFallbackMarker)
|
||||||
// hydrated. this means the parent was either deleted or blocked
|
|
||||||
feed = feed.filter(item => {
|
|
||||||
if (
|
|
||||||
AppBskyFeedPost.isRecord(item.post.record) &&
|
|
||||||
item.post.record.reply &&
|
|
||||||
!item.reply
|
|
||||||
) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
|
|
||||||
if (maintainOrder) {
|
|
||||||
slices = feed.map(item => new FeedViewPostsSlice(item))
|
|
||||||
} else {
|
|
||||||
// arrange the posts into thread slices
|
|
||||||
for (let i = feed.length - 1; i >= 0; i--) {
|
|
||||||
const item = feed[i]
|
|
||||||
|
|
||||||
const selfReplyUri = getSelfReplyUri(item)
|
|
||||||
if (selfReplyUri) {
|
|
||||||
const index = slices.findIndex(slice =>
|
|
||||||
slice.isNextInThread(selfReplyUri),
|
|
||||||
)
|
|
||||||
|
|
||||||
if (index !== -1) {
|
|
||||||
const parent = slices[index]
|
|
||||||
|
|
||||||
parent.insert(item)
|
|
||||||
|
|
||||||
// If our slice isn't currently on the top, reinsert it to the top.
|
|
||||||
if (index !== 0) {
|
|
||||||
slices.splice(index, 1)
|
|
||||||
slices.unshift(parent)
|
|
||||||
}
|
|
||||||
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
slices.unshift(new FeedViewPostsSlice(item))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// run the custom tuners
|
// run the custom tuners
|
||||||
for (const tunerFn of this.tunerFns) {
|
for (const tunerFn of this.tunerFns) {
|
||||||
slices = tunerFn(this, slices.slice())
|
slices = tunerFn(this, slices.slice(), dryRun)
|
||||||
}
|
}
|
||||||
|
|
||||||
// remove any items already "seen"
|
|
||||||
const soonToBeSeenUris: Set<string> = new Set()
|
|
||||||
for (let i = slices.length - 1; i >= 0; i--) {
|
|
||||||
if (!slices[i].isThread && this.seenUris.has(slices[i].uri)) {
|
|
||||||
slices.splice(i, 1)
|
|
||||||
} else {
|
|
||||||
for (const item of slices[i].items) {
|
|
||||||
soonToBeSeenUris.add(item.post.uri)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// turn non-threads with reply parents into threads
|
|
||||||
for (const slice of slices) {
|
|
||||||
if (!slice.isThread && !slice.reason && slice.items[0].reply) {
|
|
||||||
const reply = slice.items[0].reply
|
|
||||||
if (
|
|
||||||
AppBskyFeedDefs.isPostView(reply.parent) &&
|
|
||||||
!this.seenUris.has(reply.parent.uri) &&
|
|
||||||
!soonToBeSeenUris.has(reply.parent.uri)
|
|
||||||
) {
|
|
||||||
const uri = reply.parent.uri
|
|
||||||
slice.flattenReplyParent()
|
|
||||||
soonToBeSeenUris.add(uri)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!dryRun) {
|
|
||||||
slices = slices.filter(slice => {
|
slices = slices.filter(slice => {
|
||||||
if (this.seenKeys.has(slice._reactKey)) {
|
if (this.seenKeys.has(slice._reactKey)) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
for (const item of slice.items) {
|
// Some feeds, like Following, dedupe by thread, so you only see the most recent reply.
|
||||||
|
// However, we don't want per-thread dedupe for author feeds (where we need to show every post)
|
||||||
|
// or for feedgens (where we want to let the feed serve multiple replies if it chooses to).
|
||||||
|
// To avoid showing the same context (root and/or parent) more than once, we do last resort
|
||||||
|
// per-post deduplication. It hides already seen posts as long as this doesn't break the thread.
|
||||||
|
for (let i = 0; i < slice.items.length; i++) {
|
||||||
|
const item = slice.items[i]
|
||||||
|
if (this.seenUris.has(item.post.uri)) {
|
||||||
|
if (i === 0) {
|
||||||
|
// Omit contiguous seen leading items.
|
||||||
|
// For example, [A -> B -> C], [A -> D -> E], [A -> D -> F]
|
||||||
|
// would turn into [A -> B -> C], [D -> E], [F].
|
||||||
|
slice.items.splice(0, 1)
|
||||||
|
i--
|
||||||
|
}
|
||||||
|
if (i === slice.items.length - 1) {
|
||||||
|
// If the last item in the slice was already seen, omit the whole slice.
|
||||||
|
// This means we'd miss its parents, but the user can "show more" to see them.
|
||||||
|
// For example, [A ... E -> F], [A ... D -> E], [A ... C -> D], [A -> B -> C]
|
||||||
|
// would get collapsed into [A ... E -> F], with B/C/D considered seen.
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!dryRun) {
|
||||||
this.seenUris.add(item.post.uri)
|
this.seenUris.add(item.post.uri)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!dryRun) {
|
||||||
this.seenKeys.add(slice._reactKey)
|
this.seenKeys.add(slice._reactKey)
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
}
|
|
||||||
|
|
||||||
return slices
|
return slices
|
||||||
}
|
}
|
||||||
|
|
||||||
static removeReplies(tuner: FeedTuner, slices: FeedViewPostsSlice[]) {
|
static removeReplies(
|
||||||
for (let i = slices.length - 1; i >= 0; i--) {
|
|
||||||
if (slices[i].isReply) {
|
|
||||||
slices.splice(i, 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return slices
|
|
||||||
}
|
|
||||||
|
|
||||||
static removeReposts(tuner: FeedTuner, slices: FeedViewPostsSlice[]) {
|
|
||||||
for (let i = slices.length - 1; i >= 0; i--) {
|
|
||||||
if (slices[i].isRepost) {
|
|
||||||
slices.splice(i, 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return slices
|
|
||||||
}
|
|
||||||
|
|
||||||
static removeQuotePosts(tuner: FeedTuner, slices: FeedViewPostsSlice[]) {
|
|
||||||
for (let i = slices.length - 1; i >= 0; i--) {
|
|
||||||
if (slices[i].isQuotePost) {
|
|
||||||
slices.splice(i, 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return slices
|
|
||||||
}
|
|
||||||
|
|
||||||
static dedupReposts(
|
|
||||||
tuner: FeedTuner,
|
tuner: FeedTuner,
|
||||||
slices: FeedViewPostsSlice[],
|
slices: FeedViewPostsSlice[],
|
||||||
): FeedViewPostsSlice[] {
|
_dryRun: boolean,
|
||||||
// remove duplicates caused by reposts
|
) {
|
||||||
for (let i = 0; i < slices.length; i++) {
|
for (let i = 0; i < slices.length; i++) {
|
||||||
const item1 = slices[i]
|
const slice = slices[i]
|
||||||
for (let j = i + 1; j < slices.length; j++) {
|
if (
|
||||||
const item2 = slices[j]
|
slice.isReply &&
|
||||||
if (item2.isThread) {
|
!slice.isRepost &&
|
||||||
// dont dedup items that are rendering in a thread as this can cause rendering errors
|
// This is not perfect but it's close as we can get to
|
||||||
continue
|
// detecting threads without having to peek ahead.
|
||||||
|
!areSameAuthor(slice.getAuthors())
|
||||||
|
) {
|
||||||
|
slices.splice(i, 1)
|
||||||
|
i--
|
||||||
}
|
}
|
||||||
if (item1.containsUri(item2.items[0].post.uri)) {
|
}
|
||||||
slices.splice(j, 1)
|
return slices
|
||||||
j--
|
}
|
||||||
|
|
||||||
|
static removeReposts(
|
||||||
|
tuner: FeedTuner,
|
||||||
|
slices: FeedViewPostsSlice[],
|
||||||
|
_dryRun: boolean,
|
||||||
|
) {
|
||||||
|
for (let i = 0; i < slices.length; i++) {
|
||||||
|
if (slices[i].isRepost) {
|
||||||
|
slices.splice(i, 1)
|
||||||
|
i--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return slices
|
||||||
|
}
|
||||||
|
|
||||||
|
static removeQuotePosts(
|
||||||
|
tuner: FeedTuner,
|
||||||
|
slices: FeedViewPostsSlice[],
|
||||||
|
_dryRun: boolean,
|
||||||
|
) {
|
||||||
|
for (let i = 0; i < slices.length; i++) {
|
||||||
|
if (slices[i].isQuotePost) {
|
||||||
|
slices.splice(i, 1)
|
||||||
|
i--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return slices
|
||||||
|
}
|
||||||
|
|
||||||
|
static removeOrphans(
|
||||||
|
tuner: FeedTuner,
|
||||||
|
slices: FeedViewPostsSlice[],
|
||||||
|
_dryRun: boolean,
|
||||||
|
) {
|
||||||
|
for (let i = 0; i < slices.length; i++) {
|
||||||
|
if (slices[i].isOrphan) {
|
||||||
|
slices.splice(i, 1)
|
||||||
|
i--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return slices
|
||||||
|
}
|
||||||
|
|
||||||
|
static dedupThreads(
|
||||||
|
tuner: FeedTuner,
|
||||||
|
slices: FeedViewPostsSlice[],
|
||||||
|
dryRun: boolean,
|
||||||
|
): FeedViewPostsSlice[] {
|
||||||
|
for (let i = 0; i < slices.length; i++) {
|
||||||
|
const rootUri = slices[i].rootUri
|
||||||
|
if (!slices[i].isRepost && tuner.seenRootUris.has(rootUri)) {
|
||||||
|
slices.splice(i, 1)
|
||||||
|
i--
|
||||||
|
} else {
|
||||||
|
if (!dryRun) {
|
||||||
|
tuner.seenRootUris.add(rootUri)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -298,15 +362,17 @@ export class FeedTuner {
|
|||||||
return (
|
return (
|
||||||
tuner: FeedTuner,
|
tuner: FeedTuner,
|
||||||
slices: FeedViewPostsSlice[],
|
slices: FeedViewPostsSlice[],
|
||||||
|
_dryRun: boolean,
|
||||||
): FeedViewPostsSlice[] => {
|
): FeedViewPostsSlice[] => {
|
||||||
for (let i = slices.length - 1; i >= 0; i--) {
|
for (let i = 0; i < slices.length; i++) {
|
||||||
const slice = slices[i]
|
const slice = slices[i]
|
||||||
if (
|
if (
|
||||||
slice.isReply &&
|
slice.isReply &&
|
||||||
!slice.isRepost &&
|
!slice.isRepost &&
|
||||||
!slice.isFollowingAllAuthors(userDid)
|
!shouldDisplayReplyInFollowing(slice.getAuthors(), userDid)
|
||||||
) {
|
) {
|
||||||
slices.splice(i, 1)
|
slices.splice(i, 1)
|
||||||
|
i--
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return slices
|
return slices
|
||||||
@@ -324,6 +390,7 @@ export class FeedTuner {
|
|||||||
return (
|
return (
|
||||||
tuner: FeedTuner,
|
tuner: FeedTuner,
|
||||||
slices: FeedViewPostsSlice[],
|
slices: FeedViewPostsSlice[],
|
||||||
|
_dryRun: boolean,
|
||||||
): FeedViewPostsSlice[] => {
|
): FeedViewPostsSlice[] => {
|
||||||
const candidateSlices = slices.slice()
|
const candidateSlices = slices.slice()
|
||||||
|
|
||||||
@@ -332,7 +399,7 @@ export class FeedTuner {
|
|||||||
return slices
|
return slices
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let i = slices.length - 1; i >= 0; i--) {
|
for (let i = 0; i < slices.length; i++) {
|
||||||
let hasPreferredLang = false
|
let hasPreferredLang = false
|
||||||
for (const item of slices[i].items) {
|
for (const item of slices[i].items) {
|
||||||
if (isPostInLanguage(item.post, preferredLangsCode2)) {
|
if (isPostInLanguage(item.post, preferredLangsCode2)) {
|
||||||
@@ -358,16 +425,66 @@ export class FeedTuner {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSelfReplyUri(item: FeedViewPost): string | undefined {
|
function areSameAuthor(authors: AuthorContext): boolean {
|
||||||
if (item.reply) {
|
const {author, parentAuthor, grandparentAuthor, rootAuthor} = authors
|
||||||
|
const authorDid = author.did
|
||||||
|
if (parentAuthor && parentAuthor.did !== authorDid) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (grandparentAuthor && grandparentAuthor.did !== authorDid) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (rootAuthor && rootAuthor.did !== authorDid) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldDisplayReplyInFollowing(
|
||||||
|
authors: AuthorContext,
|
||||||
|
userDid: string,
|
||||||
|
): boolean {
|
||||||
|
const {author, parentAuthor, grandparentAuthor, rootAuthor} = authors
|
||||||
|
if (!isSelfOrFollowing(author, userDid)) {
|
||||||
|
// Only show replies from self or people you follow.
|
||||||
|
return false
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
AppBskyFeedDefs.isPostView(item.reply.parent) &&
|
(!parentAuthor || parentAuthor.did === author.did) &&
|
||||||
!AppBskyFeedDefs.isReasonRepost(item.reason) // don't thread reposted self-replies
|
(!rootAuthor || rootAuthor.did === author.did) &&
|
||||||
|
(!grandparentAuthor || grandparentAuthor.did === author.did)
|
||||||
) {
|
) {
|
||||||
return item.reply.parent.author.did === item.post.author.did
|
// Always show self-threads.
|
||||||
? item.reply.parent.uri
|
return true
|
||||||
: undefined
|
|
||||||
}
|
}
|
||||||
|
// From this point on we need at least one more reason to show it.
|
||||||
|
if (
|
||||||
|
parentAuthor &&
|
||||||
|
parentAuthor.did !== author.did &&
|
||||||
|
isSelfOrFollowing(parentAuthor, userDid)
|
||||||
|
) {
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
return undefined
|
if (
|
||||||
|
grandparentAuthor &&
|
||||||
|
grandparentAuthor.did !== author.did &&
|
||||||
|
isSelfOrFollowing(grandparentAuthor, userDid)
|
||||||
|
) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
rootAuthor &&
|
||||||
|
rootAuthor.did !== author.did &&
|
||||||
|
isSelfOrFollowing(rootAuthor, userDid)
|
||||||
|
) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSelfOrFollowing(
|
||||||
|
profile: AppBskyActorDefs.ProfileViewBasic,
|
||||||
|
userDid: string,
|
||||||
|
) {
|
||||||
|
return Boolean(profile.did === userDid || profile.viewer?.following)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -193,12 +193,6 @@ class MergeFeedSource {
|
|||||||
return this.hasMore && this.queue.length === 0
|
return this.hasMore && this.queue.length === 0
|
||||||
}
|
}
|
||||||
|
|
||||||
reset() {
|
|
||||||
this.cursor = undefined
|
|
||||||
this.queue = []
|
|
||||||
this.hasMore = true
|
|
||||||
}
|
|
||||||
|
|
||||||
take(n: number): AppBskyFeedDefs.FeedViewPost[] {
|
take(n: number): AppBskyFeedDefs.FeedViewPost[] {
|
||||||
return this.queue.splice(0, n)
|
return this.queue.splice(0, n)
|
||||||
}
|
}
|
||||||
@@ -232,11 +226,6 @@ class MergeFeedSource {
|
|||||||
class MergeFeedSource_Following extends MergeFeedSource {
|
class MergeFeedSource_Following extends MergeFeedSource {
|
||||||
tuner = new FeedTuner(this.feedTuners)
|
tuner = new FeedTuner(this.feedTuners)
|
||||||
|
|
||||||
reset() {
|
|
||||||
super.reset()
|
|
||||||
this.tuner.reset()
|
|
||||||
}
|
|
||||||
|
|
||||||
async fetchNext(n: number) {
|
async fetchNext(n: number) {
|
||||||
return this._fetchNextInner(n)
|
return this._fetchNextInner(n)
|
||||||
}
|
}
|
||||||
@@ -249,7 +238,6 @@ class MergeFeedSource_Following extends MergeFeedSource {
|
|||||||
// run the tuner pre-emptively to ensure better mixing
|
// run the tuner pre-emptively to ensure better mixing
|
||||||
const slices = this.tuner.tune(res.data.feed, {
|
const slices = this.tuner.tune(res.data.feed, {
|
||||||
dryRun: false,
|
dryRun: false,
|
||||||
maintainOrder: true,
|
|
||||||
})
|
})
|
||||||
res.data.feed = slices.map(slice => slice._feedPost)
|
res.data.feed = slices.map(slice => slice._feedPost)
|
||||||
return res
|
return res
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
AppBskyFeedThreadgate,
|
AppBskyFeedThreadgate,
|
||||||
BskyAgent,
|
BskyAgent,
|
||||||
ComAtprotoLabelDefs,
|
ComAtprotoLabelDefs,
|
||||||
ComAtprotoRepoUploadBlob,
|
|
||||||
RichText,
|
RichText,
|
||||||
} from '@atproto/api'
|
} from '@atproto/api'
|
||||||
import {AtUri} from '@atproto/api'
|
import {AtUri} from '@atproto/api'
|
||||||
@@ -15,10 +14,13 @@ import {logger} from '#/logger'
|
|||||||
import {ThreadgateSetting} from '#/state/queries/threadgate'
|
import {ThreadgateSetting} 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 +30,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
|
||||||
@@ -301,7 +284,7 @@ export async function createThreadgate(
|
|||||||
|
|
||||||
const postUrip = new AtUri(postUri)
|
const postUrip = new AtUri(postUri)
|
||||||
await agent.api.com.atproto.repo.putRecord({
|
await agent.api.com.atproto.repo.putRecord({
|
||||||
repo: agent.session!.did,
|
repo: agent.accountDid,
|
||||||
collection: 'app.bsky.feed.threadgate',
|
collection: 'app.bsky.feed.threadgate',
|
||||||
rkey: postUrip.rkey,
|
rkey: postUrip.rkey,
|
||||||
record: {
|
record: {
|
||||||
@@ -312,15 +295,3 @@ export async function createThreadgate(
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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}`)
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import {interpolate, useAnimatedStyle} from 'react-native-reanimated'
|
|||||||
|
|
||||||
import {useMinimalShellMode} from '#/state/shell/minimal-mode'
|
import {useMinimalShellMode} from '#/state/shell/minimal-mode'
|
||||||
import {useShellLayout} from '#/state/shell/shell-layout'
|
import {useShellLayout} from '#/state/shell/shell-layout'
|
||||||
|
import {useGate} from '../statsig/statsig'
|
||||||
|
|
||||||
// Keep these separated so that we only pay for useAnimatedStyle that gets used.
|
// Keep these separated so that we only pay for useAnimatedStyle that gets used.
|
||||||
|
|
||||||
@@ -27,8 +28,13 @@ export function useMinimalShellHeaderTransform() {
|
|||||||
export function useMinimalShellFooterTransform() {
|
export function useMinimalShellFooterTransform() {
|
||||||
const mode = useMinimalShellMode()
|
const mode = useMinimalShellMode()
|
||||||
const {footerHeight} = useShellLayout()
|
const {footerHeight} = useShellLayout()
|
||||||
|
const gate = useGate()
|
||||||
|
const isFixedBottomBar = gate('fixed_bottom_bar')
|
||||||
|
|
||||||
const footerTransform = useAnimatedStyle(() => {
|
const footerTransform = useAnimatedStyle(() => {
|
||||||
|
if (isFixedBottomBar) {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
pointerEvents: mode.value === 0 ? 'auto' : 'none',
|
pointerEvents: mode.value === 0 ? 'auto' : 'none',
|
||||||
opacity: Math.pow(1 - mode.value, 2),
|
opacity: Math.pow(1 - mode.value, 2),
|
||||||
@@ -39,13 +45,25 @@ export function useMinimalShellFooterTransform() {
|
|||||||
],
|
],
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
return footerTransform
|
return footerTransform
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useMinimalShellFabTransform() {
|
export function useMinimalShellFabTransform() {
|
||||||
const mode = useMinimalShellMode()
|
const mode = useMinimalShellMode()
|
||||||
|
const gate = useGate()
|
||||||
|
const isFixedBottomBar = gate('fixed_bottom_bar')
|
||||||
|
|
||||||
const fabTransform = useAnimatedStyle(() => {
|
const fabTransform = useAnimatedStyle(() => {
|
||||||
|
if (isFixedBottomBar) {
|
||||||
|
return {
|
||||||
|
transform: [
|
||||||
|
{
|
||||||
|
translateY: -44,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
transform: [
|
transform: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -218,13 +218,7 @@ export async function safeDeleteAsync(path: string) {
|
|||||||
// Normalize is necessary for Android, otherwise it doesn't delete.
|
// Normalize is necessary for Android, otherwise it doesn't delete.
|
||||||
const normalizedPath = normalizePath(path)
|
const normalizedPath = normalizePath(path)
|
||||||
try {
|
try {
|
||||||
await Promise.allSettled([
|
await deleteAsync(normalizedPath, {idempotent: true})
|
||||||
deleteAsync(normalizedPath, {idempotent: true}),
|
|
||||||
// HACK: Try this one too. Might exist due to api-polyfill hack.
|
|
||||||
deleteAsync(normalizedPath.replace(/\.jpe?g$/, '.bin'), {
|
|
||||||
idempotent: true,
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to delete file', e)
|
console.error('Failed to delete file', e)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ export function useModerationCauseDescription(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (def.identifier === 'porn' || def.identifier === 'sexual') {
|
if (def.identifier === 'porn' || def.identifier === 'sexual') {
|
||||||
strings.name = 'Adult Content'
|
strings.name = _(msg`Adult Content`)
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {BskyAgent} from '@atproto/api'
|
|||||||
|
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {SessionAccount, useAgent, useSession} from '#/state/session'
|
import {SessionAccount, useAgent, useSession} from '#/state/session'
|
||||||
import {logEvent, useGate} from 'lib/statsig/statsig'
|
import {logEvent} from 'lib/statsig/statsig'
|
||||||
import {devicePlatform, isAndroid, isNative} from 'platform/detection'
|
import {devicePlatform, isAndroid, isNative} from 'platform/detection'
|
||||||
import BackgroundNotificationHandler from '../../../modules/expo-background-notification-handler'
|
import BackgroundNotificationHandler from '../../../modules/expo-background-notification-handler'
|
||||||
|
|
||||||
@@ -86,7 +86,6 @@ export function useNotificationsRegistration() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useRequestNotificationsPermission() {
|
export function useRequestNotificationsPermission() {
|
||||||
const gate = useGate()
|
|
||||||
const {currentAccount} = useSession()
|
const {currentAccount} = useSession()
|
||||||
const agent = useAgent()
|
const agent = useAgent()
|
||||||
|
|
||||||
@@ -102,16 +101,7 @@ export function useRequestNotificationsPermission() {
|
|||||||
) {
|
) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (
|
if (context === 'AfterOnboarding') {
|
||||||
context === 'StartOnboarding' &&
|
|
||||||
gate('request_notifications_permission_after_onboarding_v2')
|
|
||||||
) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
context === 'AfterOnboarding' &&
|
|
||||||
!gate('request_notifications_permission_after_onboarding_v2')
|
|
||||||
) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (context === 'Home' && !currentAccount) {
|
if (context === 'Home' && !currentAccount) {
|
||||||
|
|||||||
@@ -2,18 +2,83 @@ import React, {useRef, useState} from 'react'
|
|||||||
import {AppState, AppStateStatus} from 'react-native'
|
import {AppState, AppStateStatus} from 'react-native'
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||||
import {createAsyncStoragePersister} from '@tanstack/query-async-storage-persister'
|
import {createAsyncStoragePersister} from '@tanstack/query-async-storage-persister'
|
||||||
import {focusManager, QueryClient} from '@tanstack/react-query'
|
import {focusManager, onlineManager, QueryClient} from '@tanstack/react-query'
|
||||||
import {
|
import {
|
||||||
PersistQueryClientProvider,
|
PersistQueryClientProvider,
|
||||||
PersistQueryClientProviderProps,
|
PersistQueryClientProviderProps,
|
||||||
} from '@tanstack/react-query-persist-client'
|
} from '@tanstack/react-query-persist-client'
|
||||||
|
|
||||||
import {isNative} from '#/platform/detection'
|
import {isNative} from '#/platform/detection'
|
||||||
|
import {listenNetworkConfirmed, listenNetworkLost} from '#/state/events'
|
||||||
|
|
||||||
// any query keys in this array will be persisted to AsyncStorage
|
// any query keys in this array will be persisted to AsyncStorage
|
||||||
export const labelersDetailedInfoQueryKeyRoot = 'labelers-detailed-info'
|
export const labelersDetailedInfoQueryKeyRoot = 'labelers-detailed-info'
|
||||||
const STORED_CACHE_QUERY_KEY_ROOTS = [labelersDetailedInfoQueryKeyRoot]
|
const STORED_CACHE_QUERY_KEY_ROOTS = [labelersDetailedInfoQueryKeyRoot]
|
||||||
|
|
||||||
|
async function checkIsOnline(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const controller = new AbortController()
|
||||||
|
setTimeout(() => {
|
||||||
|
controller.abort()
|
||||||
|
}, 15e3)
|
||||||
|
const res = await fetch('https://public.api.bsky.app/xrpc/_health', {
|
||||||
|
cache: 'no-store',
|
||||||
|
signal: controller.signal,
|
||||||
|
})
|
||||||
|
const json = await res.json()
|
||||||
|
if (json.version) {
|
||||||
|
return true
|
||||||
|
} else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let receivedNetworkLost = false
|
||||||
|
let receivedNetworkConfirmed = false
|
||||||
|
let isNetworkStateUnclear = false
|
||||||
|
|
||||||
|
listenNetworkLost(() => {
|
||||||
|
receivedNetworkLost = true
|
||||||
|
onlineManager.setOnline(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
listenNetworkConfirmed(() => {
|
||||||
|
receivedNetworkConfirmed = true
|
||||||
|
onlineManager.setOnline(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
let checkPromise: Promise<void> | undefined
|
||||||
|
function checkIsOnlineIfNeeded() {
|
||||||
|
if (checkPromise) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
receivedNetworkLost = false
|
||||||
|
receivedNetworkConfirmed = false
|
||||||
|
checkPromise = checkIsOnline().then(nextIsOnline => {
|
||||||
|
checkPromise = undefined
|
||||||
|
if (nextIsOnline && receivedNetworkLost) {
|
||||||
|
isNetworkStateUnclear = true
|
||||||
|
}
|
||||||
|
if (!nextIsOnline && receivedNetworkConfirmed) {
|
||||||
|
isNetworkStateUnclear = true
|
||||||
|
}
|
||||||
|
if (!isNetworkStateUnclear) {
|
||||||
|
onlineManager.setOnline(nextIsOnline)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
setInterval(() => {
|
||||||
|
if (AppState.currentState === 'active') {
|
||||||
|
if (!onlineManager.isOnline() || isNetworkStateUnclear) {
|
||||||
|
checkIsOnlineIfNeeded()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 2000)
|
||||||
|
|
||||||
focusManager.setEventListener(onFocus => {
|
focusManager.setEventListener(onFocus => {
|
||||||
if (isNative) {
|
if (isNative) {
|
||||||
const subscription = AppState.addEventListener(
|
const subscription = AppState.addEventListener(
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export type LogEvents = {
|
|||||||
}
|
}
|
||||||
'account:loggedOut': {
|
'account:loggedOut': {
|
||||||
logContext: 'SwitchAccount' | 'Settings' | 'SignupQueued' | 'Deactivated'
|
logContext: 'SwitchAccount' | 'Settings' | 'SignupQueued' | 'Deactivated'
|
||||||
|
scope: 'current' | 'every'
|
||||||
}
|
}
|
||||||
'notifications:openApp': {}
|
'notifications:openApp': {}
|
||||||
'notifications:request': {
|
'notifications:request': {
|
||||||
@@ -159,6 +160,7 @@ export type LogEvents = {
|
|||||||
| 'AvatarButton'
|
| 'AvatarButton'
|
||||||
| 'StarterPackProfilesList'
|
| 'StarterPackProfilesList'
|
||||||
| 'FeedInterstitial'
|
| 'FeedInterstitial'
|
||||||
|
| 'ProfileHeaderSuggestedFollows'
|
||||||
}
|
}
|
||||||
'profile:unfollow': {
|
'profile:unfollow': {
|
||||||
logContext:
|
logContext:
|
||||||
@@ -173,6 +175,7 @@ export type LogEvents = {
|
|||||||
| 'AvatarButton'
|
| 'AvatarButton'
|
||||||
| 'StarterPackProfilesList'
|
| 'StarterPackProfilesList'
|
||||||
| 'FeedInterstitial'
|
| 'FeedInterstitial'
|
||||||
|
| 'ProfileHeaderSuggestedFollows'
|
||||||
}
|
}
|
||||||
'chat:create': {
|
'chat:create': {
|
||||||
logContext: 'ProfileHeader' | 'NewChatDialog' | 'SendViaChatDialog'
|
logContext: 'ProfileHeader' | 'NewChatDialog' | 'SendViaChatDialog'
|
||||||
@@ -211,6 +214,14 @@ export type LogEvents = {
|
|||||||
'feed:interstitial:profileCard:press': {}
|
'feed:interstitial:profileCard:press': {}
|
||||||
'feed:interstitial:feedCard:press': {}
|
'feed:interstitial:feedCard:press': {}
|
||||||
|
|
||||||
|
'profile:header:suggestedFollowsCard:press': {}
|
||||||
|
|
||||||
|
'debug:followingPrefs': {
|
||||||
|
followingShowRepliesFromPref: 'all' | 'following' | 'off'
|
||||||
|
followingRepliesMinLikePref: number
|
||||||
|
}
|
||||||
|
'debug:followingDisplayed': {}
|
||||||
|
|
||||||
'test:all:always': {}
|
'test:all:always': {}
|
||||||
'test:all:sometimes': {}
|
'test:all:sometimes': {}
|
||||||
'test:all:boosted_by_gate1': {reason: 'base' | 'gate1'}
|
'test:all:boosted_by_gate1': {reason: 'base' | 'gate1'}
|
||||||
|
|||||||
@@ -1,17 +1,10 @@
|
|||||||
export type Gate =
|
export type Gate =
|
||||||
// Keep this alphabetic please.
|
// Keep this alphabetic please.
|
||||||
| 'debug_show_feedcontext'
|
| 'debug_show_feedcontext'
|
||||||
| 'explore_page_profile_card_social_proof'
|
| 'fixed_bottom_bar'
|
||||||
| 'native_pwi_disabled'
|
|
||||||
| 'new_user_guided_tour'
|
| 'new_user_guided_tour'
|
||||||
| 'new_user_progress_guide'
|
|
||||||
| 'onboarding_minimum_interests'
|
| 'onboarding_minimum_interests'
|
||||||
| 'request_notifications_permission_after_onboarding_v2'
|
|
||||||
| 'session_withproxy_fix'
|
|
||||||
| 'show_avi_follow_button'
|
|
||||||
| 'show_follow_back_label_v2'
|
| 'show_follow_back_label_v2'
|
||||||
| 'suggested_feeds_interstitial'
|
| 'suggested_feeds_interstitial'
|
||||||
| 'suggested_follows_interstitial'
|
| 'video_debug'
|
||||||
| 'ungroup_follow_backs'
|
|
||||||
| 'videos'
|
| 'videos'
|
||||||
| 'small_avi_thumb'
|
|
||||||
|
|||||||
@@ -103,16 +103,21 @@ export function parseEmbedPlayerFromUrl(
|
|||||||
urlp.hostname === 'm.youtube.com' ||
|
urlp.hostname === 'm.youtube.com' ||
|
||||||
urlp.hostname === 'music.youtube.com'
|
urlp.hostname === 'music.youtube.com'
|
||||||
) {
|
) {
|
||||||
const [_, page, shortVideoId] = urlp.pathname.split('/')
|
const [_, page, shortOrLiveVideoId] = urlp.pathname.split('/')
|
||||||
|
|
||||||
|
const isShorts = page === 'shorts'
|
||||||
|
const isLive = page === 'live'
|
||||||
const videoId =
|
const videoId =
|
||||||
page === 'shorts' ? shortVideoId : (urlp.searchParams.get('v') as string)
|
isShorts || isLive
|
||||||
|
? shortOrLiveVideoId
|
||||||
|
: (urlp.searchParams.get('v') as string)
|
||||||
const seek = encodeURIComponent(urlp.searchParams.get('t') ?? 0)
|
const seek = encodeURIComponent(urlp.searchParams.get('t') ?? 0)
|
||||||
|
|
||||||
if (videoId) {
|
if (videoId) {
|
||||||
return {
|
return {
|
||||||
type: page === 'shorts' ? 'youtube_short' : 'youtube_video',
|
type: isShorts ? 'youtube_short' : 'youtube_video',
|
||||||
source: page === 'shorts' ? 'youtubeShorts' : 'youtube',
|
source: isShorts ? 'youtubeShorts' : 'youtube',
|
||||||
hideDetails: page === 'shorts' ? true : undefined,
|
hideDetails: isShorts ? true : undefined,
|
||||||
playerUri: `${IFRAME_HOST}/iframe/youtube.html?videoId=${videoId}&start=${seek}`,
|
playerUri: `${IFRAME_HOST}/iframe/youtube.html?videoId=${videoId}&start=${seek}`,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {Dimensions, StyleProp, StyleSheet, TextStyle} from 'react-native'
|
import {Dimensions, StyleProp, StyleSheet, TextStyle} from 'react-native'
|
||||||
import {Theme, TypographyVariant} from './ThemeContext'
|
|
||||||
import {isWeb} from 'platform/detection'
|
import {isWeb} from 'platform/detection'
|
||||||
|
import {Theme, TypographyVariant} from './ThemeContext'
|
||||||
|
|
||||||
// 1 is lightest, 2 is light, 3 is mid, 4 is dark, 5 is darkest
|
// 1 is lightest, 2 is light, 3 is mid, 4 is dark, 5 is darkest
|
||||||
export const colors = {
|
export const colors = {
|
||||||
|
|||||||
@@ -295,9 +295,9 @@ export const darkTheme: Theme = {
|
|||||||
...defaultTheme.palette,
|
...defaultTheme.palette,
|
||||||
default: {
|
default: {
|
||||||
background: darkPalette.black,
|
background: darkPalette.black,
|
||||||
backgroundLight: darkPalette.contrast_50,
|
backgroundLight: darkPalette.contrast_25,
|
||||||
text: darkPalette.white,
|
text: darkPalette.white,
|
||||||
textLight: darkPalette.contrast_700,
|
textLight: darkPalette.contrast_600,
|
||||||
textInverted: darkPalette.black,
|
textInverted: darkPalette.black,
|
||||||
link: darkPalette.primary_500,
|
link: darkPalette.primary_500,
|
||||||
border: darkPalette.contrast_100,
|
border: darkPalette.contrast_100,
|
||||||
@@ -344,7 +344,7 @@ export const dimTheme: Theme = {
|
|||||||
default: {
|
default: {
|
||||||
...darkTheme.palette.default,
|
...darkTheme.palette.default,
|
||||||
background: dimPalette.black,
|
background: dimPalette.black,
|
||||||
backgroundLight: dimPalette.contrast_50,
|
backgroundLight: dimPalette.contrast_25,
|
||||||
text: dimPalette.white,
|
text: dimPalette.white,
|
||||||
textLight: dimPalette.contrast_700,
|
textLight: dimPalette.contrast_700,
|
||||||
textInverted: dimPalette.black,
|
textInverted: dimPalette.black,
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import {Platform} from 'react-native'
|
import {Platform} from 'react-native'
|
||||||
import {isReducedMotion} from 'react-native-reanimated'
|
|
||||||
import {getLocales} from 'expo-localization'
|
import {getLocales} from 'expo-localization'
|
||||||
|
|
||||||
import {fixLegacyLanguageCode} from '#/locale/helpers'
|
import {fixLegacyLanguageCode} from '#/locale/helpers'
|
||||||
@@ -15,11 +14,10 @@ export const isMobileWeb =
|
|||||||
isWeb &&
|
isWeb &&
|
||||||
// @ts-ignore we know window exists -prf
|
// @ts-ignore we know window exists -prf
|
||||||
global.window.matchMedia(isMobileWebMediaQuery)?.matches
|
global.window.matchMedia(isMobileWebMediaQuery)?.matches
|
||||||
|
export const isIPhoneWeb = isWeb && /iPhone/.test(navigator.userAgent)
|
||||||
|
|
||||||
export const deviceLocales = dedupArray(
|
export const deviceLocales = dedupArray(
|
||||||
getLocales?.()
|
getLocales?.()
|
||||||
.map?.(locale => fixLegacyLanguageCode(locale.languageCode))
|
.map?.(locale => fixLegacyLanguageCode(locale.languageCode))
|
||||||
.filter(code => typeof code === 'string'),
|
.filter(code => typeof code === 'string'),
|
||||||
) as string[]
|
) as string[]
|
||||||
|
|
||||||
export const prefersReducedMotion = isReducedMotion()
|
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export function Deactivated() {
|
|||||||
const {setShowLoggedOut} = useLoggedOutViewControls()
|
const {setShowLoggedOut} = useLoggedOutViewControls()
|
||||||
const hasOtherAccounts = accounts.length > 1
|
const hasOtherAccounts = accounts.length > 1
|
||||||
const setMinimalShellMode = useSetMinimalShellMode()
|
const setMinimalShellMode = useSetMinimalShellMode()
|
||||||
const {logout} = useSessionApi()
|
const {logoutCurrentAccount} = useSessionApi()
|
||||||
const agent = useAgent()
|
const agent = useAgent()
|
||||||
const [pending, setPending] = React.useState(false)
|
const [pending, setPending] = React.useState(false)
|
||||||
const [error, setError] = React.useState<string | undefined>()
|
const [error, setError] = React.useState<string | undefined>()
|
||||||
@@ -72,8 +72,8 @@ export function Deactivated() {
|
|||||||
// So we change the URL ourselves. The navigator will pick it up on remount.
|
// So we change the URL ourselves. The navigator will pick it up on remount.
|
||||||
history.pushState(null, '', '/')
|
history.pushState(null, '', '/')
|
||||||
}
|
}
|
||||||
logout('Deactivated')
|
logoutCurrentAccount('Deactivated')
|
||||||
}, [logout])
|
}, [logoutCurrentAccount])
|
||||||
|
|
||||||
const handleActivate = React.useCallback(async () => {
|
const handleActivate = React.useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -387,9 +387,6 @@ export function MessagesList({
|
|||||||
renderItem={renderItem}
|
renderItem={renderItem}
|
||||||
keyExtractor={keyExtractor}
|
keyExtractor={keyExtractor}
|
||||||
disableFullWindowScroll={true}
|
disableFullWindowScroll={true}
|
||||||
// Prevents wrong position in Firefox when sending a message
|
|
||||||
// as well as scroll getting stuck on Chome when scrolling upwards.
|
|
||||||
disableContainStyle={true}
|
|
||||||
disableVirtualization={true}
|
disableVirtualization={true}
|
||||||
style={animatedListStyle}
|
style={animatedListStyle}
|
||||||
// The extra two items account for the header and the footer components
|
// The extra two items account for the header and the footer components
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ let ProfileHeaderStandard = ({
|
|||||||
hideBackButton={hideBackButton}
|
hideBackButton={hideBackButton}
|
||||||
isPlaceholderProfile={isPlaceholderProfile}>
|
isPlaceholderProfile={isPlaceholderProfile}>
|
||||||
<View
|
<View
|
||||||
style={[a.px_lg, a.pt_md, a.pb_sm]}
|
style={[a.px_lg, a.pt_md, a.pb_sm, a.overflow_hidden]}
|
||||||
pointerEvents={isIOS ? 'auto' : 'box-none'}>
|
pointerEvents={isIOS ? 'auto' : 'box-none'}>
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ export const ProfileFeedSection = React.forwardRef<
|
|||||||
headerOffset={headerHeight}
|
headerOffset={headerHeight}
|
||||||
renderEndOfFeed={ProfileEndOfFeed}
|
renderEndOfFeed={ProfileEndOfFeed}
|
||||||
ignoreFilterFor={ignoreFilterFor}
|
ignoreFilterFor={ignoreFilterFor}
|
||||||
|
outsideHeaderOffset={headerHeight}
|
||||||
/>
|
/>
|
||||||
{(isScrolledDown || hasNew) && (
|
{(isScrolledDown || hasNew) && (
|
||||||
<LoadLatestBtn
|
<LoadLatestBtn
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ function DeactivateAccountDialogInner({
|
|||||||
const {gtMobile} = useBreakpoints()
|
const {gtMobile} = useBreakpoints()
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const agent = useAgent()
|
const agent = useAgent()
|
||||||
const {logout} = useSessionApi()
|
const {logoutCurrentAccount} = useSessionApi()
|
||||||
const [pending, setPending] = React.useState(false)
|
const [pending, setPending] = React.useState(false)
|
||||||
const [error, setError] = React.useState<string | undefined>()
|
const [error, setError] = React.useState<string | undefined>()
|
||||||
|
|
||||||
@@ -44,7 +44,7 @@ function DeactivateAccountDialogInner({
|
|||||||
setPending(true)
|
setPending(true)
|
||||||
await agent.com.atproto.server.deactivateAccount({})
|
await agent.com.atproto.server.deactivateAccount({})
|
||||||
control.close(() => {
|
control.close(() => {
|
||||||
logout('Deactivated')
|
logoutCurrentAccount('Deactivated')
|
||||||
})
|
})
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
switch (e.message) {
|
switch (e.message) {
|
||||||
@@ -66,7 +66,7 @@ function DeactivateAccountDialogInner({
|
|||||||
} finally {
|
} finally {
|
||||||
setPending(false)
|
setPending(false)
|
||||||
}
|
}
|
||||||
}, [agent, control, logout, _, setPending])
|
}, [agent, control, logoutCurrentAccount, _, setPending])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export function SignupQueued() {
|
|||||||
const insets = useSafeAreaInsets()
|
const insets = useSafeAreaInsets()
|
||||||
const {gtMobile} = useBreakpoints()
|
const {gtMobile} = useBreakpoints()
|
||||||
const onboardingDispatch = useOnboardingDispatch()
|
const onboardingDispatch = useOnboardingDispatch()
|
||||||
const {logout} = useSessionApi()
|
const {logoutCurrentAccount} = useSessionApi()
|
||||||
const agent = useAgent()
|
const agent = useAgent()
|
||||||
|
|
||||||
const [isProcessing, setProcessing] = React.useState(false)
|
const [isProcessing, setProcessing] = React.useState(false)
|
||||||
@@ -40,7 +40,7 @@ export function SignupQueued() {
|
|||||||
const res = await agent.com.atproto.temp.checkSignupQueue()
|
const res = await agent.com.atproto.temp.checkSignupQueue()
|
||||||
if (res.data.activated) {
|
if (res.data.activated) {
|
||||||
// ready to go, exchange the access token for a usable one and kick off onboarding
|
// ready to go, exchange the access token for a usable one and kick off onboarding
|
||||||
await agent.refreshSession()
|
await agent.sessionManager.refreshSession()
|
||||||
if (!isSignupQueued(agent.session?.accessJwt)) {
|
if (!isSignupQueued(agent.session?.accessJwt)) {
|
||||||
onboardingDispatch({type: 'start'})
|
onboardingDispatch({type: 'start'})
|
||||||
}
|
}
|
||||||
@@ -153,7 +153,7 @@ export function SignupQueued() {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="large"
|
size="large"
|
||||||
label={_(msg`Log out`)}
|
label={_(msg`Log out`)}
|
||||||
onPress={() => logout('SignupQueued')}>
|
onPress={() => logoutCurrentAccount('SignupQueued')}>
|
||||||
<ButtonText style={[{color: t.palette.primary_500}]}>
|
<ButtonText style={[{color: t.palette.primary_500}]}>
|
||||||
<Trans>Log out</Trans>
|
<Trans>Log out</Trans>
|
||||||
</ButtonText>
|
</ButtonText>
|
||||||
@@ -182,7 +182,7 @@ export function SignupQueued() {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="large"
|
size="large"
|
||||||
label={_(msg`Log out`)}
|
label={_(msg`Log out`)}
|
||||||
onPress={() => logout('SignupQueued')}>
|
onPress={() => logoutCurrentAccount('SignupQueued')}>
|
||||||
<ButtonText style={[{color: t.palette.primary_500}]}>
|
<ButtonText style={[{color: t.palette.primary_500}]}>
|
||||||
<Trans>Log out</Trans>
|
<Trans>Log out</Trans>
|
||||||
</ButtonText>
|
</ButtonText>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {AccessibilityInfo} from 'react-native'
|
import {AccessibilityInfo} from 'react-native'
|
||||||
import {isReducedMotion} from 'react-native-reanimated'
|
|
||||||
|
|
||||||
import {isWeb} from '#/platform/detection'
|
import {isWeb} from '#/platform/detection'
|
||||||
|
import {PlatformInfo} from '../../modules/expo-bluesky-swiss-army'
|
||||||
|
|
||||||
const Context = React.createContext({
|
const Context = React.createContext({
|
||||||
reduceMotionEnabled: false,
|
reduceMotionEnabled: false,
|
||||||
@@ -15,7 +15,7 @@ export function useA11y() {
|
|||||||
|
|
||||||
export function Provider({children}: React.PropsWithChildren<{}>) {
|
export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||||
const [reduceMotionEnabled, setReduceMotionEnabled] = React.useState(() =>
|
const [reduceMotionEnabled, setReduceMotionEnabled] = React.useState(() =>
|
||||||
isReducedMotion(),
|
PlatformInfo.getIsReducedMotionEnabled(),
|
||||||
)
|
)
|
||||||
const [screenReaderEnabled, setScreenReaderEnabled] = React.useState(false)
|
const [screenReaderEnabled, setScreenReaderEnabled] = React.useState(false)
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,22 @@ export function listenSessionDropped(fn: () => void): UnlistenFn {
|
|||||||
return () => emitter.off('session-dropped', fn)
|
return () => emitter.off('session-dropped', fn)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function emitNetworkConfirmed() {
|
||||||
|
emitter.emit('network-confirmed')
|
||||||
|
}
|
||||||
|
export function listenNetworkConfirmed(fn: () => void): UnlistenFn {
|
||||||
|
emitter.on('network-confirmed', fn)
|
||||||
|
return () => emitter.off('network-confirmed', fn)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function emitNetworkLost() {
|
||||||
|
emitter.emit('network-lost')
|
||||||
|
}
|
||||||
|
export function listenNetworkLost(fn: () => void): UnlistenFn {
|
||||||
|
emitter.on('network-lost', fn)
|
||||||
|
return () => emitter.off('network-lost', fn)
|
||||||
|
}
|
||||||
|
|
||||||
export function emitPostCreated() {
|
export function emitPostCreated() {
|
||||||
emitter.emit('post-created')
|
emitter.emit('post-created')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {AppState, AppStateStatus} from 'react-native'
|
import {AppState, AppStateStatus} from 'react-native'
|
||||||
import {AppBskyFeedDefs, BskyAgent} from '@atproto/api'
|
import {AppBskyFeedDefs} from '@atproto/api'
|
||||||
import throttle from 'lodash.throttle'
|
import throttle from 'lodash.throttle'
|
||||||
|
|
||||||
import {PROD_DEFAULT_FEED} from '#/lib/constants'
|
import {PROD_DEFAULT_FEED} from '#/lib/constants'
|
||||||
import {logEvent} from '#/lib/statsig/statsig'
|
import {logEvent} from '#/lib/statsig/statsig'
|
||||||
import {useGate} from '#/lib/statsig/statsig'
|
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {FeedDescriptor, FeedPostSliceItem} from '#/state/queries/post-feed'
|
import {FeedDescriptor, FeedPostSliceItem} from '#/state/queries/post-feed'
|
||||||
import {getFeedPostSlice} from '#/view/com/posts/Feed'
|
import {getFeedPostSlice} from '#/view/com/posts/Feed'
|
||||||
@@ -25,7 +24,6 @@ const stateContext = React.createContext<StateContext>({
|
|||||||
|
|
||||||
export function useFeedFeedback(feed: FeedDescriptor, hasSession: boolean) {
|
export function useFeedFeedback(feed: FeedDescriptor, hasSession: boolean) {
|
||||||
const agent = useAgent()
|
const agent = useAgent()
|
||||||
const gate = useGate()
|
|
||||||
const enabled = isDiscoverFeed(feed) && hasSession
|
const enabled = isDiscoverFeed(feed) && hasSession
|
||||||
const queue = React.useRef<Set<string>>(new Set())
|
const queue = React.useRef<Set<string>>(new Set())
|
||||||
const history = React.useRef<
|
const history = React.useRef<
|
||||||
@@ -49,7 +47,6 @@ export function useFeedFeedback(feed: FeedDescriptor, hasSession: boolean) {
|
|||||||
queue.current.clear()
|
queue.current.clear()
|
||||||
|
|
||||||
// Send to the feed
|
// Send to the feed
|
||||||
if (gate('session_withproxy_fix')) {
|
|
||||||
agent.app.bsky.feed
|
agent.app.bsky.feed
|
||||||
.sendInteractions(
|
.sendInteractions(
|
||||||
{interactions},
|
{interactions},
|
||||||
@@ -64,19 +61,6 @@ export function useFeedFeedback(feed: FeedDescriptor, hasSession: boolean) {
|
|||||||
.catch((e: any) => {
|
.catch((e: any) => {
|
||||||
logger.warn('Failed to send feed interactions', {error: e})
|
logger.warn('Failed to send feed interactions', {error: e})
|
||||||
})
|
})
|
||||||
} else {
|
|
||||||
const proxyAgent = agent.withProxy(
|
|
||||||
// @ts-ignore TODO need to update withProxy() to support this key -prf
|
|
||||||
'bsky_fg',
|
|
||||||
// TODO when we start sending to other feeds, we need to grab their DID -prf
|
|
||||||
'did:web:discover.bsky.app',
|
|
||||||
) as BskyAgent
|
|
||||||
proxyAgent.app.bsky.feed
|
|
||||||
.sendInteractions({interactions})
|
|
||||||
.catch((e: any) => {
|
|
||||||
logger.warn('Failed to send feed interactions', {error: e})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send to Statsig
|
// Send to Statsig
|
||||||
if (aggregatedStats.current === null) {
|
if (aggregatedStats.current === null) {
|
||||||
@@ -84,7 +68,7 @@ export function useFeedFeedback(feed: FeedDescriptor, hasSession: boolean) {
|
|||||||
}
|
}
|
||||||
sendOrAggregateInteractionsForStats(aggregatedStats.current, interactions)
|
sendOrAggregateInteractionsForStats(aggregatedStats.current, interactions)
|
||||||
throttledFlushAggregatedStats()
|
throttledFlushAggregatedStats()
|
||||||
}, [agent, gate, throttledFlushAggregatedStats])
|
}, [agent, throttledFlushAggregatedStats])
|
||||||
|
|
||||||
const sendToFeed = React.useMemo(
|
const sendToFeed = React.useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -123,7 +107,7 @@ export function useFeedFeedback(feed: FeedDescriptor, hasSession: boolean) {
|
|||||||
toString({
|
toString({
|
||||||
item: postItem.uri,
|
item: postItem.uri,
|
||||||
event: 'app.bsky.feed.defs#interactionSeen',
|
event: 'app.bsky.feed.defs#interactionSeen',
|
||||||
feedContext: postItem.feedContext,
|
feedContext: slice.feedContext,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
sendToFeed()
|
sendToFeed()
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
|
||||||
import * as persisted from '#/state/persisted'
|
import * as persisted from '#/state/persisted'
|
||||||
|
|
||||||
type StateContext = persisted.Schema['invites']
|
type StateContext = persisted.Schema['invites']
|
||||||
@@ -35,8 +36,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
return persisted.onUpdate(() => {
|
return persisted.onUpdate('invites', nextInvites => {
|
||||||
setState(persisted.get('invites'))
|
setState(nextInvites)
|
||||||
})
|
})
|
||||||
}, [setState])
|
}, [setState])
|
||||||
|
|
||||||
|
|||||||
@@ -1,67 +0,0 @@
|
|||||||
import type {LegacySchema} from '#/state/persisted/legacy'
|
|
||||||
|
|
||||||
export const ALICE_DID = 'did:plc:ALICE_DID'
|
|
||||||
export const BOB_DID = 'did:plc:BOB_DID'
|
|
||||||
|
|
||||||
export const LEGACY_DATA_DUMP: LegacySchema = {
|
|
||||||
session: {
|
|
||||||
data: {
|
|
||||||
service: 'https://bsky.social/',
|
|
||||||
did: ALICE_DID,
|
|
||||||
},
|
|
||||||
accounts: [
|
|
||||||
{
|
|
||||||
service: 'https://bsky.social',
|
|
||||||
did: ALICE_DID,
|
|
||||||
refreshJwt: 'refreshJwt',
|
|
||||||
accessJwt: 'accessJwt',
|
|
||||||
handle: 'alice.test',
|
|
||||||
email: 'alice@bsky.test',
|
|
||||||
displayName: 'Alice',
|
|
||||||
aviUrl: 'avi',
|
|
||||||
emailConfirmed: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
service: 'https://bsky.social',
|
|
||||||
did: BOB_DID,
|
|
||||||
refreshJwt: 'refreshJwt',
|
|
||||||
accessJwt: 'accessJwt',
|
|
||||||
handle: 'bob.test',
|
|
||||||
email: 'bob@bsky.test',
|
|
||||||
displayName: 'Bob',
|
|
||||||
aviUrl: 'avi',
|
|
||||||
emailConfirmed: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
me: {
|
|
||||||
did: ALICE_DID,
|
|
||||||
handle: 'alice.test',
|
|
||||||
displayName: 'Alice',
|
|
||||||
description: '',
|
|
||||||
avatar: 'avi',
|
|
||||||
},
|
|
||||||
onboarding: {step: 'Home'},
|
|
||||||
shell: {colorMode: 'system'},
|
|
||||||
preferences: {
|
|
||||||
primaryLanguage: 'en',
|
|
||||||
contentLanguages: ['en'],
|
|
||||||
postLanguage: 'en',
|
|
||||||
postLanguageHistory: ['en', 'en', 'ja', 'pt', 'de', 'en'],
|
|
||||||
contentLabels: {
|
|
||||||
nsfw: 'warn',
|
|
||||||
nudity: 'warn',
|
|
||||||
suggestive: 'warn',
|
|
||||||
gore: 'warn',
|
|
||||||
hate: 'hide',
|
|
||||||
spam: 'hide',
|
|
||||||
impersonation: 'warn',
|
|
||||||
},
|
|
||||||
savedFeeds: ['feed_a', 'feed_b', 'feed_c'],
|
|
||||||
pinnedFeeds: ['feed_a', 'feed_b'],
|
|
||||||
requireAltTextEnabled: false,
|
|
||||||
},
|
|
||||||
invitedUsers: {seenDids: [], copiedInvites: []},
|
|
||||||
mutedThreads: {uris: []},
|
|
||||||
reminders: {},
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
import {jest, expect, test, afterEach} from '@jest/globals'
|
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
|
||||||
|
|
||||||
import {defaults} from '#/state/persisted/schema'
|
|
||||||
import {migrate} from '#/state/persisted/legacy'
|
|
||||||
import * as store from '#/state/persisted/store'
|
|
||||||
import * as persisted from '#/state/persisted'
|
|
||||||
|
|
||||||
const write = jest.mocked(store.write)
|
|
||||||
const read = jest.mocked(store.read)
|
|
||||||
|
|
||||||
jest.mock('#/logger')
|
|
||||||
jest.mock('#/state/persisted/legacy', () => ({
|
|
||||||
migrate: jest.fn(),
|
|
||||||
}))
|
|
||||||
jest.mock('#/state/persisted/store', () => ({
|
|
||||||
write: jest.fn(),
|
|
||||||
read: jest.fn(),
|
|
||||||
}))
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
jest.useFakeTimers()
|
|
||||||
jest.clearAllMocks()
|
|
||||||
AsyncStorage.clear()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('init: fresh install, no migration', async () => {
|
|
||||||
await persisted.init()
|
|
||||||
|
|
||||||
expect(migrate).toHaveBeenCalledTimes(1)
|
|
||||||
expect(read).toHaveBeenCalledTimes(1)
|
|
||||||
expect(write).toHaveBeenCalledWith(defaults)
|
|
||||||
|
|
||||||
// default value
|
|
||||||
expect(persisted.get('colorMode')).toBe('system')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('init: fresh install, migration ran', async () => {
|
|
||||||
read.mockResolvedValueOnce(defaults)
|
|
||||||
|
|
||||||
await persisted.init()
|
|
||||||
|
|
||||||
expect(migrate).toHaveBeenCalledTimes(1)
|
|
||||||
expect(read).toHaveBeenCalledTimes(1)
|
|
||||||
expect(write).not.toHaveBeenCalled()
|
|
||||||
|
|
||||||
// default value
|
|
||||||
expect(persisted.get('colorMode')).toBe('system')
|
|
||||||
})
|
|
||||||