Merge remote-tracking branch 'upstream/main' into Improve-notification-localization
@@ -31,6 +31,7 @@ module.exports = {
|
||||
},
|
||||
},
|
||||
],
|
||||
'bsky-internal/use-exact-imports': 'error',
|
||||
'bsky-internal/use-typed-gates': 'error',
|
||||
'simple-import-sort/imports': [
|
||||
'warn',
|
||||
|
||||
@@ -4,6 +4,7 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- divy/bskylink
|
||||
|
||||
env:
|
||||
REGISTRY: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_REGISTRY }}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
name: build-and-push-link-aws
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- divy/bskylink
|
||||
|
||||
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: bskylink
|
||||
|
||||
jobs:
|
||||
link-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.bskylink
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
@@ -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,4 +1,4 @@
|
||||
#!/usr/bin/env sh
|
||||
. "$(dirname -- "$0")/_/husky.sh"
|
||||
|
||||
yarn lint-staged
|
||||
npx lint-staged
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
FROM node:20.11-alpine3.18 as build
|
||||
|
||||
# Move files into the image and install
|
||||
WORKDIR /app
|
||||
|
||||
COPY ./bskylink/package.json ./
|
||||
COPY ./bskylink/yarn.lock ./
|
||||
RUN yarn install --frozen-lockfile
|
||||
|
||||
COPY ./bskylink ./
|
||||
|
||||
# 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 LINK_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 Link Service"
|
||||
LABEL org.opencontainers.image.licenses=UNLICENSED
|
||||
@@ -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
|
||||
@@ -1,12 +1,16 @@
|
||||
import {RichText} from '@atproto/api'
|
||||
|
||||
import {parseEmbedPlayerFromUrl} from 'lib/strings/embed-player'
|
||||
import {
|
||||
createStarterPackGooglePlayUri,
|
||||
createStarterPackLinkFromAndroidReferrer,
|
||||
parseStarterPackUri,
|
||||
} from 'lib/strings/starter-pack'
|
||||
import {cleanError} from '../../src/lib/strings/errors'
|
||||
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 +146,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',
|
||||
@@ -870,3 +801,179 @@ describe('parseEmbedPlayerFromUrl', () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('createStarterPackLinkFromAndroidReferrer', () => {
|
||||
const validOutput = 'at://haileyok.com/app.bsky.graph.starterpack/rkey'
|
||||
|
||||
it('returns a link when input contains utm_source and utm_content', () => {
|
||||
expect(
|
||||
createStarterPackLinkFromAndroidReferrer(
|
||||
'utm_source=bluesky&utm_content=starterpack_haileyok.com_rkey',
|
||||
),
|
||||
).toEqual(validOutput)
|
||||
|
||||
expect(
|
||||
createStarterPackLinkFromAndroidReferrer(
|
||||
'utm_source=bluesky&utm_content=starterpack_test-lover-9000.com_rkey',
|
||||
),
|
||||
).toEqual('at://test-lover-9000.com/app.bsky.graph.starterpack/rkey')
|
||||
})
|
||||
|
||||
it('returns a link when input contains utm_source and utm_content in different order', () => {
|
||||
expect(
|
||||
createStarterPackLinkFromAndroidReferrer(
|
||||
'utm_content=starterpack_haileyok.com_rkey&utm_source=bluesky',
|
||||
),
|
||||
).toEqual(validOutput)
|
||||
})
|
||||
|
||||
it('returns a link when input contains other parameters as well', () => {
|
||||
expect(
|
||||
createStarterPackLinkFromAndroidReferrer(
|
||||
'utm_source=bluesky&utm_medium=starterpack&utm_content=starterpack_haileyok.com_rkey',
|
||||
),
|
||||
).toEqual(validOutput)
|
||||
})
|
||||
|
||||
it('returns null when utm_source is not present', () => {
|
||||
expect(
|
||||
createStarterPackLinkFromAndroidReferrer(
|
||||
'utm_content=starterpack_haileyok.com_rkey',
|
||||
),
|
||||
).toEqual(null)
|
||||
})
|
||||
|
||||
it('returns null when utm_content is not present', () => {
|
||||
expect(
|
||||
createStarterPackLinkFromAndroidReferrer('utm_source=bluesky'),
|
||||
).toEqual(null)
|
||||
})
|
||||
|
||||
it('returns null when utm_content is malformed', () => {
|
||||
expect(
|
||||
createStarterPackLinkFromAndroidReferrer(
|
||||
'utm_content=starterpack_haileyok.com',
|
||||
),
|
||||
).toEqual(null)
|
||||
|
||||
expect(
|
||||
createStarterPackLinkFromAndroidReferrer('utm_content=starterpack'),
|
||||
).toEqual(null)
|
||||
|
||||
expect(
|
||||
createStarterPackLinkFromAndroidReferrer(
|
||||
'utm_content=starterpack_haileyok.com_rkey_more',
|
||||
),
|
||||
).toEqual(null)
|
||||
|
||||
expect(
|
||||
createStarterPackLinkFromAndroidReferrer(
|
||||
'utm_content=notastarterpack_haileyok.com_rkey',
|
||||
),
|
||||
).toEqual(null)
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseStarterPackHttpUri', () => {
|
||||
const baseUri = 'https://bsky.app/start'
|
||||
|
||||
it('returns a valid at uri when http uri is valid', () => {
|
||||
const validHttpUri = `${baseUri}/haileyok.com/rkey`
|
||||
expect(parseStarterPackUri(validHttpUri)).toEqual({
|
||||
name: 'haileyok.com',
|
||||
rkey: 'rkey',
|
||||
})
|
||||
|
||||
const validHttpUri2 = `${baseUri}/haileyok.com/ilovetesting`
|
||||
expect(parseStarterPackUri(validHttpUri2)).toEqual({
|
||||
name: 'haileyok.com',
|
||||
rkey: 'ilovetesting',
|
||||
})
|
||||
|
||||
const validHttpUri3 = `${baseUri}/testlover9000.com/rkey`
|
||||
expect(parseStarterPackUri(validHttpUri3)).toEqual({
|
||||
name: 'testlover9000.com',
|
||||
rkey: 'rkey',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns null when there is no rkey', () => {
|
||||
const validHttpUri = `${baseUri}/haileyok.com`
|
||||
expect(parseStarterPackUri(validHttpUri)).toEqual(null)
|
||||
})
|
||||
|
||||
it('returns null when there is an extra path', () => {
|
||||
const validHttpUri = `${baseUri}/haileyok.com/rkey/other`
|
||||
expect(parseStarterPackUri(validHttpUri)).toEqual(null)
|
||||
})
|
||||
|
||||
it('returns null when there is no handle or rkey', () => {
|
||||
const validHttpUri = `${baseUri}`
|
||||
expect(parseStarterPackUri(validHttpUri)).toEqual(null)
|
||||
})
|
||||
|
||||
it('returns null when the route is not /start or /starter-pack', () => {
|
||||
const validHttpUri = 'https://bsky.app/start/haileyok.com/rkey'
|
||||
expect(parseStarterPackUri(validHttpUri)).toEqual({
|
||||
name: 'haileyok.com',
|
||||
rkey: 'rkey',
|
||||
})
|
||||
|
||||
const validHttpUri2 = 'https://bsky.app/starter-pack/haileyok.com/rkey'
|
||||
expect(parseStarterPackUri(validHttpUri2)).toEqual({
|
||||
name: 'haileyok.com',
|
||||
rkey: 'rkey',
|
||||
})
|
||||
|
||||
const invalidHttpUri = 'https://bsky.app/profile/haileyok.com/rkey'
|
||||
expect(parseStarterPackUri(invalidHttpUri)).toEqual(null)
|
||||
})
|
||||
|
||||
it('returns the at uri when the input is a valid starterpack at uri', () => {
|
||||
const validAtUri = 'at://did:123/app.bsky.graph.starterpack/rkey'
|
||||
expect(parseStarterPackUri(validAtUri)).toEqual({
|
||||
name: 'did:123',
|
||||
rkey: 'rkey',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns null when the at uri has no rkey', () => {
|
||||
const validAtUri = 'at://did:123/app.bsky.graph.starterpack'
|
||||
expect(parseStarterPackUri(validAtUri)).toEqual(null)
|
||||
})
|
||||
|
||||
it('returns null when the collection is not app.bsky.graph.starterpack', () => {
|
||||
const validAtUri = 'at://did:123/app.bsky.graph.list/rkey'
|
||||
expect(parseStarterPackUri(validAtUri)).toEqual(null)
|
||||
})
|
||||
|
||||
it('returns null when the input is undefined', () => {
|
||||
expect(parseStarterPackUri(undefined)).toEqual(null)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createStarterPackGooglePlayUri', () => {
|
||||
const base =
|
||||
'https://play.google.com/store/apps/details?id=xyz.blueskyweb.app&referrer=utm_source%3Dbluesky%26utm_medium%3Dstarterpack%26utm_content%3Dstarterpack_'
|
||||
|
||||
it('returns valid google play uri when input is valid', () => {
|
||||
expect(createStarterPackGooglePlayUri('name', 'rkey')).toEqual(
|
||||
`${base}name_rkey`,
|
||||
)
|
||||
})
|
||||
|
||||
it('returns null when no rkey is supplied', () => {
|
||||
// @ts-expect-error test
|
||||
expect(createStarterPackGooglePlayUri('name', undefined)).toEqual(null)
|
||||
})
|
||||
|
||||
it('returns null when no name or rkey are supplied', () => {
|
||||
// @ts-expect-error test
|
||||
expect(createStarterPackGooglePlayUri(undefined, undefined)).toEqual(null)
|
||||
})
|
||||
|
||||
it('returns null when rkey is supplied but no name', () => {
|
||||
// @ts-expect-error test
|
||||
expect(createStarterPackGooglePlayUri(undefined, 'rkey')).toEqual(null)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -39,6 +39,17 @@ module.exports = function (config) {
|
||||
const IS_TESTFLIGHT = process.env.EXPO_PUBLIC_ENV === 'testflight'
|
||||
const IS_PRODUCTION = process.env.EXPO_PUBLIC_ENV === 'production'
|
||||
|
||||
const ASSOCIATED_DOMAINS = [
|
||||
'applinks:bsky.app',
|
||||
'applinks:staging.bsky.app',
|
||||
'appclips:bsky.app',
|
||||
'appclips:go.bsky.app', // Allows App Clip to work when scanning QR codes
|
||||
// When testing local services, enter an ngrok (et al) domain here. It must use a standard HTTP/HTTPS port.
|
||||
...(IS_DEV || IS_TESTFLIGHT
|
||||
? ['appclips:sptesting.haileyok.com', 'applinks:sptesting.haileyok.com']
|
||||
: []),
|
||||
]
|
||||
|
||||
const UPDATES_CHANNEL = IS_TESTFLIGHT
|
||||
? 'testflight'
|
||||
: IS_PRODUCTION
|
||||
@@ -83,7 +94,7 @@ module.exports = function (config) {
|
||||
NSPhotoLibraryUsageDescription:
|
||||
'Used for profile pictures, posts, and other kinds of content',
|
||||
},
|
||||
associatedDomains: ['applinks:bsky.app', 'applinks:staging.bsky.app'],
|
||||
associatedDomains: ASSOCIATED_DOMAINS,
|
||||
splash: {
|
||||
...SPLASH_CONFIG,
|
||||
dark: DARK_SPLASH_CONFIG,
|
||||
@@ -202,6 +213,7 @@ module.exports = function (config) {
|
||||
sounds: PLATFORM === 'ios' ? ['assets/dm.aiff'] : ['assets/dm.mp3'],
|
||||
},
|
||||
],
|
||||
'./plugins/starterPackAppClipExtension/withStarterPackAppClip.js',
|
||||
'./plugins/withAndroidManifestPlugin.js',
|
||||
'./plugins/withAndroidManifestFCMIconPlugin.js',
|
||||
'./plugins/withAndroidStylesWindowBackgroundPlugin.js',
|
||||
@@ -234,6 +246,10 @@ module.exports = function (config) {
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
targetName: 'BlueskyClip',
|
||||
bundleIdentifier: 'xyz.blueskyweb.app.AppClip',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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="#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="#080B12" fill-rule="evenodd" d="M3 5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5Zm6 0H5v4h4V5ZM3 15a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4Zm6 0H5v4h4v-4ZM13 5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2h-4a2 2 0 0 1-2-2V5Zm6 0h-4v4h4V5ZM14 13a1 1 0 0 1 1 1v1h1a1 1 0 1 1 0 2h-2a1 1 0 0 1-1-1v-2a1 1 0 0 1 1-1Zm3 1a1 1 0 0 1 1-1h2a1 1 0 1 1 0 2h-2a1 1 0 0 1-1-1Zm0 4a1 1 0 0 1 1-1h2a1 1 0 1 1 0 2h-1v1a1 1 0 1 1-2 0v-2Z" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 580 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="M11.26 5.227 5.02 6.899c-.734.197-1.17.95-.973 1.685l1.672 6.24c.197.734.951 1.17 1.685.973l6.24-1.672c.734-.197 1.17-.951.973-1.685L12.945 6.2a1.375 1.375 0 0 0-1.685-.973Zm-6.566.459a2.632 2.632 0 0 0-1.86 3.223l1.672 6.24a2.632 2.632 0 0 0 3.223 1.861l6.24-1.672a2.631 2.631 0 0 0 1.861-3.223l-1.672-6.24a2.632 2.632 0 0 0-3.223-1.861l-6.24 1.672Z" clip-rule="evenodd"/><path fill="#000" fill-rule="evenodd" d="M15.138 18.411a4.606 4.606 0 1 0 0-9.211 4.606 4.606 0 0 0 0 9.211Zm0 1.257a5.862 5.862 0 1 0 0-11.724 5.862 5.862 0 0 0 0 11.724Z" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 687 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 153 133"><path fill="url(#a)" fill-rule="evenodd" d="m60.196 105.445-18.1 4.85c-11.73 3.143-23.788-3.819-26.931-15.55L1.19 42.597c-3.143-11.731 3.819-23.79 15.55-26.932L68.889 1.69C80.62-1.452 92.68 5.51 95.821 17.241l4.667 17.416a49.7 49.7 0 0 1 3.522-.125c27.053 0 48.984 21.931 48.984 48.984S131.063 132.5 104.01 132.5c-19.17 0-35.769-11.012-43.814-27.055ZM19.457 25.804 71.606 11.83c6.131-1.643 12.434 1.996 14.076 8.127l4.44 16.571c-20.289 5.987-35.096 24.758-35.096 46.988 0 4.157.517 8.193 1.492 12.047l-17.138 4.593c-6.131 1.642-12.434-1.996-14.077-8.128L11.33 39.88c-1.643-6.131 1.996-12.434 8.127-14.077Zm83.812 19.232c.246-.005.493-.007.741-.007 21.256 0 38.487 17.231 38.487 38.487s-17.231 38.488-38.487 38.488c-14.29 0-26.76-7.788-33.4-19.35l23.635-6.333c11.731-3.143 18.693-15.2 15.55-26.932l-6.526-24.353Zm-10.428 1.638 6.815 25.432c1.642 6.131-1.996 12.434-8.128 14.076l-24.867 6.664a38.57 38.57 0 0 1-1.139-9.33c0-17.372 11.51-32.056 27.32-36.842Z" clip-rule="evenodd"/><defs><linearGradient id="a" x1="76.715" x2="76.715" y1=".937" y2="132.5" gradientUnits="userSpaceOnUse"><stop stop-color="#0A7AFF"/><stop offset="1" stop-color="#59B9FF"/></linearGradient></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 9.9 KiB |
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "bskylink",
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
"test": "./tests/infra/with-test-db.sh node --loader ts-node/esm --test ./tests/index.ts",
|
||||
"build": "tsc"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atproto/common": "^0.4.0",
|
||||
"body-parser": "^1.20.2",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.19.2",
|
||||
"http-terminator": "^3.2.0",
|
||||
"kysely": "^0.27.3",
|
||||
"pg": "^8.12.0",
|
||||
"pino": "^9.2.0",
|
||||
"uint8arrays": "^5.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/pg": "^8.11.6",
|
||||
"typescript": "^5.4.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import {Database, envToCfg, httpLogger, LinkService, readEnv} from './index.js'
|
||||
|
||||
async function main() {
|
||||
const env = readEnv()
|
||||
const cfg = envToCfg(env)
|
||||
if (cfg.db.migrationUrl) {
|
||||
const migrateDb = Database.postgres({
|
||||
url: cfg.db.migrationUrl,
|
||||
schema: cfg.db.schema,
|
||||
})
|
||||
await migrateDb.migrateToLatestOrThrow()
|
||||
await migrateDb.close()
|
||||
}
|
||||
const link = await LinkService.create(cfg)
|
||||
await link.start()
|
||||
httpLogger.info('link service is running')
|
||||
process.on('SIGTERM', async () => {
|
||||
httpLogger.info('link service is stopping')
|
||||
await link.destroy()
|
||||
httpLogger.info('link service is stopped')
|
||||
})
|
||||
}
|
||||
|
||||
main()
|
||||
@@ -0,0 +1,82 @@
|
||||
import {envInt, envList, envStr} from '@atproto/common'
|
||||
|
||||
export type Config = {
|
||||
service: ServiceConfig
|
||||
db: DbConfig
|
||||
}
|
||||
|
||||
export type ServiceConfig = {
|
||||
port: number
|
||||
version?: string
|
||||
hostnames: string[]
|
||||
appHostname: string
|
||||
}
|
||||
|
||||
export type DbConfig = {
|
||||
url: string
|
||||
migrationUrl?: string
|
||||
pool: DbPoolConfig
|
||||
schema?: string
|
||||
}
|
||||
|
||||
export type DbPoolConfig = {
|
||||
size: number
|
||||
maxUses: number
|
||||
idleTimeoutMs: number
|
||||
}
|
||||
|
||||
export type Environment = {
|
||||
port?: number
|
||||
version?: string
|
||||
hostnames: string[]
|
||||
appHostname?: string
|
||||
dbPostgresUrl?: string
|
||||
dbPostgresMigrationUrl?: string
|
||||
dbPostgresSchema?: string
|
||||
dbPostgresPoolSize?: number
|
||||
dbPostgresPoolMaxUses?: number
|
||||
dbPostgresPoolIdleTimeoutMs?: number
|
||||
}
|
||||
|
||||
export const readEnv = (): Environment => {
|
||||
return {
|
||||
port: envInt('LINK_PORT'),
|
||||
version: envStr('LINK_VERSION'),
|
||||
hostnames: envList('LINK_HOSTNAMES'),
|
||||
appHostname: envStr('LINK_APP_HOSTNAME'),
|
||||
dbPostgresUrl: envStr('LINK_DB_POSTGRES_URL'),
|
||||
dbPostgresMigrationUrl: envStr('LINK_DB_POSTGRES_MIGRATION_URL'),
|
||||
dbPostgresSchema: envStr('LINK_DB_POSTGRES_SCHEMA'),
|
||||
dbPostgresPoolSize: envInt('LINK_DB_POSTGRES_POOL_SIZE'),
|
||||
dbPostgresPoolMaxUses: envInt('LINK_DB_POSTGRES_POOL_MAX_USES'),
|
||||
dbPostgresPoolIdleTimeoutMs: envInt(
|
||||
'LINK_DB_POSTGRES_POOL_IDLE_TIMEOUT_MS',
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
export const envToCfg = (env: Environment): Config => {
|
||||
const serviceCfg: ServiceConfig = {
|
||||
port: env.port ?? 3000,
|
||||
version: env.version,
|
||||
hostnames: env.hostnames,
|
||||
appHostname: env.appHostname || 'bsky.app',
|
||||
}
|
||||
if (!env.dbPostgresUrl) {
|
||||
throw new Error('Must configure postgres url (LINK_DB_POSTGRES_URL)')
|
||||
}
|
||||
const dbCfg: DbConfig = {
|
||||
url: env.dbPostgresUrl,
|
||||
migrationUrl: env.dbPostgresMigrationUrl,
|
||||
schema: env.dbPostgresSchema,
|
||||
pool: {
|
||||
idleTimeoutMs: env.dbPostgresPoolIdleTimeoutMs ?? 10000,
|
||||
maxUses: env.dbPostgresPoolMaxUses ?? Infinity,
|
||||
size: env.dbPostgresPoolSize ?? 10,
|
||||
},
|
||||
}
|
||||
return {
|
||||
service: serviceCfg,
|
||||
db: dbCfg,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import {Config} from './config.js'
|
||||
import Database from './db/index.js'
|
||||
|
||||
export type AppContextOptions = {
|
||||
cfg: Config
|
||||
db: Database
|
||||
}
|
||||
|
||||
export class AppContext {
|
||||
cfg: Config
|
||||
db: Database
|
||||
abortController = new AbortController()
|
||||
|
||||
constructor(private opts: AppContextOptions) {
|
||||
this.cfg = this.opts.cfg
|
||||
this.db = this.opts.db
|
||||
}
|
||||
|
||||
static async fromConfig(cfg: Config, overrides?: Partial<AppContextOptions>) {
|
||||
const db = Database.postgres({
|
||||
url: cfg.db.url,
|
||||
schema: cfg.db.schema,
|
||||
poolSize: cfg.db.pool.size,
|
||||
poolMaxUses: cfg.db.pool.maxUses,
|
||||
poolIdleTimeoutMs: cfg.db.pool.idleTimeoutMs,
|
||||
})
|
||||
return new AppContext({
|
||||
cfg,
|
||||
db,
|
||||
...overrides,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import assert from 'assert'
|
||||
import {
|
||||
Kysely,
|
||||
KyselyPlugin,
|
||||
Migrator,
|
||||
PluginTransformQueryArgs,
|
||||
PluginTransformResultArgs,
|
||||
PostgresDialect,
|
||||
QueryResult,
|
||||
RootOperationNode,
|
||||
UnknownRow,
|
||||
} from 'kysely'
|
||||
import {default as Pg} from 'pg'
|
||||
|
||||
import {dbLogger as log} from '../logger.js'
|
||||
import {default as migrations} from './migrations/index.js'
|
||||
import {DbMigrationProvider} from './migrations/provider.js'
|
||||
import {DbSchema} from './schema.js'
|
||||
|
||||
export class Database {
|
||||
migrator: Migrator
|
||||
destroyed = false
|
||||
|
||||
constructor(public db: Kysely<DbSchema>, public cfg: PgConfig) {
|
||||
this.migrator = new Migrator({
|
||||
db,
|
||||
migrationTableSchema: cfg.schema,
|
||||
provider: new DbMigrationProvider(migrations),
|
||||
})
|
||||
}
|
||||
|
||||
static postgres(opts: PgOptions): Database {
|
||||
const {schema, url, txLockNonce} = opts
|
||||
const pool =
|
||||
opts.pool ??
|
||||
new Pg.Pool({
|
||||
connectionString: url,
|
||||
max: opts.poolSize,
|
||||
maxUses: opts.poolMaxUses,
|
||||
idleTimeoutMillis: opts.poolIdleTimeoutMs,
|
||||
})
|
||||
|
||||
// Select count(*) and other pg bigints as js integer
|
||||
Pg.types.setTypeParser(Pg.types.builtins.INT8, n => parseInt(n, 10))
|
||||
|
||||
// Setup schema usage, primarily for test parallelism (each test suite runs in its own pg schema)
|
||||
if (schema && !/^[a-z_]+$/i.test(schema)) {
|
||||
throw new Error(`Postgres schema must only contain [A-Za-z_]: ${schema}`)
|
||||
}
|
||||
|
||||
pool.on('error', onPoolError)
|
||||
|
||||
const db = new Kysely<DbSchema>({
|
||||
dialect: new PostgresDialect({pool}),
|
||||
})
|
||||
|
||||
return new Database(db, {
|
||||
pool,
|
||||
schema,
|
||||
url,
|
||||
txLockNonce,
|
||||
})
|
||||
}
|
||||
|
||||
async transaction<T>(fn: (db: Database) => Promise<T>): Promise<T> {
|
||||
const leakyTxPlugin = new LeakyTxPlugin()
|
||||
return this.db
|
||||
.withPlugin(leakyTxPlugin)
|
||||
.transaction()
|
||||
.execute(txn => {
|
||||
const dbTxn = new Database(txn, this.cfg)
|
||||
return fn(dbTxn)
|
||||
.catch(async err => {
|
||||
leakyTxPlugin.endTx()
|
||||
// ensure that all in-flight queries are flushed & the connection is open
|
||||
await dbTxn.db.getExecutor().provideConnection(async () => {})
|
||||
throw err
|
||||
})
|
||||
.finally(() => leakyTxPlugin.endTx())
|
||||
})
|
||||
}
|
||||
|
||||
get schema(): string | undefined {
|
||||
return this.cfg.schema
|
||||
}
|
||||
|
||||
get isTransaction() {
|
||||
return this.db.isTransaction
|
||||
}
|
||||
|
||||
assertTransaction() {
|
||||
assert(this.isTransaction, 'Transaction required')
|
||||
}
|
||||
|
||||
assertNotTransaction() {
|
||||
assert(!this.isTransaction, 'Cannot be in a transaction')
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
if (this.destroyed) return
|
||||
await this.db.destroy()
|
||||
this.destroyed = true
|
||||
}
|
||||
|
||||
async migrateToOrThrow(migration: string) {
|
||||
if (this.schema) {
|
||||
await this.db.schema.createSchema(this.schema).ifNotExists().execute()
|
||||
}
|
||||
const {error, results} = await this.migrator.migrateTo(migration)
|
||||
if (error) {
|
||||
throw error
|
||||
}
|
||||
if (!results) {
|
||||
throw new Error('An unknown failure occurred while migrating')
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
async migrateToLatestOrThrow() {
|
||||
if (this.schema) {
|
||||
await this.db.schema.createSchema(this.schema).ifNotExists().execute()
|
||||
}
|
||||
const {error, results} = await this.migrator.migrateToLatest()
|
||||
if (error) {
|
||||
throw error
|
||||
}
|
||||
if (!results) {
|
||||
throw new Error('An unknown failure occurred while migrating')
|
||||
}
|
||||
return results
|
||||
}
|
||||
}
|
||||
|
||||
export default Database
|
||||
|
||||
export type PgConfig = {
|
||||
pool: Pg.Pool
|
||||
url: string
|
||||
schema?: string
|
||||
txLockNonce?: string
|
||||
}
|
||||
|
||||
type PgOptions = {
|
||||
url: string
|
||||
pool?: Pg.Pool
|
||||
schema?: string
|
||||
poolSize?: number
|
||||
poolMaxUses?: number
|
||||
poolIdleTimeoutMs?: number
|
||||
txLockNonce?: string
|
||||
}
|
||||
|
||||
class LeakyTxPlugin implements KyselyPlugin {
|
||||
private txOver = false
|
||||
|
||||
endTx() {
|
||||
this.txOver = true
|
||||
}
|
||||
|
||||
transformQuery(args: PluginTransformQueryArgs): RootOperationNode {
|
||||
if (this.txOver) {
|
||||
throw new Error('tx already failed')
|
||||
}
|
||||
return args.node
|
||||
}
|
||||
|
||||
async transformResult(
|
||||
args: PluginTransformResultArgs,
|
||||
): Promise<QueryResult<UnknownRow>> {
|
||||
return args.result
|
||||
}
|
||||
}
|
||||
|
||||
const onPoolError = (err: Error) => log.error({err}, 'db pool error')
|
||||
@@ -0,0 +1,15 @@
|
||||
import {Kysely} from 'kysely'
|
||||
|
||||
export async function up(db: Kysely<unknown>): Promise<void> {
|
||||
await db.schema
|
||||
.createTable('link')
|
||||
.addColumn('id', 'varchar', col => col.primaryKey())
|
||||
.addColumn('type', 'smallint', col => col.notNull()) // integer enum: 1->starterpack
|
||||
.addColumn('path', 'varchar', col => col.notNull())
|
||||
.addUniqueConstraint('link_path_unique', ['path'])
|
||||
.execute()
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<unknown>): Promise<void> {
|
||||
await db.schema.dropTable('link').execute()
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import * as init from './001-init.js'
|
||||
|
||||
export default {
|
||||
'001': init,
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import {Migration, MigrationProvider} from 'kysely'
|
||||
|
||||
export class DbMigrationProvider implements MigrationProvider {
|
||||
constructor(private migrations: Record<string, Migration>) {}
|
||||
async getMigrations(): Promise<Record<string, Migration>> {
|
||||
return this.migrations
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import {Selectable} from 'kysely'
|
||||
|
||||
export type DbSchema = {
|
||||
link: Link
|
||||
}
|
||||
|
||||
export interface Link {
|
||||
id: string
|
||||
type: LinkType
|
||||
path: string
|
||||
}
|
||||
|
||||
export enum LinkType {
|
||||
StarterPack = 1,
|
||||
}
|
||||
|
||||
export type LinkEntry = Selectable<Link>
|
||||
@@ -0,0 +1,45 @@
|
||||
import events from 'node:events'
|
||||
import http from 'node:http'
|
||||
|
||||
import cors from 'cors'
|
||||
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 './db/index.js'
|
||||
export * from './logger.js'
|
||||
|
||||
export class LinkService {
|
||||
public server?: http.Server
|
||||
private terminator?: HttpTerminator
|
||||
|
||||
constructor(public app: express.Application, public ctx: AppContext) {}
|
||||
|
||||
static async create(cfg: Config): Promise<LinkService> {
|
||||
let app = express()
|
||||
app.use(cors())
|
||||
|
||||
const ctx = await AppContext.fromConfig(cfg)
|
||||
app = routes(ctx, app)
|
||||
app.use(errorHandler)
|
||||
|
||||
return new LinkService(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()
|
||||
await this.ctx.db.close()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import {subsystemLogger} from '@atproto/common'
|
||||
|
||||
export const httpLogger = subsystemLogger('bskylink')
|
||||
export const dbLogger = subsystemLogger('bskylink:db')
|
||||
@@ -0,0 +1,111 @@
|
||||
import assert from 'node:assert'
|
||||
|
||||
import bodyParser from 'body-parser'
|
||||
import {Express, Request} from 'express'
|
||||
|
||||
import {AppContext} from '../context.js'
|
||||
import {LinkType} from '../db/schema.js'
|
||||
import {randomId} from '../util.js'
|
||||
import {handler} from './util.js'
|
||||
|
||||
export default function (ctx: AppContext, app: Express) {
|
||||
return app.post(
|
||||
'/link',
|
||||
bodyParser.json(),
|
||||
handler(async (req, res) => {
|
||||
let path: string
|
||||
if (typeof req.body?.path === 'string') {
|
||||
path = req.body.path
|
||||
} else {
|
||||
return res.status(400).json({
|
||||
error: 'InvalidPath',
|
||||
message: '"path" parameter is missing or not a string',
|
||||
})
|
||||
}
|
||||
if (!path.startsWith('/')) {
|
||||
return res.status(400).json({
|
||||
error: 'InvalidPath',
|
||||
message:
|
||||
'"path" parameter must be formatted as a path, starting with a "/"',
|
||||
})
|
||||
}
|
||||
const parts = getPathParts(path)
|
||||
if (parts.length === 3 && parts[0] === 'start') {
|
||||
// link pattern: /start/{did}/{rkey}
|
||||
if (!parts[1].startsWith('did:')) {
|
||||
// enforce strong links
|
||||
return res.status(400).json({
|
||||
error: 'InvalidPath',
|
||||
message:
|
||||
'"path" parameter for starter pack must contain the actor\'s DID',
|
||||
})
|
||||
}
|
||||
const id = await ensureLink(ctx, LinkType.StarterPack, parts)
|
||||
return res.json({url: getUrl(ctx, req, id)})
|
||||
}
|
||||
return res.status(400).json({
|
||||
error: 'InvalidPath',
|
||||
message: '"path" parameter does not have a known format',
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const ensureLink = async (ctx: AppContext, type: LinkType, parts: string[]) => {
|
||||
const normalizedPath = normalizedPathFromParts(parts)
|
||||
const created = await ctx.db.db
|
||||
.insertInto('link')
|
||||
.values({
|
||||
id: randomId(),
|
||||
type,
|
||||
path: normalizedPath,
|
||||
})
|
||||
.onConflict(oc => oc.column('path').doNothing())
|
||||
.returningAll()
|
||||
.executeTakeFirst()
|
||||
if (created) {
|
||||
return created.id
|
||||
}
|
||||
const found = await ctx.db.db
|
||||
.selectFrom('link')
|
||||
.selectAll()
|
||||
.where('path', '=', normalizedPath)
|
||||
.executeTakeFirstOrThrow()
|
||||
return found.id
|
||||
}
|
||||
|
||||
const getUrl = (ctx: AppContext, req: Request, id: string) => {
|
||||
if (!ctx.cfg.service.hostnames.length) {
|
||||
assert(req.headers.host, 'request must be made with host header')
|
||||
const baseUrl =
|
||||
req.protocol === 'http' && req.headers.host.startsWith('localhost:')
|
||||
? `http://${req.headers.host}`
|
||||
: `https://${req.headers.host}`
|
||||
return `${baseUrl}/${id}`
|
||||
}
|
||||
const baseUrl = ctx.cfg.service.hostnames.includes(req.headers.host)
|
||||
? `https://${req.headers.host}`
|
||||
: `https://${ctx.cfg.service.hostnames[0]}`
|
||||
return `${baseUrl}/${id}`
|
||||
}
|
||||
|
||||
const normalizedPathFromParts = (parts: string[]): string => {
|
||||
return (
|
||||
'/' +
|
||||
parts
|
||||
.map(encodeURIComponent)
|
||||
.map(part => part.replaceAll('%3A', ':')) // preserve colons
|
||||
.join('/')
|
||||
)
|
||||
}
|
||||
|
||||
const getPathParts = (path: string): string[] => {
|
||||
if (path === '/') return []
|
||||
if (path.endsWith('/')) {
|
||||
path = path.slice(0, -1) // ignore trailing slash
|
||||
}
|
||||
return path
|
||||
.slice(1) // remove leading slash
|
||||
.split('/')
|
||||
.map(decodeURIComponent)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import {Express} from 'express'
|
||||
import {sql} from 'kysely'
|
||||
|
||||
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
|
||||
try {
|
||||
await sql`select 1`.execute(ctx.db.db)
|
||||
return res.send({version})
|
||||
} catch (err) {
|
||||
return res.status(503).send({version, error: 'Service Unavailable'})
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import {Express} from 'express'
|
||||
|
||||
import {AppContext} from '../context.js'
|
||||
import {default as create} from './create.js'
|
||||
import {default as health} from './health.js'
|
||||
import {default as redirect} from './redirect.js'
|
||||
import {default as siteAssociation} from './siteAssociation.js'
|
||||
|
||||
export * from './util.js'
|
||||
|
||||
export default function (ctx: AppContext, app: Express) {
|
||||
app = health(ctx, app) // GET /_health
|
||||
app = siteAssociation(ctx, app) // GET /.well-known/apple-app-site-association
|
||||
app = create(ctx, app) // POST /link
|
||||
app = redirect(ctx, app) // GET /:linkId (should go last due to permissive matching)
|
||||
return app
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import assert from 'node:assert'
|
||||
|
||||
import {DAY, SECOND} from '@atproto/common'
|
||||
import {Express} from 'express'
|
||||
|
||||
import {AppContext} from '../context.js'
|
||||
import {handler} from './util.js'
|
||||
|
||||
export default function (ctx: AppContext, app: Express) {
|
||||
return app.get(
|
||||
'/:linkId',
|
||||
handler(async (req, res) => {
|
||||
const linkId = req.params.linkId
|
||||
assert(
|
||||
typeof linkId === 'string',
|
||||
'express guarantees id parameter is a string',
|
||||
)
|
||||
const found = await ctx.db.db
|
||||
.selectFrom('link')
|
||||
.selectAll()
|
||||
.where('id', '=', linkId)
|
||||
.executeTakeFirst()
|
||||
if (!found) {
|
||||
// potentially broken or mistyped link— send user to the app
|
||||
res.setHeader('Location', `https://${ctx.cfg.service.appHostname}`)
|
||||
res.setHeader('Cache-Control', 'no-store')
|
||||
return res.status(302).end()
|
||||
}
|
||||
// build url from original url in order to preserve query params
|
||||
const url = new URL(
|
||||
req.originalUrl,
|
||||
`https://${ctx.cfg.service.appHostname}`,
|
||||
)
|
||||
url.pathname = found.path
|
||||
res.setHeader('Location', url.href)
|
||||
res.setHeader('Cache-Control', `max-age=${(7 * DAY) / SECOND}`)
|
||||
return res.status(301).end()
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import {Express} from 'express'
|
||||
|
||||
import {AppContext} from '../context.js'
|
||||
|
||||
export default function (ctx: AppContext, app: Express) {
|
||||
return app.get('/.well-known/apple-app-site-association', (req, res) => {
|
||||
res.json({
|
||||
appclips: {
|
||||
apps: ['B3LX46C5HS.xyz.blueskyweb.app.AppClip'],
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import {ErrorRequestHandler, Request, RequestHandler, Response} from 'express'
|
||||
|
||||
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 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')
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import {randomBytes} from 'node:crypto'
|
||||
|
||||
import {toString} from 'uint8arrays'
|
||||
|
||||
// 40bit random id of 5-7 characters
|
||||
export const randomId = () => {
|
||||
return toString(randomBytes(5), 'base58btc')
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import assert from 'node:assert'
|
||||
import {AddressInfo} from 'node:net'
|
||||
import {after, before, describe, it} from 'node:test'
|
||||
|
||||
import {Database, envToCfg, LinkService, readEnv} from '../src/index.js'
|
||||
|
||||
describe('link service', async () => {
|
||||
let linkService: LinkService
|
||||
let baseUrl: string
|
||||
before(async () => {
|
||||
const env = readEnv()
|
||||
const cfg = envToCfg({
|
||||
...env,
|
||||
hostnames: ['test.bsky.link'],
|
||||
appHostname: 'test.bsky.app',
|
||||
dbPostgresSchema: 'link_test',
|
||||
dbPostgresUrl: process.env.DB_POSTGRES_URL,
|
||||
})
|
||||
const migrateDb = Database.postgres({
|
||||
url: cfg.db.url,
|
||||
schema: cfg.db.schema,
|
||||
})
|
||||
await migrateDb.migrateToLatestOrThrow()
|
||||
await migrateDb.close()
|
||||
linkService = await LinkService.create(cfg)
|
||||
await linkService.start()
|
||||
const {port} = linkService.server?.address() as AddressInfo
|
||||
baseUrl = `http://localhost:${port}`
|
||||
})
|
||||
|
||||
after(async () => {
|
||||
await linkService?.destroy()
|
||||
})
|
||||
|
||||
it('creates a starter pack link', async () => {
|
||||
const link = await getLink('/start/did:example:alice/xxx')
|
||||
const url = new URL(link)
|
||||
assert.strictEqual(url.origin, 'https://test.bsky.link')
|
||||
assert.match(url.pathname, /^\/[a-z0-9]+$/i)
|
||||
})
|
||||
|
||||
it('normalizes input paths and provides same link each time.', async () => {
|
||||
const link1 = await getLink('/start/did%3Aexample%3Abob/yyy')
|
||||
const link2 = await getLink('/start/did:example:bob/yyy/')
|
||||
assert.strictEqual(link1, link2)
|
||||
})
|
||||
|
||||
it('serves permanent redirect, preserving query params.', async () => {
|
||||
const link = await getLink('/start/did:example:carol/zzz/')
|
||||
const [status, location] = await getRedirect(`${link}?a=b`)
|
||||
assert.strictEqual(status, 301)
|
||||
const locationUrl = new URL(location)
|
||||
assert.strictEqual(
|
||||
locationUrl.pathname + locationUrl.search,
|
||||
'/start/did:example:carol/zzz?a=b',
|
||||
)
|
||||
})
|
||||
|
||||
async function getRedirect(link: string): Promise<[number, string]> {
|
||||
const url = new URL(link)
|
||||
const base = new URL(baseUrl)
|
||||
url.protocol = base.protocol
|
||||
url.host = base.host
|
||||
const res = await fetch(url, {redirect: 'manual'})
|
||||
await res.arrayBuffer() // drain
|
||||
assert(
|
||||
res.status === 301 || res.status === 303,
|
||||
'response was not a redirect',
|
||||
)
|
||||
return [res.status, res.headers.get('location') ?? '']
|
||||
}
|
||||
|
||||
async function getLink(path: string): Promise<string> {
|
||||
const res = await fetch(new URL('/link', baseUrl), {
|
||||
method: 'post',
|
||||
headers: {'content-type': 'application/json'},
|
||||
body: JSON.stringify({path}),
|
||||
})
|
||||
assert.strictEqual(res.status, 200)
|
||||
const payload = await res.json()
|
||||
assert(typeof payload.url === 'string')
|
||||
return payload.url
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,157 @@
|
||||
#!/usr/bin/env sh
|
||||
|
||||
# Exit if any command fails
|
||||
set -e
|
||||
|
||||
get_container_id() {
|
||||
local compose_file=$1
|
||||
local service=$2
|
||||
if [ -z "${compose_file}" ] || [ -z "${service}" ]; then
|
||||
echo "usage: get_container_id <compose_file> <service>"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# first line of jq normalizes for docker compose breaking change, see docker/compose#10958
|
||||
docker compose --file $compose_file ps --format json --status running \
|
||||
| jq -sc '.[] | if type=="array" then .[] else . end' | jq -s \
|
||||
| jq -r '.[]? | select(.Service == "'${service}'") | .ID'
|
||||
}
|
||||
|
||||
# Exports all environment variables
|
||||
export_env() {
|
||||
export_pg_env
|
||||
}
|
||||
|
||||
# Exports postgres environment variables
|
||||
export_pg_env() {
|
||||
# Based on creds in compose.yaml
|
||||
export PGPORT=5433
|
||||
export PGHOST=localhost
|
||||
export PGUSER=pg
|
||||
export PGPASSWORD=password
|
||||
export PGDATABASE=postgres
|
||||
export DB_POSTGRES_URL="postgresql://pg:password@127.0.0.1:5433/postgres"
|
||||
}
|
||||
|
||||
|
||||
pg_clear() {
|
||||
local pg_uri=$1
|
||||
|
||||
for schema_name in `psql "${pg_uri}" -c "SELECT schema_name FROM information_schema.schemata WHERE schema_name NOT LIKE 'pg_%' AND schema_name NOT LIKE 'information_schema';" -t`; do
|
||||
psql "${pg_uri}" -c "DROP SCHEMA \"${schema_name}\" CASCADE;"
|
||||
done
|
||||
}
|
||||
|
||||
pg_init() {
|
||||
local pg_uri=$1
|
||||
|
||||
psql "${pg_uri}" -c "CREATE SCHEMA IF NOT EXISTS \"public\";"
|
||||
}
|
||||
|
||||
main_native() {
|
||||
local services=${SERVICES}
|
||||
local postgres_url_env_var=`[[ $services == *"db_test"* ]] && echo "DB_TEST_POSTGRES_URL" || echo "DB_POSTGRES_URL"`
|
||||
|
||||
postgres_url="${!postgres_url_env_var}"
|
||||
|
||||
if [ -n "${postgres_url}" ]; then
|
||||
echo "Using ${postgres_url_env_var} (${postgres_url}) to connect to postgres."
|
||||
pg_init "${postgres_url}"
|
||||
else
|
||||
echo "Postgres connection string missing did you set ${postgres_url_env_var}?"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cleanup() {
|
||||
local services=$@
|
||||
|
||||
if [ -n "${postgres_url}" ] && [[ $services == *"db_test"* ]]; then
|
||||
pg_clear "${postgres_url}" &> /dev/null
|
||||
fi
|
||||
}
|
||||
|
||||
# trap SIGINT and performs cleanup
|
||||
trap "on_sigint ${services}" INT
|
||||
on_sigint() {
|
||||
cleanup $@
|
||||
exit $?
|
||||
}
|
||||
|
||||
# Run the arguments as a command
|
||||
DB_POSTGRES_URL="${postgres_url}" \
|
||||
"$@"
|
||||
code=$?
|
||||
|
||||
cleanup ${services}
|
||||
|
||||
exit ${code}
|
||||
}
|
||||
|
||||
main_docker() {
|
||||
# Expect a SERVICES env var to be set with the docker service names
|
||||
local services=${SERVICES}
|
||||
|
||||
dir=$(dirname $0)
|
||||
compose_file="${dir}/docker-compose.yaml"
|
||||
|
||||
# whether this particular script started the container(s)
|
||||
started_container=false
|
||||
|
||||
# performs cleanup as necessary, i.e. taking down containers
|
||||
# if this script started them
|
||||
cleanup() {
|
||||
local services=$@
|
||||
echo # newline
|
||||
if $started_container; then
|
||||
docker compose --file $compose_file rm --force --stop --volumes ${services}
|
||||
fi
|
||||
}
|
||||
|
||||
# trap SIGINT and performs cleanup
|
||||
trap "on_sigint ${services}" INT
|
||||
on_sigint() {
|
||||
cleanup $@
|
||||
exit $?
|
||||
}
|
||||
|
||||
# check if all services are running already
|
||||
not_running=false
|
||||
for service in $services; do
|
||||
container_id=$(get_container_id $compose_file $service)
|
||||
if [ -z $container_id ]; then
|
||||
not_running=true
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
# if any are missing, recreate all services
|
||||
if $not_running; then
|
||||
started_container=true
|
||||
docker compose --file $compose_file up --wait --force-recreate ${services}
|
||||
else
|
||||
echo "all services ${services} are already running"
|
||||
fi
|
||||
|
||||
# do not exit when following commands fail, so we can intercept exit code & tear down docker
|
||||
set +e
|
||||
|
||||
# setup environment variables and run args
|
||||
export_env
|
||||
"$@"
|
||||
# save return code for later
|
||||
code=$?
|
||||
|
||||
# performs cleanup as necessary
|
||||
cleanup ${services}
|
||||
exit ${code}
|
||||
}
|
||||
|
||||
# Main entry point
|
||||
main() {
|
||||
if ! docker ps >/dev/null 2>&1; then
|
||||
echo "Docker unavailable. Running on host."
|
||||
main_native $@
|
||||
else
|
||||
main_docker $@
|
||||
fi
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
version: '3.8'
|
||||
services:
|
||||
# An ephermerally-stored postgres database for single-use test runs
|
||||
db_test: &db_test
|
||||
image: postgres:14.11-alpine
|
||||
environment:
|
||||
- POSTGRES_USER=pg
|
||||
- POSTGRES_PASSWORD=password
|
||||
ports:
|
||||
- '5433:5432'
|
||||
# Healthcheck ensures db is queryable when `docker-compose up --wait` completes
|
||||
healthcheck:
|
||||
test: 'pg_isready -U pg'
|
||||
interval: 500ms
|
||||
timeout: 10s
|
||||
retries: 20
|
||||
# A persistently-stored postgres database
|
||||
db:
|
||||
<<: *db_test
|
||||
ports:
|
||||
- '5432:5432'
|
||||
healthcheck:
|
||||
disable: true
|
||||
volumes:
|
||||
- link_db:/var/lib/postgresql/data
|
||||
volumes:
|
||||
link_db:
|
||||
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env sh
|
||||
|
||||
# Example usage:
|
||||
# ./with-test-db.sh psql postgresql://pg:password@localhost:5433/postgres -c 'select 1;'
|
||||
|
||||
dir=$(dirname $0)
|
||||
. ${dir}/_common.sh
|
||||
|
||||
SERVICES="db_test" main "$@"
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "NodeNext",
|
||||
"esModuleInterop": true,
|
||||
"moduleResolution": "NodeNext",
|
||||
"outDir": "dist",
|
||||
"lib": ["ES2021.String"]
|
||||
},
|
||||
"include": ["./src/index.ts", "./src/bin.ts"]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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')}`} />
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import {subsystemLogger} from '@atproto/common'
|
||||
|
||||
export const httpLogger = subsystemLogger('bskyogcard')
|
||||
@@ -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})
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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',
|
||||
])
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -35,7 +35,7 @@ func run(args []string) {
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "appview-host",
|
||||
Usage: "method, hostname, and port of PDS instance",
|
||||
Usage: "scheme, hostname, and port of PDS instance",
|
||||
Value: "http://localhost:2584",
|
||||
// retain old PDS env var for easy transition
|
||||
EnvVars: []string{"ATP_APPVIEW_HOST", "ATP_PDS_HOST"},
|
||||
@@ -47,6 +47,13 @@ func run(args []string) {
|
||||
Value: ":8100",
|
||||
EnvVars: []string{"HTTP_ADDRESS"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "link-host",
|
||||
Usage: "scheme, hostname, and port of link service",
|
||||
Required: false,
|
||||
Value: "",
|
||||
EnvVars: []string{"LINK_HOST"},
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "debug",
|
||||
Usage: "Enable debug mode",
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
@@ -36,6 +37,7 @@ func serve(cctx *cli.Context) error {
|
||||
debug := cctx.Bool("debug")
|
||||
httpAddress := cctx.String("http-address")
|
||||
appviewHost := cctx.String("appview-host")
|
||||
linkHost := cctx.String("link-host")
|
||||
|
||||
// Echo
|
||||
e := echo.New()
|
||||
@@ -221,6 +223,18 @@ func serve(cctx *cli.Context) error {
|
||||
e.GET("/profile/:handleOrDID/post/:rkey/liked-by", server.WebGeneric)
|
||||
e.GET("/profile/:handleOrDID/post/:rkey/reposted-by", server.WebGeneric)
|
||||
|
||||
// starter packs
|
||||
e.GET("/starter-pack/:handleOrDID/:rkey", server.WebGeneric)
|
||||
e.GET("/start/:handleOrDID/:rkey", server.WebGeneric)
|
||||
|
||||
if linkHost != "" {
|
||||
linkUrl, err := url.Parse(linkHost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
e.Group("/:linkId", server.LinkProxyMiddleware(linkUrl))
|
||||
}
|
||||
|
||||
// Start the server.
|
||||
log.Infof("starting server address=%s", httpAddress)
|
||||
go func() {
|
||||
@@ -292,6 +306,30 @@ func (srv *Server) Download(c echo.Context) error {
|
||||
return c.Redirect(http.StatusFound, "/")
|
||||
}
|
||||
|
||||
// Handler for proxying top-level paths to link service, which ends up serving a redirect
|
||||
func (srv *Server) LinkProxyMiddleware(url *url.URL) echo.MiddlewareFunc {
|
||||
return middleware.ProxyWithConfig(
|
||||
middleware.ProxyConfig{
|
||||
Balancer: middleware.NewRoundRobinBalancer(
|
||||
[]*middleware.ProxyTarget{{URL: url}},
|
||||
),
|
||||
Skipper: func(c echo.Context) bool {
|
||||
req := c.Request()
|
||||
if req.Method == "GET" &&
|
||||
strings.LastIndex(strings.TrimRight(req.URL.Path, "/"), "/") == 0 && // top-level path
|
||||
!strings.HasPrefix(req.URL.Path, "/_") { // e.g. /_health endpoint
|
||||
return false
|
||||
}
|
||||
return true
|
||||
},
|
||||
RetryCount: 2,
|
||||
ErrorHandler: func(c echo.Context, err error) error {
|
||||
return c.Redirect(302, "/")
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// handler for endpoint that have no specific server-side handling
|
||||
func (srv *Server) WebGeneric(c echo.Context) error {
|
||||
data := pongo2.Context{}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
{
|
||||
"applinks": {
|
||||
"apps": [],
|
||||
"appclips": {
|
||||
"apps": ["B3LX46C5HS.xyz.blueskyweb.app.AppClip"]
|
||||
},
|
||||
"details": [
|
||||
{
|
||||
"appID": "B3LX46C5HS.xyz.blueskyweb.app",
|
||||
@@ -10,4 +12,4 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import UIKit
|
||||
|
||||
@main
|
||||
class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
var window: UIWindow?
|
||||
var controller: ViewController?
|
||||
|
||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
|
||||
let window = UIWindow()
|
||||
self.window = UIWindow()
|
||||
|
||||
let controller = ViewController(window: window)
|
||||
self.controller = controller
|
||||
|
||||
window.rootViewController = self.controller
|
||||
window.makeKeyAndVisible()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
|
||||
self.controller?.handleURL(url: url)
|
||||
return true
|
||||
}
|
||||
|
||||
func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
|
||||
if let incomingURL = userActivity.webpageURL {
|
||||
self.controller?.handleURL(url: incomingURL)
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 463 KiB |
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "App-Icon-1024x1024@1x.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import UIKit
|
||||
import WebKit
|
||||
import StoreKit
|
||||
|
||||
class ViewController: UIViewController, WKScriptMessageHandler, WKNavigationDelegate {
|
||||
let defaults = UserDefaults(suiteName: "group.app.bsky")
|
||||
|
||||
var window: UIWindow
|
||||
var webView: WKWebView?
|
||||
|
||||
var prevUrl: URL?
|
||||
var starterPackUrl: URL?
|
||||
|
||||
init(window: UIWindow) {
|
||||
self.window = window
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
let contentController = WKUserContentController()
|
||||
contentController.add(self, name: "onMessage")
|
||||
let configuration = WKWebViewConfiguration()
|
||||
configuration.userContentController = contentController
|
||||
|
||||
let webView = WKWebView(frame: self.view.bounds, configuration: configuration)
|
||||
webView.translatesAutoresizingMaskIntoConstraints = false
|
||||
webView.contentMode = .scaleToFill
|
||||
webView.navigationDelegate = self
|
||||
self.view.addSubview(webView)
|
||||
self.webView = webView
|
||||
}
|
||||
|
||||
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
|
||||
guard let response = message.body as? String,
|
||||
let data = response.data(using: .utf8),
|
||||
let payload = try? JSONDecoder().decode(WebViewActionPayload.self, from: data) else {
|
||||
return
|
||||
}
|
||||
|
||||
switch payload.action {
|
||||
case .present:
|
||||
guard let url = self.starterPackUrl else {
|
||||
return
|
||||
}
|
||||
|
||||
self.presentAppStoreOverlay()
|
||||
defaults?.setValue(url.absoluteString, forKey: "starterPackUri")
|
||||
|
||||
case .store:
|
||||
guard let keyToStoreAs = payload.keyToStoreAs, let jsonToStore = payload.jsonToStore else {
|
||||
return
|
||||
}
|
||||
|
||||
self.defaults?.setValue(jsonToStore, forKey: keyToStoreAs)
|
||||
}
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction) async -> WKNavigationActionPolicy {
|
||||
// Detect when we land on the right URL. This is incase of a short link opening the app clip
|
||||
guard let url = navigationAction.request.url else {
|
||||
return .allow
|
||||
}
|
||||
|
||||
// Store the previous one to compare later, but only set starterPackUrl when we find the right one
|
||||
prevUrl = url
|
||||
// pathComponents starts with "/" as the first component, then each path name. so...
|
||||
// ["/", "start", "name", "rkey"]
|
||||
if url.pathComponents.count == 4,
|
||||
url.pathComponents[1] == "start" {
|
||||
self.starterPackUrl = url
|
||||
}
|
||||
|
||||
return .allow
|
||||
}
|
||||
|
||||
func handleURL(url: URL) {
|
||||
let urlString = "\(url.absoluteString)?clip=true"
|
||||
if let url = URL(string: urlString) {
|
||||
self.webView?.load(URLRequest(url: url))
|
||||
}
|
||||
}
|
||||
|
||||
func presentAppStoreOverlay() {
|
||||
guard let windowScene = self.window.windowScene else {
|
||||
return
|
||||
}
|
||||
|
||||
let configuration = SKOverlay.AppClipConfiguration(position: .bottomRaised)
|
||||
let overlay = SKOverlay(configuration: configuration)
|
||||
|
||||
overlay.present(in: windowScene)
|
||||
}
|
||||
|
||||
func getHost(_ url: URL?) -> String? {
|
||||
if #available(iOS 16.0, *) {
|
||||
return url?.host()
|
||||
} else {
|
||||
return url?.host
|
||||
}
|
||||
}
|
||||
|
||||
func getQuery(_ url: URL?) -> String? {
|
||||
if #available(iOS 16.0, *) {
|
||||
return url?.query()
|
||||
} else {
|
||||
return url?.query
|
||||
}
|
||||
}
|
||||
|
||||
func urlMatchesPrevious(_ url: URL?) -> Bool {
|
||||
if #available(iOS 16.0, *) {
|
||||
return url?.query() == prevUrl?.query() && url?.host() == prevUrl?.host() && url?.query() == prevUrl?.query()
|
||||
} else {
|
||||
return url?.query == prevUrl?.query && url?.host == prevUrl?.host && url?.query == prevUrl?.query
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct WebViewActionPayload: Decodable {
|
||||
enum Action: String, Decodable {
|
||||
case present, store
|
||||
}
|
||||
|
||||
let action: Action
|
||||
let keyToStoreAs: String?
|
||||
let jsonToStore: String?
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
apply plugin: 'com.android.library'
|
||||
|
||||
group = 'expo.modules.blueskyswissarmy'
|
||||
version = '0.6.0'
|
||||
|
||||
def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
|
||||
apply from: expoModulesCorePlugin
|
||||
applyKotlinExpoModulesCorePlugin()
|
||||
useCoreDependencies()
|
||||
useExpoPublishing()
|
||||
|
||||
// If you want to use the managed Android SDK versions from expo-modules-core, set this to true.
|
||||
// The Android SDK versions will be bumped from time to time in SDK releases and may introduce breaking changes in your module code.
|
||||
// Most of the time, you may like to manage the Android SDK versions yourself.
|
||||
def useManagedAndroidSdkVersions = false
|
||||
if (useManagedAndroidSdkVersions) {
|
||||
useDefaultAndroidSdkVersions()
|
||||
} else {
|
||||
buildscript {
|
||||
// Simple helper that allows the root project to override versions declared by this library.
|
||||
ext.safeExtGet = { prop, fallback ->
|
||||
rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
|
||||
}
|
||||
}
|
||||
project.android {
|
||||
compileSdkVersion safeExtGet("compileSdkVersion", 34)
|
||||
defaultConfig {
|
||||
minSdkVersion safeExtGet("minSdkVersion", 21)
|
||||
targetSdkVersion safeExtGet("targetSdkVersion", 34)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
android {
|
||||
namespace "expo.modules.blueskyswissarmy"
|
||||
defaultConfig {
|
||||
versionCode 1
|
||||
versionName "0.6.0"
|
||||
}
|
||||
lintOptions {
|
||||
abortOnError false
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("com.android.installreferrer:installreferrer:2.2")
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
<manifest>
|
||||
</manifest>
|
||||
@@ -0,0 +1,10 @@
|
||||
package expo.modules.blueskyswissarmy.deviceprefs
|
||||
|
||||
import expo.modules.kotlin.modules.Module
|
||||
import expo.modules.kotlin.modules.ModuleDefinition
|
||||
|
||||
class ExpoBlueskyDevicePrefsModule : Module() {
|
||||
override fun definition() = ModuleDefinition {
|
||||
Name("ExpoBlueskyDevicePrefs")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package expo.modules.blueskyswissarmy.referrer
|
||||
|
||||
import android.util.Log
|
||||
import com.android.installreferrer.api.InstallReferrerClient
|
||||
import com.android.installreferrer.api.InstallReferrerStateListener
|
||||
import expo.modules.kotlin.modules.Module
|
||||
import expo.modules.kotlin.modules.ModuleDefinition
|
||||
import expo.modules.kotlin.Promise
|
||||
|
||||
class ExpoBlueskyReferrerModule : Module() {
|
||||
override fun definition() = ModuleDefinition {
|
||||
Name("ExpoBlueskyReferrer")
|
||||
|
||||
AsyncFunction("getGooglePlayReferrerInfoAsync") { promise: Promise ->
|
||||
val referrerClient = InstallReferrerClient.newBuilder(appContext.reactContext).build()
|
||||
referrerClient.startConnection(object : InstallReferrerStateListener {
|
||||
override fun onInstallReferrerSetupFinished(responseCode: Int) {
|
||||
if (responseCode == InstallReferrerClient.InstallReferrerResponse.OK) {
|
||||
Log.d("ExpoGooglePlayReferrer", "Successfully retrieved referrer info.")
|
||||
|
||||
val response = referrerClient.installReferrer
|
||||
Log.d("ExpoGooglePlayReferrer", "Install referrer: ${response.installReferrer}")
|
||||
|
||||
promise.resolve(
|
||||
mapOf(
|
||||
"installReferrer" to response.installReferrer,
|
||||
"clickTimestamp" to response.referrerClickTimestampSeconds,
|
||||
"installTimestamp" to response.installBeginTimestampSeconds
|
||||
)
|
||||
)
|
||||
} else {
|
||||
Log.d("ExpoGooglePlayReferrer", "Failed to get referrer info. Unknown error.")
|
||||
promise.reject(
|
||||
"ERR_GOOGLE_PLAY_REFERRER_UNKNOWN",
|
||||
"Failed to get referrer info",
|
||||
Exception("Failed to get referrer info")
|
||||
)
|
||||
}
|
||||
referrerClient.endConnection()
|
||||
}
|
||||
|
||||
override fun onInstallReferrerServiceDisconnected() {
|
||||
Log.d("ExpoGooglePlayReferrer", "Failed to get referrer info. Service disconnected.")
|
||||
referrerClient.endConnection()
|
||||
promise.reject(
|
||||
"ERR_GOOGLE_PLAY_REFERRER_DISCONNECTED",
|
||||
"Failed to get referrer info",
|
||||
Exception("Failed to get referrer info")
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"platforms": ["ios", "tvos", "android", "web"],
|
||||
"ios": {
|
||||
"modules": ["ExpoBlueskyDevicePrefsModule", "ExpoBlueskyReferrerModule"]
|
||||
},
|
||||
"android": {
|
||||
"modules": [
|
||||
"expo.modules.blueskyswissarmy.deviceprefs.ExpoBlueskyDevicePrefsModule",
|
||||
"expo.modules.blueskyswissarmy.referrer.ExpoBlueskyReferrerModule"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import * as DevicePrefs from './src/DevicePrefs'
|
||||
import * as Referrer from './src/Referrer'
|
||||
|
||||
export {DevicePrefs, Referrer}
|
||||
@@ -0,0 +1,23 @@
|
||||
import ExpoModulesCore
|
||||
|
||||
public class ExpoBlueskyDevicePrefsModule: Module {
|
||||
func getDefaults(_ useAppGroup: Bool) -> UserDefaults? {
|
||||
if useAppGroup {
|
||||
return UserDefaults(suiteName: "group.app.bsky")
|
||||
} else {
|
||||
return UserDefaults.standard
|
||||
}
|
||||
}
|
||||
|
||||
public func definition() -> ModuleDefinition {
|
||||
Name("ExpoBlueskyDevicePrefs")
|
||||
|
||||
AsyncFunction("getStringValueAsync") { (key: String, useAppGroup: Bool) in
|
||||
return self.getDefaults(useAppGroup)?.string(forKey: key)
|
||||
}
|
||||
|
||||
AsyncFunction("setStringValueAsync") { (key: String, value: String?, useAppGroup: Bool) in
|
||||
self.getDefaults(useAppGroup)?.setValue(value, forKey: key)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
Pod::Spec.new do |s|
|
||||
s.name = 'ExpoBlueskySwissArmy'
|
||||
s.version = '1.0.0'
|
||||
s.summary = 'A collection of native tools for Bluesky'
|
||||
s.description = 'A collection of native tools for Bluesky'
|
||||
s.author = ''
|
||||
s.homepage = 'https://github.com/bluesky-social/social-app'
|
||||
s.platforms = { :ios => '13.4', :tvos => '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
|
||||
@@ -0,0 +1,7 @@
|
||||
import ExpoModulesCore
|
||||
|
||||
public class ExpoBlueskyReferrerModule: Module {
|
||||
public func definition() -> ModuleDefinition {
|
||||
Name("ExpoBlueskyReferrer")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import {requireNativeModule} from 'expo-modules-core'
|
||||
|
||||
const NativeModule = requireNativeModule('ExpoBlueskyDevicePrefs')
|
||||
|
||||
export function getStringValueAsync(
|
||||
key: string,
|
||||
useAppGroup?: boolean,
|
||||
): Promise<string | null> {
|
||||
return NativeModule.getStringValueAsync(key, useAppGroup)
|
||||
}
|
||||
|
||||
export function setStringValueAsync(
|
||||
key: string,
|
||||
value: string | null,
|
||||
useAppGroup?: boolean,
|
||||
): Promise<void> {
|
||||
return NativeModule.setStringValueAsync(key, value, useAppGroup)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import {NotImplementedError} from '../NotImplemented'
|
||||
|
||||
export function getStringValueAsync(
|
||||
key: string,
|
||||
useAppGroup?: boolean,
|
||||
): Promise<string | null> {
|
||||
throw new NotImplementedError({key, useAppGroup})
|
||||
}
|
||||
|
||||
export function setStringValueAsync(
|
||||
key: string,
|
||||
value: string | null,
|
||||
useAppGroup?: boolean,
|
||||
): Promise<string | null> {
|
||||
throw new NotImplementedError({key, value, useAppGroup})
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import {Platform} from 'react-native'
|
||||
|
||||
export class NotImplementedError extends Error {
|
||||
constructor(params = {}) {
|
||||
if (__DEV__) {
|
||||
const caller = new Error().stack?.split('\n')[2]
|
||||
super(
|
||||
`Not implemented on ${Platform.OS}. Given params: ${JSON.stringify(
|
||||
params,
|
||||
)} ${caller}`,
|
||||
)
|
||||
} else {
|
||||
super('Not implemented')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import {requireNativeModule} from 'expo'
|
||||
|
||||
import {GooglePlayReferrerInfo} from './types'
|
||||
|
||||
export const NativeModule = requireNativeModule('ExpoBlueskyReferrer')
|
||||
|
||||
export function getGooglePlayReferrerInfoAsync(): Promise<GooglePlayReferrerInfo> {
|
||||
return NativeModule.getGooglePlayReferrerInfoAsync()
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import {NotImplementedError} from '../NotImplemented'
|
||||
import {GooglePlayReferrerInfo} from './types'
|
||||
|
||||
// @ts-ignore throws
|
||||
export function getGooglePlayReferrerInfoAsync(): Promise<GooglePlayReferrerInfo> {
|
||||
throw new NotImplementedError()
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export type GooglePlayReferrerInfo =
|
||||
| {
|
||||
installReferrer?: string
|
||||
clickTimestamp?: number
|
||||
installTimestamp?: number
|
||||
}
|
||||
| undefined
|
||||
@@ -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.18",
|
||||
"@atproto/api": "0.12.22-next.0",
|
||||
"@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",
|
||||
@@ -176,6 +177,7 @@
|
||||
"react-native-pager-view": "6.2.3",
|
||||
"react-native-picker-select": "^9.1.3",
|
||||
"react-native-progress": "bluesky-social/react-native-progress",
|
||||
"react-native-qrcode-styled": "^0.3.1",
|
||||
"react-native-reanimated": "^3.11.0",
|
||||
"react-native-root-siblings": "^4.1.1",
|
||||
"react-native-safe-area-context": "4.10.1",
|
||||
@@ -204,7 +206,7 @@
|
||||
"@babel/preset-env": "^7.20.0",
|
||||
"@babel/runtime": "^7.20.0",
|
||||
"@did-plc/server": "^0.0.1",
|
||||
"@expo/config-plugins": "7.8.0",
|
||||
"@expo/config-plugins": "8.0.4",
|
||||
"@expo/prebuild-config": "6.7.0",
|
||||
"@lingui/cli": "^4.5.0",
|
||||
"@lingui/macro": "^4.5.0",
|
||||
@@ -271,6 +273,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()
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
const {withEntitlementsPlist} = require('@expo/config-plugins')
|
||||
|
||||
const withAppEntitlements = config => {
|
||||
// eslint-disable-next-line no-shadow
|
||||
return withEntitlementsPlist(config, async config => {
|
||||
config.modResults['com.apple.security.application-groups'] = [
|
||||
`group.app.bsky`,
|
||||
]
|
||||
config.modResults[
|
||||
'com.apple.developer.associated-appclip-app-identifiers'
|
||||
] = [`$(AppIdentifierPrefix)${config.ios.bundleIdentifier}.AppClip`]
|
||||
return config
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = {withAppEntitlements}
|
||||
@@ -0,0 +1,32 @@
|
||||
const {withInfoPlist} = require('@expo/config-plugins')
|
||||
const plist = require('@expo/plist')
|
||||
const path = require('path')
|
||||
const fs = require('fs')
|
||||
|
||||
const withClipEntitlements = (config, {targetName}) => {
|
||||
// eslint-disable-next-line no-shadow
|
||||
return withInfoPlist(config, config => {
|
||||
const entitlementsPath = path.join(
|
||||
config.modRequest.platformProjectRoot,
|
||||
targetName,
|
||||
`${targetName}.entitlements`,
|
||||
)
|
||||
|
||||
const appClipEntitlements = {
|
||||
'com.apple.security.application-groups': [`group.app.bsky`],
|
||||
'com.apple.developer.parent-application-identifiers': [
|
||||
`$(AppIdentifierPrefix)${config.ios.bundleIdentifier}`,
|
||||
],
|
||||
'com.apple.developer.associated-domains': config.ios.associatedDomains,
|
||||
}
|
||||
|
||||
fs.mkdirSync(path.dirname(entitlementsPath), {
|
||||
recursive: true,
|
||||
})
|
||||
fs.writeFileSync(entitlementsPath, plist.default.build(appClipEntitlements))
|
||||
|
||||
return config
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = {withClipEntitlements}
|
||||
@@ -0,0 +1,38 @@
|
||||
const {withInfoPlist} = require('@expo/config-plugins')
|
||||
const plist = require('@expo/plist')
|
||||
const path = require('path')
|
||||
const fs = require('fs')
|
||||
|
||||
const withClipInfoPlist = (config, {targetName}) => {
|
||||
// eslint-disable-next-line no-shadow
|
||||
return withInfoPlist(config, config => {
|
||||
const targetPath = path.join(
|
||||
config.modRequest.platformProjectRoot,
|
||||
targetName,
|
||||
'Info.plist',
|
||||
)
|
||||
|
||||
const newPlist = plist.default.build({
|
||||
NSAppClip: {
|
||||
NSAppClipRequestEphemeralUserNotification: false,
|
||||
NSAppClipRequestLocationConfirmation: false,
|
||||
},
|
||||
UILaunchScreen: {},
|
||||
CFBundleName: '$(PRODUCT_NAME)',
|
||||
CFBundleIdentifier: '$(PRODUCT_BUNDLE_IDENTIFIER)',
|
||||
CFBundleVersion: '$(CURRENT_PROJECT_VERSION)',
|
||||
CFBundleExecutable: '$(EXECUTABLE_NAME)',
|
||||
CFBundlePackageType: '$(PRODUCT_BUNDLE_PACKAGE_TYPE)',
|
||||
CFBundleShortVersionString: config.version,
|
||||
CFBundleIconName: 'AppIcon',
|
||||
UIViewControllerBasedStatusBarAppearance: 'NO',
|
||||
})
|
||||
|
||||
fs.mkdirSync(path.dirname(targetPath), {recursive: true})
|
||||
fs.writeFileSync(targetPath, newPlist)
|
||||
|
||||
return config
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = {withClipInfoPlist}
|
||||
@@ -0,0 +1,40 @@
|
||||
const {withXcodeProject} = require('@expo/config-plugins')
|
||||
const path = require('path')
|
||||
const fs = require('fs')
|
||||
|
||||
const FILES = ['AppDelegate.swift', 'ViewController.swift']
|
||||
|
||||
const withFiles = (config, {targetName}) => {
|
||||
// eslint-disable-next-line no-shadow
|
||||
return withXcodeProject(config, config => {
|
||||
const basePath = path.join(
|
||||
config.modRequest.projectRoot,
|
||||
'modules',
|
||||
targetName,
|
||||
)
|
||||
|
||||
for (const file of FILES) {
|
||||
const sourcePath = path.join(basePath, file)
|
||||
const targetPath = path.join(
|
||||
config.modRequest.platformProjectRoot,
|
||||
targetName,
|
||||
file,
|
||||
)
|
||||
|
||||
fs.mkdirSync(path.dirname(targetPath), {recursive: true})
|
||||
fs.copyFileSync(sourcePath, targetPath)
|
||||
}
|
||||
|
||||
const imagesBasePath = path.join(basePath, 'Images.xcassets')
|
||||
const imagesTargetPath = path.join(
|
||||
config.modRequest.platformProjectRoot,
|
||||
targetName,
|
||||
'Images.xcassets',
|
||||
)
|
||||
fs.cpSync(imagesBasePath, imagesTargetPath, {recursive: true})
|
||||
|
||||
return config
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = {withFiles}
|
||||
@@ -0,0 +1,40 @@
|
||||
const {withPlugins} = require('@expo/config-plugins')
|
||||
const {withAppEntitlements} = require('./withAppEntitlements')
|
||||
const {withClipEntitlements} = require('./withClipEntitlements')
|
||||
const {withClipInfoPlist} = require('./withClipInfoPlist')
|
||||
const {withFiles} = require('./withFiles')
|
||||
const {withXcodeTarget} = require('./withXcodeTarget')
|
||||
|
||||
const APP_CLIP_TARGET_NAME = 'BlueskyClip'
|
||||
|
||||
const withStarterPackAppClip = config => {
|
||||
return withPlugins(config, [
|
||||
withAppEntitlements,
|
||||
[
|
||||
withClipEntitlements,
|
||||
{
|
||||
targetName: APP_CLIP_TARGET_NAME,
|
||||
},
|
||||
],
|
||||
[
|
||||
withClipInfoPlist,
|
||||
{
|
||||
targetName: APP_CLIP_TARGET_NAME,
|
||||
},
|
||||
],
|
||||
[
|
||||
withFiles,
|
||||
{
|
||||
targetName: APP_CLIP_TARGET_NAME,
|
||||
},
|
||||
],
|
||||
[
|
||||
withXcodeTarget,
|
||||
{
|
||||
targetName: APP_CLIP_TARGET_NAME,
|
||||
},
|
||||
],
|
||||
])
|
||||
}
|
||||
|
||||
module.exports = withStarterPackAppClip
|
||||
@@ -0,0 +1,91 @@
|
||||
const {withXcodeProject} = require('@expo/config-plugins')
|
||||
|
||||
const BUILD_PHASE_FILES = ['AppDelegate.swift', 'ViewController.swift']
|
||||
|
||||
const withXcodeTarget = (config, {targetName}) => {
|
||||
// eslint-disable-next-line no-shadow
|
||||
return withXcodeProject(config, config => {
|
||||
const pbxProject = config.modResults
|
||||
|
||||
const target = pbxProject.addTarget(targetName, 'application', targetName)
|
||||
target.pbxNativeTarget.productType = `"com.apple.product-type.application.on-demand-install-capable"`
|
||||
pbxProject.addBuildPhase(
|
||||
BUILD_PHASE_FILES.map(f => `${targetName}/${f}`),
|
||||
'PBXSourcesBuildPhase',
|
||||
'Sources',
|
||||
target.uuid,
|
||||
'application',
|
||||
'"AppClips"',
|
||||
)
|
||||
pbxProject.addBuildPhase(
|
||||
[`${targetName}/Images.xcassets`],
|
||||
'PBXResourcesBuildPhase',
|
||||
'Resources',
|
||||
target.uuid,
|
||||
'application',
|
||||
'"AppClips"',
|
||||
)
|
||||
|
||||
const pbxGroup = pbxProject.addPbxGroup([
|
||||
'AppDelegate.swift',
|
||||
'ViewController.swift',
|
||||
'Images.xcassets',
|
||||
`${targetName}.entitlements`,
|
||||
'Info.plist',
|
||||
])
|
||||
|
||||
pbxProject.addFile(`${targetName}/Info.plist`, pbxGroup.uuid)
|
||||
const configurations = pbxProject.pbxXCBuildConfigurationSection()
|
||||
for (const key in configurations) {
|
||||
if (typeof configurations[key].buildSettings !== 'undefined') {
|
||||
const buildSettingsObj = configurations[key].buildSettings
|
||||
if (
|
||||
typeof buildSettingsObj.PRODUCT_NAME !== 'undefined' &&
|
||||
buildSettingsObj.PRODUCT_NAME === `"${targetName}"`
|
||||
) {
|
||||
buildSettingsObj.CLANG_ENABLE_MODULES = 'YES'
|
||||
buildSettingsObj.INFOPLIST_FILE = `"${targetName}/Info.plist"`
|
||||
buildSettingsObj.CODE_SIGN_ENTITLEMENTS = `"${targetName}/${targetName}.entitlements"`
|
||||
buildSettingsObj.CODE_SIGN_STYLE = 'Automatic'
|
||||
buildSettingsObj.CURRENT_PROJECT_VERSION = `"${
|
||||
process.env.BSKY_IOS_BUILD_NUMBER ?? '1'
|
||||
}"`
|
||||
buildSettingsObj.GENERATE_INFOPLIST_FILE = 'YES'
|
||||
buildSettingsObj.MARKETING_VERSION = `"${config.version}"`
|
||||
buildSettingsObj.PRODUCT_BUNDLE_IDENTIFIER = `"${config.ios?.bundleIdentifier}.AppClip"`
|
||||
buildSettingsObj.SWIFT_EMIT_LOC_STRINGS = 'YES'
|
||||
buildSettingsObj.SWIFT_VERSION = '5.0'
|
||||
buildSettingsObj.TARGETED_DEVICE_FAMILY = `"1"`
|
||||
buildSettingsObj.DEVELOPMENT_TEAM = 'B3LX46C5HS'
|
||||
buildSettingsObj.IPHONEOS_DEPLOYMENT_TARGET = '14.0'
|
||||
buildSettingsObj.ASSETCATALOG_COMPILER_APPICON_NAME = 'AppIcon'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pbxProject.addTargetAttribute('DevelopmentTeam', 'B3LX46C5HS', targetName)
|
||||
|
||||
if (!pbxProject.hash.project.objects.PBXTargetDependency) {
|
||||
pbxProject.hash.project.objects.PBXTargetDependency = {}
|
||||
}
|
||||
if (!pbxProject.hash.project.objects.PBXContainerItemProxy) {
|
||||
pbxProject.hash.project.objects.PBXContainerItemProxy = {}
|
||||
}
|
||||
pbxProject.addTargetDependency(pbxProject.getFirstTarget().uuid, [
|
||||
target.uuid,
|
||||
])
|
||||
|
||||
pbxProject.addBuildPhase(
|
||||
[`${targetName}.app`],
|
||||
'PBXCopyFilesBuildPhase',
|
||||
'Embed App Clips',
|
||||
pbxProject.getFirstTarget().uuid,
|
||||
'application',
|
||||
'"AppClips"',
|
||||
)
|
||||
|
||||
return config
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = {withXcodeTarget}
|
||||
@@ -1,6 +1,7 @@
|
||||
#!/bin/bash
|
||||
IOS_SHARE_EXTENSION_DIRECTORY="./ios/Share-with-Bluesky"
|
||||
IOS_NOTIFICATION_EXTENSION_DIRECTORY="./ios/BlueskyNSE"
|
||||
IOS_APP_CLIP_DIRECTORY="./ios/BlueskyClip"
|
||||
MODULES_DIRECTORY="./modules"
|
||||
|
||||
if [ ! -d $IOS_SHARE_EXTENSION_DIRECTORY ]; then
|
||||
@@ -16,3 +17,11 @@ if [ ! -d $IOS_NOTIFICATION_EXTENSION_DIRECTORY ]; then
|
||||
else
|
||||
cp -R $IOS_NOTIFICATION_EXTENSION_DIRECTORY $MODULES_DIRECTORY
|
||||
fi
|
||||
|
||||
|
||||
if [ ! -d $IOS_APP_CLIP_DIRECTORY ]; then
|
||||
echo "$IOS_APP_CLIP_DIRECTORY not found inside of your iOS project."
|
||||
exit 1
|
||||
else
|
||||
cp -R $IOS_APP_CLIP_DIRECTORY $MODULES_DIRECTORY
|
||||
fi
|
||||
|
||||
@@ -14,38 +14,45 @@ 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 {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
|
||||
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 {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
|
||||
import {Provider as PortalProvider} from '#/components/Portal'
|
||||
import {Splash} from '#/Splash'
|
||||
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
|
||||
@@ -62,6 +69,7 @@ function InnerApp() {
|
||||
const {_} = useLingui()
|
||||
|
||||
useIntentHandler()
|
||||
const hasCheckedReferrer = useStarterPackEntry()
|
||||
|
||||
// init
|
||||
useEffect(() => {
|
||||
@@ -69,6 +77,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})
|
||||
@@ -90,7 +101,7 @@ function InnerApp() {
|
||||
<SafeAreaProvider initialMetrics={initialWindowMetrics}>
|
||||
<Alf theme={theme}>
|
||||
<ThemeProvider theme={theme}>
|
||||
<Splash isReady={isReady}>
|
||||
<Splash isReady={isReady && hasCheckedReferrer}>
|
||||
<RootSiblingParent>
|
||||
<React.Fragment
|
||||
// Resets the entire tree below when it changes:
|
||||
@@ -105,10 +116,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,29 +156,31 @@ function App() {
|
||||
* that is set up in the InnerApp component above.
|
||||
*/
|
||||
return (
|
||||
<KeyboardProvider enabled={false} statusBarTranslucent={true}>
|
||||
<SessionProvider>
|
||||
<ShellStateProvider>
|
||||
<PrefsStateProvider>
|
||||
<MutedThreadsProvider>
|
||||
<A11yProvider>
|
||||
<KeyboardProvider enabled={false} statusBarTranslucent={true}>
|
||||
<SessionProvider>
|
||||
<ShellStateProvider>
|
||||
<PrefsStateProvider>
|
||||
<InvitesStateProvider>
|
||||
<ModalStateProvider>
|
||||
<DialogStateProvider>
|
||||
<LightboxStateProvider>
|
||||
<I18nProvider>
|
||||
<PortalProvider>
|
||||
<InnerApp />
|
||||
<StarterPackProvider>
|
||||
<InnerApp />
|
||||
</StarterPackProvider>
|
||||
</PortalProvider>
|
||||
</I18nProvider>
|
||||
</LightboxStateProvider>
|
||||
</DialogStateProvider>
|
||||
</ModalStateProvider>
|
||||
</InvitesStateProvider>
|
||||
</MutedThreadsProvider>
|
||||
</PrefsStateProvider>
|
||||
</ShellStateProvider>
|
||||
</SessionProvider>
|
||||
</KeyboardProvider>
|
||||
</PrefsStateProvider>
|
||||
</ShellStateProvider>
|
||||
</SessionProvider>
|
||||
</KeyboardProvider>
|
||||
</A11yProvider>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -8,37 +8,40 @@ 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 {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import {ToastContainer} from '#/view/com/util/Toast.web'
|
||||
import {Shell} from '#/view/shell/index'
|
||||
import {ThemeProvider as Alf} from '#/alf'
|
||||
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
|
||||
import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
|
||||
import {Provider as PortalProvider} from '#/components/Portal'
|
||||
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
|
||||
import I18nProvider from './locale/i18nProvider'
|
||||
@@ -51,6 +54,7 @@ function InnerApp() {
|
||||
const theme = useColorModeTheme()
|
||||
const {_} = useLingui()
|
||||
useIntentHandler()
|
||||
const hasCheckedReferrer = useStarterPackEntry()
|
||||
|
||||
// init
|
||||
useEffect(() => {
|
||||
@@ -76,7 +80,7 @@ function InnerApp() {
|
||||
}, [_])
|
||||
|
||||
// wait for session to resume
|
||||
if (!isReady) return null
|
||||
if (!isReady || !hasCheckedReferrer) return null
|
||||
|
||||
return (
|
||||
<KeyboardProvider enabled={false}>
|
||||
@@ -96,9 +100,11 @@ function InnerApp() {
|
||||
<SelectedFeedProvider>
|
||||
<UnreadNotifsProvider>
|
||||
<BackgroundNotificationPreferencesProvider>
|
||||
<SafeAreaProvider>
|
||||
<Shell />
|
||||
</SafeAreaProvider>
|
||||
<MutedThreadsProvider>
|
||||
<SafeAreaProvider>
|
||||
<Shell />
|
||||
</SafeAreaProvider>
|
||||
</MutedThreadsProvider>
|
||||
</BackgroundNotificationPreferencesProvider>
|
||||
</UnreadNotifsProvider>
|
||||
</SelectedFeedProvider>
|
||||
@@ -133,27 +139,29 @@ function App() {
|
||||
* that is set up in the InnerApp component above.
|
||||
*/
|
||||
return (
|
||||
<SessionProvider>
|
||||
<ShellStateProvider>
|
||||
<PrefsStateProvider>
|
||||
<MutedThreadsProvider>
|
||||
<A11yProvider>
|
||||
<SessionProvider>
|
||||
<ShellStateProvider>
|
||||
<PrefsStateProvider>
|
||||
<InvitesStateProvider>
|
||||
<ModalStateProvider>
|
||||
<DialogStateProvider>
|
||||
<LightboxStateProvider>
|
||||
<I18nProvider>
|
||||
<PortalProvider>
|
||||
<InnerApp />
|
||||
<StarterPackProvider>
|
||||
<InnerApp />
|
||||
</StarterPackProvider>
|
||||
</PortalProvider>
|
||||
</I18nProvider>
|
||||
</LightboxStateProvider>
|
||||
</DialogStateProvider>
|
||||
</ModalStateProvider>
|
||||
</InvitesStateProvider>
|
||||
</MutedThreadsProvider>
|
||||
</PrefsStateProvider>
|
||||
</ShellStateProvider>
|
||||
</SessionProvider>
|
||||
</PrefsStateProvider>
|
||||
</ShellStateProvider>
|
||||
</SessionProvider>
|
||||
</A11yProvider>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -43,6 +43,8 @@ import HashtagScreen from '#/screens/Hashtag'
|
||||
import {ModerationScreen} from '#/screens/Moderation'
|
||||
import {ProfileKnownFollowersScreen} from '#/screens/Profile/KnownFollowers'
|
||||
import {ProfileLabelerLikedByScreen} from '#/screens/Profile/ProfileLabelerLikedBy'
|
||||
import {StarterPackScreen} from '#/screens/StarterPack/StarterPackScreen'
|
||||
import {Wizard} from '#/screens/StarterPack/Wizard'
|
||||
import {init as initAnalytics} from './lib/analytics/analytics'
|
||||
import {useWebScrollRestoration} from './lib/hooks/useWebScrollRestoration'
|
||||
import {attachRouteToLogEvents, logEvent} from './lib/statsig/statsig'
|
||||
@@ -54,8 +56,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'
|
||||
@@ -312,7 +314,26 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
|
||||
getComponent={() => MessagesSettingsScreen}
|
||||
options={{title: title(msg`Chat settings`), requireAuth: true}}
|
||||
/>
|
||||
<Stack.Screen name="Feeds" getComponent={() => FeedsScreen} />
|
||||
<Stack.Screen
|
||||
name="Feeds"
|
||||
getComponent={() => FeedsScreen}
|
||||
options={{title: title(msg`Feeds`)}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="StarterPack"
|
||||
getComponent={() => StarterPackScreen}
|
||||
options={{title: title(msg`Starter Pack`), requireAuth: true}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="StarterPackWizard"
|
||||
getComponent={() => Wizard}
|
||||
options={{title: title(msg`Create a starter pack`), requireAuth: true}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="StarterPackEdit"
|
||||
getComponent={() => Wizard}
|
||||
options={{title: title(msg`Edit your starter pack`), requireAuth: true}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -367,6 +388,7 @@ function HomeTabNavigator() {
|
||||
contentStyle: pal.view,
|
||||
}}>
|
||||
<HomeTab.Screen name="Home" getComponent={() => HomeScreen} />
|
||||
<HomeTab.Screen name="Start" getComponent={() => HomeScreen} />
|
||||
{commonScreens(HomeTab)}
|
||||
</HomeTab.Navigator>
|
||||
)
|
||||
@@ -503,6 +525,11 @@ const FlatNavigator = () => {
|
||||
getComponent={() => MessagesScreen}
|
||||
options={{title: title(msg`Messages`), requireAuth: true}}
|
||||
/>
|
||||
<Flat.Screen
|
||||
name="Start"
|
||||
getComponent={() => HomeScreen}
|
||||
options={{title: title(msg`Home`)}}
|
||||
/>
|
||||
{commonScreens(Flat as typeof HomeTab, numUnread)}
|
||||
</Flat.Navigator>
|
||||
)
|
||||
@@ -585,7 +612,7 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
|
||||
|
||||
if (currentAccount && shouldRequestEmailConfirmation(currentAccount)) {
|
||||
openModal({name: 'verify-email', showReminder: true})
|
||||
setEmailConfirmationRequested()
|
||||
snoozeEmailConfirmationPrompt()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -267,6 +267,9 @@ export const atoms = {
|
||||
font_bold: {
|
||||
fontWeight: tokens.fontWeight.bold,
|
||||
},
|
||||
font_heavy: {
|
||||
fontWeight: tokens.fontWeight.heavy,
|
||||
},
|
||||
italic: {
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
|
||||
@@ -118,6 +118,7 @@ export const fontWeight = {
|
||||
normal: '400',
|
||||
semibold: '500',
|
||||
bold: '600',
|
||||
heavy: '700',
|
||||
} as const
|
||||
|
||||
export const gradients = {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
import React from 'react'
|
||||
import {GestureResponderEvent, View} from 'react-native'
|
||||
import {
|
||||
AppBskyActorDefs,
|
||||
AppBskyFeedDefs,
|
||||
AppBskyGraphDefs,
|
||||
AtUri,
|
||||
} from '@atproto/api'
|
||||
import {msg, plural, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {logger} from '#/logger'
|
||||
import {
|
||||
useAddSavedFeedsMutation,
|
||||
usePreferencesQuery,
|
||||
useRemoveFeedMutation,
|
||||
} from '#/state/queries/preferences'
|
||||
import {sanitizeHandle} from 'lib/strings/handles'
|
||||
import {precacheFeedFromGeneratorView, precacheList} from 'state/queries/feed'
|
||||
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, LinkProps} from '#/components/Link'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
import {RichText} from '#/components/RichText'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
type Props =
|
||||
| {
|
||||
type: 'feed'
|
||||
view: AppBskyFeedDefs.GeneratorView
|
||||
}
|
||||
| {
|
||||
type: 'list'
|
||||
view: AppBskyGraphDefs.ListView
|
||||
}
|
||||
|
||||
export function Default(props: Props) {
|
||||
const {type, view} = props
|
||||
const displayName = type === 'feed' ? view.displayName : view.name
|
||||
const purpose = type === 'list' ? view.purpose : undefined
|
||||
return (
|
||||
<Link label={displayName} {...props}>
|
||||
<Outer>
|
||||
<Header>
|
||||
<Avatar src={view.avatar} />
|
||||
<TitleAndByline
|
||||
title={displayName}
|
||||
creator={view.creator}
|
||||
type={type}
|
||||
purpose={purpose}
|
||||
/>
|
||||
<Action uri={view.uri} pin type={type} purpose={purpose} />
|
||||
</Header>
|
||||
<Description description={view.description} />
|
||||
{type === 'feed' && <Likes count={view.likeCount || 0} />}
|
||||
</Outer>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export function Link({
|
||||
type,
|
||||
view,
|
||||
label,
|
||||
children,
|
||||
}: Props & Omit<LinkProps, 'to'>) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const href = React.useMemo(() => {
|
||||
return createProfileFeedHref({feed: view})
|
||||
}, [view])
|
||||
|
||||
return (
|
||||
<InternalLink
|
||||
to={href}
|
||||
label={label}
|
||||
onPress={() => {
|
||||
if (type === 'feed') {
|
||||
precacheFeedFromGeneratorView(queryClient, view)
|
||||
} else {
|
||||
precacheList(queryClient, view)
|
||||
}
|
||||
}}>
|
||||
{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_1, a.flex_row, a.align_center, a.gap_md]}>
|
||||
{children}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export type AvatarProps = {src: string | undefined; size?: number}
|
||||
|
||||
export function Avatar({src, size = 40}: AvatarProps) {
|
||||
return <UserAvatar type="algo" size={size} avatar={src} />
|
||||
}
|
||||
|
||||
export function AvatarPlaceholder({size = 40}: Omit<AvatarProps, 'src'>) {
|
||||
const t = useTheme()
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
t.atoms.bg_contrast_25,
|
||||
{
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: 8,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function TitleAndByline({
|
||||
title,
|
||||
creator,
|
||||
type,
|
||||
purpose,
|
||||
}: {
|
||||
title: string
|
||||
creator?: AppBskyActorDefs.ProfileViewBasic
|
||||
type: 'feed' | 'list'
|
||||
purpose?: AppBskyGraphDefs.ListView['purpose']
|
||||
}) {
|
||||
const t = useTheme()
|
||||
|
||||
return (
|
||||
<View style={[a.flex_1]}>
|
||||
<Text style={[a.text_md, a.font_bold, a.leading_snug]} numberOfLines={1}>
|
||||
{title}
|
||||
</Text>
|
||||
{creator && (
|
||||
<Text
|
||||
style={[a.leading_snug, t.atoms.text_contrast_medium]}
|
||||
numberOfLines={1}>
|
||||
{type === 'list' && purpose === 'app.bsky.graph.defs#curatelist' ? (
|
||||
<Trans>List by {sanitizeHandle(creator.handle, '@')}</Trans>
|
||||
) : type === 'list' && purpose === 'app.bsky.graph.defs#modlist' ? (
|
||||
<Trans>
|
||||
Moderation list by {sanitizeHandle(creator.handle, '@')}
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>Feed by {sanitizeHandle(creator.handle, '@')}</Trans>
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export function TitleAndBylinePlaceholder({creator}: {creator?: boolean}) {
|
||||
const t = useTheme()
|
||||
|
||||
return (
|
||||
<View style={[a.flex_1, a.gap_xs]}>
|
||||
<View
|
||||
style={[
|
||||
a.rounded_xs,
|
||||
t.atoms.bg_contrast_50,
|
||||
{
|
||||
width: '60%',
|
||||
height: 14,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{creator && (
|
||||
<View
|
||||
style={[
|
||||
a.rounded_xs,
|
||||
t.atoms.bg_contrast_25,
|
||||
{
|
||||
width: '40%',
|
||||
height: 10,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</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,
|
||||
type,
|
||||
purpose,
|
||||
}: {
|
||||
uri: string
|
||||
pin?: boolean
|
||||
type: 'feed' | 'list'
|
||||
purpose?: AppBskyGraphDefs.ListView['purpose']
|
||||
}) {
|
||||
const {hasSession} = useSession()
|
||||
if (!hasSession || purpose !== 'app.bsky.graph.defs#curatelist') return null
|
||||
return <ActionInner uri={uri} pin={pin} type={type} />
|
||||
}
|
||||
|
||||
function ActionInner({
|
||||
uri,
|
||||
pin,
|
||||
type,
|
||||
}: {
|
||||
uri: string
|
||||
pin?: boolean
|
||||
type: 'feed' | 'list'
|
||||
}) {
|
||||
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.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,
|
||||
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, type],
|
||||
)
|
||||
|
||||
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"
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function createProfileFeedHref({
|
||||
feed,
|
||||
}: {
|
||||
feed: AppBskyFeedDefs.GeneratorView | AppBskyGraphDefs.ListView
|
||||
}) {
|
||||
const urip = new AtUri(feed.uri)
|
||||
const type = urip.collection === 'app.bsky.feed.generator' ? 'feed' : 'list'
|
||||
const handleOrDid = feed.creator.handle || feed.creator.did
|
||||
return `/profile/${handleOrDid}/${type === 'feed' ? 'feed' : 'lists'}/${
|
||||
urip.rkey
|
||||
}`
|
||||
}
|
||||
@@ -1,14 +1,14 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {AppBskyActorDefs, moderateProfile, ModerationOpts} from '@atproto/api'
|
||||
import {msg, plural, Trans} from '@lingui/macro'
|
||||
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} from '#/components/Link'
|
||||
import {Link, LinkProps} from '#/components/Link'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
const AVI_SIZE = 30
|
||||
@@ -29,9 +29,11 @@ export function shouldShowKnownFollowers(
|
||||
export function KnownFollowers({
|
||||
profile,
|
||||
moderationOpts,
|
||||
onLinkPress,
|
||||
}: {
|
||||
profile: AppBskyActorDefs.ProfileViewDetailed
|
||||
moderationOpts: ModerationOpts
|
||||
onLinkPress?: LinkProps['onPress']
|
||||
}) {
|
||||
const cache = React.useRef<Map<string, AppBskyActorDefs.KnownFollowers>>(
|
||||
new Map(),
|
||||
@@ -56,6 +58,7 @@ export function KnownFollowers({
|
||||
profile={profile}
|
||||
cachedKnownFollowers={cachedKnownFollowers}
|
||||
moderationOpts={moderationOpts}
|
||||
onLinkPress={onLinkPress}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -67,10 +70,12 @@ function KnownFollowersInner({
|
||||
profile,
|
||||
moderationOpts,
|
||||
cachedKnownFollowers,
|
||||
onLinkPress,
|
||||
}: {
|
||||
profile: AppBskyActorDefs.ProfileViewDetailed
|
||||
moderationOpts: ModerationOpts
|
||||
cachedKnownFollowers: AppBskyActorDefs.KnownFollowers
|
||||
onLinkPress?: LinkProps['onPress']
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
@@ -82,15 +87,6 @@ function KnownFollowersInner({
|
||||
t.atoms.text_contrast_medium,
|
||||
]
|
||||
|
||||
// list of users, minus blocks
|
||||
const returnedCount = cachedKnownFollowers.followers.length
|
||||
// db count, includes blocks
|
||||
const fullCount = cachedKnownFollowers.count
|
||||
// knownFollowers can return up to 5 users, but will exclude blocks
|
||||
// therefore, if we have less 5 users, use whichever count is lower
|
||||
const count =
|
||||
returnedCount < 5 ? Math.min(fullCount, returnedCount) : fullCount
|
||||
|
||||
const slice = cachedKnownFollowers.followers.slice(0, 3).map(f => {
|
||||
const moderation = moderateProfile(f, moderationOpts)
|
||||
return {
|
||||
@@ -105,11 +101,21 @@ function KnownFollowersInner({
|
||||
}
|
||||
})
|
||||
|
||||
// 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,
|
||||
@@ -166,31 +172,60 @@ function KnownFollowersInner({
|
||||
},
|
||||
]}
|
||||
numberOfLines={2}>
|
||||
<Trans>Followed by</Trans>{' '}
|
||||
{count > 2 ? (
|
||||
<>
|
||||
{slice.slice(0, 2).map(({profile: prof}, i) => (
|
||||
<Text key={prof.did} style={textStyle}>
|
||||
{prof.displayName}
|
||||
{i === 0 && ', '}
|
||||
{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>
|
||||
))}
|
||||
{', '}
|
||||
{plural(count - 2, {
|
||||
one: 'and # other',
|
||||
other: 'and # others',
|
||||
})}
|
||||
</>
|
||||
) : count === 2 ? (
|
||||
slice.map(({profile: prof}, i) => (
|
||||
<Text key={prof.did} style={textStyle}>
|
||||
{prof.displayName} {i === 0 ? _(msg`and`) + ' ' : ''}
|
||||
</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>
|
||||
) : (
|
||||
<Text key={slice[0].profile.did} style={textStyle}>
|
||||
{slice[0].profile.displayName}
|
||||
</Text>
|
||||
// only 1
|
||||
<Trans>
|
||||
Followed by{' '}
|
||||
<Text key={slice[0].profile.did} style={textStyle}>
|
||||
{slice[0].profile.displayName}
|
||||
</Text>
|
||||
</Trans>
|
||||
)}
|
||||
</Text>
|
||||
</>
|
||||
|
||||