Merge branch 'main' into by-you

This commit is contained in:
Minseo Lee
2024-06-21 09:11:54 +09:00
committed by GitHub
205 changed files with 14574 additions and 9084 deletions
+1
View File
@@ -31,6 +31,7 @@ module.exports = {
},
},
],
'bsky-internal/use-exact-imports': 'error',
'bsky-internal/use-typed-gates': 'error',
'simple-import-sort/imports': [
'warn',
@@ -0,0 +1,55 @@
name: build-and-push-ogcard-aws
on:
push:
branches:
- divy/bskycard
env:
REGISTRY: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_REGISTRY }}
USERNAME: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_USERNAME }}
PASSWORD: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_PASSWORD }}
IMAGE_NAME: bskyogcard
jobs:
ogcard-container-aws:
if: github.repository == 'bluesky-social/social-app'
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Setup Docker buildx
uses: docker/setup-buildx-action@v1
- name: Log into registry ${{ env.REGISTRY }}
uses: docker/login-action@v2
with:
registry: ${{ env.REGISTRY }}
username: ${{ env.USERNAME}}
password: ${{ env.PASSWORD }}
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v4
with:
images: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=sha,enable=true,priority=100,prefix=,suffix=,format=long
- name: Build and push Docker image
id: build-and-push
uses: docker/build-push-action@v4
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
file: ./Dockerfile.bskyogcard
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
+1 -1
View File
@@ -1,4 +1,4 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
yarn lint-staged
npx lint-staged
+41
View File
@@ -0,0 +1,41 @@
FROM node:20.11-alpine3.18 as build
# Move files into the image and install
WORKDIR /app
COPY ./bskyogcard/package.json ./
COPY ./bskyogcard/yarn.lock ./
RUN yarn install --frozen-lockfile
COPY ./bskyogcard ./
# build then prune dev deps
RUN yarn build
RUN yarn install --production --ignore-scripts --prefer-offline
# Uses assets from build stage to reduce build size
FROM node:20.11-alpine3.18
RUN apk add --update dumb-init
# Avoid zombie processes, handle signal forwarding
ENTRYPOINT ["dumb-init", "--"]
WORKDIR /app
COPY --from=build /app /app
RUN mkdir /app/data && chown node /app/data
VOLUME /app/data
EXPOSE 3000
ENV CARD_PORT=3000
ENV NODE_ENV=production
# potential perf issues w/ io_uring on this version of node
ENV UV_USE_IO_URING=0
# https://github.com/nodejs/docker-node/blob/master/docs/BestPractices.md#non-root-user
USER node
CMD ["node", "--heapsnapshot-signal=SIGUSR2", "--enable-source-maps", "dist/bin.js"]
LABEL org.opencontainers.image.source=https://github.com/bluesky-social/social-app
LABEL org.opencontainers.image.description="Bsky Card Service"
LABEL org.opencontainers.image.licenses=UNLICENSED
+15 -4
View File
@@ -36,12 +36,15 @@ appId: xyz.blueskyweb.app
id: "viewHeaderDrawerBtn"
- tapOn:
id: "menuItemButton-Feeds"
- tapOn: "Edit Saved Feeds"
- tapOn:
id: "editFeedsBtn"
- tapOn:
label: "Tap on down arrow"
point: "79%,23%"
- tapOn:
id: "bottomBarHomeBtn"
id: "viewHeaderDrawerBtn"
- tapOn:
id: "viewHeaderDrawerBtn"
- assertVisible:
id: "homeScreenFeedTabs-selector-0"
text: "alice-favs"
@@ -54,11 +57,15 @@ appId: xyz.blueskyweb.app
id: "viewHeaderDrawerBtn"
- tapOn:
id: "menuItemButton-Feeds"
- tapOn:
id: "editFeedsBtn"
- tapOn:
label: "Tap on down arrow"
point: "79%,23%"
- tapOn:
id: "bottomBarHomeBtn"
id: "viewHeaderDrawerBtn"
- tapOn:
id: "viewHeaderDrawerBtn"
- assertVisible:
id: "homeScreenFeedTabs-selector-0"
text: "Following"
@@ -71,11 +78,15 @@ appId: xyz.blueskyweb.app
id: "viewHeaderDrawerBtn"
- tapOn:
id: "menuItemButton-Feeds"
- tapOn:
id: "editFeedsBtn"
- tapOn:
label: "Tap on unpin"
point: "91%,23%"
- tapOn:
id: "bottomBarHomeBtn"
id: "viewHeaderDrawerBtn"
- tapOn:
id: "viewHeaderDrawerBtn"
- assertVisible:
id: "homeScreenFeedTabs-selector-0"
text: "alice-favs"
+2
View File
@@ -28,6 +28,8 @@ appId: xyz.blueskyweb.app
- tapOn: "Pin to Home"
- tapOn:
id: "bottomBarHomeBtn"
- tapOn:
id: "viewHeaderDrawerBtn"
- assertNotVisible: "Feeds ✨"
- tapOn:
+2 -2
View File
@@ -58,7 +58,7 @@ appId: xyz.blueskyweb.app
id: "repostBtn"
childOf:
id: "postThreadItem-by-bob.test"
- tapOn: "Undo repost"
- tapOn: "Remove repost"
- assertNotVisible:
id: "repostCount-expanded"
@@ -77,7 +77,7 @@ appId: xyz.blueskyweb.app
id: "repostBtn"
childOf:
id: "postThreadItem-by-carla.test"
- tapOn: "Undo repost"
- tapOn: "Remove repost"
- assertNotVisible:
id: "repostCount"
childOf:
-74
View File
@@ -6,7 +6,6 @@ import {createFullHandle, makeValidHandle} from '../../src/lib/strings/handles'
import {enforceLen} from '../../src/lib/strings/helpers'
import {detectLinkables} from '../../src/lib/strings/rich-text-detection'
import {shortenLinks} from '../../src/lib/strings/rich-text-manip'
import {ago} from '../../src/lib/strings/time'
import {
makeRecordUri,
toNiceDomain,
@@ -142,79 +141,6 @@ describe('makeRecordUri', () => {
})
})
// FIXME: Reenable after fixing non-deterministic test.
describe.skip('ago', () => {
const oneYearDate = new Date(
new Date().setMonth(new Date().getMonth() - 11),
).setDate(new Date().getDate() - 28)
const inputs = [
1671461038,
'04 Dec 1995 00:12:00 GMT',
new Date(),
new Date().setSeconds(new Date().getSeconds() - 10),
new Date().setMinutes(new Date().getMinutes() - 10),
new Date().setHours(new Date().getHours() - 1),
new Date().setDate(new Date().getDate() - 1),
new Date().setDate(new Date().getDate() - 20),
new Date().setDate(new Date().getDate() - 25),
new Date().setDate(new Date().getDate() - 28),
new Date().setDate(new Date().getDate() - 29),
new Date().setDate(new Date().getDate() - 30),
new Date().setMonth(new Date().getMonth() - 1),
new Date(new Date().setMonth(new Date().getMonth() - 1)).setDate(
new Date().getDate() - 20,
),
new Date(new Date().setMonth(new Date().getMonth() - 1)).setDate(
new Date().getDate() - 25,
),
new Date(new Date().setMonth(new Date().getMonth() - 1)).setDate(
new Date().getDate() - 28,
),
new Date(new Date().setMonth(new Date().getMonth() - 1)).setDate(
new Date().getDate() - 29,
),
new Date().setMonth(new Date().getMonth() - 11),
new Date(new Date().setMonth(new Date().getMonth() - 11)).setDate(
new Date().getDate() - 20,
),
new Date(new Date().setMonth(new Date().getMonth() - 11)).setDate(
new Date().getDate() - 25,
),
oneYearDate,
]
const outputs = [
new Date(1671461038).toLocaleDateString(),
new Date('04 Dec 1995 00:12:00 GMT').toLocaleDateString(),
'now',
'10s',
'10m',
'1h',
'1d',
'20d',
'25d',
'28d',
'29d',
'1mo',
'1mo',
'1mo',
'1mo',
'2mo',
'2mo',
'11mo',
'11mo',
'11mo',
new Date(oneYearDate).toLocaleDateString(),
]
it('correctly calculates how much time passed, in a string', () => {
for (let i = 0; i < inputs.length; i++) {
const result = ago(inputs[i])
expect(result).toEqual(outputs[i])
}
})
})
describe('makeValidHandle', () => {
const inputs = [
'test-handle-123',
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M12 21a1 1 0 0 1-.707-.293l-6-6a1 1 0 1 1 1.414-1.414L11 17.586V4a1 1 0 1 1 2 0v13.586l4.293-4.293a1 1 0 0 1 1.414 1.414l-6 6A1 1 0 0 1 12 21Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 285 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M4 5a1 1 0 0 0 0 2h16a1 1 0 1 0 0-2H4Zm0 12a1 1 0 1 0 0 2h3a1 1 0 1 0 0-2H4Zm-1-5a1 1 0 0 1 1-1h5a1 1 0 1 1 0 2H4a1 1 0 0 1-1-1Zm14-3c.552 0 1 .41 1 .917V13.5h3.583c.507 0 .917.448.917 1s-.41 1-.917 1H18v3.583c0 .507-.448.917-1 .917s-1-.41-1-.917V15.5h-3.583c-.507 0-.917-.448-.917-1s.41-1 .917-1H16V9.917C16 9.41 16.448 9 17 9Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 471 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M2 6a1 1 0 0 1 1-1h18a1 1 0 1 1 0 2H3a1 1 0 0 1-1-1Zm0 6a1 1 0 0 1 1-1h18a1 1 0 1 1 0 2H3a1 1 0 0 1-1-1Zm0 6a1 1 0 0 1 1-1h18a1 1 0 1 1 0 2H3a1 1 0 0 1-1-1Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 299 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#FFC404" fill-rule="evenodd" d="M11.183 8.561c0 .544.348.984.892.984.545 0 .893-.44.893-.985V6.985c0-.544-.348-.985-.893-.985-.543 0-.892.44-.892.985v1.576Zm5.94 7.481c0 .539-.438.942-.976.942H8.004c-.538 0-.975-.411-.975-.95 0-2.782 2.264-5.021 5.046-5.021 2.783 0 5.047 2.247 5.047 5.03Zm-.43-4.584a.983.983 0 0 1 0-1.393l1.114-1.114a.985.985 0 0 1 1.393 1.393l-1.114 1.114a.985.985 0 0 1-1.393 0Zm2.897 3.741h1.575c.544 0 .985.349.985.892 0 .544-.44.892-.985.892h-1.67a.872.872 0 0 1-.89-.887c0-.543.44-.897.985-.897Zm-14.045.893c0-.544-.44-.892-.985-.892H2.985c-.544 0-.985.349-.985.892 0 .544.44.892.985.892H4.56c.545 0 .985-.349.985-.892Zm1.913-6.027a.985.985 0 0 1-1.393 1.393L4.95 10.344A.985.985 0 0 1 6.344 8.95l1.114 1.114Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 848 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M6.5 3a1 1 0 0 1 1-1h9a1 1 0 0 1 1 1v3.997a6.25 6.25 0 0 0 1.83 4.42l.377.376A1 1 0 0 1 20 12.5V15a1 1 0 0 1-1 1h-6v5a1 1 0 1 1-2 0v-5H5a1 1 0 0 1-1-1v-2.5a1 1 0 0 1 .293-.707l.376-.377A6.25 6.25 0 0 0 6.5 6.996V3.001Zm2 1v2.997a8.25 8.25 0 0 1-2.416 5.834L6 12.914V14h12v-1.086l-.084-.083A8.25 8.25 0 0 1 15.5 6.997V4h-7Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 465 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M7 3a1 1 0 0 1 1 1v2h2a1 1 0 1 1 0 2H8v2a1 1 0 1 1-2 0V8H4a1 1 0 0 1 0-2h2V4a1 1 0 0 1 1-1Zm6 4a4 4 0 1 1 8 0 4 4 0 0 1-8 0Zm4-2a2 2 0 1 0 0 4 2 2 0 0 0 0-4ZM3 14a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1v-6Zm2 1v4h4v-4H5Zm9.171-.829a1 1 0 0 1 1.415 0L17 15.585l1.414-1.414a1 1 0 1 1 1.414 1.414L18.414 17l1.414 1.414a1 1 0 0 1-1.414 1.414L17 18.414l-1.415 1.414a1 1 0 0 1-1.414-1.414l1.415-1.415-1.415-1.414a1 1 0 0 1 0-1.414Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 590 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M12.902 1.568a1 1 0 0 0-1.804 0L8.455 7.085l-6.085.799a1 1 0 0 0-.557 1.718l4.45 4.207-1.117 6.008a1 1 0 0 0 1.458 1.063L12 17.962l5.396 2.918a1 1 0 0 0 1.459-1.063l-1.117-6.008 4.45-4.207a1 1 0 0 0-.558-1.718l-6.085-.8-2.643-5.516Z"/></svg>

After

Width:  |  Height:  |  Size: 335 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M12 1a1 1 0 0 1 .902.568l2.643 5.517 6.085.799a1 1 0 0 1 .557 1.718l-4.45 4.207 1.118 6.008a1 1 0 0 1-1.46 1.063L12 17.962 6.604 20.88a1 1 0 0 1-1.459-1.063l1.117-6.008-4.45-4.207a1 1 0 0 1 .558-1.718l6.085-.8 2.643-5.516A1 1 0 0 1 12 1Zm0 3.315-1.975 4.123a1 1 0 0 1-.772.56l-4.538.595 3.317 3.137a1 1 0 0 1 .296.91l-.834 4.485 4.03-2.179a1 1 0 0 1 .952 0l4.03 2.179-.834-4.485a1 1 0 0 1 .296-.91l3.317-3.137-4.538-.596a1 1 0 0 1-.772-.56L12 4.316Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 592 B

+24
View File
@@ -0,0 +1,24 @@
{
"name": "bskyogcard",
"version": "0.0.0",
"type": "module",
"main": "src/index.ts",
"scripts": {
"start": "node --loader ts-node/esm ./src/bin.ts",
"build": "tsc && cp -r src/assets dist/assets"
},
"dependencies": {
"@atproto/api": "0.12.19-next.0",
"@atproto/common": "^0.4.0",
"@resvg/resvg-js": "^2.6.2",
"express": "^4.19.2",
"http-terminator": "^3.2.0",
"pino": "^9.2.0",
"react": "^18.3.1",
"satori": "^0.10.13"
},
"devDependencies": {
"@types/node": "^20.14.3",
"typescript": "^5.4.5"
}
}
Binary file not shown.
+48
View File
@@ -0,0 +1,48 @@
import cluster, {Worker} from 'node:cluster'
import {envInt} from '@atproto/common'
import {CardService, envToCfg, httpLogger, readEnv} from './index.js'
async function main() {
const env = readEnv()
const cfg = envToCfg(env)
const card = await CardService.create(cfg)
await card.start()
httpLogger.info('card service is running')
process.on('SIGTERM', async () => {
httpLogger.info('card service is stopping')
await card.destroy()
httpLogger.info('card service is stopped')
if (cluster.isWorker) process.exit(0)
})
}
const workerCount = envInt('CARD_CLUSTER_WORKER_COUNT')
if (workerCount) {
if (cluster.isPrimary) {
httpLogger.info(`primary ${process.pid} is running`)
const workers = new Set<Worker>()
for (let i = 0; i < workerCount; ++i) {
workers.add(cluster.fork())
}
let teardown = false
cluster.on('exit', worker => {
workers.delete(worker)
if (!teardown) {
workers.add(cluster.fork()) // restart on crash
}
})
process.on('SIGTERM', () => {
teardown = true
httpLogger.info('disconnecting workers')
workers.forEach(w => w.kill('SIGTERM'))
})
} else {
httpLogger.info(`worker ${process.pid} is running`)
main()
}
} else {
main() // non-clustering
}
+16
View File
@@ -0,0 +1,16 @@
import React from 'react'
export function Butterfly(props: React.SVGAttributes<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 568 501"
{...props}>
<path
fill="currentColor"
d="M123.121 33.664C188.241 82.553 258.281 181.68 284 234.873c25.719-53.192 95.759-152.32 160.879-201.21C491.866-1.611 568-28.906 568 57.947c0 17.346-9.945 145.713-15.778 166.555-20.275 72.453-94.155 90.933-159.875 79.748C507.222 323.8 536.444 388.56 473.333 453.32c-119.86 122.992-172.272-30.859-185.702-70.281-2.462-7.227-3.614-10.608-3.631-7.733-.017-2.875-1.169.506-3.631 7.733-13.43 39.422-65.842 193.273-185.702 70.281-63.111-64.76-33.89-129.52 80.986-149.071-65.72 11.185-139.6-7.295-159.875-79.748C9.945 203.659 0 75.291 0 57.946 0-28.906 76.135-1.612 123.121 33.664Z"
/>
</svg>
)
}
+10
View File
@@ -0,0 +1,10 @@
import React from 'react'
export function Img(
props: Omit<React.ImgHTMLAttributes<HTMLImageElement>, 'src'> & {src: Buffer},
) {
const {src, ...others} = props
return (
<img {...others} src={`data:image/jpeg;base64,${src.toString('base64')}`} />
)
}
+149
View File
@@ -0,0 +1,149 @@
/* eslint-disable bsky-internal/avoid-unwrapped-text */
import React from 'react'
import {AppBskyGraphDefs, AppBskyGraphStarterpack} from '@atproto/api'
import {Butterfly} from './Butterfly.js'
import {Img} from './Img.js'
export const STARTERPACK_HEIGHT = 630
export const STARTERPACK_WIDTH = 1200
export const TILE_SIZE = STARTERPACK_HEIGHT / 3
const GRADIENT_TOP = '#0A7AFF'
const GRADIENT_BOTTOM = '#59B9FF'
const IMAGE_STROKE = '#359CFF'
export function StarterPack(props: {
starterPack: AppBskyGraphDefs.StarterPackView
images: Map<string, Buffer>
}) {
const {starterPack, images} = props
const record = AppBskyGraphStarterpack.isRecord(starterPack.record)
? starterPack.record
: null
const imagesArray = [...images.values()]
const imageOfCreator = images.get(starterPack.creator.did)
const imagesExceptCreator = [...images.entries()]
.filter(([did]) => did !== starterPack.creator.did)
.map(([, image]) => image)
const imagesAcross: Buffer[] = []
if (imageOfCreator) {
if (imagesExceptCreator.length >= 6) {
imagesAcross.push(...imagesExceptCreator.slice(0, 3))
imagesAcross.push(imageOfCreator)
imagesAcross.push(...imagesExceptCreator.slice(3, 6))
} else {
const firstHalf = Math.floor(imagesExceptCreator.length / 2)
imagesAcross.push(...imagesExceptCreator.slice(0, firstHalf))
imagesAcross.push(imageOfCreator)
imagesAcross.push(
...imagesExceptCreator.slice(firstHalf, imagesExceptCreator.length),
)
}
} else {
imagesAcross.push(...imagesExceptCreator.slice(0, 7))
}
return (
<div
style={{
display: 'flex',
justifyContent: 'center',
width: STARTERPACK_WIDTH,
height: STARTERPACK_HEIGHT,
backgroundColor: 'black',
color: 'white',
fontFamily: 'Inter',
}}>
{/* image tiles */}
<div
style={{
display: 'flex',
flexWrap: 'wrap',
alignItems: 'stretch',
width: TILE_SIZE * 6,
height: TILE_SIZE * 3,
}}>
{[...Array(18)].map((_, i) => {
const image = imagesArray.at(i % imagesArray.length)
return (
<div
key={i}
style={{
display: 'flex',
height: TILE_SIZE,
width: TILE_SIZE,
}}>
{image && <Img height="100%" width="100%" src={image} />}
</div>
)
})}
{/* background overlay */}
<div
style={{
display: 'flex',
width: '100%',
height: '100%',
position: 'absolute',
backgroundImage: `linear-gradient(to bottom, ${GRADIENT_TOP}, ${GRADIENT_BOTTOM})`,
opacity: 0.9,
}}
/>
</div>
{/* foreground text & images */}
<div
style={{
display: 'flex',
alignItems: 'center',
flexDirection: 'column',
width: '100%',
height: '100%',
position: 'absolute',
color: 'white',
}}>
<div
style={{
color: 'white',
padding: 60,
fontSize: 40,
}}>
JOIN THE CONVERSATION
</div>
<div style={{display: 'flex'}}>
{imagesAcross.map((image, i) => {
return (
<div
key={i}
style={{
display: 'flex',
height: 172 + 15 * 2,
width: 172 + 15 * 2,
margin: -15,
border: `15px solid ${IMAGE_STROKE}`,
borderRadius: '50%',
overflow: 'hidden',
}}>
<Img height="100%" width="100%" src={image} />
</div>
)
})}
</div>
<div
style={{
padding: '75px 30px 0px',
fontSize: 65,
}}>
{record?.name || 'Starter Pack'}
</div>
<div
style={{
display: 'flex',
fontSize: 40,
justifyContent: 'center',
padding: '30px 30px 10px',
}}>
on <Butterfly width="65" style={{margin: '-7px 10px 0'}} /> Bluesky
</div>
</div>
</div>
)
}
+40
View File
@@ -0,0 +1,40 @@
import {envInt, envStr} from '@atproto/common'
export type Config = {
service: ServiceConfig
}
export type ServiceConfig = {
port: number
version?: string
appviewUrl: string
originVerify?: string
}
export type Environment = {
port?: number
version?: string
appviewUrl?: string
originVerify?: string
}
export const readEnv = (): Environment => {
return {
port: envInt('CARD_PORT'),
version: envStr('CARD_VERSION'),
appviewUrl: envStr('CARD_APPVIEW_URL'),
originVerify: envStr('CARD_ORIGIN_VERIFY'),
}
}
export const envToCfg = (env: Environment): Config => {
const serviceCfg: ServiceConfig = {
port: env.port ?? 3000,
version: env.version,
appviewUrl: env.appviewUrl ?? 'https://api.bsky.app',
originVerify: env.originVerify,
}
return {
service: serviceCfg,
}
}
+44
View File
@@ -0,0 +1,44 @@
import {readFileSync} from 'node:fs'
import {AtpAgent} from '@atproto/api'
import * as path from 'path'
import {fileURLToPath} from 'url'
import {Config} from './config.js'
const __DIRNAME = path.dirname(fileURLToPath(import.meta.url))
export type AppContextOptions = {
cfg: Config
appviewAgent: AtpAgent
fonts: {name: string; data: Buffer}[]
}
export class AppContext {
cfg: Config
appviewAgent: AtpAgent
fonts: {name: string; data: Buffer}[]
abortController = new AbortController()
constructor(private opts: AppContextOptions) {
this.cfg = this.opts.cfg
this.appviewAgent = this.opts.appviewAgent
this.fonts = this.opts.fonts
}
static async fromConfig(cfg: Config, overrides?: Partial<AppContextOptions>) {
const appviewAgent = new AtpAgent({service: cfg.service.appviewUrl})
const fonts = [
{
name: 'Inter',
data: readFileSync(path.join(__DIRNAME, 'assets', 'Inter-Bold.ttf')),
},
]
return new AppContext({
cfg,
appviewAgent,
fonts,
...overrides,
})
}
}
+41
View File
@@ -0,0 +1,41 @@
import events from 'node:events'
import http from 'node:http'
import express from 'express'
import {createHttpTerminator, HttpTerminator} from 'http-terminator'
import {Config} from './config.js'
import {AppContext} from './context.js'
import {default as routes, errorHandler} from './routes/index.js'
export * from './config.js'
export * from './logger.js'
export class CardService {
public server?: http.Server
private terminator?: HttpTerminator
constructor(public app: express.Application, public ctx: AppContext) {}
static async create(cfg: Config): Promise<CardService> {
let app = express()
const ctx = await AppContext.fromConfig(cfg)
app = routes(ctx, app)
app.use(errorHandler)
return new CardService(app, ctx)
}
async start() {
this.server = this.app.listen(this.ctx.cfg.service.port)
this.server.keepAliveTimeout = 90000
this.terminator = createHttpTerminator({server: this.server})
await events.once(this.server, 'listening')
}
async destroy() {
this.ctx.abortController.abort()
await this.terminator?.terminate()
}
}
+3
View File
@@ -0,0 +1,3 @@
import {subsystemLogger} from '@atproto/common'
export const httpLogger = subsystemLogger('bskyogcard')
+14
View File
@@ -0,0 +1,14 @@
import {Express} from 'express'
import {AppContext} from '../context.js'
import {handler} from './util.js'
export default function (ctx: AppContext, app: Express) {
return app.get(
'/_health',
handler(async (_req, res) => {
const {version} = ctx.cfg.service
return res.send({version})
}),
)
}
+13
View File
@@ -0,0 +1,13 @@
import {Express} from 'express'
import {AppContext} from '../context.js'
import {default as health} from './health.js'
import {default as starterPack} from './starter-pack.js'
export * from './util.js'
export default function (ctx: AppContext, app: Express) {
app = health(ctx, app) // GET /_health
app = starterPack(ctx, app) // GET /start/:actor/:rkey
return app
}
+102
View File
@@ -0,0 +1,102 @@
import assert from 'node:assert'
import React from 'react'
import {AppBskyGraphDefs, AtUri} from '@atproto/api'
import resvg from '@resvg/resvg-js'
import {Express} from 'express'
import satori from 'satori'
import {
StarterPack,
STARTERPACK_HEIGHT,
STARTERPACK_WIDTH,
} from '../components/StarterPack.js'
import {AppContext} from '../context.js'
import {httpLogger} from '../logger.js'
import {handler, originVerifyMiddleware} from './util.js'
export default function (ctx: AppContext, app: Express) {
return app.get(
'/start/:actor/:rkey',
originVerifyMiddleware(ctx),
handler(async (req, res) => {
const {actor, rkey} = req.params
const uri = AtUri.make(actor, 'app.bsky.graph.starterpack', rkey)
let starterPack: AppBskyGraphDefs.StarterPackView
try {
const result = await ctx.appviewAgent.api.app.bsky.graph.getStarterPack(
{starterPack: uri.toString()},
)
starterPack = result.data.starterPack
} catch (err) {
httpLogger.warn(
{err, uri: uri.toString()},
'could not fetch starter pack',
)
return res.status(404).end('not found')
}
const imageEntries = await Promise.all(
[starterPack.creator]
.concat(starterPack.listItemsSample.map(li => li.subject))
// has avatar
.filter(p => p.avatar)
// no sensitive labels
.filter(p => !p.labels.some(l => hideAvatarLabels.has(l.val)))
.map(async p => {
try {
assert(p.avatar)
const image = await getImage(p.avatar)
return [p.did, image] as const
} catch (err) {
httpLogger.warn(
{err, uri: uri.toString(), did: p.did},
'could not fetch image',
)
return [p.did, null] as const
}
}),
)
const images = new Map(
imageEntries.filter(([_, image]) => image !== null).slice(0, 7),
)
const svg = await satori(
<StarterPack starterPack={starterPack} images={images} />,
{
fonts: ctx.fonts,
height: STARTERPACK_HEIGHT,
width: STARTERPACK_WIDTH,
},
)
const output = await resvg.renderAsync(svg)
res.statusCode = 200
res.setHeader('content-type', 'image/png')
res.setHeader('cdn-tag', [...images.keys()].join(','))
return res.end(output.asPng())
}),
)
}
async function getImage(url: string) {
const response = await fetch(url)
const arrayBuf = await response.arrayBuffer() // must drain body even if it will be discarded
if (response.status !== 200) return null
return Buffer.from(arrayBuf)
}
const hideAvatarLabels = new Set([
'!hide',
'!warn',
'porn',
'sexual',
'nudity',
'sexual-figurative',
'graphic-media',
'self-harm',
'sensitive',
'security',
'impersonation',
'scam',
'spam',
'misleading',
'inauthentic',
])
+36
View File
@@ -0,0 +1,36 @@
import {ErrorRequestHandler, Request, RequestHandler, Response} from 'express'
import {AppContext} from '../context.js'
import {httpLogger} from '../logger.js'
export type Handler = (req: Request, res: Response) => Awaited<void>
export const handler = (runHandler: Handler): RequestHandler => {
return async (req, res, next) => {
try {
await runHandler(req, res)
} catch (err) {
next(err)
}
}
}
export function originVerifyMiddleware(ctx: AppContext): RequestHandler {
const {originVerify} = ctx.cfg.service
if (!originVerify) return (_req, _res, next) => next()
return (req, res, next) => {
const verifyHeader = req.headers['x-origin-verify']
if (verifyHeader !== originVerify) {
return res.status(404).end('not found')
}
next()
}
}
export const errorHandler: ErrorRequestHandler = (err, req, res, next) => {
httpLogger.error({err}, 'request error')
if (res.headersSent) {
return next(err)
}
return res.status(500).end('server error')
}
+11
View File
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "NodeNext",
"esModuleInterop": true,
"moduleResolution": "NodeNext",
"jsx": "react-jsx",
"outDir": "dist"
},
"include": ["./src/index.ts", "./src/bin.ts"]
}
+1113
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -207,6 +207,7 @@ func serve(cctx *cli.Context) error {
e.GET("/profile/:handleOrDID", server.WebProfile)
e.GET("/profile/:handleOrDID/follows", server.WebGeneric)
e.GET("/profile/:handleOrDID/followers", server.WebGeneric)
e.GET("/profile/:handleOrDID/known-followers", server.WebGeneric)
e.GET("/profile/:handleOrDID/lists/:rkey", server.WebGeneric)
e.GET("/profile/:handleOrDID/feed/:rkey", server.WebGeneric)
e.GET("/profile/:handleOrDID/feed/:rkey/liked-by", server.WebGeneric)
+1
View File
@@ -3,6 +3,7 @@
module.exports = {
rules: {
'avoid-unwrapped-text': require('./avoid-unwrapped-text'),
'use-exact-imports': require('./use-exact-imports'),
'use-typed-gates': require('./use-typed-gates'),
},
}
+22
View File
@@ -0,0 +1,22 @@
/* eslint-disable bsky-internal/use-exact-imports */
const BANNED_IMPORTS = [
'@fortawesome/free-regular-svg-icons',
'@fortawesome/free-solid-svg-icons',
]
exports.create = function create(context) {
return {
Literal(node) {
if (typeof node.value !== 'string') {
return
}
if (BANNED_IMPORTS.includes(node.value)) {
context.report({
node,
message:
'Import the specific thing you want instead of the entire package',
})
}
},
}
}
@@ -1,6 +0,0 @@
{
"platforms": ["ios"],
"ios": {
"modules": ["ExpoBlueskyTranslateModule"]
}
}
-6
View File
@@ -1,6 +0,0 @@
export {
isAvailable,
isLanguageSupported,
NativeTranslationModule,
NativeTranslationView,
} from './src/ExpoBlueskyTranslateView'
@@ -1,20 +0,0 @@
import ExpoModulesCore
import SwiftUI
// Thanks to Andrew Levy for this code snippet
// https://github.com/andrew-levy/swiftui-react-native/blob/d3fbb2abf07601ff0d4b83055e7717bb980910d6/ios/Common/ExpoView%2BUIHostingController.swift
extension ExpoView {
func setupHostingController(_ hostingController: UIHostingController<some View>) {
hostingController.view.translatesAutoresizingMaskIntoConstraints = false
hostingController.view.backgroundColor = .clear
addSubview(hostingController.view)
NSLayoutConstraint.activate([
hostingController.view.topAnchor.constraint(equalTo: self.topAnchor),
hostingController.view.bottomAnchor.constraint(equalTo: self.bottomAnchor),
hostingController.view.leftAnchor.constraint(equalTo: self.leftAnchor),
hostingController.view.rightAnchor.constraint(equalTo: self.rightAnchor),
])
}
}
@@ -1,21 +0,0 @@
Pod::Spec.new do |s|
s.name = 'ExpoBlueskyTranslate'
s.version = '1.0.0'
s.summary = 'Uses SwiftUI translation to translate text.'
s.description = 'Uses SwiftUI translation to translate text.'
s.author = ''
s.homepage = 'https://docs.expo.dev/modules/'
s.platforms = { :ios => '13.4' }
s.source = { git: '' }
s.static_framework = true
s.dependency 'ExpoModulesCore'
# Swift/Objective-C compatibility
s.pod_target_xcconfig = {
'DEFINES_MODULE' => 'YES',
'SWIFT_COMPILATION_MODE' => 'wholemodule'
}
s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}"
end
@@ -1,18 +0,0 @@
import ExpoModulesCore
import Foundation
import SwiftUI
public class ExpoBlueskyTranslateModule: Module {
public func definition() -> ModuleDefinition {
Name("ExpoBlueskyTranslate")
AsyncFunction("presentAsync") { (text: String) in
DispatchQueue.main.async { [weak state = TranslateViewState.shared] in
state?.isPresented = true
state?.text = text
}
}
View(ExpoBlueskyTranslateView.self) {}
}
}
@@ -1,22 +0,0 @@
import ExpoModulesCore
import Foundation
import SwiftUI
class TranslateViewState: ObservableObject {
static var shared = TranslateViewState()
@Published var isPresented = false
@Published var text = ""
}
class ExpoBlueskyTranslateView: ExpoView {
required init(appContext: AppContext? = nil) {
if #available(iOS 14.0, *) {
let hostingController = UIHostingController(rootView: TranslateView())
super.init(appContext: appContext)
setupHostingController(hostingController)
} else {
super.init(appContext: appContext)
}
}
}
@@ -1,31 +0,0 @@
import SwiftUI
// conditionally import the Translation module
#if canImport(Translation)
import Translation
#endif
struct TranslateView: View {
@ObservedObject var state = TranslateViewState.shared
var body: some View {
if #available(iOS 17.4, *) {
VStack {
UIViewRepresentableWrapper(view: UIView(frame: .zero))
}
.translationPresentation(
isPresented: $state.isPresented,
text: state.text
)
}
}
}
struct UIViewRepresentableWrapper: UIViewRepresentable {
let view: UIView
func makeUIView(context: Context) -> UIView {
return view
}
func updateUIView(_ uiView: UIView, context: Context) {}
}
@@ -1,3 +0,0 @@
export type ExpoBlueskyTranslateModule = {
presentAsync: (text: string) => Promise<void>
}
@@ -1,51 +0,0 @@
import React from 'react'
import {Platform} from 'react-native'
import {requireNativeModule, requireNativeViewManager} from 'expo-modules-core'
import {ExpoBlueskyTranslateModule} from './ExpoBlueskyTranslate.types'
export const NativeTranslationModule =
requireNativeModule<ExpoBlueskyTranslateModule>('ExpoBlueskyTranslate')
const NativeView: React.ComponentType = requireNativeViewManager(
'ExpoBlueskyTranslate',
)
export function NativeTranslationView() {
return <NativeView />
}
// can be something like "17.5.1", so just take the first two parts
const version = String(Platform.Version).split('.').slice(0, 2).join('.')
export const isAvailable = Number(version) >= 17.4
// https://en.wikipedia.org/wiki/Translate_(Apple)#Languages
const SUPPORTED_LANGUAGES = [
'ar',
'zh',
'zh',
'nl',
'en',
'en',
'fr',
'de',
'id',
'it',
'ja',
'ko',
'pl',
'pt',
'ru',
'es',
'th',
'tr',
'uk',
'vi',
]
export function isLanguageSupported(lang?: string) {
// If the language is not provided, we assume it is supported
if (!lang) return true
return SUPPORTED_LANGUAGES.includes(lang)
}
@@ -1,13 +0,0 @@
export const NativeTranslationModule = {
presentAsync: async (_: string) => {},
}
export function NativeTranslationView() {
return null
}
export const isAvailable = false
export function isLanguageSupported(_lang?: string) {
return false
}
+6 -4
View File
@@ -1,6 +1,6 @@
{
"name": "bsky.app",
"version": "1.86.0",
"version": "1.87.0",
"private": true,
"engines": {
"node": ">=18"
@@ -49,7 +49,7 @@
"open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web"
},
"dependencies": {
"@atproto/api": "^0.12.16",
"@atproto/api": "^0.12.20",
"@bam.tech/react-native-image-resizer": "^3.0.4",
"@braintree/sanitize-url": "^6.0.2",
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
@@ -58,8 +58,9 @@
"@expo/webpack-config": "^19.0.0",
"@floating-ui/dom": "^1.6.3",
"@floating-ui/react-dom": "^2.0.8",
"@formatjs/intl-locale": "^3.4.3",
"@formatjs/intl-pluralrules": "^5.2.10",
"@formatjs/intl-locale": "^4.0.0",
"@formatjs/intl-numberformat": "^8.10.3",
"@formatjs/intl-pluralrules": "^5.2.14",
"@fortawesome/fontawesome-svg-core": "^6.1.1",
"@fortawesome/free-regular-svg-icons": "^6.1.1",
"@fortawesome/free-solid-svg-icons": "^6.1.1",
@@ -271,6 +272,7 @@
"resolutions": {
"@types/react": "^18",
"**/zeed-dom": "0.10.9",
"**/zod": "3.23.8",
"**/expo-constants": "16.0.1",
"**/expo-device": "6.0.2",
"@react-native/babel-preset": "0.74.1"
@@ -1,9 +1,9 @@
diff --git a/node_modules/expo-haptics/android/src/main/java/expo/modules/haptics/HapticsModule.kt b/node_modules/expo-haptics/android/src/main/java/expo/modules/haptics/HapticsModule.kt
index 26c52af..b949a4c 100644
index 1520465..6ea988a 100644
--- a/node_modules/expo-haptics/android/src/main/java/expo/modules/haptics/HapticsModule.kt
+++ b/node_modules/expo-haptics/android/src/main/java/expo/modules/haptics/HapticsModule.kt
@@ -42,7 +42,7 @@ class HapticsModule : Module() {
private fun vibrate(type: HapticsVibrationType) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
- vibrator.vibrate(VibrationEffect.createWaveform(type.timings, type.amplitudes, -1))
@@ -1,11 +1,11 @@
diff --git a/node_modules/expo-updates/ios/EXUpdates/Update/ExpoUpdatesUpdate.swift b/node_modules/expo-updates/ios/EXUpdates/Update/ExpoUpdatesUpdate.swift
index b85291e..07a5d3c 100644
index b85291e..546709d 100644
--- a/node_modules/expo-updates/ios/EXUpdates/Update/ExpoUpdatesUpdate.swift
+++ b/node_modules/expo-updates/ios/EXUpdates/Update/ExpoUpdatesUpdate.swift
@@ -78,13 +78,20 @@ public final class ExpoUpdatesUpdate: Update {
status = UpdateStatus.StatusPending
}
+ // Instead of relying on various hacks to get the correct format for the specific
+ // platform on the backend, we can just add this little patch..
+ let dateFormatter = DateFormatter()
+36 -10
View File
@@ -1,3 +1,29 @@
diff --git a/node_modules/react-native/Libraries/Text/TextInput/RCTBaseTextInputView.mm b/node_modules/react-native/Libraries/Text/TextInput/RCTBaseTextInputView.mm
index b0d71dc..9974932 100644
--- a/node_modules/react-native/Libraries/Text/TextInput/RCTBaseTextInputView.mm
+++ b/node_modules/react-native/Libraries/Text/TextInput/RCTBaseTextInputView.mm
@@ -377,10 +377,6 @@ - (void)textInputDidBeginEditing
self.backedTextInputView.attributedText = [NSAttributedString new];
}
- if (_selectTextOnFocus) {
- [self.backedTextInputView selectAll:nil];
- }
-
[_eventDispatcher sendTextEventWithType:RCTTextEventTypeFocus
reactTag:self.reactTag
text:[self.backedTextInputView.attributedText.string copy]
@@ -611,6 +607,10 @@ - (UIView *)reactAccessibilityElement
- (void)reactFocus
{
[self.backedTextInputView reactFocus];
+
+ if (_selectTextOnFocus) {
+ [self.backedTextInputView selectAll:nil];
+ }
}
- (void)reactBlur
diff --git a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.h b/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.h
index e9b330f..1ecdf0a 100644
--- a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.h
@@ -5,7 +31,7 @@ index e9b330f..1ecdf0a 100644
@@ -16,4 +16,6 @@
@property (nonatomic, copy) RCTDirectEventBlock onRefresh;
@property (nonatomic, weak) UIScrollView *scrollView;
+- (void)forwarderBeginRefreshing;
+
@end
@@ -16,7 +42,7 @@ index b09e653..4c32b31 100644
@@ -198,9 +198,53 @@ - (void)refreshControlValueChanged
[self setCurrentRefreshingState:super.refreshing];
_refreshingProgrammatically = NO;
+ if (@available(iOS 17.4, *)) {
+ if (_currentRefreshingState) {
+ UIImpactFeedbackGenerator *feedbackGenerator = [[UIImpactFeedbackGenerator alloc] initWithStyle:UIImpactFeedbackStyleLight];
@@ -29,7 +55,7 @@ index b09e653..4c32b31 100644
_onRefresh(nil);
}
}
+/*
+ This method is used by Bluesky's ExpoScrollForwarder. This allows other React Native
+ libraries to perform a refresh of a scrollview and access the refresh control's onRefresh
@@ -38,15 +64,15 @@ index b09e653..4c32b31 100644
+- (void)forwarderBeginRefreshing
+{
+ _refreshingProgrammatically = NO;
+
+
+ [self sizeToFit];
+
+
+ if (!self.scrollView) {
+ return;
+ }
+
+
+ UIScrollView *scrollView = (UIScrollView *)self.scrollView;
+
+
+ [UIView animateWithDuration:0.3
+ delay:0
+ options:UIViewAnimationOptionBeginFromCurrentState
@@ -58,7 +84,7 @@ index b09e653..4c32b31 100644
+ completion:^(__unused BOOL finished) {
+ [super beginRefreshing];
+ [self setCurrentRefreshingState:super.refreshing];
+
+
+ if (self->_onRefresh) {
+ self->_onRefresh(nil);
+ }
@@ -73,7 +99,7 @@ index 5f5e1ab..aac00b6 100644
+++ b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/core/JavaTimerManager.java
@@ -99,8 +99,9 @@ public class JavaTimerManager {
}
// If the JS thread is busy for multiple frames we cancel any other pending runnable.
- if (mCurrentIdleCallbackRunnable != null) {
- mCurrentIdleCallbackRunnable.cancel();
@@ -81,5 +107,5 @@ index 5f5e1ab..aac00b6 100644
+ if (currentRunnable != null) {
+ currentRunnable.cancel();
}
mCurrentIdleCallbackRunnable = new IdleCallbackRunnable(frameTimeNanos);
+7
View File
@@ -11,3 +11,10 @@ in the RN repo: https://github.com/facebook/react-native/issues/43388
Patching `RCTRefreshControl.m` and `RCTRefreshControl.h` to add a new `forwarderBeginRefreshing` method to the class.
This method is used by `ExpoScrollForwarder` to initiate a refresh of the underlying `UIScrollView` from inside that
module.
## TextInput Patch - `selectTextOnFocus` fix
Patching `RCTBaseTextInputView.m` to fix an issue where `selectTextOnFocus` does not work as expected on iOS 17. This
patch _only_ fixes the Paper version of `TextInput`. If we migrate to Fabric and the fix has not been made upstream,
we can apply the same fix. See https://github.com/facebook/react-native/pull/44307.
+44 -34
View File
@@ -14,36 +14,41 @@ import * as SplashScreen from 'expo-splash-screen'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {Provider as StatsigProvider} from '#/lib/statsig/statsig'
import {useIntentHandler} from '#/lib/hooks/useIntentHandler'
import {QueryProvider} from '#/lib/react-query'
import {
initialize,
Provider as StatsigProvider,
tryFetchGates,
} from '#/lib/statsig/statsig'
import {s} from '#/lib/styles'
import {ThemeProvider} from '#/lib/ThemeContext'
import {logger} from '#/logger'
import {Provider as A11yProvider} from '#/state/a11y'
import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes'
import {Provider as DialogStateProvider} from '#/state/dialogs'
import {Provider as InvitesStateProvider} from '#/state/invites'
import {Provider as LightboxStateProvider} from '#/state/lightbox'
import {MessagesProvider} from '#/state/messages'
import {Provider as ModalStateProvider} from '#/state/modals'
import {init as initPersistedState} from '#/state/persisted'
import {Provider as PrefsStateProvider} from '#/state/preferences'
import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs'
import {Provider as ModerationOptsProvider} from '#/state/preferences/moderation-opts'
import {readLastActiveAccount} from '#/state/session/util'
import {useIntentHandler} from 'lib/hooks/useIntentHandler'
import {QueryProvider} from 'lib/react-query'
import {s} from 'lib/styles'
import {ThemeProvider} from 'lib/ThemeContext'
import {Provider as DialogStateProvider} from 'state/dialogs'
import {Provider as InvitesStateProvider} from 'state/invites'
import {Provider as LightboxStateProvider} from 'state/lightbox'
import {Provider as ModalStateProvider} from 'state/modals'
import {Provider as MutedThreadsProvider} from 'state/muted-threads'
import {Provider as PrefsStateProvider} from 'state/preferences'
import {Provider as UnreadNotifsProvider} from 'state/queries/notifications/unread'
import {Provider as UnreadNotifsProvider} from '#/state/queries/notifications/unread'
import {
Provider as SessionProvider,
SessionAccount,
useSession,
useSessionApi,
} from 'state/session'
import {Provider as ShellStateProvider} from 'state/shell'
import {Provider as LoggedOutViewProvider} from 'state/shell/logged-out'
import {Provider as SelectedFeedProvider} from 'state/shell/selected-feed'
import {TestCtrls} from 'view/com/testing/TestCtrls'
import * as Toast from 'view/com/util/Toast'
import {Shell} from 'view/shell'
} from '#/state/session'
import {readLastActiveAccount} from '#/state/session/util'
import {Provider as ShellStateProvider} from '#/state/shell'
import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out'
import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
import {TestCtrls} from '#/view/com/testing/TestCtrls'
import * as Toast from '#/view/com/util/Toast'
import {Shell} from '#/view/shell'
import {ThemeProvider as Alf} from '#/alf'
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
import {Provider as PortalProvider} from '#/components/Portal'
@@ -69,6 +74,9 @@ function InnerApp() {
try {
if (account) {
await resumeSession(account)
} else {
await initialize()
await tryFetchGates(undefined, 'prefer-fresh-gates')
}
} catch (e) {
logger.error(`session: resume failed`, {message: e})
@@ -105,10 +113,12 @@ function InnerApp() {
<SelectedFeedProvider>
<UnreadNotifsProvider>
<BackgroundNotificationPreferencesProvider>
<GestureHandlerRootView style={s.h100pct}>
<TestCtrls />
<Shell />
</GestureHandlerRootView>
<MutedThreadsProvider>
<GestureHandlerRootView style={s.h100pct}>
<TestCtrls />
<Shell />
</GestureHandlerRootView>
</MutedThreadsProvider>
</BackgroundNotificationPreferencesProvider>
</UnreadNotifsProvider>
</SelectedFeedProvider>
@@ -143,11 +153,11 @@ function App() {
* that is set up in the InnerApp component above.
*/
return (
<KeyboardProvider enabled={true} statusBarTranslucent={true}>
<SessionProvider>
<ShellStateProvider>
<PrefsStateProvider>
<MutedThreadsProvider>
<A11yProvider>
<KeyboardProvider enabled={false} statusBarTranslucent={true}>
<SessionProvider>
<ShellStateProvider>
<PrefsStateProvider>
<InvitesStateProvider>
<ModalStateProvider>
<DialogStateProvider>
@@ -161,11 +171,11 @@ function App() {
</DialogStateProvider>
</ModalStateProvider>
</InvitesStateProvider>
</MutedThreadsProvider>
</PrefsStateProvider>
</ShellStateProvider>
</SessionProvider>
</KeyboardProvider>
</PrefsStateProvider>
</ShellStateProvider>
</SessionProvider>
</KeyboardProvider>
</A11yProvider>
)
}
+32 -29
View File
@@ -8,35 +8,36 @@ import {SafeAreaProvider} from 'react-native-safe-area-context'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useIntentHandler} from '#/lib/hooks/useIntentHandler'
import {QueryProvider} from '#/lib/react-query'
import {Provider as StatsigProvider} from '#/lib/statsig/statsig'
import {ThemeProvider} from '#/lib/ThemeContext'
import {logger} from '#/logger'
import {Provider as A11yProvider} from '#/state/a11y'
import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes'
import {Provider as DialogStateProvider} from '#/state/dialogs'
import {Provider as InvitesStateProvider} from '#/state/invites'
import {Provider as LightboxStateProvider} from '#/state/lightbox'
import {MessagesProvider} from '#/state/messages'
import {Provider as ModalStateProvider} from '#/state/modals'
import {init as initPersistedState} from '#/state/persisted'
import {Provider as PrefsStateProvider} from '#/state/preferences'
import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs'
import {Provider as ModerationOptsProvider} from '#/state/preferences/moderation-opts'
import {readLastActiveAccount} from '#/state/session/util'
import {useIntentHandler} from 'lib/hooks/useIntentHandler'
import {QueryProvider} from 'lib/react-query'
import {ThemeProvider} from 'lib/ThemeContext'
import {Provider as DialogStateProvider} from 'state/dialogs'
import {Provider as InvitesStateProvider} from 'state/invites'
import {Provider as LightboxStateProvider} from 'state/lightbox'
import {Provider as ModalStateProvider} from 'state/modals'
import {Provider as MutedThreadsProvider} from 'state/muted-threads'
import {Provider as PrefsStateProvider} from 'state/preferences'
import {Provider as UnreadNotifsProvider} from 'state/queries/notifications/unread'
import {Provider as UnreadNotifsProvider} from '#/state/queries/notifications/unread'
import {
Provider as SessionProvider,
SessionAccount,
useSession,
useSessionApi,
} from 'state/session'
import {Provider as ShellStateProvider} from 'state/shell'
import {Provider as LoggedOutViewProvider} from 'state/shell/logged-out'
import {Provider as SelectedFeedProvider} from 'state/shell/selected-feed'
import * as Toast from 'view/com/util/Toast'
import {ToastContainer} from 'view/com/util/Toast.web'
import {Shell} from 'view/shell/index'
} from '#/state/session'
import {readLastActiveAccount} from '#/state/session/util'
import {Provider as ShellStateProvider} from '#/state/shell'
import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out'
import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
import * as Toast from '#/view/com/util/Toast'
import {ToastContainer} from '#/view/com/util/Toast.web'
import {Shell} from '#/view/shell/index'
import {ThemeProvider as Alf} from '#/alf'
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
import {Provider as PortalProvider} from '#/components/Portal'
@@ -96,9 +97,11 @@ function InnerApp() {
<SelectedFeedProvider>
<UnreadNotifsProvider>
<BackgroundNotificationPreferencesProvider>
<SafeAreaProvider>
<Shell />
</SafeAreaProvider>
<MutedThreadsProvider>
<SafeAreaProvider>
<Shell />
</SafeAreaProvider>
</MutedThreadsProvider>
</BackgroundNotificationPreferencesProvider>
</UnreadNotifsProvider>
</SelectedFeedProvider>
@@ -133,10 +136,10 @@ function App() {
* that is set up in the InnerApp component above.
*/
return (
<SessionProvider>
<ShellStateProvider>
<PrefsStateProvider>
<MutedThreadsProvider>
<A11yProvider>
<SessionProvider>
<ShellStateProvider>
<PrefsStateProvider>
<InvitesStateProvider>
<ModalStateProvider>
<DialogStateProvider>
@@ -150,10 +153,10 @@ function App() {
</DialogStateProvider>
</ModalStateProvider>
</InvitesStateProvider>
</MutedThreadsProvider>
</PrefsStateProvider>
</ShellStateProvider>
</SessionProvider>
</PrefsStateProvider>
</ShellStateProvider>
</SessionProvider>
</A11yProvider>
)
}
+11 -28
View File
@@ -22,7 +22,6 @@ import {buildStateObject} from 'lib/routes/helpers'
import {
AllNavigatorParams,
BottomTabNavigatorParams,
FeedsTabNavigatorParams,
FlatNavigatorParams,
HomeTabNavigatorParams,
MessagesTabNavigatorParams,
@@ -42,6 +41,7 @@ import {PreferencesThreads} from 'view/screens/PreferencesThreads'
import {SavedFeeds} from 'view/screens/SavedFeeds'
import HashtagScreen from '#/screens/Hashtag'
import {ModerationScreen} from '#/screens/Moderation'
import {ProfileKnownFollowersScreen} from '#/screens/Profile/KnownFollowers'
import {ProfileLabelerLikedByScreen} from '#/screens/Profile/ProfileLabelerLikedBy'
import {init as initAnalytics} from './lib/analytics/analytics'
import {useWebScrollRestoration} from './lib/hooks/useWebScrollRestoration'
@@ -54,8 +54,8 @@ import {useModalControls} from './state/modals'
import {useUnreadNotifications} from './state/queries/notifications/unread'
import {useSession} from './state/session'
import {
setEmailConfirmationRequested,
shouldRequestEmailConfirmation,
snoozeEmailConfirmationPrompt,
} from './state/shell/reminders'
import {AccessibilitySettingsScreen} from './view/screens/AccessibilitySettings'
import {CommunityGuidelinesScreen} from './view/screens/CommunityGuidelines'
@@ -91,7 +91,6 @@ const navigationRef = createNavigationContainerRef<AllNavigatorParams>()
const HomeTab = createNativeStackNavigatorWithAuth<HomeTabNavigatorParams>()
const SearchTab = createNativeStackNavigatorWithAuth<SearchTabNavigatorParams>()
const FeedsTab = createNativeStackNavigatorWithAuth<FeedsTabNavigatorParams>()
const NotificationsTab =
createNativeStackNavigatorWithAuth<NotificationsTabNavigatorParams>()
const MyProfileTab =
@@ -171,6 +170,13 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
title: title(msg`People followed by @${route.params.name}`),
})}
/>
<Stack.Screen
name="ProfileKnownFollowers"
getComponent={() => ProfileKnownFollowersScreen}
options={({route}) => ({
title: title(msg`Followers of @${route.params.name} that you know`),
})}
/>
<Stack.Screen
name="ProfileList"
getComponent={() => ProfileListScreen}
@@ -306,6 +312,7 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
getComponent={() => MessagesSettingsScreen}
options={{title: title(msg`Chat settings`), requireAuth: true}}
/>
<Stack.Screen name="Feeds" getComponent={() => FeedsScreen} />
</>
)
}
@@ -330,7 +337,6 @@ function TabsNavigator() {
tabBar={tabBar}>
<Tab.Screen name="HomeTab" getComponent={() => HomeTabNavigator} />
<Tab.Screen name="SearchTab" getComponent={() => SearchTabNavigator} />
<Tab.Screen name="FeedsTab" getComponent={() => FeedsTabNavigator} />
<Tab.Screen
name="NotificationsTab"
getComponent={() => NotificationsTabNavigator}
@@ -384,24 +390,6 @@ function SearchTabNavigator() {
)
}
function FeedsTabNavigator() {
const pal = usePalette('default')
return (
<FeedsTab.Navigator
screenOptions={{
animation: isAndroid ? 'ios' : undefined,
animationDuration: 285,
gestureEnabled: true,
fullScreenGestureEnabled: true,
headerShown: false,
contentStyle: pal.view,
}}>
<FeedsTab.Screen name="Feeds" getComponent={() => FeedsScreen} />
{commonScreens(FeedsTab as typeof HomeTab)}
</FeedsTab.Navigator>
)
}
function NotificationsTabNavigator() {
const pal = usePalette('default')
return (
@@ -505,11 +493,6 @@ const FlatNavigator = () => {
getComponent={() => SearchScreen}
options={{title: title(msg`Search`)}}
/>
<Flat.Screen
name="Feeds"
getComponent={() => FeedsScreen}
options={{title: title(msg`Feeds`)}}
/>
<Flat.Screen
name="Notifications"
getComponent={() => NotificationsScreen}
@@ -602,7 +585,7 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
if (currentAccount && shouldRequestEmailConfirmation(currentAccount)) {
openModal({name: 'verify-email', showReminder: true})
setEmailConfirmationRequested()
snoozeEmailConfirmationPrompt()
}
}
+3
View File
@@ -267,6 +267,9 @@ export const atoms = {
font_bold: {
fontWeight: tokens.fontWeight.bold,
},
font_heavy: {
fontWeight: tokens.fontWeight.heavy,
},
italic: {
fontStyle: 'italic',
},
+1
View File
@@ -118,6 +118,7 @@ export const fontWeight = {
normal: '400',
semibold: '500',
bold: '600',
heavy: '700',
} as const
export const gradients = {
+343 -324
View File
@@ -88,336 +88,355 @@ export function useButtonContext() {
return React.useContext(Context)
}
export function Button({
children,
variant,
color,
size,
shape = 'default',
label,
disabled = false,
style,
hoverStyle: hoverStyleProp,
...rest
}: ButtonProps) {
const t = useTheme()
const [state, setState] = React.useState({
pressed: false,
hovered: false,
focused: false,
})
const onPressIn = React.useCallback(() => {
setState(s => ({
...s,
pressed: true,
}))
}, [setState])
const onPressOut = React.useCallback(() => {
setState(s => ({
...s,
pressed: false,
}))
}, [setState])
const onHoverIn = React.useCallback(() => {
setState(s => ({
...s,
hovered: true,
}))
}, [setState])
const onHoverOut = React.useCallback(() => {
setState(s => ({
...s,
hovered: false,
}))
}, [setState])
const onFocus = React.useCallback(() => {
setState(s => ({
...s,
focused: true,
}))
}, [setState])
const onBlur = React.useCallback(() => {
setState(s => ({
...s,
focused: false,
}))
}, [setState])
const {baseStyles, hoverStyles} = React.useMemo(() => {
const baseStyles: ViewStyle[] = []
const hoverStyles: ViewStyle[] = []
const light = t.name === 'light'
if (color === 'primary') {
if (variant === 'solid') {
if (!disabled) {
baseStyles.push({
backgroundColor: t.palette.primary_500,
})
hoverStyles.push({
backgroundColor: t.palette.primary_600,
})
} else {
baseStyles.push({
backgroundColor: t.palette.primary_700,
})
}
} else if (variant === 'outline') {
baseStyles.push(a.border, t.atoms.bg, {
borderWidth: 1,
})
if (!disabled) {
baseStyles.push(a.border, {
borderColor: t.palette.primary_500,
})
hoverStyles.push(a.border, {
backgroundColor: light
? t.palette.primary_50
: t.palette.primary_950,
})
} else {
baseStyles.push(a.border, {
borderColor: light ? t.palette.primary_200 : t.palette.primary_900,
})
}
} else if (variant === 'ghost') {
if (!disabled) {
baseStyles.push(t.atoms.bg)
hoverStyles.push({
backgroundColor: light
? t.palette.primary_100
: t.palette.primary_900,
})
}
}
} else if (color === 'secondary') {
if (variant === 'solid') {
if (!disabled) {
baseStyles.push({
backgroundColor: t.palette.contrast_25,
})
hoverStyles.push({
backgroundColor: t.palette.contrast_50,
})
} else {
baseStyles.push({
backgroundColor: t.palette.contrast_100,
})
}
} else if (variant === 'outline') {
baseStyles.push(a.border, t.atoms.bg, {
borderWidth: 1,
})
if (!disabled) {
baseStyles.push(a.border, {
borderColor: t.palette.contrast_300,
})
hoverStyles.push(t.atoms.bg_contrast_50)
} else {
baseStyles.push(a.border, {
borderColor: t.palette.contrast_200,
})
}
} else if (variant === 'ghost') {
if (!disabled) {
baseStyles.push(t.atoms.bg)
hoverStyles.push({
backgroundColor: t.palette.contrast_100,
})
}
}
} else if (color === 'negative') {
if (variant === 'solid') {
if (!disabled) {
baseStyles.push({
backgroundColor: t.palette.negative_500,
})
hoverStyles.push({
backgroundColor: t.palette.negative_600,
})
} else {
baseStyles.push({
backgroundColor: t.palette.negative_700,
})
}
} else if (variant === 'outline') {
baseStyles.push(a.border, t.atoms.bg, {
borderWidth: 1,
})
if (!disabled) {
baseStyles.push(a.border, {
borderColor: t.palette.negative_500,
})
hoverStyles.push(a.border, {
backgroundColor: light
? t.palette.negative_50
: t.palette.negative_975,
})
} else {
baseStyles.push(a.border, {
borderColor: light
? t.palette.negative_200
: t.palette.negative_900,
})
}
} else if (variant === 'ghost') {
if (!disabled) {
baseStyles.push(t.atoms.bg)
hoverStyles.push({
backgroundColor: light
? t.palette.negative_100
: t.palette.negative_975,
})
}
}
}
if (shape === 'default') {
if (size === 'large') {
baseStyles.push({paddingVertical: 15}, a.px_2xl, a.rounded_sm, a.gap_md)
} else if (size === 'medium') {
baseStyles.push({paddingVertical: 12}, a.px_2xl, a.rounded_sm, a.gap_md)
} else if (size === 'small') {
baseStyles.push({paddingVertical: 9}, a.px_lg, a.rounded_sm, a.gap_sm)
} else if (size === 'xsmall') {
baseStyles.push({paddingVertical: 6}, a.px_sm, a.rounded_sm, a.gap_sm)
} else if (size === 'tiny') {
baseStyles.push({paddingVertical: 4}, a.px_sm, a.rounded_xs, a.gap_xs)
}
} else if (shape === 'round' || shape === 'square') {
if (size === 'large') {
if (shape === 'round') {
baseStyles.push({height: 54, width: 54})
} else {
baseStyles.push({height: 50, width: 50})
}
} else if (size === 'small') {
baseStyles.push({height: 34, width: 34})
} else if (size === 'xsmall') {
baseStyles.push({height: 28, width: 28})
} else if (size === 'tiny') {
baseStyles.push({height: 20, width: 20})
}
if (shape === 'round') {
baseStyles.push(a.rounded_full)
} else if (shape === 'square') {
if (size === 'tiny') {
baseStyles.push(a.rounded_xs)
} else {
baseStyles.push(a.rounded_sm)
}
}
}
return {
baseStyles,
hoverStyles,
}
}, [t, variant, color, size, shape, disabled])
const {gradientColors, gradientHoverColors, gradientLocations} =
React.useMemo(() => {
const colors: string[] = []
const hoverColors: string[] = []
const locations: number[] = []
const gradient = {
primary: tokens.gradients.sky,
secondary: tokens.gradients.sky,
negative: tokens.gradients.sky,
gradient_sky: tokens.gradients.sky,
gradient_midnight: tokens.gradients.midnight,
gradient_sunrise: tokens.gradients.sunrise,
gradient_sunset: tokens.gradients.sunset,
gradient_nordic: tokens.gradients.nordic,
gradient_bonfire: tokens.gradients.bonfire,
}[color || 'primary']
if (variant === 'gradient') {
colors.push(...gradient.values.map(([_, color]) => color))
hoverColors.push(...gradient.values.map(_ => gradient.hover_value))
locations.push(...gradient.values.map(([location, _]) => location))
}
return {
gradientColors: colors,
gradientHoverColors: hoverColors,
gradientLocations: locations,
}
}, [variant, color])
const context = React.useMemo<ButtonContext>(
() => ({
...state,
export const Button = React.forwardRef<View, ButtonProps>(
(
{
children,
variant,
color,
size,
disabled: disabled || false,
}),
[state, variant, color, size, disabled],
)
shape = 'default',
label,
disabled = false,
style,
hoverStyle: hoverStyleProp,
...rest
},
ref,
) => {
const t = useTheme()
const [state, setState] = React.useState({
pressed: false,
hovered: false,
focused: false,
})
const flattenedBaseStyles = flatten(baseStyles)
const onPressIn = React.useCallback(() => {
setState(s => ({
...s,
pressed: true,
}))
}, [setState])
const onPressOut = React.useCallback(() => {
setState(s => ({
...s,
pressed: false,
}))
}, [setState])
const onHoverIn = React.useCallback(() => {
setState(s => ({
...s,
hovered: true,
}))
}, [setState])
const onHoverOut = React.useCallback(() => {
setState(s => ({
...s,
hovered: false,
}))
}, [setState])
const onFocus = React.useCallback(() => {
setState(s => ({
...s,
focused: true,
}))
}, [setState])
const onBlur = React.useCallback(() => {
setState(s => ({
...s,
focused: false,
}))
}, [setState])
return (
<Pressable
role="button"
accessibilityHint={undefined} // optional
{...rest}
aria-label={label}
aria-pressed={state.pressed}
accessibilityLabel={label}
disabled={disabled || false}
accessibilityState={{
const {baseStyles, hoverStyles} = React.useMemo(() => {
const baseStyles: ViewStyle[] = []
const hoverStyles: ViewStyle[] = []
const light = t.name === 'light'
if (color === 'primary') {
if (variant === 'solid') {
if (!disabled) {
baseStyles.push({
backgroundColor: t.palette.primary_500,
})
hoverStyles.push({
backgroundColor: t.palette.primary_600,
})
} else {
baseStyles.push({
backgroundColor: t.palette.primary_700,
})
}
} else if (variant === 'outline') {
baseStyles.push(a.border, t.atoms.bg, {
borderWidth: 1,
})
if (!disabled) {
baseStyles.push(a.border, {
borderColor: t.palette.primary_500,
})
hoverStyles.push(a.border, {
backgroundColor: light
? t.palette.primary_50
: t.palette.primary_950,
})
} else {
baseStyles.push(a.border, {
borderColor: light
? t.palette.primary_200
: t.palette.primary_900,
})
}
} else if (variant === 'ghost') {
if (!disabled) {
baseStyles.push(t.atoms.bg)
hoverStyles.push({
backgroundColor: light
? t.palette.primary_100
: t.palette.primary_900,
})
}
}
} else if (color === 'secondary') {
if (variant === 'solid') {
if (!disabled) {
baseStyles.push({
backgroundColor: t.palette.contrast_25,
})
hoverStyles.push({
backgroundColor: t.palette.contrast_50,
})
} else {
baseStyles.push({
backgroundColor: t.palette.contrast_100,
})
}
} else if (variant === 'outline') {
baseStyles.push(a.border, t.atoms.bg, {
borderWidth: 1,
})
if (!disabled) {
baseStyles.push(a.border, {
borderColor: t.palette.contrast_300,
})
hoverStyles.push(t.atoms.bg_contrast_50)
} else {
baseStyles.push(a.border, {
borderColor: t.palette.contrast_200,
})
}
} else if (variant === 'ghost') {
if (!disabled) {
baseStyles.push(t.atoms.bg)
hoverStyles.push({
backgroundColor: t.palette.contrast_25,
})
}
}
} else if (color === 'negative') {
if (variant === 'solid') {
if (!disabled) {
baseStyles.push({
backgroundColor: t.palette.negative_500,
})
hoverStyles.push({
backgroundColor: t.palette.negative_600,
})
} else {
baseStyles.push({
backgroundColor: t.palette.negative_700,
})
}
} else if (variant === 'outline') {
baseStyles.push(a.border, t.atoms.bg, {
borderWidth: 1,
})
if (!disabled) {
baseStyles.push(a.border, {
borderColor: t.palette.negative_500,
})
hoverStyles.push(a.border, {
backgroundColor: light
? t.palette.negative_50
: t.palette.negative_975,
})
} else {
baseStyles.push(a.border, {
borderColor: light
? t.palette.negative_200
: t.palette.negative_900,
})
}
} else if (variant === 'ghost') {
if (!disabled) {
baseStyles.push(t.atoms.bg)
hoverStyles.push({
backgroundColor: light
? t.palette.negative_100
: t.palette.negative_975,
})
}
}
}
if (shape === 'default') {
if (size === 'large') {
baseStyles.push(
{paddingVertical: 15},
a.px_2xl,
a.rounded_sm,
a.gap_md,
)
} else if (size === 'medium') {
baseStyles.push(
{paddingVertical: 12},
a.px_2xl,
a.rounded_sm,
a.gap_md,
)
} else if (size === 'small') {
baseStyles.push({paddingVertical: 9}, a.px_lg, a.rounded_sm, a.gap_sm)
} else if (size === 'xsmall') {
baseStyles.push({paddingVertical: 6}, a.px_sm, a.rounded_sm, a.gap_sm)
} else if (size === 'tiny') {
baseStyles.push({paddingVertical: 4}, a.px_sm, a.rounded_xs, a.gap_xs)
}
} else if (shape === 'round' || shape === 'square') {
if (size === 'large') {
if (shape === 'round') {
baseStyles.push({height: 54, width: 54})
} else {
baseStyles.push({height: 50, width: 50})
}
} else if (size === 'small') {
baseStyles.push({height: 34, width: 34})
} else if (size === 'xsmall') {
baseStyles.push({height: 28, width: 28})
} else if (size === 'tiny') {
baseStyles.push({height: 20, width: 20})
}
if (shape === 'round') {
baseStyles.push(a.rounded_full)
} else if (shape === 'square') {
if (size === 'tiny') {
baseStyles.push(a.rounded_xs)
} else {
baseStyles.push(a.rounded_sm)
}
}
}
return {
baseStyles,
hoverStyles,
}
}, [t, variant, color, size, shape, disabled])
const {gradientColors, gradientHoverColors, gradientLocations} =
React.useMemo(() => {
const colors: string[] = []
const hoverColors: string[] = []
const locations: number[] = []
const gradient = {
primary: tokens.gradients.sky,
secondary: tokens.gradients.sky,
negative: tokens.gradients.sky,
gradient_sky: tokens.gradients.sky,
gradient_midnight: tokens.gradients.midnight,
gradient_sunrise: tokens.gradients.sunrise,
gradient_sunset: tokens.gradients.sunset,
gradient_nordic: tokens.gradients.nordic,
gradient_bonfire: tokens.gradients.bonfire,
}[color || 'primary']
if (variant === 'gradient') {
colors.push(...gradient.values.map(([_, color]) => color))
hoverColors.push(...gradient.values.map(_ => gradient.hover_value))
locations.push(...gradient.values.map(([location, _]) => location))
}
return {
gradientColors: colors,
gradientHoverColors: hoverColors,
gradientLocations: locations,
}
}, [variant, color])
const context = React.useMemo<ButtonContext>(
() => ({
...state,
variant,
color,
size,
disabled: disabled || false,
}}
style={[
a.flex_row,
a.align_center,
a.justify_center,
flattenedBaseStyles,
flatten(style),
...(state.hovered || state.pressed
? [hoverStyles, flatten(hoverStyleProp)]
: []),
]}
onPressIn={onPressIn}
onPressOut={onPressOut}
onHoverIn={onHoverIn}
onHoverOut={onHoverOut}
onFocus={onFocus}
onBlur={onBlur}>
{variant === 'gradient' && (
<View
style={[
a.absolute,
a.inset_0,
a.overflow_hidden,
{borderRadius: flattenedBaseStyles.borderRadius},
]}>
<LinearGradient
colors={
state.hovered || state.pressed
? gradientHoverColors
: gradientColors
}
locations={gradientLocations}
start={{x: 0, y: 0}}
end={{x: 1, y: 1}}
style={[a.absolute, a.inset_0]}
/>
</View>
)}
<Context.Provider value={context}>
{typeof children === 'function' ? children(context) : children}
</Context.Provider>
</Pressable>
)
}
}),
[state, variant, color, size, disabled],
)
const flattenedBaseStyles = flatten(baseStyles)
return (
<Pressable
role="button"
accessibilityHint={undefined} // optional
{...rest}
ref={ref}
aria-label={label}
aria-pressed={state.pressed}
accessibilityLabel={label}
disabled={disabled || false}
accessibilityState={{
disabled: disabled || false,
}}
style={[
a.flex_row,
a.align_center,
a.justify_center,
flattenedBaseStyles,
flatten(style),
...(state.hovered || state.pressed
? [hoverStyles, flatten(hoverStyleProp)]
: []),
]}
onPressIn={onPressIn}
onPressOut={onPressOut}
onHoverIn={onHoverIn}
onHoverOut={onHoverOut}
onFocus={onFocus}
onBlur={onBlur}>
{variant === 'gradient' && (
<View
style={[
a.absolute,
a.inset_0,
a.overflow_hidden,
{borderRadius: flattenedBaseStyles.borderRadius},
]}>
<LinearGradient
colors={
state.hovered || state.pressed
? gradientHoverColors
: gradientColors
}
locations={gradientLocations}
start={{x: 0, y: 0}}
end={{x: 1, y: 1}}
style={[a.absolute, a.inset_0]}
/>
</View>
)}
<Context.Provider value={context}>
{typeof children === 'function' ? children(context) : children}
</Context.Provider>
</Pressable>
)
},
)
Button.displayName = 'Button'
export function useSharedButtonTextStyles() {
const t = useTheme()
+205
View File
@@ -0,0 +1,205 @@
import React from 'react'
import {GestureResponderEvent, View} from 'react-native'
import {AppBskyActorDefs, AppBskyFeedDefs, AtUri} from '@atproto/api'
import {msg, plural, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {logger} from '#/logger'
import {
useAddSavedFeedsMutation,
usePreferencesQuery,
useRemoveFeedMutation,
} from '#/state/queries/preferences'
import {sanitizeHandle} from 'lib/strings/handles'
import {useSession} from 'state/session'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import * as Toast from 'view/com/util/Toast'
import {useTheme} from '#/alf'
import {atoms as a} from '#/alf'
import {Button, ButtonIcon} from '#/components/Button'
import {useRichText} from '#/components/hooks/useRichText'
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
import {Link as InternalLink} from '#/components/Link'
import {Loader} from '#/components/Loader'
import * as Prompt from '#/components/Prompt'
import {RichText} from '#/components/RichText'
import {Text} from '#/components/Typography'
export function Default({feed}: {feed: AppBskyFeedDefs.GeneratorView}) {
return (
<Link feed={feed}>
<Outer>
<Header>
<Avatar src={feed.avatar} />
<TitleAndByline title={feed.displayName} creator={feed.creator} />
<Action uri={feed.uri} pin />
</Header>
<Description description={feed.description} />
<Likes count={feed.likeCount || 0} />
</Outer>
</Link>
)
}
export function Link({
children,
feed,
}: {
children: React.ReactElement
feed: AppBskyFeedDefs.GeneratorView
}) {
const href = React.useMemo(() => {
const urip = new AtUri(feed.uri)
const handleOrDid = feed.creator.handle || feed.creator.did
return `/profile/${handleOrDid}/feed/${urip.rkey}`
}, [feed])
return <InternalLink to={href}>{children}</InternalLink>
}
export function Outer({children}: {children: React.ReactNode}) {
return <View style={[a.flex_1, a.gap_md]}>{children}</View>
}
export function Header({children}: {children: React.ReactNode}) {
return <View style={[a.flex_row, a.align_center, a.gap_md]}>{children}</View>
}
export function Avatar({src}: {src: string | undefined}) {
return <UserAvatar type="algo" size={40} avatar={src} />
}
export function TitleAndByline({
title,
creator,
}: {
title: string
creator: AppBskyActorDefs.ProfileViewBasic
}) {
const t = useTheme()
return (
<View style={[a.flex_1]}>
<Text
style={[a.text_md, a.font_bold, a.flex_1, a.leading_snug]}
numberOfLines={1}>
{title}
</Text>
<Text
style={[a.flex_1, a.leading_snug, t.atoms.text_contrast_medium]}
numberOfLines={1}>
<Trans>Feed by {sanitizeHandle(creator.handle, '@')}</Trans>
</Text>
</View>
)
}
export function Description({description}: {description?: string}) {
const [rt, isResolving] = useRichText(description || '')
if (!description) return null
return isResolving ? (
<RichText value={description} style={[a.leading_snug]} />
) : (
<RichText value={rt} style={[a.leading_snug]} />
)
}
export function Likes({count}: {count: number}) {
const t = useTheme()
return (
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
{plural(count || 0, {
one: 'Liked by # user',
other: 'Liked by # users',
})}
</Text>
)
}
export function Action({uri, pin}: {uri: string; pin?: boolean}) {
const {hasSession} = useSession()
if (!hasSession) return null
return <ActionInner uri={uri} pin={pin} />
}
function ActionInner({uri, pin}: {uri: string; pin?: boolean}) {
const {_} = useLingui()
const {data: preferences} = usePreferencesQuery()
const {isPending: isAddSavedFeedPending, mutateAsync: saveFeeds} =
useAddSavedFeedsMutation()
const {isPending: isRemovePending, mutateAsync: removeFeed} =
useRemoveFeedMutation()
const savedFeedConfig = React.useMemo(() => {
return preferences?.savedFeeds?.find(
feed => feed.type === 'feed' && feed.value === uri,
)
}, [preferences?.savedFeeds, uri])
const removePromptControl = Prompt.usePromptControl()
const isPending = isAddSavedFeedPending || isRemovePending
const toggleSave = React.useCallback(
async (e: GestureResponderEvent) => {
e.preventDefault()
e.stopPropagation()
try {
if (savedFeedConfig) {
await removeFeed(savedFeedConfig)
} else {
await saveFeeds([
{
type: 'feed',
value: uri,
pinned: pin || false,
},
])
}
Toast.show(_(msg`Feeds updated!`))
} catch (e: any) {
logger.error(e, {context: `FeedCard: failed to update feeds`, pin})
Toast.show(_(msg`Failed to update feeds`))
}
},
[_, pin, saveFeeds, removeFeed, uri, savedFeedConfig],
)
const onPrompRemoveFeed = React.useCallback(
async (e: GestureResponderEvent) => {
e.preventDefault()
e.stopPropagation()
removePromptControl.open()
},
[removePromptControl],
)
return (
<>
<Button
disabled={isPending}
label={_(msg`Add this feed to your feeds`)}
size="small"
variant="ghost"
color="secondary"
shape="square"
onPress={savedFeedConfig ? onPrompRemoveFeed : toggleSave}>
{savedFeedConfig ? (
<ButtonIcon size="md" icon={isPending ? Loader : Trash} />
) : (
<ButtonIcon size="md" icon={isPending ? Loader : Plus} />
)}
</Button>
<Prompt.Basic
control={removePromptControl}
title={_(msg`Remove from my feeds?`)}
description={_(
msg`Are you sure you want to remove this from your feeds?`,
)}
onConfirm={toggleSave}
confirmButtonCta={_(msg`Remove`)}
confirmButtonColor="negative"
/>
</>
)
}
@@ -5,7 +5,7 @@ import Animated, {
useSharedValue,
} from 'react-native-reanimated'
export function KeyboardPadding({maxHeight}: {maxHeight?: number}) {
export function KeyboardControllerPadding({maxHeight}: {maxHeight?: number}) {
const keyboardHeight = useSharedValue(0)
useKeyboardHandler(
@@ -0,0 +1,7 @@
export function KeyboardControllerPadding({
maxHeight: _,
}: {
maxHeight?: number
}) {
return null
}
-3
View File
@@ -1,3 +0,0 @@
export function KeyboardPadding({maxHeight: _}: {maxHeight?: number}) {
return null
}
+235
View File
@@ -0,0 +1,235 @@
import React from 'react'
import {View} from 'react-native'
import {AppBskyActorDefs, moderateProfile, ModerationOpts} from '@atproto/api'
import {msg, Plural, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {makeProfileLink} from '#/lib/routes/links'
import {sanitizeDisplayName} from 'lib/strings/display-names'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useTheme} from '#/alf'
import {Link, LinkProps} from '#/components/Link'
import {Text} from '#/components/Typography'
const AVI_SIZE = 30
const AVI_BORDER = 1
/**
* Shared logic to determine if `KnownFollowers` should be shown.
*
* Checks the # of actual returned users instead of the `count` value, because
* `count` includes blocked users and `followers` does not.
*/
export function shouldShowKnownFollowers(
knownFollowers?: AppBskyActorDefs.KnownFollowers,
) {
return knownFollowers && knownFollowers.followers.length > 0
}
export function KnownFollowers({
profile,
moderationOpts,
onLinkPress,
}: {
profile: AppBskyActorDefs.ProfileViewDetailed
moderationOpts: ModerationOpts
onLinkPress?: LinkProps['onPress']
}) {
const cache = React.useRef<Map<string, AppBskyActorDefs.KnownFollowers>>(
new Map(),
)
/*
* Results for `knownFollowers` are not sorted consistently, so when
* revalidating we can see a flash of this data updating. This cache prevents
* this happening for screens that remain in memory. When pushing a new
* screen, or once this one is popped, this cache is empty, so new data is
* displayed.
*/
if (profile.viewer?.knownFollowers && !cache.current.has(profile.did)) {
cache.current.set(profile.did, profile.viewer.knownFollowers)
}
const cachedKnownFollowers = cache.current.get(profile.did)
if (cachedKnownFollowers && shouldShowKnownFollowers(cachedKnownFollowers)) {
return (
<KnownFollowersInner
profile={profile}
cachedKnownFollowers={cachedKnownFollowers}
moderationOpts={moderationOpts}
onLinkPress={onLinkPress}
/>
)
}
return null
}
function KnownFollowersInner({
profile,
moderationOpts,
cachedKnownFollowers,
onLinkPress,
}: {
profile: AppBskyActorDefs.ProfileViewDetailed
moderationOpts: ModerationOpts
cachedKnownFollowers: AppBskyActorDefs.KnownFollowers
onLinkPress?: LinkProps['onPress']
}) {
const t = useTheme()
const {_} = useLingui()
const textStyle = [
a.flex_1,
a.text_sm,
a.leading_snug,
t.atoms.text_contrast_medium,
]
const slice = cachedKnownFollowers.followers.slice(0, 3).map(f => {
const moderation = moderateProfile(f, moderationOpts)
return {
profile: {
...f,
displayName: sanitizeDisplayName(
f.displayName || f.handle,
moderation.ui('displayName'),
),
},
moderation,
}
})
// Does not have blocks applied. Always >= slices.length
const serverCount = cachedKnownFollowers.count
/*
* We check above too, but here for clarity and a reminder to _check for
* valid indices_
*/
if (slice.length === 0) return null
return (
<Link
label={_(
msg`Press to view followers of this account that you also follow`,
)}
onPress={onLinkPress}
to={makeProfileLink(profile, 'known-followers')}
style={[
a.flex_1,
a.flex_row,
a.gap_md,
a.align_center,
{marginLeft: -AVI_BORDER},
]}>
{({hovered, pressed}) => (
<>
<View
style={[
{
height: AVI_SIZE,
width: AVI_SIZE + (slice.length - 1) * a.gap_md.gap,
},
pressed && {
opacity: 0.5,
},
]}>
{slice.map(({profile: prof, moderation}, i) => (
<View
key={prof.did}
style={[
a.absolute,
a.rounded_full,
{
borderWidth: AVI_BORDER,
borderColor: t.atoms.bg.backgroundColor,
width: AVI_SIZE + AVI_BORDER * 2,
height: AVI_SIZE + AVI_BORDER * 2,
left: i * a.gap_md.gap,
zIndex: AVI_BORDER - i,
},
]}>
<UserAvatar
size={AVI_SIZE}
avatar={prof.avatar}
moderation={moderation.ui('avatar')}
/>
</View>
))}
</View>
<Text
style={[
textStyle,
hovered && {
textDecorationLine: 'underline',
textDecorationColor: t.atoms.text_contrast_medium.color,
},
pressed && {
opacity: 0.5,
},
]}
numberOfLines={2}>
{slice.length >= 2 ? (
// 2-n followers, including blocks
serverCount > 2 ? (
<Trans>
Followed by{' '}
<Text key={slice[0].profile.did} style={textStyle}>
{slice[0].profile.displayName}
</Text>
,{' '}
<Text key={slice[1].profile.did} style={textStyle}>
{slice[1].profile.displayName}
</Text>
, and{' '}
<Plural
value={serverCount - 2}
one="# other"
other="# others"
/>
</Trans>
) : (
// only 2
<Trans>
Followed by{' '}
<Text key={slice[0].profile.did} style={textStyle}>
{slice[0].profile.displayName}
</Text>{' '}
and{' '}
<Text key={slice[1].profile.did} style={textStyle}>
{slice[1].profile.displayName}
</Text>
</Trans>
)
) : serverCount > 1 ? (
// 1-n followers, including blocks
<Trans>
Followed by{' '}
<Text key={slice[0].profile.did} style={textStyle}>
{slice[0].profile.displayName}
</Text>{' '}
and{' '}
<Plural
value={serverCount - 1}
one="# other"
other="# others"
/>
</Trans>
) : (
// only 1
<Trans>
Followed by{' '}
<Text key={slice[0].profile.did} style={textStyle}>
{slice[0].profile.displayName}
</Text>
</Trans>
)}
</Text>
</>
)}
</Link>
)
}
+85
View File
@@ -0,0 +1,85 @@
import React from 'react'
import {View} from 'react-native'
import {AppBskyActorDefs, moderateProfile} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {differenceInSeconds} from 'date-fns'
import {useGetTimeAgo} from '#/lib/hooks/useTimeAgo'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {HITSLOP_10} from 'lib/constants'
import {sanitizeDisplayName} from 'lib/strings/display-names'
import {atoms as a} from '#/alf'
import {Button} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {useDialogControl} from '#/components/Dialog'
import {Newskie} from '#/components/icons/Newskie'
import {Text} from '#/components/Typography'
export function NewskieDialog({
profile,
disabled,
}: {
profile: AppBskyActorDefs.ProfileViewDetailed
disabled?: boolean
}) {
const {_} = useLingui()
const moderationOpts = useModerationOpts()
const control = useDialogControl()
const profileName = React.useMemo(() => {
const name = profile.displayName || profile.handle
if (!moderationOpts) return name
const moderation = moderateProfile(profile, moderationOpts)
return sanitizeDisplayName(name, moderation.ui('displayName'))
}, [moderationOpts, profile])
const [now] = React.useState(() => Date.now())
const timeAgo = useGetTimeAgo()
const createdAt = profile.createdAt as string | undefined
const daysOld = React.useMemo(() => {
if (!createdAt) return Infinity
return differenceInSeconds(now, new Date(createdAt)) / 86400
}, [createdAt, now])
if (!createdAt || daysOld > 7) return null
return (
<View style={[a.pr_2xs]}>
<Button
disabled={disabled}
label={_(
msg`This user is new here. Press for more info about when they joined.`,
)}
hitSlop={HITSLOP_10}
onPress={control.open}>
{({hovered, pressed}) => (
<Newskie
size="lg"
fill="#FFC404"
style={{
opacity: hovered || pressed ? 0.5 : 1,
}}
/>
)}
</Button>
<Dialog.Outer control={control}>
<Dialog.Handle />
<Dialog.ScrollableInner
label={_(msg`New user info dialog`)}
style={[{width: 'auto', maxWidth: 400, minWidth: 200}]}>
<View style={[a.gap_sm]}>
<Text style={[a.font_bold, a.text_xl]}>
<Trans>Say hello!</Trans>
</Text>
<Text style={[a.text_md]}>
<Trans>
{profileName} joined Bluesky{' '}
{timeAgo(createdAt, now, {format: 'long'})} ago
</Trans>
</Text>
</View>
</Dialog.ScrollableInner>
</Dialog.Outer>
</View>
)
}
+84 -27
View File
@@ -5,6 +5,7 @@ import {flip, offset, shift, size, useFloating} from '@floating-ui/react-dom'
import {msg, plural} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {getModerationCauseKey} from '#/lib/moderation'
import {makeProfileLink} from '#/lib/routes/links'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
@@ -22,11 +23,16 @@ import {useFollowMethods} from '#/components/hooks/useFollowMethods'
import {useRichText} from '#/components/hooks/useRichText'
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
import {
KnownFollowers,
shouldShowKnownFollowers,
} from '#/components/KnownFollowers'
import {InlineLinkText, Link} from '#/components/Link'
import {Loader} from '#/components/Loader'
import {Portal} from '#/components/Portal'
import {RichText} from '#/components/RichText'
import {Text} from '#/components/Typography'
import {ProfileLabel} from '../moderation/ProfileHeaderAlerts'
import {ProfileHoverCardProps} from './types'
const floatingMiddlewares = [
@@ -45,10 +51,23 @@ const floatingMiddlewares = [
]
export function ProfileHoverCard(props: ProfileHoverCardProps) {
const prefetchProfileQuery = usePrefetchProfileQuery()
const prefetchedProfile = React.useRef(false)
const onPointerMove = () => {
if (!prefetchedProfile.current) {
prefetchedProfile.current = true
prefetchProfileQuery(props.did)
}
}
if (props.disable || isTouchDevice) {
return props.children
} else {
return <ProfileHoverCardInner {...props} />
return (
<View onPointerMove={onPointerMove}>
<ProfileHoverCardInner {...props} />
</View>
)
}
}
@@ -370,7 +389,10 @@ function Inner({
profile: profileShadow,
logContext: 'ProfileHoverCard',
})
const blockHide = profile.viewer?.blocking || profile.viewer?.blockedBy
const isBlockedUser =
profile.viewer?.blocking ||
profile.viewer?.blockedBy ||
profile.viewer?.blockingByList
const following = formatCount(profile.followsCount || 0)
const followers = formatCount(profile.followersCount || 0)
const pluralizedFollowers = plural(profile.followersCount || 0, {
@@ -401,29 +423,41 @@ function Inner({
/>
</Link>
{!isMe && (
<Button
size="small"
color={profileShadow.viewer?.following ? 'secondary' : 'primary'}
variant="solid"
label={
profileShadow.viewer?.following
? _(msg`Following`)
: _(msg`Follow`)
}
style={[a.rounded_full]}
onPress={profileShadow.viewer?.following ? unfollow : follow}>
<ButtonIcon
position="left"
icon={profileShadow.viewer?.following ? Check : Plus}
/>
<ButtonText>
{profileShadow.viewer?.following
? _(msg`Following`)
: _(msg`Follow`)}
</ButtonText>
</Button>
)}
{!isMe &&
(isBlockedUser ? (
<Link
to={profileURL}
label={_(msg`View blocked user's profile`)}
onPress={hide}
size="small"
color="secondary"
variant="solid"
style={[a.rounded_full]}>
<ButtonText>{_(msg`View profile`)}</ButtonText>
</Link>
) : (
<Button
size="small"
color={profileShadow.viewer?.following ? 'secondary' : 'primary'}
variant="solid"
label={
profileShadow.viewer?.following
? _(msg`Following`)
: _(msg`Follow`)
}
style={[a.rounded_full]}
onPress={profileShadow.viewer?.following ? unfollow : follow}>
<ButtonIcon
position="left"
icon={profileShadow.viewer?.following ? Check : Plus}
/>
<ButtonText>
{profileShadow.viewer?.following
? _(msg`Following`)
: _(msg`Follow`)}
</ButtonText>
</Button>
))}
</View>
<Link to={profileURL} label={_(msg`View profile`)} onPress={hide}>
@@ -435,11 +469,23 @@ function Inner({
)}
</Text>
<ProfileHeaderHandle profile={profileShadow} />
<ProfileHeaderHandle profile={profileShadow} disableTaps />
</View>
</Link>
{!blockHide && (
{isBlockedUser && (
<View style={[a.flex_row, a.flex_wrap, a.gap_xs]}>
{moderation.ui('profileView').alerts.map(cause => (
<ProfileLabel
key={getModerationCauseKey(cause)}
cause={cause}
disableDetailsDialog
/>
))}
</View>
)}
{!isBlockedUser && (
<>
<View style={[a.flex_row, a.flex_wrap, a.gap_md, a.pt_xs]}>
<InlineLinkText
@@ -473,6 +519,17 @@ function Inner({
/>
</View>
) : undefined}
{!isMe &&
shouldShowKnownFollowers(profile.viewer?.knownFollowers) && (
<View style={[a.flex_row, a.align_center, a.gap_sm, a.pt_md]}>
<KnownFollowers
profile={profile}
moderationOpts={moderationOpts}
onLinkPress={hide}
/>
</View>
)}
</>
)}
</View>
+10 -7
View File
@@ -1,10 +1,10 @@
import React from 'react'
import {View} from 'react-native'
import {GestureResponderEvent, View} from 'react-native'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {Button, ButtonColor, ButtonText} from '#/components/Button'
import {Button, ButtonColor, ButtonProps, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {Text} from '#/components/Typography'
@@ -136,7 +136,7 @@ export function Action({
* Note: The dialog will close automatically when the action is pressed, you
* should NOT close the dialog as a side effect of this method.
*/
onPress: () => void
onPress: ButtonProps['onPress']
color?: ButtonColor
/**
* Optional i18n string. If undefined, it will default to "Confirm".
@@ -147,9 +147,12 @@ export function Action({
const {_} = useLingui()
const {gtMobile} = useBreakpoints()
const {close} = Dialog.useDialogContext()
const handleOnPress = React.useCallback(() => {
close(onPress)
}, [close, onPress])
const handleOnPress = React.useCallback(
(e: GestureResponderEvent) => {
close(() => onPress?.(e))
},
[close, onPress],
)
return (
<Button
@@ -186,7 +189,7 @@ export function Basic({
* Note: The dialog will close automatically when the action is pressed, you
* should NOT close the dialog as a side effect of this method.
*/
onConfirm: () => void
onConfirm: ButtonProps['onPress']
confirmButtonColor?: ButtonColor
showCancel?: boolean
}>) {
@@ -15,7 +15,6 @@ import * as Dialog from '#/components/Dialog'
import * as Toggle from '#/components/forms/Toggle'
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
import {ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeft} from '#/components/icons/Chevron'
import {KeyboardPadding} from '#/components/KeyboardPadding'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
import {ReportDialogProps} from './types'
@@ -222,7 +221,6 @@ export function SubmitView({
{submitting && <ButtonIcon icon={Loader} />}
</Button>
</View>
<KeyboardPadding />
</View>
)
}
-2
View File
@@ -28,7 +28,6 @@ import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Has
import {PageText_Stroke2_Corner0_Rounded as PageText} from '#/components/icons/PageText'
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
import {KeyboardPadding} from '#/components/KeyboardPadding'
import {Loader} from '#/components/Loader'
import * as Prompt from '#/components/Prompt'
import {Text} from '#/components/Typography'
@@ -257,7 +256,6 @@ function MutedWordsInner() {
</View>
<Dialog.Close />
<KeyboardPadding maxHeight={100} />
</Dialog.ScrollableInner>
)
}
+1 -1
View File
@@ -49,7 +49,7 @@ export function LeaveConvoPrompt({
)}
confirmButtonCta={_(msg`Leave`)}
confirmButtonColor="negative"
onConfirm={leaveConvo}
onConfirm={() => leaveConvo()}
/>
)
}
+2
View File
@@ -16,6 +16,7 @@ import {CharProgress} from '#/view/com/composer/char-progress/CharProgress'
import * as Toast from '#/view/com/util/Toast'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import * as Dialog from '#/components/Dialog'
import {KeyboardControllerPadding} from '#/components/KeyboardControllerPadding'
import {Button, ButtonIcon, ButtonText} from '../Button'
import {Divider} from '../Divider'
import {ChevronLeft_Stroke2_Corner0_Rounded as Chevron} from '../icons/Chevron'
@@ -47,6 +48,7 @@ let ReportDialog = ({
<Dialog.ScrollableInner label={_(msg`Report this message`)}>
<DialogInner params={params} />
<Dialog.Close />
<KeyboardControllerPadding />
</Dialog.ScrollableInner>
</Dialog.Outer>
)
+5 -2
View File
@@ -1,5 +1,5 @@
import React from 'react'
import {View} from 'react-native'
import {Keyboard, View} from 'react-native'
import DatePicker from 'react-native-date-picker'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
@@ -49,7 +49,10 @@ export function DateField({
<DateFieldButton
label={label}
value={value}
onPress={control.open}
onPress={() => {
Keyboard.dismiss()
control.open()
}}
isInvalid={isInvalid}
accessibilityHint={accessibilityHint}
/>
+7
View File
@@ -196,6 +196,13 @@ export function createInput(Component: typeof TextInput) {
textAlignVertical: rest.multiline ? 'top' : undefined,
minHeight: rest.multiline ? 80 : undefined,
},
// fix for autofill styles covering border
web({
paddingTop: 12,
paddingBottom: 12,
marginTop: 2,
marginBottom: 2,
}),
android({
paddingBottom: 16,
}),
+4
View File
@@ -7,3 +7,7 @@ export const ArrowTopRight_Stroke2_Corner0_Rounded = createSinglePathSVG({
export const ArrowLeft_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M3 12a1 1 0 0 1 .293-.707l6-6a1 1 0 0 1 1.414 1.414L6.414 11H20a1 1 0 1 1 0 2H6.414l4.293 4.293a1 1 0 0 1-1.414 1.414l-6-6A1 1 0 0 1 3 12Z',
})
export const ArrowBottom_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M12 21a1 1 0 0 1-.707-.293l-6-6a1 1 0 1 1 1.414-1.414L11 17.586V4a1 1 0 1 1 2 0v13.586l4.293-4.293a1 1 0 0 1 1.414 1.414l-6 6A1 1 0 0 1 12 21Z',
})
+9
View File
@@ -0,0 +1,9 @@
import {createSinglePathSVG} from './TEMPLATE'
/*
* This icon is off-menu, not part of the icon set
*/
export const ListPlus_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M4 5a1 1 0 0 0 0 2h16a1 1 0 1 0 0-2H4Zm0 12a1 1 0 1 0 0 2h3a1 1 0 1 0 0-2H4Zm-1-5a1 1 0 0 1 1-1h5a1 1 0 1 1 0 2H4a1 1 0 0 1-1-1Zm14-3c.552 0 1 .41 1 .917V13.5h3.583c.507 0 .917.448.917 1s-.41 1-.917 1H18v3.583c0 .507-.448.917-1 .917s-1-.41-1-.917V15.5h-3.583c-.507 0-.917-.448-.917-1s.41-1 .917-1H16V9.917C16 9.41 16.448 9 17 9Z',
})
+5
View File
@@ -0,0 +1,5 @@
import {createSinglePathSVG} from './TEMPLATE'
export const Menu_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M2 6a1 1 0 0 1 1-1h18a1 1 0 1 1 0 2H3a1 1 0 0 1-1-1Zm0 6a1 1 0 0 1 1-1h18a1 1 0 1 1 0 2H3a1 1 0 0 1-1-1Zm0 6a1 1 0 0 1 1-1h18a1 1 0 1 1 0 2H3a1 1 0 0 1-1-1Z',
})
+5
View File
@@ -0,0 +1,5 @@
import {createSinglePathSVG} from './TEMPLATE'
export const Newskie = createSinglePathSVG({
path: 'M11.183 8.561c0 .544.348.984.892.984.545 0 .893-.44.893-.985V6.985c0-.544-.348-.985-.893-.985-.543 0-.892.44-.892.985v1.576Zm5.94 7.481c0 .539-.438.942-.976.942H8.004c-.538 0-.975-.411-.975-.95 0-2.782 2.264-5.021 5.046-5.021 2.783 0 5.047 2.247 5.047 5.03Zm-.43-4.584a.983.983 0 0 1 0-1.393l1.114-1.114a.985.985 0 0 1 1.393 1.393l-1.114 1.114a.985.985 0 0 1-1.393 0Zm2.897 3.741h1.575c.544 0 .985.349.985.892 0 .544-.44.892-.985.892h-1.67a.872.872 0 0 1-.89-.887c0-.543.44-.897.985-.897Zm-14.045.893c0-.544-.44-.892-.985-.892H2.985c-.544 0-.985.349-.985.892 0 .544.44.892.985.892H4.56c.545 0 .985-.349.985-.892Zm1.913-6.027a.985.985 0 0 1-1.393 1.393L4.95 10.344A.985.985 0 0 1 6.344 8.95l1.114 1.114Z',
})
+5
View File
@@ -0,0 +1,5 @@
import {createSinglePathSVG} from './TEMPLATE'
export const Pin_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M6.5 3a1 1 0 0 1 1-1h9a1 1 0 0 1 1 1v3.997a6.25 6.25 0 0 0 1.83 4.42l.377.376A1 1 0 0 1 20 12.5V15a1 1 0 0 1-1 1h-6v5a1 1 0 1 1-2 0v-5H5a1 1 0 0 1-1-1v-2.5a1 1 0 0 1 .293-.707l.376-.377A6.25 6.25 0 0 0 6.5 6.996V3.001Zm2 1v2.997a8.25 8.25 0 0 1-2.416 5.834L6 12.914V14h12v-1.086l-.084-.083A8.25 8.25 0 0 1 15.5 6.997V4h-7Z',
})
+5
View File
@@ -0,0 +1,5 @@
import {createSinglePathSVG} from './TEMPLATE'
export const Shapes_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M7 3a1 1 0 0 1 1 1v2h2a1 1 0 1 1 0 2H8v2a1 1 0 1 1-2 0V8H4a1 1 0 0 1 0-2h2V4a1 1 0 0 1 1-1Zm6 4a4 4 0 1 1 8 0 4 4 0 0 1-8 0Zm4-2a2 2 0 1 0 0 4 2 2 0 0 0 0-4ZM3 14a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1v-6Zm2 1v4h4v-4H5Zm9.171-.829a1 1 0 0 1 1.415 0L17 15.585l1.414-1.414a1 1 0 1 1 1.414 1.414L18.414 17l1.414 1.414a1 1 0 0 1-1.414 1.414L17 18.414l-1.415 1.414a1 1 0 0 1-1.414-1.414l1.415-1.415-1.415-1.414a1 1 0 0 1 0-1.414Z',
})
+9
View File
@@ -0,0 +1,9 @@
import {createSinglePathSVG} from './TEMPLATE'
export const Star_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M12 1a1 1 0 0 1 .902.568l2.643 5.517 6.085.799a1 1 0 0 1 .557 1.718l-4.45 4.207 1.118 6.008a1 1 0 0 1-1.46 1.063L12 17.962 6.604 20.88a1 1 0 0 1-1.459-1.063l1.117-6.008-4.45-4.207a1 1 0 0 1 .558-1.718l6.085-.8 2.643-5.516A1 1 0 0 1 12 1Zm0 3.315-1.975 4.123a1 1 0 0 1-.772.56l-4.538.595 3.317 3.137a1 1 0 0 1 .296.91l-.834 4.485 4.03-2.179a1 1 0 0 1 .952 0l4.03 2.179-.834-4.485a1 1 0 0 1 .296-.91l3.317-3.137-4.538-.596a1 1 0 0 1-.772-.56L12 4.316Z',
})
export const Star_Filled_Corner0_Rounded = createSinglePathSVG({
path: 'M12.902 1.568a1 1 0 0 0-1.804 0L8.455 7.085l-6.085.799a1 1 0 0 0-.557 1.718l4.45 4.207-1.117 6.008a1 1 0 0 0 1.458 1.063L12 17.962l5.396 2.918a1 1 0 0 0 1.459-1.063l-1.117-6.008 4.45-4.207a1 1 0 0 0-.558-1.718l-6.085-.8-2.643-5.516Z',
})
@@ -14,7 +14,6 @@ import * as Toast from '#/view/com/util/Toast'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {KeyboardPadding} from '#/components/KeyboardPadding'
import {InlineLinkText} from '#/components/Link'
import {Text} from '#/components/Typography'
import {Divider} from '../Divider'
@@ -110,7 +109,6 @@ function LabelsOnMeDialogInner(props: LabelsOnMeDialogProps) {
</>
)}
<Dialog.Close />
<KeyboardPadding />
</Dialog.ScrollableInner>
)
}
+2
View File
@@ -92,6 +92,8 @@ function PostLabel({
<UserAvatar
avatar={desc.sourceAvi}
size={size === 'large' ? 16 : 12}
type="labeler"
shape="circle"
/>
) : (
<desc.icon size="sm" fill={t.atoms.text_contrast_medium.color} />
+10 -2
View File
@@ -1,6 +1,6 @@
import React, {ComponentProps} from 'react'
import {Pressable, StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
import {AppBskyActorDefs, ModerationUI} from '@atproto/api'
import {AppBskyActorDefs, ModerationCause, ModerationUI} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useQueryClient} from '@tanstack/react-query'
@@ -45,7 +45,8 @@ export function PostHider({
const [override, setOverride] = React.useState(false)
const control = useModerationDetailsDialogControl()
const blur =
modui.blurs[0] || (interpretFilterAsBlur ? modui.filters[0] : undefined)
modui.blurs[0] ||
(interpretFilterAsBlur ? getBlurrableFilter(modui) : undefined)
const desc = useModerationCauseDescription(blur)
const onBeforePress = React.useCallback(() => {
@@ -134,6 +135,13 @@ export function PostHider({
)
}
function getBlurrableFilter(modui: ModerationUI): ModerationCause | undefined {
// moderation causes get "downgraded" when they originate from embedded content
// a downgraded cause should *only* drive filtering in feeds, so we want to look
// for filters that arent downgraded
return modui.filters.find(filter => !filter.downgraded)
}
const styles = StyleSheet.create({
child: {
borderWidth: 0,
@@ -43,7 +43,13 @@ export function ProfileHeaderAlerts({
)
}
function ProfileLabel({cause}: {cause: ModerationCause}) {
export function ProfileLabel({
cause,
disableDetailsDialog,
}: {
cause: ModerationCause
disableDetailsDialog?: boolean
}) {
const t = useTheme()
const control = useModerationDetailsDialogControl()
const desc = useModerationCauseDescription(cause)
@@ -51,6 +57,7 @@ function ProfileLabel({cause}: {cause: ModerationCause}) {
return (
<>
<Button
disabled={disableDetailsDialog}
label={desc.name}
onPress={() => {
control.open()
@@ -87,7 +94,9 @@ function ProfileLabel({cause}: {cause: ModerationCause}) {
)}
</Button>
<ModerationDetailsDialog control={control} modcause={cause} />
{!disableDetailsDialog && (
<ModerationDetailsDialog control={control} modcause={cause} />
)}
</>
)
}
+2
View File
@@ -32,6 +32,8 @@ export type TrackPropertiesMap = {
'Post:ThreadMute': {} // CAN BE SERVER
'Post:ThreadUnmute': {} // CAN BE SERVER
'Post:Reply': {} // CAN BE SERVER
'Post:EditThreadgateOpened': {}
'Post:ThreadgateEdited': {}
// PROFILE events
'Profile:Follow': {
username: string
+12 -5
View File
@@ -270,7 +270,7 @@ export async function post(agent: BskyAgent, opts: PostOpts) {
return res
}
async function createThreadgate(
export async function createThreadgate(
agent: BskyAgent,
postUri: string,
threadgate: ThreadgateSetting[],
@@ -296,10 +296,17 @@ async function createThreadgate(
}
const postUrip = new AtUri(postUri)
await agent.api.app.bsky.feed.threadgate.create(
{repo: agent.session!.did, rkey: postUrip.rkey},
{post: postUri, createdAt: new Date().toISOString(), allow},
)
await agent.api.com.atproto.repo.putRecord({
repo: agent.session!.did,
collection: 'app.bsky.feed.threadgate',
rkey: postUrip.rkey,
record: {
$type: 'app.bsky.feed.threadgate',
post: postUri,
allow,
createdAt: new Date().toISOString(),
},
})
}
// helpers
+1
View File
@@ -84,6 +84,7 @@ export const createHitslop = (size: number): Insets => ({
export const HITSLOP_10 = createHitslop(10)
export const HITSLOP_20 = createHitslop(20)
export const HITSLOP_30 = createHitslop(30)
export const POST_CTRL_HITSLOP = {top: 5, bottom: 10, left: 10, right: 10}
export const BACK_HITSLOP = HITSLOP_30
export const MAX_POST_LINES = 25
+102
View File
@@ -0,0 +1,102 @@
import {describe, expect, it} from '@jest/globals'
import {MessageDescriptor} from '@lingui/core'
import {addDays, subDays, subHours, subMinutes, subSeconds} from 'date-fns'
import {dateDiff} from '../useTimeAgo'
const lingui: any = (obj: MessageDescriptor) => obj.message
const base = new Date('2024-06-17T00:00:00Z')
describe('dateDiff', () => {
it(`works with numbers`, () => {
expect(dateDiff(subDays(base, 3), Number(base), {lingui})).toEqual('3d')
})
it(`works with strings`, () => {
expect(dateDiff(subDays(base, 3), base.toString(), {lingui})).toEqual('3d')
})
it(`works with dates`, () => {
expect(dateDiff(subDays(base, 3), base, {lingui})).toEqual('3d')
})
it(`equal values return now`, () => {
expect(dateDiff(base, base, {lingui})).toEqual('now')
})
it(`future dates return now`, () => {
expect(dateDiff(addDays(base, 3), base, {lingui})).toEqual('now')
})
it(`values < 5 seconds ago return now`, () => {
const then = subSeconds(base, 4)
expect(dateDiff(then, base, {lingui})).toEqual('now')
})
it(`values >= 5 seconds ago return seconds`, () => {
const then = subSeconds(base, 5)
expect(dateDiff(then, base, {lingui})).toEqual('5s')
})
it(`values < 1 min return seconds`, () => {
const then = subSeconds(base, 59)
expect(dateDiff(then, base, {lingui})).toEqual('59s')
})
it(`values >= 1 min return minutes`, () => {
const then = subSeconds(base, 60)
expect(dateDiff(then, base, {lingui})).toEqual('1m')
})
it(`minutes round down`, () => {
const then = subSeconds(base, 119)
expect(dateDiff(then, base, {lingui})).toEqual('1m')
})
it(`values < 1 hour return minutes`, () => {
const then = subMinutes(base, 59)
expect(dateDiff(then, base, {lingui})).toEqual('59m')
})
it(`values >= 1 hour return hours`, () => {
const then = subMinutes(base, 60)
expect(dateDiff(then, base, {lingui})).toEqual('1h')
})
it(`hours round down`, () => {
const then = subMinutes(base, 119)
expect(dateDiff(then, base, {lingui})).toEqual('1h')
})
it(`values < 1 day return hours`, () => {
const then = subHours(base, 23)
expect(dateDiff(then, base, {lingui})).toEqual('23h')
})
it(`values >= 1 day return days`, () => {
const then = subHours(base, 24)
expect(dateDiff(then, base, {lingui})).toEqual('1d')
})
it(`days round down`, () => {
const then = subHours(base, 47)
expect(dateDiff(then, base, {lingui})).toEqual('1d')
})
it(`values < 30 days return days`, () => {
const then = subDays(base, 29)
expect(dateDiff(then, base, {lingui})).toEqual('29d')
})
it(`values >= 30 days return months`, () => {
const then = subDays(base, 30)
expect(dateDiff(then, base, {lingui})).toEqual('1mo')
})
it(`months round down`, () => {
const then = subDays(base, 59)
expect(dateDiff(then, base, {lingui})).toEqual('1mo')
})
it(`values are rounded by increments of 30`, () => {
const then = subDays(base, 61)
expect(dateDiff(then, base, {lingui})).toEqual('2mo')
})
it(`values < 360 days return months`, () => {
const then = subDays(base, 359)
expect(dateDiff(then, base, {lingui})).toEqual('11mo')
})
it(`values >= 360 days return the earlier value`, () => {
const then = subDays(base, 360)
expect(dateDiff(then, base, {lingui})).toEqual(then.toLocaleDateString())
})
})
+1
View File
@@ -7,6 +7,7 @@ export function useNavigationTabState() {
const res = {
isAtHome: getTabState(state, 'Home') !== TabState.Outside,
isAtSearch: getTabState(state, 'Search') !== TabState.Outside,
// FeedsTab no longer exists, but this check works for `Feeds` screen as well
isAtFeeds: getTabState(state, 'Feeds') !== TabState.Outside,
isAtNotifications:
getTabState(state, 'Notifications') !== TabState.Outside,
+86
View File
@@ -0,0 +1,86 @@
import {useCallback} from 'react'
import {msg, plural} from '@lingui/macro'
import {I18nContext, useLingui} from '@lingui/react'
import {differenceInSeconds} from 'date-fns'
export type TimeAgoOptions = {
lingui: I18nContext['_']
format?: 'long' | 'short'
}
export function useGetTimeAgo() {
const {_} = useLingui()
return useCallback(
(
earlier: number | string | Date,
later: number | string | Date,
options?: Omit<TimeAgoOptions, 'lingui'>,
) => {
return dateDiff(earlier, later, {lingui: _, format: options?.format})
},
[_],
)
}
const NOW = 5
const MINUTE = 60
const HOUR = MINUTE * 60
const DAY = HOUR * 24
const MONTH_30 = DAY * 30
/**
* Returns the difference between `earlier` and `later` dates, formatted as a
* natural language string.
*
* - All month are considered exactly 30 days.
* - Dates assume `earlier` <= `later`, and will otherwise return 'now'.
* - Differences >= 360 days are returned as the "M/D/YYYY" string
* - All values round down
*/
export function dateDiff(
earlier: number | string | Date,
later: number | string | Date,
options: TimeAgoOptions,
): string {
const _ = options.lingui
const format = options?.format || 'short'
const long = format === 'long'
const diffSeconds = differenceInSeconds(new Date(later), new Date(earlier))
if (diffSeconds < NOW) {
return _(msg`now`)
} else if (diffSeconds < MINUTE) {
return `${diffSeconds}${
long ? ` ${plural(diffSeconds, {one: 'second', other: 'seconds'})}` : 's'
}`
} else if (diffSeconds < HOUR) {
const diff = Math.floor(diffSeconds / MINUTE)
return `${diff}${
long ? ` ${plural(diff, {one: 'minute', other: 'minutes'})}` : 'm'
}`
} else if (diffSeconds < DAY) {
const diff = Math.floor(diffSeconds / HOUR)
return `${diff}${
long ? ` ${plural(diff, {one: 'hour', other: 'hours'})}` : 'h'
}`
} else if (diffSeconds < MONTH_30) {
const diff = Math.floor(diffSeconds / DAY)
return `${diff}${
long ? ` ${plural(diff, {one: 'day', other: 'days'})}` : 'd'
}`
} else {
const diff = Math.floor(diffSeconds / MONTH_30)
if (diff < 12) {
return `${diff}${
long ? ` ${plural(diff, {one: 'month', other: 'months'})}` : 'mo'
}`
} else {
const str = new Date(earlier).toLocaleDateString()
if (long) {
return _(msg`on ${str}`)
}
return str
}
}
}
+15 -1
View File
@@ -88,6 +88,7 @@ export function useNotificationsRegistration() {
export function useRequestNotificationsPermission() {
const gate = useGate()
const {currentAccount} = useSession()
const agent = useAgent()
return async (
context: 'StartOnboarding' | 'AfterOnboarding' | 'Login' | 'Home',
@@ -125,7 +126,20 @@ export function useRequestNotificationsPermission() {
if (res.granted) {
// This will fire a pushTokenEvent, which will handle registration of the token
getPushToken(true)
const token = await getPushToken(true)
// Same hack as above. We cannot rely on the `addPushTokenListener` to fire on Android due to an Expo bug, so we
// will manually register it here. Note that this will occur only:
// 1. right after the user signs in, leading to no `currentAccount` account being available - this will be instead
// picked up from the useEffect above on `currentAccount` change
// 2. right after onboarding. In this case, we _need_ this registration, since `currentAccount` will not change
// and we need to ensure the token is registered right after permission is granted. `currentAccount` will already
// be available in this case, so the registration will succeed.
// We should remove this once expo-notifications (and possibly FCMv1) is fixed and the `addPushTokenListener` is
// working again. See https://github.com/expo/expo/issues/28656
if (isAndroid && currentAccount && token) {
registerPushToken(agent, currentAccount, token)
}
}
}
}
+1 -1
View File
@@ -35,7 +35,7 @@ export function isStateAtTabRoot(state: State | undefined) {
return (
isTab(currentRoute.name, 'Home') ||
isTab(currentRoute.name, 'Search') ||
isTab(currentRoute.name, 'Feeds') ||
isTab(currentRoute.name, 'Messages') ||
isTab(currentRoute.name, 'Notifications') ||
isTab(currentRoute.name, 'MyProfile')
)
+2 -6
View File
@@ -15,6 +15,7 @@ export type CommonNavigatorParams = {
Profile: {name: string; hideBackButton?: boolean}
ProfileFollowers: {name: string}
ProfileFollows: {name: string}
ProfileKnownFollowers: {name: string}
ProfileList: {name: string; rkey: string}
PostThread: {name: string; rkey: string}
PostLikedBy: {name: string; rkey: string}
@@ -40,12 +41,12 @@ export type CommonNavigatorParams = {
Hashtag: {tag: string; author?: string}
MessagesConversation: {conversation: string; embed?: string}
MessagesSettings: undefined
Feeds: undefined
}
export type BottomTabNavigatorParams = CommonNavigatorParams & {
HomeTab: undefined
SearchTab: undefined
FeedsTab: undefined
NotificationsTab: undefined
MyProfileTab: undefined
MessagesTab: undefined
@@ -59,10 +60,6 @@ export type SearchTabNavigatorParams = CommonNavigatorParams & {
Search: {q?: string}
}
export type FeedsTabNavigatorParams = CommonNavigatorParams & {
Feeds: undefined
}
export type NotificationsTabNavigatorParams = CommonNavigatorParams & {
Notifications: undefined
}
@@ -89,7 +86,6 @@ export type AllNavigatorParams = CommonNavigatorParams & {
Home: undefined
SearchTab: undefined
Search: {q?: string}
FeedsTab: undefined
Feeds: undefined
NotificationsTab: undefined
Notifications: undefined
+2
View File
@@ -103,6 +103,8 @@ export type LogEvents = {
'post:unrepost': {
logContext: 'FeedItem' | 'PostThreadItem' | 'Post'
}
'post:mute': {}
'post:unmute': {}
'profile:follow': {
didBecomeMutual: boolean | undefined
followeeClout: number | undefined
+1
View File
@@ -1,5 +1,6 @@
export type Gate =
// Keep this alphabetic please.
| 'native_pwi_disabled'
| 'request_notifications_permission_after_onboarding_v2'
| 'show_avi_follow_button'
| 'show_follow_back_label_v2'
+29 -2
View File
@@ -14,6 +14,8 @@ import {useNonReactiveCallback} from '../hooks/useNonReactiveCallback'
import {LogEvents} from './events'
import {Gate} from './gates'
const SDK_KEY = 'client-SXJakO39w9vIhl3D44u8UupyzFl4oZ2qPIkjwcvuPsV'
type StatsigUser = {
userID: string | undefined
// TODO: Remove when enough users have custom.platform:
@@ -26,6 +28,8 @@ type StatsigUser = {
bundleDate: number
refSrc: string
refUrl: string
referrer: string
referrerHostname: string
appLanguage: string
contentLanguages: string[]
}
@@ -33,12 +37,29 @@ type StatsigUser = {
let refSrc = ''
let refUrl = ''
let referrer = ''
let referrerHostname = ''
if (isWeb && typeof window !== 'undefined') {
const params = new URLSearchParams(window.location.search)
refSrc = params.get('ref_src') ?? ''
refUrl = decodeURIComponent(params.get('ref_url') ?? '')
}
if (
isWeb &&
typeof document !== 'undefined' &&
document != null &&
document.referrer
) {
try {
const url = new URL(document.referrer)
if (url.hostname !== 'bsky.app') {
referrer = document.referrer
referrerHostname = url.hostname
}
} catch {}
}
export type {LogEvents}
function createStatsigOptions(prefetchUsers: StatsigUser[]) {
@@ -198,6 +219,8 @@ function toStatsigUser(did: string | undefined): StatsigUser {
custom: {
refSrc,
refUrl,
referrer,
referrerHostname,
platform: Platform.OS as 'ios' | 'android' | 'web',
bundleIdentifier: BUNDLE_IDENTIFIER,
bundleDate: BUNDLE_DATE,
@@ -230,7 +253,7 @@ AppState.addEventListener('change', (state: AppStateStatus) => {
})
export async function tryFetchGates(
did: string,
did: string | undefined,
strategy: 'prefer-low-latency' | 'prefer-fresh-gates',
) {
try {
@@ -254,6 +277,10 @@ export async function tryFetchGates(
}
}
export function initialize() {
return Statsig.initialize(SDK_KEY, null, createStatsigOptions([]))
}
export function Provider({children}: {children: React.ReactNode}) {
const {currentAccount, accounts} = useSession()
const did = currentAccount?.did
@@ -299,7 +326,7 @@ export function Provider({children}: {children: React.ReactNode}) {
<GateCache.Provider value={gateCache}>
<StatsigProvider
key={did}
sdkKey="client-SXJakO39w9vIhl3D44u8UupyzFl4oZ2qPIkjwcvuPsV"
sdkKey={SDK_KEY}
mountKey={currentStatsigUser.userID}
user={currentStatsigUser}
// This isn't really blocking due to short initTimeoutMs above.
+64 -34
View File
@@ -1,7 +1,8 @@
import {Dimensions, Platform} from 'react-native'
import {Dimensions} from 'react-native'
import {isSafari} from 'lib/browser'
import {isWeb} from 'platform/detection'
const {height: SCREEN_HEIGHT} = Dimensions.get('window')
const IFRAME_HOST = isWeb
@@ -342,40 +343,17 @@ export function parseEmbedPlayerFromUrl(
}
}
if (urlp.hostname === 'media.tenor.com') {
let [_, id, filename] = urlp.pathname.split('/')
const tenorGif = parseTenorGif(urlp)
if (tenorGif.success) {
const {playerUri, dimensions} = tenorGif
const h = urlp.searchParams.get('hh')
const w = urlp.searchParams.get('ww')
let dimensions
if (h && w) {
dimensions = {
height: Number(h),
width: Number(w),
}
}
if (id && filename && dimensions && id.includes('AAAAC')) {
if (Platform.OS === 'web') {
if (isSafari) {
id = id.replace('AAAAC', 'AAAP1')
filename = filename.replace('.gif', '.mp4')
} else {
id = id.replace('AAAAC', 'AAAP3')
filename = filename.replace('.gif', '.webm')
}
} else {
id = id.replace('AAAAC', 'AAAAM')
}
return {
type: 'tenor_gif',
source: 'tenor',
isGif: true,
hideDetails: true,
playerUri: `https://t.gifs.bsky.app/${id}/${filename}`,
dimensions,
}
return {
type: 'tenor_gif',
source: 'tenor',
isGif: true,
hideDetails: true,
playerUri,
dimensions,
}
}
@@ -516,3 +494,55 @@ export function getGiphyMetaUri(url: URL) {
}
}
}
export function parseTenorGif(urlp: URL):
| {success: false}
| {
success: true
playerUri: string
dimensions: {height: number; width: number}
} {
if (urlp.hostname !== 'media.tenor.com') {
return {success: false}
}
let [_, id, filename] = urlp.pathname.split('/')
if (!id || !filename) {
return {success: false}
}
if (!id.includes('AAAAC')) {
return {success: false}
}
const h = urlp.searchParams.get('hh')
const w = urlp.searchParams.get('ww')
if (!h || !w) {
return {success: false}
}
const dimensions = {
height: Number(h),
width: Number(w),
}
if (isWeb) {
if (isSafari) {
id = id.replace('AAAAC', 'AAAP1')
filename = filename.replace('.gif', '.mp4')
} else {
id = id.replace('AAAAC', 'AAAP3')
filename = filename.replace('.gif', '.webm')
}
} else {
id = id.replace('AAAAC', 'AAAAM')
}
return {
success: true,
playerUri: `https://t.gifs.bsky.app/${id}/${filename}`,
dimensions,
}
}
+11 -42
View File
@@ -1,45 +1,3 @@
const NOW = 5
const MINUTE = 60
const HOUR = MINUTE * 60
const DAY = HOUR * 24
const MONTH_30 = DAY * 30
const MONTH = DAY * 30.41675 // This results in 365.001 days in a year, which is close enough for nearly all cases
export function ago(date: number | string | Date): string {
let ts: number
if (typeof date === 'string') {
ts = Number(new Date(date))
} else if (date instanceof Date) {
ts = Number(date)
} else {
ts = date
}
const diffSeconds = Math.floor((Date.now() - ts) / 1e3)
if (diffSeconds < NOW) {
return `now`
} else if (diffSeconds < MINUTE) {
return `${diffSeconds}s`
} else if (diffSeconds < HOUR) {
return `${Math.floor(diffSeconds / MINUTE)}m`
} else if (diffSeconds < DAY) {
return `${Math.floor(diffSeconds / HOUR)}h`
} else if (diffSeconds < MONTH_30) {
return `${Math.round(diffSeconds / DAY)}d`
} else {
let months = diffSeconds / MONTH
if (months % 1 >= 0.9) {
months = Math.ceil(months)
} else {
months = Math.floor(months)
}
if (months < 12) {
return `${months}mo`
} else {
return new Date(ts).toLocaleDateString()
}
}
}
export function niceDate(date: number | string | Date) {
const d = new Date(date)
return `${d.toLocaleDateString('en-us', {
@@ -61,3 +19,14 @@ export function getAge(birthDate: Date): number {
}
return age
}
/**
* Compares two dates by year, month, and day only
*/
export function simpleAreDatesEqual(a: Date, b: Date): boolean {
return (
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate()
)
}
+6 -2
View File
@@ -1,6 +1,10 @@
import '@formatjs/intl-locale/polyfill'
import '@formatjs/intl-pluralrules/polyfill'
// Don't remove -force from these because detection is VERY slow on low-end Android.
// https://github.com/formatjs/formatjs/issues/4463#issuecomment-2176070577
import '@formatjs/intl-locale/polyfill-force'
import '@formatjs/intl-pluralrules/polyfill-force'
import '@formatjs/intl-numberformat/polyfill-force'
import '@formatjs/intl-pluralrules/locale-data/en'
import '@formatjs/intl-numberformat/locale-data/en'
import {useEffect} from 'react'
import {i18n} from '@lingui/core'
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff

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