Merge remote-tracking branch 'upstream/main' into by-you
This commit is contained in:
@@ -0,0 +1,53 @@
|
|||||||
|
name: build-and-push-link-aws
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
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
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
name: build-and-push-ogcard-aws
|
name: build-and-push-ogcard-aws
|
||||||
on:
|
on:
|
||||||
push:
|
workflow_dispatch:
|
||||||
branches:
|
|
||||||
- divy/bskycard
|
|
||||||
|
|
||||||
env:
|
env:
|
||||||
REGISTRY: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_REGISTRY }}
|
REGISTRY: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_REGISTRY }}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ jobs:
|
|||||||
- name: Set up Go tooling
|
- name: Set up Go tooling
|
||||||
uses: actions/setup-go@v3
|
uses: actions/setup-go@v3
|
||||||
with:
|
with:
|
||||||
go-version: '1.21'
|
go-version: '1.22'
|
||||||
- name: Dummy Static Files
|
- name: Dummy Static Files
|
||||||
run: touch bskyweb/static/js/blah.js && touch bskyweb/static/media/blah.txt
|
run: touch bskyweb/static/js/blah.js && touch bskyweb/static/media/blah.txt
|
||||||
- name: Check
|
- name: Check
|
||||||
@@ -36,7 +36,7 @@ jobs:
|
|||||||
- name: Set up Go tooling
|
- name: Set up Go tooling
|
||||||
uses: actions/setup-go@v3
|
uses: actions/setup-go@v3
|
||||||
with:
|
with:
|
||||||
go-version: '1.21'
|
go-version: '1.22'
|
||||||
- name: Dummy Static Files
|
- name: Dummy Static Files
|
||||||
run: touch bskyweb/static/js/blah.js && touch bskyweb/static/media/blah.txt
|
run: touch bskyweb/static/js/blah.js && touch bskyweb/static/media/blah.txt
|
||||||
- name: Lint
|
- name: Lint
|
||||||
|
|||||||
@@ -115,3 +115,6 @@ src/locale/locales/**/*.js
|
|||||||
*.apk
|
*.apk
|
||||||
*.aab
|
*.aab
|
||||||
*.ipa
|
*.ipa
|
||||||
|
|
||||||
|
# ogcard assets
|
||||||
|
bskyogcard/src/assets/fonts/noto-*
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
FROM golang:1.21-bullseye AS build-env
|
FROM golang:1.22-bullseye AS build-env
|
||||||
|
|
||||||
WORKDIR /usr/src/social-app
|
WORKDIR /usr/src/social-app
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -10,7 +10,7 @@ RUN yarn install --frozen-lockfile
|
|||||||
COPY ./bskyogcard ./
|
COPY ./bskyogcard ./
|
||||||
|
|
||||||
# build then prune dev deps
|
# build then prune dev deps
|
||||||
RUN yarn build
|
RUN yarn install-fonts && yarn build
|
||||||
RUN yarn install --production --ignore-scripts --prefer-offline
|
RUN yarn install --production --ignore-scripts --prefer-offline
|
||||||
|
|
||||||
# Uses assets from build stage to reduce build size
|
# Uses assets from build stage to reduce build size
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
FROM golang:1.21-bullseye AS build-env
|
FROM golang:1.22-bullseye AS build-env
|
||||||
|
|
||||||
WORKDIR /usr/src/social-app
|
WORKDIR /usr/src/social-app
|
||||||
|
|
||||||
|
|||||||
@@ -132,8 +132,7 @@ appId: xyz.blueskyweb.app
|
|||||||
id: "feedItem-by-bob.test"
|
id: "feedItem-by-bob.test"
|
||||||
- tapOn:
|
- tapOn:
|
||||||
id: "e2eGotoFeeds"
|
id: "e2eGotoFeeds"
|
||||||
- tapOn:
|
- tapOn: "Good Ppl"
|
||||||
id: "saved-feed-Good Ppl"
|
|
||||||
- assertVisible:
|
- assertVisible:
|
||||||
id: "feedItem-by-bob.test"
|
id: "feedItem-by-bob.test"
|
||||||
- tapOn:
|
- tapOn:
|
||||||
@@ -144,8 +143,7 @@ appId: xyz.blueskyweb.app
|
|||||||
id: "homeScreenFeedTabs-Good Ppl"
|
id: "homeScreenFeedTabs-Good Ppl"
|
||||||
- tapOn:
|
- tapOn:
|
||||||
id: "e2eGotoLists"
|
id: "e2eGotoLists"
|
||||||
- tapOn:
|
- tapOn: "Good Ppl"
|
||||||
id: "list-Good Ppl"
|
|
||||||
|
|
||||||
- tapOn: "About"
|
- tapOn: "About"
|
||||||
- assertVisible:
|
- assertVisible:
|
||||||
@@ -171,8 +169,7 @@ appId: xyz.blueskyweb.app
|
|||||||
direction: LEFT
|
direction: LEFT
|
||||||
- tapOn:
|
- tapOn:
|
||||||
id: "profilePager-selector-5"
|
id: "profilePager-selector-5"
|
||||||
- tapOn:
|
- tapOn: "Good Ppl"
|
||||||
id: "list-Good Ppl"
|
|
||||||
|
|
||||||
- tapOn:
|
- tapOn:
|
||||||
label: "Adds and removes users on curatelists from the profile"
|
label: "Adds and removes users on curatelists from the profile"
|
||||||
|
|||||||
@@ -18,8 +18,7 @@ appId: xyz.blueskyweb.app
|
|||||||
direction: LEFT
|
direction: LEFT
|
||||||
- tapOn:
|
- tapOn:
|
||||||
id: "profilePager-selector-4"
|
id: "profilePager-selector-4"
|
||||||
- tapOn:
|
- tapOn: "alice-favs"
|
||||||
id: "feed-alice-favs"
|
|
||||||
- tapOn: "Pin to Home"
|
- tapOn: "Pin to Home"
|
||||||
- tapOn:
|
- tapOn:
|
||||||
id: "bottomBarHomeBtn"
|
id: "bottomBarHomeBtn"
|
||||||
|
|||||||
@@ -23,8 +23,7 @@ appId: xyz.blueskyweb.app
|
|||||||
direction: LEFT
|
direction: LEFT
|
||||||
- tapOn:
|
- tapOn:
|
||||||
id: "profilePager-selector-4"
|
id: "profilePager-selector-4"
|
||||||
- tapOn:
|
- tapOn: "alice-favs"
|
||||||
id: "feed-alice-favs"
|
|
||||||
- tapOn: "Pin to Home"
|
- tapOn: "Pin to Home"
|
||||||
- tapOn:
|
- tapOn:
|
||||||
id: "bottomBarHomeBtn"
|
id: "bottomBarHomeBtn"
|
||||||
|
|||||||
@@ -21,8 +21,7 @@ appId: xyz.blueskyweb.app
|
|||||||
direction: LEFT
|
direction: LEFT
|
||||||
- tapOn:
|
- tapOn:
|
||||||
id: "profilePager-selector-4"
|
id: "profilePager-selector-4"
|
||||||
- assertVisible:
|
- assertVisible: "alice-favs"
|
||||||
id: "feed-alice-favs"
|
|
||||||
- swipe:
|
- swipe:
|
||||||
from:
|
from:
|
||||||
id: "profilePager-selector"
|
id: "profilePager-selector"
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import {RichText} from '@atproto/api'
|
import {RichText} from '@atproto/api'
|
||||||
|
|
||||||
import {parseEmbedPlayerFromUrl} from 'lib/strings/embed-player'
|
import {parseEmbedPlayerFromUrl} from 'lib/strings/embed-player'
|
||||||
|
import {
|
||||||
|
createStarterPackGooglePlayUri,
|
||||||
|
createStarterPackLinkFromAndroidReferrer,
|
||||||
|
parseStarterPackUri,
|
||||||
|
} from 'lib/strings/starter-pack'
|
||||||
import {cleanError} from '../../src/lib/strings/errors'
|
import {cleanError} from '../../src/lib/strings/errors'
|
||||||
import {createFullHandle, makeValidHandle} from '../../src/lib/strings/handles'
|
import {createFullHandle, makeValidHandle} from '../../src/lib/strings/handles'
|
||||||
import {enforceLen} from '../../src/lib/strings/helpers'
|
import {enforceLen} from '../../src/lib/strings/helpers'
|
||||||
@@ -796,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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
+15
-1
@@ -39,6 +39,15 @@ module.exports = function (config) {
|
|||||||
const IS_TESTFLIGHT = process.env.EXPO_PUBLIC_ENV === 'testflight'
|
const IS_TESTFLIGHT = process.env.EXPO_PUBLIC_ENV === 'testflight'
|
||||||
const IS_PRODUCTION = process.env.EXPO_PUBLIC_ENV === 'production'
|
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 ? [] : []),
|
||||||
|
]
|
||||||
|
|
||||||
const UPDATES_CHANNEL = IS_TESTFLIGHT
|
const UPDATES_CHANNEL = IS_TESTFLIGHT
|
||||||
? 'testflight'
|
? 'testflight'
|
||||||
: IS_PRODUCTION
|
: IS_PRODUCTION
|
||||||
@@ -83,7 +92,7 @@ module.exports = function (config) {
|
|||||||
NSPhotoLibraryUsageDescription:
|
NSPhotoLibraryUsageDescription:
|
||||||
'Used for profile pictures, posts, and other kinds of content',
|
'Used for profile pictures, posts, and other kinds of content',
|
||||||
},
|
},
|
||||||
associatedDomains: ['applinks:bsky.app', 'applinks:staging.bsky.app'],
|
associatedDomains: ASSOCIATED_DOMAINS,
|
||||||
splash: {
|
splash: {
|
||||||
...SPLASH_CONFIG,
|
...SPLASH_CONFIG,
|
||||||
dark: DARK_SPLASH_CONFIG,
|
dark: DARK_SPLASH_CONFIG,
|
||||||
@@ -202,6 +211,7 @@ module.exports = function (config) {
|
|||||||
sounds: PLATFORM === 'ios' ? ['assets/dm.aiff'] : ['assets/dm.mp3'],
|
sounds: PLATFORM === 'ios' ? ['assets/dm.aiff'] : ['assets/dm.mp3'],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
'./plugins/starterPackAppClipExtension/withStarterPackAppClip.js',
|
||||||
'./plugins/withAndroidManifestPlugin.js',
|
'./plugins/withAndroidManifestPlugin.js',
|
||||||
'./plugins/withAndroidManifestFCMIconPlugin.js',
|
'./plugins/withAndroidManifestFCMIconPlugin.js',
|
||||||
'./plugins/withAndroidStylesWindowBackgroundPlugin.js',
|
'./plugins/withAndroidStylesWindowBackgroundPlugin.js',
|
||||||
@@ -234,6 +244,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="M21 12a1 1 0 0 1-.293.707l-6 6a1 1 0 0 1-1.414-1.414L17.586 13H4a1 1 0 1 1 0-2h13.586l-4.293-4.293a1 1 0 0 1 1.414-1.414l6 6A1 1 0 0 1 21 12Z" clip-rule="evenodd"/></svg>
|
||||||
|
After Width: | Height: | Size: 284 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 |
Binary file not shown.
|
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,54 @@
|
|||||||
|
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
|
||||||
|
const contentType = req.accepts(['html', 'json'])
|
||||||
|
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
|
||||||
|
res.setHeader('Cache-Control', 'no-store')
|
||||||
|
if (contentType === 'json') {
|
||||||
|
return res
|
||||||
|
.status(404)
|
||||||
|
.json({
|
||||||
|
error: 'NotFound',
|
||||||
|
message: 'Link not found',
|
||||||
|
})
|
||||||
|
.end()
|
||||||
|
}
|
||||||
|
// send the user to the app
|
||||||
|
res.setHeader('Location', `https://${ctx.cfg.service.appHostname}`)
|
||||||
|
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('Cache-Control', `max-age=${(7 * DAY) / SECOND}`)
|
||||||
|
if (contentType === 'json') {
|
||||||
|
return res.json({url: url.href}).end()
|
||||||
|
}
|
||||||
|
res.setHeader('Location', url.href)
|
||||||
|
return res.status(301).end()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
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({
|
||||||
|
applinks: {
|
||||||
|
apps: [],
|
||||||
|
details: [
|
||||||
|
{
|
||||||
|
appID: 'B3LX46C5HS.xyz.blueskyweb.app',
|
||||||
|
paths: ['*'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
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,123 @@
|
|||||||
|
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',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns json object with url when requested', async () => {
|
||||||
|
const link = await getLink('/start/did:example:carol/zzz/')
|
||||||
|
const [status, json] = await getJsonRedirect(link)
|
||||||
|
assert.strictEqual(status, 200)
|
||||||
|
assert(json.url)
|
||||||
|
const url = new URL(json.url)
|
||||||
|
assert.strictEqual(url.pathname, '/start/did:example:carol/zzz')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns 404 for unknown link when requesting json', async () => {
|
||||||
|
const [status, json] = await getJsonRedirect(
|
||||||
|
'https://test.bsky.link/unknown',
|
||||||
|
)
|
||||||
|
assert(json.error)
|
||||||
|
assert(json.message)
|
||||||
|
assert.strictEqual(status, 404)
|
||||||
|
assert.strictEqual(json.error, 'NotFound')
|
||||||
|
assert.strictEqual(json.message, 'Link not found')
|
||||||
|
})
|
||||||
|
|
||||||
|
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 getJsonRedirect(
|
||||||
|
link: string,
|
||||||
|
): Promise<[number, {url?: string; error?: string; message?: 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',
|
||||||
|
headers: {accept: 'application/json,text/html'},
|
||||||
|
})
|
||||||
|
assert(
|
||||||
|
res.headers.get('content-type')?.startsWith('application/json'),
|
||||||
|
'content type was not json',
|
||||||
|
)
|
||||||
|
const json = await res.json()
|
||||||
|
return [res.status, json]
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
})
|
||||||
Executable
+157
@@ -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:
|
||||||
Executable
+9
@@ -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"]
|
||||||
|
}
|
||||||
+1027
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,9 @@
|
|||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node --loader ts-node/esm ./src/bin.ts",
|
"start": "node --loader ts-node/esm ./src/bin.ts",
|
||||||
"build": "tsc && cp -r src/assets dist/assets"
|
"dev": "node --watch-path ./src --loader ts-node/esm ./src/bin.ts",
|
||||||
|
"build": "tsc && cp -r src/assets dist/",
|
||||||
|
"install-fonts": "node --loader ts-node/esm scripts/install-fonts.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@atproto/api": "0.12.19-next.0",
|
"@atproto/api": "0.12.19-next.0",
|
||||||
@@ -15,10 +17,12 @@
|
|||||||
"http-terminator": "^3.2.0",
|
"http-terminator": "^3.2.0",
|
||||||
"pino": "^9.2.0",
|
"pino": "^9.2.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"satori": "^0.10.13"
|
"satori": "^0.10.13",
|
||||||
|
"twemoji": "^14.0.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20.14.3",
|
"@types/node": "^20.14.3",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
"typescript": "^5.4.5"
|
"typescript": "^5.4.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import {writeFile} from 'node:fs/promises'
|
||||||
|
import * as path from 'node:path'
|
||||||
|
import {fileURLToPath} from 'node:url'
|
||||||
|
|
||||||
|
const __DIRNAME = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
|
||||||
|
const FONTS = [
|
||||||
|
'https://cdn.jsdelivr.net/fontsource/fonts/noto-sans-jp@5.0/japanese-700-normal.ttf',
|
||||||
|
'https://cdn.jsdelivr.net/fontsource/fonts/noto-sans-tc@5.0/chinese-traditional-700-normal.ttf',
|
||||||
|
'https://cdn.jsdelivr.net/fontsource/fonts/noto-sans-sc@5.0/chinese-simplified-700-normal.ttf',
|
||||||
|
'https://cdn.jsdelivr.net/fontsource/fonts/noto-sans-hk@5.0/chinese-hongkong-700-normal.ttf',
|
||||||
|
'https://cdn.jsdelivr.net/fontsource/fonts/noto-sans-kr@5.0/korean-700-normal.ttf',
|
||||||
|
'https://cdn.jsdelivr.net/fontsource/fonts/noto-sans-thai@5.0/thai-700-normal.ttf',
|
||||||
|
'https://cdn.jsdelivr.net/fontsource/fonts/noto-sans-arabic@5.0/arabic-700-normal.ttf',
|
||||||
|
'https://cdn.jsdelivr.net/fontsource/fonts/noto-sans-hebrew@5.0/hebrew-700-normal.ttf',
|
||||||
|
]
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
await Promise.all(
|
||||||
|
FONTS.map(async urlStr => {
|
||||||
|
const url = new URL(urlStr)
|
||||||
|
const res = await fetch(url)
|
||||||
|
const font = await res.arrayBuffer()
|
||||||
|
const filename = url.pathname
|
||||||
|
.split('/')
|
||||||
|
.slice(-2)
|
||||||
|
.join('/')
|
||||||
|
.replace(/@[\d.]+\//, '-')
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`HTTP ${res.status}: fetching failed for ${filename}`)
|
||||||
|
}
|
||||||
|
await writeFile(
|
||||||
|
path.join(__DIRNAME, '..', 'src', 'assets', 'fonts', filename),
|
||||||
|
Buffer.from(font),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
@@ -43,6 +43,7 @@ export function StarterPack(props: {
|
|||||||
} else {
|
} else {
|
||||||
imagesAcross.push(...imagesExceptCreator.slice(0, 7))
|
imagesAcross.push(...imagesExceptCreator.slice(0, 7))
|
||||||
}
|
}
|
||||||
|
const isLongTitle = record ? record.name.length > 30 : false
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -130,7 +131,9 @@ export function StarterPack(props: {
|
|||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
padding: '75px 30px 0px',
|
padding: '75px 30px 0px',
|
||||||
fontSize: 65,
|
fontSize: isLongTitle ? 55 : 65,
|
||||||
|
display: 'flex',
|
||||||
|
textAlign: 'center',
|
||||||
}}>
|
}}>
|
||||||
{record?.name || 'Starter Pack'}
|
{record?.name || 'Starter Pack'}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import {readFileSync} from 'node:fs'
|
import {readdirSync, readFileSync} from 'node:fs'
|
||||||
|
import * as path from 'node:path'
|
||||||
|
import {fileURLToPath} from 'node:url'
|
||||||
|
|
||||||
import {AtpAgent} from '@atproto/api'
|
import {AtpAgent} from '@atproto/api'
|
||||||
import * as path from 'path'
|
|
||||||
import {fileURLToPath} from 'url'
|
|
||||||
|
|
||||||
import {Config} from './config.js'
|
import {Config} from './config.js'
|
||||||
|
|
||||||
@@ -28,12 +28,14 @@ export class AppContext {
|
|||||||
|
|
||||||
static async fromConfig(cfg: Config, overrides?: Partial<AppContextOptions>) {
|
static async fromConfig(cfg: Config, overrides?: Partial<AppContextOptions>) {
|
||||||
const appviewAgent = new AtpAgent({service: cfg.service.appviewUrl})
|
const appviewAgent = new AtpAgent({service: cfg.service.appviewUrl})
|
||||||
const fonts = [
|
const fontDirectory = path.join(__DIRNAME, 'assets', 'fonts')
|
||||||
{
|
const fontFiles = readdirSync(fontDirectory)
|
||||||
name: 'Inter',
|
const fonts = fontFiles.map(file => {
|
||||||
data: readFileSync(path.join(__DIRNAME, 'assets', 'Inter-Bold.ttf')),
|
return {
|
||||||
},
|
name: path.basename(file, path.extname(file)),
|
||||||
]
|
data: readFileSync(path.join(fontDirectory, file)),
|
||||||
|
}
|
||||||
|
})
|
||||||
return new AppContext({
|
return new AppContext({
|
||||||
cfg,
|
cfg,
|
||||||
appviewAgent,
|
appviewAgent,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
import {subsystemLogger} from '@atproto/common'
|
import {subsystemLogger} from '@atproto/common'
|
||||||
|
|
||||||
export const httpLogger = subsystemLogger('bskyogcard')
|
export const httpLogger = subsystemLogger('bskyogcard')
|
||||||
|
export const renderLogger = subsystemLogger('bskyogcard:render')
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
} from '../components/StarterPack.js'
|
} from '../components/StarterPack.js'
|
||||||
import {AppContext} from '../context.js'
|
import {AppContext} from '../context.js'
|
||||||
import {httpLogger} from '../logger.js'
|
import {httpLogger} from '../logger.js'
|
||||||
|
import {loadEmojiAsSvg} from '../util.js'
|
||||||
import {handler, originVerifyMiddleware} from './util.js'
|
import {handler, originVerifyMiddleware} from './util.js'
|
||||||
|
|
||||||
export default function (ctx: AppContext, app: Express) {
|
export default function (ctx: AppContext, app: Express) {
|
||||||
@@ -65,6 +66,11 @@ export default function (ctx: AppContext, app: Express) {
|
|||||||
fonts: ctx.fonts,
|
fonts: ctx.fonts,
|
||||||
height: STARTERPACK_HEIGHT,
|
height: STARTERPACK_HEIGHT,
|
||||||
width: STARTERPACK_WIDTH,
|
width: STARTERPACK_WIDTH,
|
||||||
|
loadAdditionalAsset: async (code, text) => {
|
||||||
|
if (code === 'emoji') {
|
||||||
|
return await loadEmojiAsSvg(text)
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
const output = await resvg.renderAsync(svg)
|
const output = await resvg.renderAsync(svg)
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import twemoji from 'twemoji'
|
||||||
|
|
||||||
|
import {renderLogger} from './logger.js'
|
||||||
|
|
||||||
|
const U200D = String.fromCharCode(0x200d)
|
||||||
|
const UFE0F_REGEXP = /\uFE0F/g
|
||||||
|
|
||||||
|
export async function loadEmojiAsSvg(chars: string) {
|
||||||
|
const cached = emojiCache.get(chars)
|
||||||
|
if (cached) return cached
|
||||||
|
const iconCode = twemoji.convert.toCodePoint(
|
||||||
|
chars.indexOf(U200D) < 0 ? chars.replace(UFE0F_REGEXP, '') : chars,
|
||||||
|
)
|
||||||
|
const res = await fetch(getEmojiUrl(iconCode))
|
||||||
|
const body = await res.arrayBuffer()
|
||||||
|
if (!res.ok) {
|
||||||
|
renderLogger.warn(
|
||||||
|
{status: res.status, err: Buffer.from(body).toString()},
|
||||||
|
'could not fetch emoji',
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const svg =
|
||||||
|
'data:image/svg+xml;base64,' + Buffer.from(body).toString('base64')
|
||||||
|
emojiCache.set(chars, svg)
|
||||||
|
return svg
|
||||||
|
}
|
||||||
|
|
||||||
|
const emojiCache = new Map<string, string>()
|
||||||
|
|
||||||
|
function getEmojiUrl(code: string) {
|
||||||
|
return (
|
||||||
|
'https://cdnjs.cloudflare.com/ajax/libs/twemoji/14.0.2/svg/' +
|
||||||
|
code.toLowerCase() +
|
||||||
|
'.svg'
|
||||||
|
)
|
||||||
|
}
|
||||||
+303
-147
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -24,7 +24,7 @@ Then build and copy over the big 'ol `bundle.web.js` file:
|
|||||||
|
|
||||||
### Golang Daemon
|
### Golang Daemon
|
||||||
|
|
||||||
Install golang. We are generally using v1.21+.
|
Install golang. We are generally using v1.22+.
|
||||||
|
|
||||||
In this directory (`bskyweb/`):
|
In this directory (`bskyweb/`):
|
||||||
|
|
||||||
|
|||||||
@@ -35,11 +35,17 @@ func run(args []string) {
|
|||||||
Flags: []cli.Flag{
|
Flags: []cli.Flag{
|
||||||
&cli.StringFlag{
|
&cli.StringFlag{
|
||||||
Name: "appview-host",
|
Name: "appview-host",
|
||||||
Usage: "method, hostname, and port of PDS instance",
|
Usage: "scheme, hostname, and port of PDS instance",
|
||||||
Value: "http://localhost:2584",
|
Value: "http://localhost:2584",
|
||||||
// retain old PDS env var for easy transition
|
// retain old PDS env var for easy transition
|
||||||
EnvVars: []string{"ATP_APPVIEW_HOST", "ATP_PDS_HOST"},
|
EnvVars: []string{"ATP_APPVIEW_HOST", "ATP_PDS_HOST"},
|
||||||
},
|
},
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "ogcard-host",
|
||||||
|
Usage: "scheme, hostname, and port of ogcard service",
|
||||||
|
Required: false,
|
||||||
|
EnvVars: []string{"OGCARD_HOST"},
|
||||||
|
},
|
||||||
&cli.StringFlag{
|
&cli.StringFlag{
|
||||||
Name: "http-address",
|
Name: "http-address",
|
||||||
Usage: "Specify the local IP/port to bind to",
|
Usage: "Specify the local IP/port to bind to",
|
||||||
@@ -47,6 +53,13 @@ func run(args []string) {
|
|||||||
Value: ":8100",
|
Value: ":8100",
|
||||||
EnvVars: []string{"HTTP_ADDRESS"},
|
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{
|
&cli.BoolFlag{
|
||||||
Name: "debug",
|
Name: "debug",
|
||||||
Usage: "Enable debug mode",
|
Usage: "Enable debug mode",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -30,12 +31,23 @@ type Server struct {
|
|||||||
echo *echo.Echo
|
echo *echo.Echo
|
||||||
httpd *http.Server
|
httpd *http.Server
|
||||||
xrpcc *xrpc.Client
|
xrpcc *xrpc.Client
|
||||||
|
cfg *Config
|
||||||
|
}
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
debug bool
|
||||||
|
httpAddress string
|
||||||
|
appviewHost string
|
||||||
|
ogcardHost string
|
||||||
|
linkHost string
|
||||||
}
|
}
|
||||||
|
|
||||||
func serve(cctx *cli.Context) error {
|
func serve(cctx *cli.Context) error {
|
||||||
debug := cctx.Bool("debug")
|
debug := cctx.Bool("debug")
|
||||||
httpAddress := cctx.String("http-address")
|
httpAddress := cctx.String("http-address")
|
||||||
appviewHost := cctx.String("appview-host")
|
appviewHost := cctx.String("appview-host")
|
||||||
|
ogcardHost := cctx.String("ogcard-host")
|
||||||
|
linkHost := cctx.String("link-host")
|
||||||
|
|
||||||
// Echo
|
// Echo
|
||||||
e := echo.New()
|
e := echo.New()
|
||||||
@@ -71,6 +83,13 @@ func serve(cctx *cli.Context) error {
|
|||||||
server := &Server{
|
server := &Server{
|
||||||
echo: e,
|
echo: e,
|
||||||
xrpcc: xrpcc,
|
xrpcc: xrpcc,
|
||||||
|
cfg: &Config{
|
||||||
|
debug: debug,
|
||||||
|
httpAddress: httpAddress,
|
||||||
|
appviewHost: appviewHost,
|
||||||
|
ogcardHost: ogcardHost,
|
||||||
|
linkHost: linkHost,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create the HTTP server.
|
// Create the HTTP server.
|
||||||
@@ -221,6 +240,18 @@ func serve(cctx *cli.Context) error {
|
|||||||
e.GET("/profile/:handleOrDID/post/:rkey/liked-by", server.WebGeneric)
|
e.GET("/profile/:handleOrDID/post/:rkey/liked-by", server.WebGeneric)
|
||||||
e.GET("/profile/:handleOrDID/post/:rkey/reposted-by", server.WebGeneric)
|
e.GET("/profile/:handleOrDID/post/:rkey/reposted-by", server.WebGeneric)
|
||||||
|
|
||||||
|
// starter packs
|
||||||
|
e.GET("/starter-pack/:handleOrDID/:rkey", server.WebStarterPack)
|
||||||
|
e.GET("/start/:handleOrDID/:rkey", server.WebStarterPack)
|
||||||
|
|
||||||
|
if linkHost != "" {
|
||||||
|
linkUrl, err := url.Parse(linkHost)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
e.Group("/:linkId", server.LinkProxyMiddleware(linkUrl))
|
||||||
|
}
|
||||||
|
|
||||||
// Start the server.
|
// Start the server.
|
||||||
log.Infof("starting server address=%s", httpAddress)
|
log.Infof("starting server address=%s", httpAddress)
|
||||||
go func() {
|
go func() {
|
||||||
@@ -292,6 +323,30 @@ func (srv *Server) Download(c echo.Context) error {
|
|||||||
return c.Redirect(http.StatusFound, "/")
|
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
|
// handler for endpoint that have no specific server-side handling
|
||||||
func (srv *Server) WebGeneric(c echo.Context) error {
|
func (srv *Server) WebGeneric(c echo.Context) error {
|
||||||
data := pongo2.Context{}
|
data := pongo2.Context{}
|
||||||
@@ -377,6 +432,45 @@ func (srv *Server) WebPost(c echo.Context) error {
|
|||||||
return c.Render(http.StatusOK, "post.html", data)
|
return c.Render(http.StatusOK, "post.html", data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (srv *Server) WebStarterPack(c echo.Context) error {
|
||||||
|
req := c.Request()
|
||||||
|
ctx := req.Context()
|
||||||
|
data := pongo2.Context{}
|
||||||
|
data["requestURI"] = fmt.Sprintf("https://%s%s", req.Host, req.URL.Path)
|
||||||
|
// sanity check arguments. don't 4xx, just let app handle if not expected format
|
||||||
|
rkeyParam := c.Param("rkey")
|
||||||
|
rkey, err := syntax.ParseRecordKey(rkeyParam)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("bad rkey: %v", err)
|
||||||
|
return c.Render(http.StatusOK, "starterpack.html", data)
|
||||||
|
}
|
||||||
|
handleOrDIDParam := c.Param("handleOrDID")
|
||||||
|
handleOrDID, err := syntax.ParseAtIdentifier(handleOrDIDParam)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("bad identifier: %v", err)
|
||||||
|
return c.Render(http.StatusOK, "starterpack.html", data)
|
||||||
|
}
|
||||||
|
identifier := handleOrDID.Normalize().String()
|
||||||
|
starterPackURI := fmt.Sprintf("at://%s/app.bsky.graph.starterpack/%s", identifier, rkey)
|
||||||
|
spv, err := appbsky.GraphGetStarterPack(ctx, srv.xrpcc, starterPackURI)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("failed to fetch starter pack view for: %s\t%v", starterPackURI, err)
|
||||||
|
return c.Render(http.StatusOK, "starterpack.html", data)
|
||||||
|
}
|
||||||
|
if spv.StarterPack == nil || spv.StarterPack.Record == nil {
|
||||||
|
return c.Render(http.StatusOK, "starterpack.html", data)
|
||||||
|
}
|
||||||
|
rec, ok := spv.StarterPack.Record.Val.(*appbsky.GraphStarterpack)
|
||||||
|
if !ok {
|
||||||
|
return c.Render(http.StatusOK, "starterpack.html", data)
|
||||||
|
}
|
||||||
|
data["title"] = rec.Name
|
||||||
|
if srv.cfg.ogcardHost != "" {
|
||||||
|
data["imgThumbUrl"] = fmt.Sprintf("%s/start/%s/%s", srv.cfg.ogcardHost, identifier, rkey)
|
||||||
|
}
|
||||||
|
return c.Render(http.StatusOK, "starterpack.html", data)
|
||||||
|
}
|
||||||
|
|
||||||
func (srv *Server) WebProfile(c echo.Context) error {
|
func (srv *Server) WebProfile(c echo.Context) error {
|
||||||
ctx := c.Request().Context()
|
ctx := c.Request().Context()
|
||||||
data := pongo2.Context{}
|
data := pongo2.Context{}
|
||||||
|
|||||||
+16
-14
@@ -1,9 +1,11 @@
|
|||||||
module github.com/bluesky-social/social-app/bskyweb
|
module github.com/bluesky-social/social-app/bskyweb
|
||||||
|
|
||||||
go 1.21
|
go 1.22
|
||||||
|
|
||||||
|
toolchain go1.22.4
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/bluesky-social/indigo v0.0.0-20231216010655-ad730a7da4f5
|
github.com/bluesky-social/indigo v0.0.0-20240624223826-fd66f93ce141
|
||||||
github.com/flosch/pongo2/v6 v6.0.0
|
github.com/flosch/pongo2/v6 v6.0.0
|
||||||
github.com/ipfs/go-log v1.0.5
|
github.com/ipfs/go-log v1.0.5
|
||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
@@ -19,7 +21,7 @@ require (
|
|||||||
github.com/cpuguy83/go-md2man/v2 v2.0.3 // indirect
|
github.com/cpuguy83/go-md2man/v2 v2.0.3 // indirect
|
||||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect
|
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect
|
||||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||||
github.com/go-logr/logr v1.3.0 // indirect
|
github.com/go-logr/logr v1.4.1 // indirect
|
||||||
github.com/go-logr/stdr v1.2.2 // indirect
|
github.com/go-logr/stdr v1.2.2 // indirect
|
||||||
github.com/goccy/go-json v0.10.2 // indirect
|
github.com/goccy/go-json v0.10.2 // indirect
|
||||||
github.com/gogo/protobuf v1.3.2 // indirect
|
github.com/gogo/protobuf v1.3.2 // indirect
|
||||||
@@ -48,7 +50,7 @@ require (
|
|||||||
github.com/jbenet/goprocess v0.1.4 // indirect
|
github.com/jbenet/goprocess v0.1.4 // indirect
|
||||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
github.com/jinzhu/now v1.1.5 // indirect
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.2.6 // indirect
|
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
||||||
github.com/labstack/gommon v0.4.1 // indirect
|
github.com/labstack/gommon v0.4.1 // indirect
|
||||||
github.com/lestrrat-go/blackmagic v1.0.1 // indirect
|
github.com/lestrrat-go/blackmagic v1.0.1 // indirect
|
||||||
github.com/lestrrat-go/httpcc v1.0.1 // indirect
|
github.com/lestrrat-go/httpcc v1.0.1 // indirect
|
||||||
@@ -58,10 +60,9 @@ require (
|
|||||||
github.com/lestrrat-go/option v1.0.1 // indirect
|
github.com/lestrrat-go/option v1.0.1 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/mattn/go-sqlite3 v1.14.18 // indirect
|
github.com/mattn/go-sqlite3 v1.14.22 // indirect
|
||||||
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // indirect
|
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // indirect
|
||||||
github.com/minio/sha256-simd v1.0.1 // indirect
|
github.com/minio/sha256-simd v1.0.1 // indirect
|
||||||
github.com/mitchellh/go-homedir v1.1.0 // indirect
|
|
||||||
github.com/mr-tron/base58 v1.2.0 // indirect
|
github.com/mr-tron/base58 v1.2.0 // indirect
|
||||||
github.com/multiformats/go-base32 v0.1.0 // indirect
|
github.com/multiformats/go-base32 v0.1.0 // indirect
|
||||||
github.com/multiformats/go-base36 v0.2.0 // indirect
|
github.com/multiformats/go-base36 v0.2.0 // indirect
|
||||||
@@ -69,6 +70,7 @@ require (
|
|||||||
github.com/multiformats/go-multihash v0.2.3 // indirect
|
github.com/multiformats/go-multihash v0.2.3 // indirect
|
||||||
github.com/multiformats/go-varint v0.0.7 // indirect
|
github.com/multiformats/go-varint v0.0.7 // indirect
|
||||||
github.com/opentracing/opentracing-go v1.2.0 // indirect
|
github.com/opentracing/opentracing-go v1.2.0 // indirect
|
||||||
|
github.com/orandin/slog-gorm v1.3.2 // indirect
|
||||||
github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f // indirect
|
github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f // indirect
|
||||||
github.com/prometheus/client_golang v1.17.0 // indirect
|
github.com/prometheus/client_golang v1.17.0 // indirect
|
||||||
github.com/prometheus/client_model v0.5.0 // indirect
|
github.com/prometheus/client_model v0.5.0 // indirect
|
||||||
@@ -79,28 +81,28 @@ require (
|
|||||||
github.com/spaolacci/murmur3 v1.1.0 // indirect
|
github.com/spaolacci/murmur3 v1.1.0 // indirect
|
||||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||||
github.com/whyrusleeping/cbor-gen v0.0.0-20230923211252-36a87e1ba72f // indirect
|
github.com/whyrusleeping/cbor-gen v0.1.1-0.20240311221002-68b9f235c302 // indirect
|
||||||
github.com/whyrusleeping/go-did v0.0.0-20230824162731-404d1707d5d6 // indirect
|
github.com/whyrusleeping/go-did v0.0.0-20230824162731-404d1707d5d6 // indirect
|
||||||
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
|
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
|
||||||
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect
|
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect
|
||||||
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect
|
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.42.0 // indirect
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1 // indirect
|
||||||
go.opentelemetry.io/otel v1.21.0 // indirect
|
go.opentelemetry.io/otel v1.21.0 // indirect
|
||||||
go.opentelemetry.io/otel/metric v1.21.0 // indirect
|
go.opentelemetry.io/otel/metric v1.21.0 // indirect
|
||||||
go.opentelemetry.io/otel/trace v1.21.0 // indirect
|
go.opentelemetry.io/otel/trace v1.21.0 // indirect
|
||||||
go.uber.org/atomic v1.11.0 // indirect
|
go.uber.org/atomic v1.11.0 // indirect
|
||||||
go.uber.org/multierr v1.11.0 // indirect
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
go.uber.org/zap v1.26.0 // indirect
|
go.uber.org/zap v1.26.0 // indirect
|
||||||
golang.org/x/crypto v0.15.0 // indirect
|
golang.org/x/crypto v0.21.0 // indirect
|
||||||
golang.org/x/net v0.18.0 // indirect
|
golang.org/x/net v0.21.0 // indirect
|
||||||
golang.org/x/sync v0.5.0 // indirect
|
golang.org/x/sync v0.5.0 // indirect
|
||||||
golang.org/x/sys v0.14.0 // indirect
|
golang.org/x/sys v0.18.0 // indirect
|
||||||
golang.org/x/text v0.14.0 // indirect
|
golang.org/x/text v0.14.0 // indirect
|
||||||
golang.org/x/time v0.3.0 // indirect
|
golang.org/x/time v0.3.0 // indirect
|
||||||
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect
|
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect
|
||||||
google.golang.org/protobuf v1.31.0 // indirect
|
google.golang.org/protobuf v1.31.0 // indirect
|
||||||
gorm.io/driver/postgres v1.5.0 // indirect
|
gorm.io/driver/postgres v1.5.7 // indirect
|
||||||
gorm.io/driver/sqlite v1.5.4 // indirect
|
gorm.io/driver/sqlite v1.5.5 // indirect
|
||||||
gorm.io/gorm v1.25.5 // indirect
|
gorm.io/gorm v1.25.9 // indirect
|
||||||
lukechampine.com/blake3 v1.2.1 // indirect
|
lukechampine.com/blake3 v1.2.1 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
+28
-48
@@ -2,8 +2,8 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03
|
|||||||
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
|
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
|
||||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||||
github.com/bluesky-social/indigo v0.0.0-20231216010655-ad730a7da4f5 h1:Zk1c+mxCYH6G/vLL0+9lO2Eci4OT3AFy73qPWa9auDM=
|
github.com/bluesky-social/indigo v0.0.0-20240624223826-fd66f93ce141 h1:F3ZceqS82fFfVV3ychWRLNDRFaFlbOwrPic0qJiUVqw=
|
||||||
github.com/bluesky-social/indigo v0.0.0-20231216010655-ad730a7da4f5/go.mod h1:a8cPbqDkRX+aPwJnXF7kAi3PF26hYiR4w5H8624MB7k=
|
github.com/bluesky-social/indigo v0.0.0-20240624223826-fd66f93ce141/go.mod h1:dBIOGhsiK0rgEETnxiGiuEyrSx6DKxEotHIPbiKD6WU=
|
||||||
github.com/carlmjohnson/versioninfo v0.22.5 h1:O00sjOLUAFxYQjlN/bzYTuZiS0y6fWDQjMRvwtKgwwc=
|
github.com/carlmjohnson/versioninfo v0.22.5 h1:O00sjOLUAFxYQjlN/bzYTuZiS0y6fWDQjMRvwtKgwwc=
|
||||||
github.com/carlmjohnson/versioninfo v0.22.5/go.mod h1:QT9mph3wcVfISUKd0i9sZfVrPviHuSF+cUtLjm2WSf8=
|
github.com/carlmjohnson/versioninfo v0.22.5/go.mod h1:QT9mph3wcVfISUKd0i9sZfVrPviHuSF+cUtLjm2WSf8=
|
||||||
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
|
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
|
||||||
@@ -11,7 +11,6 @@ github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL
|
|||||||
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
|
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.3 h1:qMCsGGgs+MAzDFyp9LpAe1Lqy/fY/qCovCm0qnXZOBM=
|
github.com/cpuguy83/go-md2man/v2 v2.0.3 h1:qMCsGGgs+MAzDFyp9LpAe1Lqy/fY/qCovCm0qnXZOBM=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
@@ -23,8 +22,8 @@ github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSw
|
|||||||
github.com/flosch/pongo2/v6 v6.0.0 h1:lsGru8IAzHgIAw6H2m4PCyleO58I40ow6apih0WprMU=
|
github.com/flosch/pongo2/v6 v6.0.0 h1:lsGru8IAzHgIAw6H2m4PCyleO58I40ow6apih0WprMU=
|
||||||
github.com/flosch/pongo2/v6 v6.0.0/go.mod h1:CuDpFm47R0uGGE7z13/tTlt1Y6zdxvr2RLT5LJhsHEU=
|
github.com/flosch/pongo2/v6 v6.0.0/go.mod h1:CuDpFm47R0uGGE7z13/tTlt1Y6zdxvr2RLT5LJhsHEU=
|
||||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||||
github.com/go-logr/logr v1.3.0 h1:2y3SDp0ZXuc6/cjLSZ+Q3ir+QB9T/iG5yYRXqsagWSY=
|
github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ=
|
||||||
github.com/go-logr/logr v1.3.0/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||||
github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0=
|
github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0=
|
||||||
@@ -35,7 +34,6 @@ github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69
|
|||||||
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
|
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
|
||||||
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
|
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
|
||||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
|
||||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
@@ -60,7 +58,6 @@ github.com/ipfs/bbloom v0.0.4 h1:Gi+8EGJ2y5qiD5FbsbpX/TMNcJw8gSqr7eyjHa4Fhvs=
|
|||||||
github.com/ipfs/bbloom v0.0.4/go.mod h1:cS9YprKXpoZ9lT0n/Mw/a6/aFV6DTjTLYHeA+gyqMG0=
|
github.com/ipfs/bbloom v0.0.4/go.mod h1:cS9YprKXpoZ9lT0n/Mw/a6/aFV6DTjTLYHeA+gyqMG0=
|
||||||
github.com/ipfs/go-block-format v0.2.0 h1:ZqrkxBA2ICbDRbK8KJs/u0O3dlp6gmAuuXUJNiW1Ycs=
|
github.com/ipfs/go-block-format v0.2.0 h1:ZqrkxBA2ICbDRbK8KJs/u0O3dlp6gmAuuXUJNiW1Ycs=
|
||||||
github.com/ipfs/go-block-format v0.2.0/go.mod h1:+jpL11nFx5A/SPpsoBn6Bzkra/zaArfSmsknbPMYgzM=
|
github.com/ipfs/go-block-format v0.2.0/go.mod h1:+jpL11nFx5A/SPpsoBn6Bzkra/zaArfSmsknbPMYgzM=
|
||||||
github.com/ipfs/go-cid v0.0.6/go.mod h1:6Ux9z5e+HpkQdckYoX1PG/6xqKspzlEIR5SDmgqgC/I=
|
|
||||||
github.com/ipfs/go-cid v0.4.1 h1:A/T3qGvxi4kpKWWcPC/PgbvDA2bjVLO7n4UeVwnbs/s=
|
github.com/ipfs/go-cid v0.4.1 h1:A/T3qGvxi4kpKWWcPC/PgbvDA2bjVLO7n4UeVwnbs/s=
|
||||||
github.com/ipfs/go-cid v0.4.1/go.mod h1:uQHwDeX4c6CtyrFwdqyhpNcxVewur1M7l7fNU7LKwZk=
|
github.com/ipfs/go-cid v0.4.1/go.mod h1:uQHwDeX4c6CtyrFwdqyhpNcxVewur1M7l7fNU7LKwZk=
|
||||||
github.com/ipfs/go-datastore v0.6.0 h1:JKyz+Gvz1QEZw0LsX1IBn+JFCJQH4SJVFtM4uWU0Myk=
|
github.com/ipfs/go-datastore v0.6.0 h1:JKyz+Gvz1QEZw0LsX1IBn+JFCJQH4SJVFtM4uWU0Myk=
|
||||||
@@ -88,10 +85,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI
|
|||||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
|
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
|
||||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
github.com/jackc/pgx/v5 v5.3.0/go.mod h1:t3JDKnCBlYIc0ewLF0Q7B8MXmoIaBOZj/ic7iHozM/8=
|
|
||||||
github.com/jackc/pgx/v5 v5.5.0 h1:NxstgwndsTRy7eq9/kqYc/BZh5w2hHJV86wjvO+1xPw=
|
github.com/jackc/pgx/v5 v5.5.0 h1:NxstgwndsTRy7eq9/kqYc/BZh5w2hHJV86wjvO+1xPw=
|
||||||
github.com/jackc/pgx/v5 v5.5.0/go.mod h1:Ig06C2Vu0t5qXC60W8sqIthScaEnFvojjj9dSljmHRA=
|
github.com/jackc/pgx/v5 v5.5.0/go.mod h1:Ig06C2Vu0t5qXC60W8sqIthScaEnFvojjj9dSljmHRA=
|
||||||
github.com/jackc/puddle/v2 v2.2.0/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
|
||||||
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
|
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
|
||||||
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
github.com/jbenet/go-cienv v0.1.0/go.mod h1:TqNnHUmJgXau0nCzC7kXWeotg3J9W34CUv5Djy1+FlA=
|
github.com/jbenet/go-cienv v0.1.0/go.mod h1:TqNnHUmJgXau0nCzC7kXWeotg3J9W34CUv5Djy1+FlA=
|
||||||
@@ -109,11 +104,9 @@ github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI
|
|||||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||||
github.com/klauspost/compress v1.17.3 h1:qkRjuerhUU1EmXLYGkSH6EZL+vPSxIrYjLNAK4slzwA=
|
github.com/klauspost/compress v1.17.3 h1:qkRjuerhUU1EmXLYGkSH6EZL+vPSxIrYjLNAK4slzwA=
|
||||||
github.com/klauspost/compress v1.17.3/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
|
github.com/klauspost/compress v1.17.3/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
|
||||||
github.com/klauspost/cpuid/v2 v2.2.6 h1:ndNyv040zDGIDh8thGkXYjnFtiN02M1PVVF+JE/48xc=
|
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
|
||||||
github.com/klauspost/cpuid/v2 v2.2.6/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
|
||||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||||
@@ -143,37 +136,28 @@ github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27k
|
|||||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/mattn/go-sqlite3 v1.14.18 h1:JL0eqdCOq6DJVNPSvArO/bIV9/P7fbGrV00LZHc+5aI=
|
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||||
github.com/mattn/go-sqlite3 v1.14.18/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg=
|
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg=
|
||||||
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k=
|
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k=
|
||||||
github.com/minio/blake2b-simd v0.0.0-20160723061019-3f5f724cb5b1/go.mod h1:pD8RvIylQ358TN4wwqatJ8rNavkEINozVn9DtGI3dfQ=
|
|
||||||
github.com/minio/sha256-simd v0.1.1-0.20190913151208-6de447530771/go.mod h1:B5e1o+1/KgNmWrSQK08Y6Z1Vb5pwIktudl0J58iy0KM=
|
|
||||||
github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM=
|
github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM=
|
||||||
github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8=
|
github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8=
|
||||||
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
|
|
||||||
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
|
|
||||||
github.com/mr-tron/base58 v1.1.0/go.mod h1:xcD2VGqlgYjBdcBLw+TuYLr8afG+Hj8g2eTVqeSzSU8=
|
|
||||||
github.com/mr-tron/base58 v1.1.3/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc=
|
|
||||||
github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o=
|
github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o=
|
||||||
github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc=
|
github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc=
|
||||||
github.com/multiformats/go-base32 v0.0.3/go.mod h1:pLiuGC8y0QR3Ue4Zug5UzK9LjgbkL8NSQj0zQ5Nz/AA=
|
|
||||||
github.com/multiformats/go-base32 v0.1.0 h1:pVx9xoSPqEIQG8o+UbAe7DNi51oej1NtK+aGkbLYxPE=
|
github.com/multiformats/go-base32 v0.1.0 h1:pVx9xoSPqEIQG8o+UbAe7DNi51oej1NtK+aGkbLYxPE=
|
||||||
github.com/multiformats/go-base32 v0.1.0/go.mod h1:Kj3tFY6zNr+ABYMqeUNeGvkIC/UYgtWibDcT0rExnbI=
|
github.com/multiformats/go-base32 v0.1.0/go.mod h1:Kj3tFY6zNr+ABYMqeUNeGvkIC/UYgtWibDcT0rExnbI=
|
||||||
github.com/multiformats/go-base36 v0.1.0/go.mod h1:kFGE83c6s80PklsHO9sRn2NCoffoRdUUOENyW/Vv6sM=
|
|
||||||
github.com/multiformats/go-base36 v0.2.0 h1:lFsAbNOGeKtuKozrtBsAkSVhv1p9D0/qedU9rQyccr0=
|
github.com/multiformats/go-base36 v0.2.0 h1:lFsAbNOGeKtuKozrtBsAkSVhv1p9D0/qedU9rQyccr0=
|
||||||
github.com/multiformats/go-base36 v0.2.0/go.mod h1:qvnKE++v+2MWCfePClUEjE78Z7P2a1UV0xHgWc0hkp4=
|
github.com/multiformats/go-base36 v0.2.0/go.mod h1:qvnKE++v+2MWCfePClUEjE78Z7P2a1UV0xHgWc0hkp4=
|
||||||
github.com/multiformats/go-multibase v0.0.3/go.mod h1:5+1R4eQrT3PkYZ24C3W2Ue2tPwIdYQD509ZjSb5y9Oc=
|
|
||||||
github.com/multiformats/go-multibase v0.2.0 h1:isdYCVLvksgWlMW9OZRYJEa9pZETFivncJHmHnnd87g=
|
github.com/multiformats/go-multibase v0.2.0 h1:isdYCVLvksgWlMW9OZRYJEa9pZETFivncJHmHnnd87g=
|
||||||
github.com/multiformats/go-multibase v0.2.0/go.mod h1:bFBZX4lKCA/2lyOFSAoKH5SS6oPyjtnzK/XTFDPkNuk=
|
github.com/multiformats/go-multibase v0.2.0/go.mod h1:bFBZX4lKCA/2lyOFSAoKH5SS6oPyjtnzK/XTFDPkNuk=
|
||||||
github.com/multiformats/go-multihash v0.0.13/go.mod h1:VdAWLKTwram9oKAatUcLxBNUjdtcVwxObEQBtRfuyjc=
|
|
||||||
github.com/multiformats/go-multihash v0.2.3 h1:7Lyc8XfX/IY2jWb/gI7JP+o7JEq9hOa7BFvVU9RSh+U=
|
github.com/multiformats/go-multihash v0.2.3 h1:7Lyc8XfX/IY2jWb/gI7JP+o7JEq9hOa7BFvVU9RSh+U=
|
||||||
github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM=
|
github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM=
|
||||||
github.com/multiformats/go-varint v0.0.5/go.mod h1:3Ls8CIEsrijN6+B7PbrXRPxHRPuXSrVKRY101jdMZYE=
|
|
||||||
github.com/multiformats/go-varint v0.0.7 h1:sWSGR+f/eu5ABZA2ZpYKBILXTTs9JWpdEM/nEGOHFS8=
|
github.com/multiformats/go-varint v0.0.7 h1:sWSGR+f/eu5ABZA2ZpYKBILXTTs9JWpdEM/nEGOHFS8=
|
||||||
github.com/multiformats/go-varint v0.0.7/go.mod h1:r8PUYw/fD/SjBCiKOoDlGF6QawOELpZAu9eioSos/OU=
|
github.com/multiformats/go-varint v0.0.7/go.mod h1:r8PUYw/fD/SjBCiKOoDlGF6QawOELpZAu9eioSos/OU=
|
||||||
github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs=
|
github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs=
|
||||||
github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc=
|
github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc=
|
||||||
|
github.com/orandin/slog-gorm v1.3.2 h1:C0lKDQPAx/pF+8K2HL7bdShPwOEJpPM0Bn80zTzxU1g=
|
||||||
|
github.com/orandin/slog-gorm v1.3.2/go.mod h1:MoZ51+b7xE9lwGNPYEhxcUtRNrYzjdcKvA8QXQQGEPA=
|
||||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
@@ -188,7 +172,6 @@ github.com/prometheus/common v0.45.0/go.mod h1:YJmSTw9BoKxJplESWWxlbyttQR4uaEcGy
|
|||||||
github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo=
|
github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo=
|
||||||
github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
|
github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
|
||||||
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
|
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
|
||||||
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
|
|
||||||
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||||
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
@@ -213,9 +196,9 @@ github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
|
|||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
|
||||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
|
||||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
|
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||||
|
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
github.com/urfave/cli v1.22.10/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
|
github.com/urfave/cli v1.22.10/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
|
||||||
github.com/urfave/cli/v2 v2.25.7 h1:VAzn5oq403l5pHjc4OhD54+XGO9cdKVL/7lDjF+iKUs=
|
github.com/urfave/cli/v2 v2.25.7 h1:VAzn5oq403l5pHjc4OhD54+XGO9cdKVL/7lDjF+iKUs=
|
||||||
github.com/urfave/cli/v2 v2.25.7/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ=
|
github.com/urfave/cli/v2 v2.25.7/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ=
|
||||||
@@ -225,8 +208,8 @@ github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQ
|
|||||||
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
||||||
github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0 h1:GDDkbFiaK8jsSDJfjId/PEGEShv6ugrt4kYsC5UIDaQ=
|
github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0 h1:GDDkbFiaK8jsSDJfjId/PEGEShv6ugrt4kYsC5UIDaQ=
|
||||||
github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0/go.mod h1:x6AKhvSSexNrVSrViXSHUEbICjmGXhtgABaHIySUSGw=
|
github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0/go.mod h1:x6AKhvSSexNrVSrViXSHUEbICjmGXhtgABaHIySUSGw=
|
||||||
github.com/whyrusleeping/cbor-gen v0.0.0-20230923211252-36a87e1ba72f h1:SBuSxXJL0/ZJMtTxbXZgHZkThl9dNrzyaNhlyaqscRo=
|
github.com/whyrusleeping/cbor-gen v0.1.1-0.20240311221002-68b9f235c302 h1:MhInbXe4SzcImAKktUvWBCWZgcw6MYf5NfumTj1BhAw=
|
||||||
github.com/whyrusleeping/cbor-gen v0.0.0-20230923211252-36a87e1ba72f/go.mod h1:fgkXqYy7bV2cFeIEOkVTZS/WjXARfBqSH6Q2qHL33hQ=
|
github.com/whyrusleeping/cbor-gen v0.1.1-0.20240311221002-68b9f235c302/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so=
|
||||||
github.com/whyrusleeping/go-did v0.0.0-20230824162731-404d1707d5d6 h1:yJ9/LwIGIk/c0CdoavpC9RNSGSruIspSZtxG3Nnldic=
|
github.com/whyrusleeping/go-did v0.0.0-20230824162731-404d1707d5d6 h1:yJ9/LwIGIk/c0CdoavpC9RNSGSruIspSZtxG3Nnldic=
|
||||||
github.com/whyrusleeping/go-did v0.0.0-20230824162731-404d1707d5d6/go.mod h1:39U9RRVr4CKbXpXYopWn+FSH5s+vWu6+RmguSPWAq5s=
|
github.com/whyrusleeping/go-did v0.0.0-20230824162731-404d1707d5d6/go.mod h1:39U9RRVr4CKbXpXYopWn+FSH5s+vWu6+RmguSPWAq5s=
|
||||||
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU=
|
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU=
|
||||||
@@ -239,8 +222,8 @@ gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b h1:CzigHMRyS
|
|||||||
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b/go.mod h1:/y/V339mxv2sZmYYR64O07VuCpdNZqCTwO8ZcouTMI8=
|
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b/go.mod h1:/y/V339mxv2sZmYYR64O07VuCpdNZqCTwO8ZcouTMI8=
|
||||||
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 h1:qwDnMxjkyLmAFgcfgTnfJrmYKWhHnci3GjDqcZp1M3Q=
|
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 h1:qwDnMxjkyLmAFgcfgTnfJrmYKWhHnci3GjDqcZp1M3Q=
|
||||||
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02/go.mod h1:JTnUj0mpYiAsuZLmKjTx/ex3AtMowcCgnE7YNyCEP0I=
|
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02/go.mod h1:JTnUj0mpYiAsuZLmKjTx/ex3AtMowcCgnE7YNyCEP0I=
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.42.0 h1:pginetY7+onl4qN1vl0xW/V/v6OBZ0vVdH+esuJgvmM=
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1 h1:aFJWCqJMNjENlcleuuOkGAPH82y0yULBScfXcIEdS24=
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.42.0/go.mod h1:XiYsayHc36K3EByOO6nbAXnAWbrUxdjUROCEeeROOH8=
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1/go.mod h1:sEGXWArGqc3tVa+ekntsN65DmVbVeW+7lTKTjZF3/Fo=
|
||||||
go.opentelemetry.io/otel v1.21.0 h1:hzLeKBZEL7Okw2mGzZ0cc4k/A7Fta0uoPgaJCr8fsFc=
|
go.opentelemetry.io/otel v1.21.0 h1:hzLeKBZEL7Okw2mGzZ0cc4k/A7Fta0uoPgaJCr8fsFc=
|
||||||
go.opentelemetry.io/otel v1.21.0/go.mod h1:QZzNPQPm1zLX4gZK4cMi+71eaorMSGT3A4znnUvNNEo=
|
go.opentelemetry.io/otel v1.21.0/go.mod h1:QZzNPQPm1zLX4gZK4cMi+71eaorMSGT3A4znnUvNNEo=
|
||||||
go.opentelemetry.io/otel/metric v1.21.0 h1:tlYWfeo+Bocx5kLEloTjbcDwBuELRrIFxwdQ36PlJu4=
|
go.opentelemetry.io/otel/metric v1.21.0 h1:tlYWfeo+Bocx5kLEloTjbcDwBuELRrIFxwdQ36PlJu4=
|
||||||
@@ -265,14 +248,12 @@ go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo=
|
|||||||
go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so=
|
go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
golang.org/x/crypto v0.0.0-20190611184440-5c40567a22f8/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
|
||||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
|
|
||||||
golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
|
golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
|
||||||
golang.org/x/crypto v0.15.0 h1:frVn1TEaCEaZcn3Tmd7Y2b5KKPaZ+I32Q2OA3kYp5TA=
|
golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA=
|
||||||
golang.org/x/crypto v0.15.0/go.mod h1:4ChreQoLWfG3xLDer1WdlH5NdlQ3+mwnQq1YTKY+72g=
|
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
|
||||||
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||||
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
|
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
|
||||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
@@ -290,8 +271,8 @@ golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96b
|
|||||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||||
golang.org/x/net v0.18.0 h1:mIYleuAkSbHh0tCv7RvjL3F6ZVbLjq4+R7zbOn3Kokg=
|
golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4=
|
||||||
golang.org/x/net v0.18.0/go.mod h1:/czyP5RqHAH4odGYxBJ1qz0+CE5WZ+2j1YgoEo8F2jQ=
|
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
@@ -316,8 +297,8 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.14.0 h1:Vz7Qs629MkJkGyHxUlRHizWJRG2j8fbQKjELVSNhy7Q=
|
golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
|
||||||
golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||||
@@ -365,13 +346,12 @@ gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C
|
|||||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gorm.io/driver/postgres v1.5.0 h1:u2FXTy14l45qc3UeCJ7QaAXZmZfDDv0YrthvmRq1l0U=
|
gorm.io/driver/postgres v1.5.7 h1:8ptbNJTDbEmhdr62uReG5BGkdQyeasu/FZHxI0IMGnM=
|
||||||
gorm.io/driver/postgres v1.5.0/go.mod h1:FUZXzO+5Uqg5zzwzv4KK49R8lvGIyscBOqYrtI1Ce9A=
|
gorm.io/driver/postgres v1.5.7/go.mod h1:3e019WlBaYI5o5LIdNV+LyxCMNtLOQETBXL2h4chKpA=
|
||||||
gorm.io/driver/sqlite v1.5.4 h1:IqXwXi8M/ZlPzH/947tn5uik3aYQslP9BVveoax0nV0=
|
gorm.io/driver/sqlite v1.5.5 h1:7MDMtUZhV065SilG62E0MquljeArQZNfJnjd9i9gx3E=
|
||||||
gorm.io/driver/sqlite v1.5.4/go.mod h1:qxAuCol+2r6PannQDpOP1FP6ag3mKi4esLnB/jHed+4=
|
gorm.io/driver/sqlite v1.5.5/go.mod h1:6NgQ7sQWAIFsPrJJl1lSNSu2TABh0ZZ/zm5fosATavE=
|
||||||
gorm.io/gorm v1.24.7-0.20230306060331-85eaf9eeda11/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k=
|
gorm.io/gorm v1.25.9 h1:wct0gxZIELDk8+ZqF/MVnHLkA1rvYlBWUMv2EdsK1g8=
|
||||||
gorm.io/gorm v1.25.5 h1:zR9lOiiYf09VNh5Q1gphfyia1JpiClIWG9hQaxB/mls=
|
gorm.io/gorm v1.25.9/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
||||||
gorm.io/gorm v1.25.5/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
|
||||||
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
|
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
|
||||||
lukechampine.com/blake3 v1.2.1 h1:YuqqRuaqsGV71BV/nm9xlI0MKUv4QC54jQnBChWbGnI=
|
lukechampine.com/blake3 v1.2.1 h1:YuqqRuaqsGV71BV/nm9xlI0MKUv4QC54jQnBChWbGnI=
|
||||||
lukechampine.com/blake3 v1.2.1/go.mod h1:0OFRp7fBtAylGVCO40o87sbupkyIGgbpv1+M1k1LM6k=
|
lukechampine.com/blake3 v1.2.1/go.mod h1:0OFRp7fBtAylGVCO40o87sbupkyIGgbpv1+M1k1LM6k=
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
{
|
{
|
||||||
"applinks": {
|
"applinks": {
|
||||||
"apps": [],
|
|
||||||
"details": [
|
"details": [
|
||||||
{
|
{
|
||||||
"appID": "B3LX46C5HS.xyz.blueskyweb.app",
|
"appID": "B3LX46C5HS.xyz.blueskyweb.app",
|
||||||
@@ -9,5 +8,8 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"appclips": {
|
||||||
|
"apps": ["B3LX46C5HS.xyz.blueskyweb.app.AppClip"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block html_head_extra -%}
|
||||||
|
<meta name="apple-itunes-app" content="app-id=xyz.blueskyweb.app, app-clip-bundle-id=xyz.blueskyweb.app.AppClip, app-clip-display=card">
|
||||||
|
{%- if requestURI %}
|
||||||
|
<meta property="og:url" content="{{ requestURI }}">
|
||||||
|
{% endif -%}
|
||||||
|
{%- if imgThumbUrl %}
|
||||||
|
<meta property="og:image" content="{{ imgThumbUrl }}">
|
||||||
|
<meta property="twitter:image" content="{{ imgThumbUrl }}">
|
||||||
|
{%- else -%}
|
||||||
|
<meta property="og:image" content="https://bsky.app/static/social-card-default-gradient.png" />
|
||||||
|
<meta property="twitter:image" content="https://bsky.app/static/social-card-default-gradient.png" />
|
||||||
|
{% endif -%}
|
||||||
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
|
{%- if title %}
|
||||||
|
<meta property="og:title" content="{{ title }}" />
|
||||||
|
<meta name="twitter:title" content="{{ title }}" />
|
||||||
|
{%- else -%}
|
||||||
|
<meta property="og:title" content="Bluesky" />
|
||||||
|
<meta name="twitter:title" content="Bluesky" />
|
||||||
|
{% endif -%}
|
||||||
|
<meta name="description" content="Join the conversation" />
|
||||||
|
<meta name="og:description" content="Join the conversation" />
|
||||||
|
<meta name="twitter:description" content="Join the conversation" />
|
||||||
|
{%- endblock %}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
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>
|
||||||
+10
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
+54
@@ -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
|
||||||
+5
-3
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "bsky.app",
|
"name": "bsky.app",
|
||||||
"version": "1.87.0",
|
"version": "1.88.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
@@ -49,7 +49,7 @@
|
|||||||
"open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web"
|
"open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@atproto/api": "^0.12.20",
|
"@atproto/api": "^0.12.23",
|
||||||
"@bam.tech/react-native-image-resizer": "^3.0.4",
|
"@bam.tech/react-native-image-resizer": "^3.0.4",
|
||||||
"@braintree/sanitize-url": "^6.0.2",
|
"@braintree/sanitize-url": "^6.0.2",
|
||||||
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
|
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
|
||||||
@@ -177,6 +177,7 @@
|
|||||||
"react-native-pager-view": "6.2.3",
|
"react-native-pager-view": "6.2.3",
|
||||||
"react-native-picker-select": "^9.1.3",
|
"react-native-picker-select": "^9.1.3",
|
||||||
"react-native-progress": "bluesky-social/react-native-progress",
|
"react-native-progress": "bluesky-social/react-native-progress",
|
||||||
|
"react-native-qrcode-styled": "^0.3.1",
|
||||||
"react-native-reanimated": "^3.11.0",
|
"react-native-reanimated": "^3.11.0",
|
||||||
"react-native-root-siblings": "^4.1.1",
|
"react-native-root-siblings": "^4.1.1",
|
||||||
"react-native-safe-area-context": "4.10.1",
|
"react-native-safe-area-context": "4.10.1",
|
||||||
@@ -192,6 +193,7 @@
|
|||||||
"react-responsive": "^9.0.2",
|
"react-responsive": "^9.0.2",
|
||||||
"react-textarea-autosize": "^8.5.3",
|
"react-textarea-autosize": "^8.5.3",
|
||||||
"rn-fetch-blob": "^0.12.0",
|
"rn-fetch-blob": "^0.12.0",
|
||||||
|
"rn-tourguide": "bluesky-social/rn-tourguide",
|
||||||
"sentry-expo": "~7.0.1",
|
"sentry-expo": "~7.0.1",
|
||||||
"statsig-react-native-expo": "^4.6.1",
|
"statsig-react-native-expo": "^4.6.1",
|
||||||
"tippy.js": "^6.3.7",
|
"tippy.js": "^6.3.7",
|
||||||
@@ -205,7 +207,7 @@
|
|||||||
"@babel/preset-env": "^7.20.0",
|
"@babel/preset-env": "^7.20.0",
|
||||||
"@babel/runtime": "^7.20.0",
|
"@babel/runtime": "^7.20.0",
|
||||||
"@did-plc/server": "^0.0.1",
|
"@did-plc/server": "^0.0.1",
|
||||||
"@expo/config-plugins": "7.8.0",
|
"@expo/config-plugins": "8.0.4",
|
||||||
"@expo/prebuild-config": "6.7.0",
|
"@expo/prebuild-config": "6.7.0",
|
||||||
"@lingui/cli": "^4.5.0",
|
"@lingui/cli": "^4.5.0",
|
||||||
"@lingui/macro": "^4.5.0",
|
"@lingui/macro": "^4.5.0",
|
||||||
|
|||||||
@@ -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,46 @@
|
|||||||
|
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',
|
||||||
|
UISupportedInterfaceOrientations: [
|
||||||
|
'UIInterfaceOrientationPortrait',
|
||||||
|
'UIInterfaceOrientationPortraitUpsideDown',
|
||||||
|
],
|
||||||
|
'UISupportedInterfaceOrientations~ipad': [
|
||||||
|
'UIInterfaceOrientationPortrait',
|
||||||
|
'UIInterfaceOrientationPortraitUpsideDown',
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
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
|
#!/bin/bash
|
||||||
IOS_SHARE_EXTENSION_DIRECTORY="./ios/Share-with-Bluesky"
|
IOS_SHARE_EXTENSION_DIRECTORY="./ios/Share-with-Bluesky"
|
||||||
IOS_NOTIFICATION_EXTENSION_DIRECTORY="./ios/BlueskyNSE"
|
IOS_NOTIFICATION_EXTENSION_DIRECTORY="./ios/BlueskyNSE"
|
||||||
|
IOS_APP_CLIP_DIRECTORY="./ios/BlueskyClip"
|
||||||
MODULES_DIRECTORY="./modules"
|
MODULES_DIRECTORY="./modules"
|
||||||
|
|
||||||
if [ ! -d $IOS_SHARE_EXTENSION_DIRECTORY ]; then
|
if [ ! -d $IOS_SHARE_EXTENSION_DIRECTORY ]; then
|
||||||
@@ -16,3 +17,11 @@ if [ ! -d $IOS_NOTIFICATION_EXTENSION_DIRECTORY ]; then
|
|||||||
else
|
else
|
||||||
cp -R $IOS_NOTIFICATION_EXTENSION_DIRECTORY $MODULES_DIRECTORY
|
cp -R $IOS_NOTIFICATION_EXTENSION_DIRECTORY $MODULES_DIRECTORY
|
||||||
fi
|
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
-2
@@ -45,14 +45,18 @@ import {
|
|||||||
import {readLastActiveAccount} from '#/state/session/util'
|
import {readLastActiveAccount} from '#/state/session/util'
|
||||||
import {Provider as ShellStateProvider} from '#/state/shell'
|
import {Provider as ShellStateProvider} from '#/state/shell'
|
||||||
import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out'
|
import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out'
|
||||||
|
import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide'
|
||||||
import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
|
import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
|
||||||
|
import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
|
||||||
import {TestCtrls} from '#/view/com/testing/TestCtrls'
|
import {TestCtrls} from '#/view/com/testing/TestCtrls'
|
||||||
import * as Toast from '#/view/com/util/Toast'
|
import * as Toast from '#/view/com/util/Toast'
|
||||||
import {Shell} from '#/view/shell'
|
import {Shell} from '#/view/shell'
|
||||||
import {ThemeProvider as Alf} from '#/alf'
|
import {ThemeProvider as Alf} from '#/alf'
|
||||||
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
|
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
|
||||||
|
import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
|
||||||
import {Provider as PortalProvider} from '#/components/Portal'
|
import {Provider as PortalProvider} from '#/components/Portal'
|
||||||
import {Splash} from '#/Splash'
|
import {Splash} from '#/Splash'
|
||||||
|
import {Provider as TourProvider} from '#/tours'
|
||||||
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
|
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
|
||||||
import I18nProvider from './locale/i18nProvider'
|
import I18nProvider from './locale/i18nProvider'
|
||||||
import {listenSessionDropped} from './state/events'
|
import {listenSessionDropped} from './state/events'
|
||||||
@@ -67,6 +71,7 @@ function InnerApp() {
|
|||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
|
|
||||||
useIntentHandler()
|
useIntentHandler()
|
||||||
|
const hasCheckedReferrer = useStarterPackEntry()
|
||||||
|
|
||||||
// init
|
// init
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -98,7 +103,7 @@ function InnerApp() {
|
|||||||
<SafeAreaProvider initialMetrics={initialWindowMetrics}>
|
<SafeAreaProvider initialMetrics={initialWindowMetrics}>
|
||||||
<Alf theme={theme}>
|
<Alf theme={theme}>
|
||||||
<ThemeProvider theme={theme}>
|
<ThemeProvider theme={theme}>
|
||||||
<Splash isReady={isReady}>
|
<Splash isReady={isReady && hasCheckedReferrer}>
|
||||||
<RootSiblingParent>
|
<RootSiblingParent>
|
||||||
<React.Fragment
|
<React.Fragment
|
||||||
// Resets the entire tree below when it changes:
|
// Resets the entire tree below when it changes:
|
||||||
@@ -114,10 +119,15 @@ function InnerApp() {
|
|||||||
<UnreadNotifsProvider>
|
<UnreadNotifsProvider>
|
||||||
<BackgroundNotificationPreferencesProvider>
|
<BackgroundNotificationPreferencesProvider>
|
||||||
<MutedThreadsProvider>
|
<MutedThreadsProvider>
|
||||||
<GestureHandlerRootView style={s.h100pct}>
|
<TourProvider>
|
||||||
|
<ProgressGuideProvider>
|
||||||
|
<GestureHandlerRootView
|
||||||
|
style={s.h100pct}>
|
||||||
<TestCtrls />
|
<TestCtrls />
|
||||||
<Shell />
|
<Shell />
|
||||||
</GestureHandlerRootView>
|
</GestureHandlerRootView>
|
||||||
|
</ProgressGuideProvider>
|
||||||
|
</TourProvider>
|
||||||
</MutedThreadsProvider>
|
</MutedThreadsProvider>
|
||||||
</BackgroundNotificationPreferencesProvider>
|
</BackgroundNotificationPreferencesProvider>
|
||||||
</UnreadNotifsProvider>
|
</UnreadNotifsProvider>
|
||||||
@@ -164,7 +174,9 @@ function App() {
|
|||||||
<LightboxStateProvider>
|
<LightboxStateProvider>
|
||||||
<I18nProvider>
|
<I18nProvider>
|
||||||
<PortalProvider>
|
<PortalProvider>
|
||||||
|
<StarterPackProvider>
|
||||||
<InnerApp />
|
<InnerApp />
|
||||||
|
</StarterPackProvider>
|
||||||
</PortalProvider>
|
</PortalProvider>
|
||||||
</I18nProvider>
|
</I18nProvider>
|
||||||
</LightboxStateProvider>
|
</LightboxStateProvider>
|
||||||
|
|||||||
+12
-1
@@ -34,13 +34,17 @@ import {
|
|||||||
import {readLastActiveAccount} from '#/state/session/util'
|
import {readLastActiveAccount} from '#/state/session/util'
|
||||||
import {Provider as ShellStateProvider} from '#/state/shell'
|
import {Provider as ShellStateProvider} from '#/state/shell'
|
||||||
import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out'
|
import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out'
|
||||||
|
import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide'
|
||||||
import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
|
import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
|
||||||
|
import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
|
||||||
import * as Toast from '#/view/com/util/Toast'
|
import * as Toast from '#/view/com/util/Toast'
|
||||||
import {ToastContainer} from '#/view/com/util/Toast.web'
|
import {ToastContainer} from '#/view/com/util/Toast.web'
|
||||||
import {Shell} from '#/view/shell/index'
|
import {Shell} from '#/view/shell/index'
|
||||||
import {ThemeProvider as Alf} from '#/alf'
|
import {ThemeProvider as Alf} from '#/alf'
|
||||||
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
|
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
|
||||||
|
import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
|
||||||
import {Provider as PortalProvider} from '#/components/Portal'
|
import {Provider as PortalProvider} from '#/components/Portal'
|
||||||
|
import {Provider as TourProvider} from '#/tours'
|
||||||
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
|
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
|
||||||
import I18nProvider from './locale/i18nProvider'
|
import I18nProvider from './locale/i18nProvider'
|
||||||
import {listenSessionDropped} from './state/events'
|
import {listenSessionDropped} from './state/events'
|
||||||
@@ -52,6 +56,7 @@ function InnerApp() {
|
|||||||
const theme = useColorModeTheme()
|
const theme = useColorModeTheme()
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
useIntentHandler()
|
useIntentHandler()
|
||||||
|
const hasCheckedReferrer = useStarterPackEntry()
|
||||||
|
|
||||||
// init
|
// init
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -77,7 +82,7 @@ function InnerApp() {
|
|||||||
}, [_])
|
}, [_])
|
||||||
|
|
||||||
// wait for session to resume
|
// wait for session to resume
|
||||||
if (!isReady) return null
|
if (!isReady || !hasCheckedReferrer) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<KeyboardProvider enabled={false}>
|
<KeyboardProvider enabled={false}>
|
||||||
@@ -99,7 +104,11 @@ function InnerApp() {
|
|||||||
<BackgroundNotificationPreferencesProvider>
|
<BackgroundNotificationPreferencesProvider>
|
||||||
<MutedThreadsProvider>
|
<MutedThreadsProvider>
|
||||||
<SafeAreaProvider>
|
<SafeAreaProvider>
|
||||||
|
<TourProvider>
|
||||||
|
<ProgressGuideProvider>
|
||||||
<Shell />
|
<Shell />
|
||||||
|
</ProgressGuideProvider>
|
||||||
|
</TourProvider>
|
||||||
</SafeAreaProvider>
|
</SafeAreaProvider>
|
||||||
</MutedThreadsProvider>
|
</MutedThreadsProvider>
|
||||||
</BackgroundNotificationPreferencesProvider>
|
</BackgroundNotificationPreferencesProvider>
|
||||||
@@ -146,7 +155,9 @@ function App() {
|
|||||||
<LightboxStateProvider>
|
<LightboxStateProvider>
|
||||||
<I18nProvider>
|
<I18nProvider>
|
||||||
<PortalProvider>
|
<PortalProvider>
|
||||||
|
<StarterPackProvider>
|
||||||
<InnerApp />
|
<InnerApp />
|
||||||
|
</StarterPackProvider>
|
||||||
</PortalProvider>
|
</PortalProvider>
|
||||||
</I18nProvider>
|
</I18nProvider>
|
||||||
</LightboxStateProvider>
|
</LightboxStateProvider>
|
||||||
|
|||||||
+36
-1
@@ -43,6 +43,11 @@ import HashtagScreen from '#/screens/Hashtag'
|
|||||||
import {ModerationScreen} from '#/screens/Moderation'
|
import {ModerationScreen} from '#/screens/Moderation'
|
||||||
import {ProfileKnownFollowersScreen} from '#/screens/Profile/KnownFollowers'
|
import {ProfileKnownFollowersScreen} from '#/screens/Profile/KnownFollowers'
|
||||||
import {ProfileLabelerLikedByScreen} from '#/screens/Profile/ProfileLabelerLikedBy'
|
import {ProfileLabelerLikedByScreen} from '#/screens/Profile/ProfileLabelerLikedBy'
|
||||||
|
import {
|
||||||
|
StarterPackScreen,
|
||||||
|
StarterPackScreenShort,
|
||||||
|
} from '#/screens/StarterPack/StarterPackScreen'
|
||||||
|
import {Wizard} from '#/screens/StarterPack/Wizard'
|
||||||
import {init as initAnalytics} from './lib/analytics/analytics'
|
import {init as initAnalytics} from './lib/analytics/analytics'
|
||||||
import {useWebScrollRestoration} from './lib/hooks/useWebScrollRestoration'
|
import {useWebScrollRestoration} from './lib/hooks/useWebScrollRestoration'
|
||||||
import {attachRouteToLogEvents, logEvent} from './lib/statsig/statsig'
|
import {attachRouteToLogEvents, logEvent} from './lib/statsig/statsig'
|
||||||
@@ -312,7 +317,31 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
|
|||||||
getComponent={() => MessagesSettingsScreen}
|
getComponent={() => MessagesSettingsScreen}
|
||||||
options={{title: title(msg`Chat settings`), requireAuth: true}}
|
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`)}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="StarterPackShort"
|
||||||
|
getComponent={() => StarterPackScreenShort}
|
||||||
|
options={{title: title(msg`Starter Pack`)}}
|
||||||
|
/>
|
||||||
|
<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 +396,7 @@ function HomeTabNavigator() {
|
|||||||
contentStyle: pal.view,
|
contentStyle: pal.view,
|
||||||
}}>
|
}}>
|
||||||
<HomeTab.Screen name="Home" getComponent={() => HomeScreen} />
|
<HomeTab.Screen name="Home" getComponent={() => HomeScreen} />
|
||||||
|
<HomeTab.Screen name="Start" getComponent={() => HomeScreen} />
|
||||||
{commonScreens(HomeTab)}
|
{commonScreens(HomeTab)}
|
||||||
</HomeTab.Navigator>
|
</HomeTab.Navigator>
|
||||||
)
|
)
|
||||||
@@ -503,6 +533,11 @@ const FlatNavigator = () => {
|
|||||||
getComponent={() => MessagesScreen}
|
getComponent={() => MessagesScreen}
|
||||||
options={{title: title(msg`Messages`), requireAuth: true}}
|
options={{title: title(msg`Messages`), requireAuth: true}}
|
||||||
/>
|
/>
|
||||||
|
<Flat.Screen
|
||||||
|
name="Start"
|
||||||
|
getComponent={() => HomeScreen}
|
||||||
|
options={{title: title(msg`Home`)}}
|
||||||
|
/>
|
||||||
{commonScreens(Flat as typeof HomeTab, numUnread)}
|
{commonScreens(Flat as typeof HomeTab, numUnread)}
|
||||||
</Flat.Navigator>
|
</Flat.Navigator>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export type ButtonVariant = 'solid' | 'outline' | 'ghost' | 'gradient'
|
|||||||
export type ButtonColor =
|
export type ButtonColor =
|
||||||
| 'primary'
|
| 'primary'
|
||||||
| 'secondary'
|
| 'secondary'
|
||||||
|
| 'secondary_inverted'
|
||||||
| 'negative'
|
| 'negative'
|
||||||
| 'gradient_sky'
|
| 'gradient_sky'
|
||||||
| 'gradient_midnight'
|
| 'gradient_midnight'
|
||||||
@@ -217,6 +218,43 @@ export const Button = React.forwardRef<View, ButtonProps>(
|
|||||||
borderWidth: 1,
|
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 === 'secondary_inverted') {
|
||||||
|
if (variant === 'solid') {
|
||||||
|
if (!disabled) {
|
||||||
|
baseStyles.push({
|
||||||
|
backgroundColor: t.palette.contrast_900,
|
||||||
|
})
|
||||||
|
hoverStyles.push({
|
||||||
|
backgroundColor: t.palette.contrast_950,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
baseStyles.push({
|
||||||
|
backgroundColor: t.palette.contrast_700,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else if (variant === 'outline') {
|
||||||
|
baseStyles.push(a.border, t.atoms.bg, {
|
||||||
|
borderWidth: 1,
|
||||||
|
})
|
||||||
|
|
||||||
if (!disabled) {
|
if (!disabled) {
|
||||||
baseStyles.push(a.border, {
|
baseStyles.push(a.border, {
|
||||||
borderColor: t.palette.contrast_300,
|
borderColor: t.palette.contrast_300,
|
||||||
@@ -344,6 +382,7 @@ export const Button = React.forwardRef<View, ButtonProps>(
|
|||||||
const gradient = {
|
const gradient = {
|
||||||
primary: tokens.gradients.sky,
|
primary: tokens.gradients.sky,
|
||||||
secondary: tokens.gradients.sky,
|
secondary: tokens.gradients.sky,
|
||||||
|
secondary_inverted: tokens.gradients.sky,
|
||||||
negative: tokens.gradients.sky,
|
negative: tokens.gradients.sky,
|
||||||
gradient_sky: tokens.gradients.sky,
|
gradient_sky: tokens.gradients.sky,
|
||||||
gradient_midnight: tokens.gradients.midnight,
|
gradient_midnight: tokens.gradients.midnight,
|
||||||
@@ -499,6 +538,38 @@ export function useSharedButtonTextStyles() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if (color === 'secondary_inverted') {
|
||||||
|
if (variant === 'solid' || variant === 'gradient') {
|
||||||
|
if (!disabled) {
|
||||||
|
baseStyles.push({
|
||||||
|
color: t.palette.contrast_100,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
baseStyles.push({
|
||||||
|
color: t.palette.contrast_400,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else if (variant === 'outline') {
|
||||||
|
if (!disabled) {
|
||||||
|
baseStyles.push({
|
||||||
|
color: t.palette.contrast_600,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
baseStyles.push({
|
||||||
|
color: t.palette.contrast_300,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else if (variant === 'ghost') {
|
||||||
|
if (!disabled) {
|
||||||
|
baseStyles.push({
|
||||||
|
color: t.palette.contrast_600,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
baseStyles.push({
|
||||||
|
color: t.palette.contrast_300,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
} else if (color === 'negative') {
|
} else if (color === 'negative') {
|
||||||
if (variant === 'solid' || variant === 'gradient') {
|
if (variant === 'solid' || variant === 'gradient') {
|
||||||
if (!disabled) {
|
if (!disabled) {
|
||||||
|
|||||||
@@ -88,7 +88,10 @@ export function Outer({
|
|||||||
if (!isOpen) return
|
if (!isOpen) return
|
||||||
|
|
||||||
function handler(e: KeyboardEvent) {
|
function handler(e: KeyboardEvent) {
|
||||||
if (e.key === 'Escape') close()
|
if (e.key === 'Escape') {
|
||||||
|
e.stopPropagation()
|
||||||
|
close()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('keydown', handler)
|
document.addEventListener('keydown', handler)
|
||||||
|
|||||||
+163
-44
@@ -1,8 +1,15 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {GestureResponderEvent, View} from 'react-native'
|
import {GestureResponderEvent, View} from 'react-native'
|
||||||
import {AppBskyActorDefs, AppBskyFeedDefs, AtUri} from '@atproto/api'
|
import {
|
||||||
|
AppBskyActorDefs,
|
||||||
|
AppBskyFeedDefs,
|
||||||
|
AppBskyGraphDefs,
|
||||||
|
AtUri,
|
||||||
|
RichText as RichTextApi,
|
||||||
|
} from '@atproto/api'
|
||||||
import {msg, plural, Trans} from '@lingui/macro'
|
import {msg, plural, Trans} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
|
import {useQueryClient} from '@tanstack/react-query'
|
||||||
|
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {
|
import {
|
||||||
@@ -11,62 +18,92 @@ import {
|
|||||||
useRemoveFeedMutation,
|
useRemoveFeedMutation,
|
||||||
} from '#/state/queries/preferences'
|
} from '#/state/queries/preferences'
|
||||||
import {sanitizeHandle} from 'lib/strings/handles'
|
import {sanitizeHandle} from 'lib/strings/handles'
|
||||||
|
import {precacheFeedFromGeneratorView} from 'state/queries/feed'
|
||||||
import {useSession} from 'state/session'
|
import {useSession} from 'state/session'
|
||||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||||
import * as Toast from 'view/com/util/Toast'
|
import * as Toast from 'view/com/util/Toast'
|
||||||
import {useTheme} from '#/alf'
|
import {useTheme} from '#/alf'
|
||||||
import {atoms as a} from '#/alf'
|
import {atoms as a} from '#/alf'
|
||||||
import {Button, ButtonIcon} from '#/components/Button'
|
import {Button, ButtonIcon} from '#/components/Button'
|
||||||
import {useRichText} from '#/components/hooks/useRichText'
|
|
||||||
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
|
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
|
||||||
import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
|
import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
|
||||||
import {Link as InternalLink} from '#/components/Link'
|
import {Link as InternalLink, LinkProps} from '#/components/Link'
|
||||||
import {Loader} from '#/components/Loader'
|
import {Loader} from '#/components/Loader'
|
||||||
import * as Prompt from '#/components/Prompt'
|
import * as Prompt from '#/components/Prompt'
|
||||||
import {RichText} from '#/components/RichText'
|
import {RichText, RichTextProps} from '#/components/RichText'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
export function Default({feed}: {feed: AppBskyFeedDefs.GeneratorView}) {
|
type Props = {
|
||||||
|
view: AppBskyFeedDefs.GeneratorView
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Default(props: Props) {
|
||||||
|
const {view} = props
|
||||||
return (
|
return (
|
||||||
<Link feed={feed}>
|
<Link label={view.displayName} {...props}>
|
||||||
<Outer>
|
<Outer>
|
||||||
<Header>
|
<Header>
|
||||||
<Avatar src={feed.avatar} />
|
<Avatar src={view.avatar} />
|
||||||
<TitleAndByline title={feed.displayName} creator={feed.creator} />
|
<TitleAndByline title={view.displayName} creator={view.creator} />
|
||||||
<Action uri={feed.uri} pin />
|
<SaveButton view={view} pin />
|
||||||
</Header>
|
</Header>
|
||||||
<Description description={feed.description} />
|
<Description description={view.description} />
|
||||||
<Likes count={feed.likeCount || 0} />
|
<Likes count={view.likeCount || 0} />
|
||||||
</Outer>
|
</Outer>
|
||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Link({
|
export function Link({
|
||||||
|
view,
|
||||||
children,
|
children,
|
||||||
feed,
|
...props
|
||||||
}: {
|
}: Props & Omit<LinkProps, 'to'>) {
|
||||||
children: React.ReactElement
|
const queryClient = useQueryClient()
|
||||||
feed: AppBskyFeedDefs.GeneratorView
|
|
||||||
}) {
|
|
||||||
const href = React.useMemo(() => {
|
const href = React.useMemo(() => {
|
||||||
const urip = new AtUri(feed.uri)
|
return createProfileFeedHref({feed: view})
|
||||||
const handleOrDid = feed.creator.handle || feed.creator.did
|
}, [view])
|
||||||
return `/profile/${handleOrDid}/feed/${urip.rkey}`
|
|
||||||
}, [feed])
|
React.useEffect(() => {
|
||||||
return <InternalLink to={href}>{children}</InternalLink>
|
precacheFeedFromGeneratorView(queryClient, view)
|
||||||
|
}, [view, queryClient])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<InternalLink to={href} style={[a.flex_col]} {...props}>
|
||||||
|
{children}
|
||||||
|
</InternalLink>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Outer({children}: {children: React.ReactNode}) {
|
export function Outer({children}: {children: React.ReactNode}) {
|
||||||
return <View style={[a.flex_1, a.gap_md]}>{children}</View>
|
return <View style={[a.w_full, a.gap_md]}>{children}</View>
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Header({children}: {children: React.ReactNode}) {
|
export function Header({children}: {children: React.ReactNode}) {
|
||||||
return <View style={[a.flex_row, a.align_center, a.gap_md]}>{children}</View>
|
return <View style={[a.flex_row, a.align_center, a.gap_md]}>{children}</View>
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Avatar({src}: {src: string | undefined}) {
|
export type AvatarProps = {src: string | undefined; size?: number}
|
||||||
return <UserAvatar type="algo" size={40} avatar={src} />
|
|
||||||
|
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({
|
export function TitleAndByline({
|
||||||
@@ -74,33 +111,91 @@ export function TitleAndByline({
|
|||||||
creator,
|
creator,
|
||||||
}: {
|
}: {
|
||||||
title: string
|
title: string
|
||||||
creator: AppBskyActorDefs.ProfileViewBasic
|
creator?: AppBskyActorDefs.ProfileViewBasic
|
||||||
}) {
|
}) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[a.flex_1]}>
|
<View style={[a.flex_1]}>
|
||||||
<Text
|
<Text style={[a.text_md, a.font_bold, a.leading_snug]} numberOfLines={1}>
|
||||||
style={[a.text_md, a.font_bold, a.flex_1, a.leading_snug]}
|
|
||||||
numberOfLines={1}>
|
|
||||||
{title}
|
{title}
|
||||||
</Text>
|
</Text>
|
||||||
|
{creator && (
|
||||||
<Text
|
<Text
|
||||||
style={[a.flex_1, a.leading_snug, t.atoms.text_contrast_medium]}
|
style={[a.leading_snug, t.atoms.text_contrast_medium]}
|
||||||
numberOfLines={1}>
|
numberOfLines={1}>
|
||||||
<Trans>Feed by {sanitizeHandle(creator.handle, '@')}</Trans>
|
<Trans>Feed by {sanitizeHandle(creator.handle, '@')}</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Description({description}: {description?: string}) {
|
export function TitleAndBylinePlaceholder({creator}: {creator?: boolean}) {
|
||||||
const [rt, isResolving] = useRichText(description || '')
|
const t = useTheme()
|
||||||
if (!description) return null
|
|
||||||
return isResolving ? (
|
return (
|
||||||
<RichText value={description} style={[a.leading_snug]} />
|
<View style={[a.flex_1, a.gap_xs]}>
|
||||||
) : (
|
<View
|
||||||
<RichText value={rt} style={[a.leading_snug]} />
|
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,
|
||||||
|
...rest
|
||||||
|
}: {description?: string} & Partial<RichTextProps>) {
|
||||||
|
const rt = React.useMemo(() => {
|
||||||
|
if (!description) return
|
||||||
|
const rt = new RichTextApi({text: description || ''})
|
||||||
|
rt.detectFacetsWithoutResolution()
|
||||||
|
return rt
|
||||||
|
}, [description])
|
||||||
|
if (!rt) return null
|
||||||
|
return <RichText value={rt} style={[a.leading_snug]} disableLinks {...rest} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DescriptionPlaceholder() {
|
||||||
|
const t = useTheme()
|
||||||
|
return (
|
||||||
|
<View style={[a.gap_xs]}>
|
||||||
|
<View
|
||||||
|
style={[a.rounded_xs, a.w_full, t.atoms.bg_contrast_50, {height: 12}]}
|
||||||
|
/>
|
||||||
|
<View
|
||||||
|
style={[a.rounded_xs, a.w_full, t.atoms.bg_contrast_50, {height: 12}]}
|
||||||
|
/>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.rounded_xs,
|
||||||
|
a.w_full,
|
||||||
|
t.atoms.bg_contrast_50,
|
||||||
|
{height: 12, width: 100},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,23 +211,37 @@ export function Likes({count}: {count: number}) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Action({uri, pin}: {uri: string; pin?: boolean}) {
|
export function SaveButton({
|
||||||
|
view,
|
||||||
|
pin,
|
||||||
|
}: {
|
||||||
|
view: AppBskyFeedDefs.GeneratorView | AppBskyGraphDefs.ListView
|
||||||
|
pin?: boolean
|
||||||
|
}) {
|
||||||
const {hasSession} = useSession()
|
const {hasSession} = useSession()
|
||||||
if (!hasSession) return null
|
if (!hasSession) return null
|
||||||
return <ActionInner uri={uri} pin={pin} />
|
return <SaveButtonInner view={view} pin={pin} />
|
||||||
}
|
}
|
||||||
|
|
||||||
function ActionInner({uri, pin}: {uri: string; pin?: boolean}) {
|
function SaveButtonInner({
|
||||||
|
view,
|
||||||
|
pin,
|
||||||
|
}: {
|
||||||
|
view: AppBskyFeedDefs.GeneratorView | AppBskyGraphDefs.ListView
|
||||||
|
pin?: boolean
|
||||||
|
}) {
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const {data: preferences} = usePreferencesQuery()
|
const {data: preferences} = usePreferencesQuery()
|
||||||
const {isPending: isAddSavedFeedPending, mutateAsync: saveFeeds} =
|
const {isPending: isAddSavedFeedPending, mutateAsync: saveFeeds} =
|
||||||
useAddSavedFeedsMutation()
|
useAddSavedFeedsMutation()
|
||||||
const {isPending: isRemovePending, mutateAsync: removeFeed} =
|
const {isPending: isRemovePending, mutateAsync: removeFeed} =
|
||||||
useRemoveFeedMutation()
|
useRemoveFeedMutation()
|
||||||
|
|
||||||
|
const uri = view.uri
|
||||||
|
const type = view.uri.includes('app.bsky.feed.generator') ? 'feed' : 'list'
|
||||||
|
|
||||||
const savedFeedConfig = React.useMemo(() => {
|
const savedFeedConfig = React.useMemo(() => {
|
||||||
return preferences?.savedFeeds?.find(
|
return preferences?.savedFeeds?.find(feed => feed.value === uri)
|
||||||
feed => feed.type === 'feed' && feed.value === uri,
|
|
||||||
)
|
|
||||||
}, [preferences?.savedFeeds, uri])
|
}, [preferences?.savedFeeds, uri])
|
||||||
const removePromptControl = Prompt.usePromptControl()
|
const removePromptControl = Prompt.usePromptControl()
|
||||||
const isPending = isAddSavedFeedPending || isRemovePending
|
const isPending = isAddSavedFeedPending || isRemovePending
|
||||||
@@ -148,7 +257,7 @@ function ActionInner({uri, pin}: {uri: string; pin?: boolean}) {
|
|||||||
} else {
|
} else {
|
||||||
await saveFeeds([
|
await saveFeeds([
|
||||||
{
|
{
|
||||||
type: 'feed',
|
type,
|
||||||
value: uri,
|
value: uri,
|
||||||
pinned: pin || false,
|
pinned: pin || false,
|
||||||
},
|
},
|
||||||
@@ -160,7 +269,7 @@ function ActionInner({uri, pin}: {uri: string; pin?: boolean}) {
|
|||||||
Toast.show(_(msg`Failed to update feeds`))
|
Toast.show(_(msg`Failed to update feeds`))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[_, pin, saveFeeds, removeFeed, uri, savedFeedConfig],
|
[_, pin, saveFeeds, removeFeed, uri, savedFeedConfig, type],
|
||||||
)
|
)
|
||||||
|
|
||||||
const onPrompRemoveFeed = React.useCallback(
|
const onPrompRemoveFeed = React.useCallback(
|
||||||
@@ -203,3 +312,13 @@ function ActionInner({uri, pin}: {uri: string; pin?: boolean}) {
|
|||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createProfileFeedHref({
|
||||||
|
feed,
|
||||||
|
}: {
|
||||||
|
feed: AppBskyFeedDefs.GeneratorView
|
||||||
|
}) {
|
||||||
|
const urip = new AtUri(feed.uri)
|
||||||
|
const handleOrDid = feed.creator.handle || feed.creator.did
|
||||||
|
return `/profile/${handleOrDid}/feed/${urip.rkey}`
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,445 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import {View} from 'react-native'
|
||||||
|
import {ScrollView} from 'react-native-gesture-handler'
|
||||||
|
import {AppBskyFeedDefs, AtUri} from '@atproto/api'
|
||||||
|
import {msg, Trans} from '@lingui/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
import {useNavigation} from '@react-navigation/native'
|
||||||
|
|
||||||
|
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
|
||||||
|
import {NavigationProp} from '#/lib/routes/types'
|
||||||
|
import {logEvent} from '#/lib/statsig/statsig'
|
||||||
|
import {logger} from '#/logger'
|
||||||
|
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||||
|
import {useGetPopularFeedsQuery} from '#/state/queries/feed'
|
||||||
|
import {useProfilesQuery} from '#/state/queries/profile'
|
||||||
|
import {useProgressGuide} from '#/state/shell/progress-guide'
|
||||||
|
import * as userActionHistory from '#/state/userActionHistory'
|
||||||
|
import {SeenPost} from '#/state/userActionHistory'
|
||||||
|
import {atoms as a, useBreakpoints, useTheme, ViewStyleProp, web} from '#/alf'
|
||||||
|
import {Button} from '#/components/Button'
|
||||||
|
import * as FeedCard from '#/components/FeedCard'
|
||||||
|
import {ArrowRight_Stroke2_Corner0_Rounded as Arrow} from '#/components/icons/Arrow'
|
||||||
|
import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag'
|
||||||
|
import {PersonPlus_Stroke2_Corner0_Rounded as Person} from '#/components/icons/Person'
|
||||||
|
import {InlineLinkText} from '#/components/Link'
|
||||||
|
import * as ProfileCard from '#/components/ProfileCard'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
import {ProgressGuideList} from './ProgressGuide/List'
|
||||||
|
|
||||||
|
function CardOuter({
|
||||||
|
children,
|
||||||
|
style,
|
||||||
|
}: {children: React.ReactNode | React.ReactNode[]} & ViewStyleProp) {
|
||||||
|
const t = useTheme()
|
||||||
|
const {gtMobile} = useBreakpoints()
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.w_full,
|
||||||
|
a.p_lg,
|
||||||
|
a.rounded_md,
|
||||||
|
a.border,
|
||||||
|
t.atoms.bg,
|
||||||
|
t.atoms.border_contrast_low,
|
||||||
|
!gtMobile && {
|
||||||
|
width: 300,
|
||||||
|
},
|
||||||
|
style,
|
||||||
|
]}>
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SuggestedFollowPlaceholder() {
|
||||||
|
const t = useTheme()
|
||||||
|
return (
|
||||||
|
<CardOuter style={[a.gap_sm, t.atoms.border_contrast_low]}>
|
||||||
|
<ProfileCard.Header>
|
||||||
|
<ProfileCard.AvatarPlaceholder />
|
||||||
|
</ProfileCard.Header>
|
||||||
|
|
||||||
|
<View style={[a.py_xs]}>
|
||||||
|
<ProfileCard.NameAndHandlePlaceholder />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ProfileCard.DescriptionPlaceholder />
|
||||||
|
</CardOuter>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SuggestedFeedsCardPlaceholder() {
|
||||||
|
const t = useTheme()
|
||||||
|
return (
|
||||||
|
<CardOuter style={[a.gap_sm, t.atoms.border_contrast_low]}>
|
||||||
|
<FeedCard.Header>
|
||||||
|
<FeedCard.AvatarPlaceholder />
|
||||||
|
<FeedCard.TitleAndBylinePlaceholder creator />
|
||||||
|
</FeedCard.Header>
|
||||||
|
|
||||||
|
<FeedCard.DescriptionPlaceholder />
|
||||||
|
</CardOuter>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRank(seenPost: SeenPost): string {
|
||||||
|
let tier: string
|
||||||
|
if (seenPost.feedContext === 'popfriends') {
|
||||||
|
tier = 'a'
|
||||||
|
} else if (seenPost.feedContext?.startsWith('cluster')) {
|
||||||
|
tier = 'b'
|
||||||
|
} else if (seenPost.feedContext?.startsWith('ntpc')) {
|
||||||
|
tier = 'c'
|
||||||
|
} else if (seenPost.feedContext?.startsWith('t-')) {
|
||||||
|
tier = 'd'
|
||||||
|
} else if (seenPost.feedContext === 'nettop') {
|
||||||
|
tier = 'e'
|
||||||
|
} else {
|
||||||
|
tier = 'f'
|
||||||
|
}
|
||||||
|
let score = Math.round(
|
||||||
|
Math.log(
|
||||||
|
1 + seenPost.likeCount + seenPost.repostCount + seenPost.replyCount,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if (seenPost.isFollowedBy || Math.random() > 0.9) {
|
||||||
|
score *= 2
|
||||||
|
}
|
||||||
|
const rank = 100 - score
|
||||||
|
return `${tier}-${rank}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortSeenPosts(postA: SeenPost, postB: SeenPost): 0 | 1 | -1 {
|
||||||
|
const rankA = getRank(postA)
|
||||||
|
const rankB = getRank(postB)
|
||||||
|
// Yes, we're comparing strings here.
|
||||||
|
// The "larger" string means a worse rank.
|
||||||
|
if (rankA > rankB) {
|
||||||
|
return 1
|
||||||
|
} else if (rankA < rankB) {
|
||||||
|
return -1
|
||||||
|
} else {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function useExperimentalSuggestedUsersQuery() {
|
||||||
|
const userActionSnapshot = userActionHistory.useActionHistorySnapshot()
|
||||||
|
const dids = React.useMemo(() => {
|
||||||
|
const {likes, follows, seen} = userActionSnapshot
|
||||||
|
const likeDids = likes
|
||||||
|
.map(l => new AtUri(l))
|
||||||
|
.map(uri => uri.host)
|
||||||
|
.filter(did => !follows.includes(did))
|
||||||
|
const seenDids = seen
|
||||||
|
.sort(sortSeenPosts)
|
||||||
|
.map(l => new AtUri(l.uri))
|
||||||
|
.map(uri => uri.host)
|
||||||
|
return [...new Set([...likeDids, ...seenDids])]
|
||||||
|
}, [userActionSnapshot])
|
||||||
|
const {data, isLoading, error} = useProfilesQuery({
|
||||||
|
handles: dids.slice(0, 16),
|
||||||
|
})
|
||||||
|
|
||||||
|
const profiles = data
|
||||||
|
? data.profiles.filter(profile => {
|
||||||
|
return !profile.viewer?.following
|
||||||
|
})
|
||||||
|
: []
|
||||||
|
|
||||||
|
return {
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
profiles: profiles.slice(0, 6),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SuggestedFollows() {
|
||||||
|
const t = useTheme()
|
||||||
|
const {_} = useLingui()
|
||||||
|
const {
|
||||||
|
isLoading: isSuggestionsLoading,
|
||||||
|
profiles,
|
||||||
|
error,
|
||||||
|
} = useExperimentalSuggestedUsersQuery()
|
||||||
|
const moderationOpts = useModerationOpts()
|
||||||
|
const navigation = useNavigation<NavigationProp>()
|
||||||
|
const {gtMobile} = useBreakpoints()
|
||||||
|
const isLoading = isSuggestionsLoading || !moderationOpts
|
||||||
|
const maxLength = gtMobile ? 4 : 6
|
||||||
|
|
||||||
|
const content = isLoading ? (
|
||||||
|
Array(maxLength)
|
||||||
|
.fill(0)
|
||||||
|
.map((_, i) => (
|
||||||
|
<View
|
||||||
|
key={i}
|
||||||
|
style={[gtMobile && web([a.flex_0, {width: 'calc(50% - 6px)'}])]}>
|
||||||
|
<SuggestedFollowPlaceholder />
|
||||||
|
</View>
|
||||||
|
))
|
||||||
|
) : error || !profiles.length ? null : (
|
||||||
|
<>
|
||||||
|
{profiles.slice(0, maxLength).map(profile => (
|
||||||
|
<ProfileCard.Link
|
||||||
|
key={profile.did}
|
||||||
|
did={profile.handle}
|
||||||
|
onPress={() => {
|
||||||
|
logEvent('feed:interstitial:profileCard:press', {})
|
||||||
|
}}
|
||||||
|
style={[
|
||||||
|
a.flex_1,
|
||||||
|
gtMobile && web([a.flex_0, {width: 'calc(50% - 6px)'}]),
|
||||||
|
]}>
|
||||||
|
{({hovered, pressed}) => (
|
||||||
|
<CardOuter
|
||||||
|
style={[
|
||||||
|
a.flex_1,
|
||||||
|
(hovered || pressed) && t.atoms.border_contrast_high,
|
||||||
|
]}>
|
||||||
|
<ProfileCard.Outer>
|
||||||
|
<ProfileCard.Header>
|
||||||
|
<ProfileCard.Avatar
|
||||||
|
profile={profile}
|
||||||
|
moderationOpts={moderationOpts}
|
||||||
|
/>
|
||||||
|
<ProfileCard.NameAndHandle
|
||||||
|
profile={profile}
|
||||||
|
moderationOpts={moderationOpts}
|
||||||
|
/>
|
||||||
|
<ProfileCard.FollowButton
|
||||||
|
profile={profile}
|
||||||
|
logContext="FeedInterstitial"
|
||||||
|
color="secondary_inverted"
|
||||||
|
shape="round"
|
||||||
|
/>
|
||||||
|
</ProfileCard.Header>
|
||||||
|
<ProfileCard.Description profile={profile} />
|
||||||
|
</ProfileCard.Outer>
|
||||||
|
</CardOuter>
|
||||||
|
)}
|
||||||
|
</ProfileCard.Link>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (error || (!isLoading && profiles.length < 4)) {
|
||||||
|
logger.debug(`Not enough profiles to show suggested follows`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[a.border_t, t.atoms.border_contrast_low, t.atoms.bg_contrast_25]}>
|
||||||
|
<View style={[a.pt_2xl, a.px_lg, a.flex_row, a.pb_xs]}>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.flex_1,
|
||||||
|
a.text_lg,
|
||||||
|
a.font_bold,
|
||||||
|
t.atoms.text_contrast_medium,
|
||||||
|
]}>
|
||||||
|
<Trans>Suggested for you</Trans>
|
||||||
|
</Text>
|
||||||
|
<Person fill={t.atoms.text_contrast_low.color} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{gtMobile ? (
|
||||||
|
<View style={[a.flex_1, a.px_lg, a.pt_md, a.pb_xl, a.gap_md]}>
|
||||||
|
<View style={[a.flex_1, a.flex_row, a.flex_wrap, a.gap_md]}>
|
||||||
|
{content}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.flex_row,
|
||||||
|
a.justify_end,
|
||||||
|
a.align_center,
|
||||||
|
a.pt_xs,
|
||||||
|
a.gap_md,
|
||||||
|
]}>
|
||||||
|
<InlineLinkText to="/search" style={[t.atoms.text_contrast_medium]}>
|
||||||
|
<Trans>Browse more suggestions</Trans>
|
||||||
|
</InlineLinkText>
|
||||||
|
<Arrow size="sm" fill={t.atoms.text_contrast_medium.color} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||||
|
<View style={[a.px_lg, a.pt_md, a.pb_xl, a.flex_row, a.gap_md]}>
|
||||||
|
{content}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
label={_(msg`Browse more accounts on the Explore page`)}
|
||||||
|
onPress={() => {
|
||||||
|
navigation.navigate('SearchTab')
|
||||||
|
}}>
|
||||||
|
<CardOuter style={[a.flex_1, {borderWidth: 0}]}>
|
||||||
|
<View style={[a.flex_1, a.justify_center]}>
|
||||||
|
<View style={[a.flex_row, a.px_lg]}>
|
||||||
|
<Text style={[a.pr_xl, a.flex_1, a.leading_snug]}>
|
||||||
|
<Trans>Browse more suggestions on the Explore page</Trans>
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Arrow size="xl" />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</CardOuter>
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SuggestedFeeds() {
|
||||||
|
const numFeedsToDisplay = 3
|
||||||
|
const t = useTheme()
|
||||||
|
const {_} = useLingui()
|
||||||
|
const {data, isLoading, error} = useGetPopularFeedsQuery({
|
||||||
|
limit: numFeedsToDisplay,
|
||||||
|
})
|
||||||
|
const navigation = useNavigation<NavigationProp>()
|
||||||
|
const {gtMobile} = useBreakpoints()
|
||||||
|
|
||||||
|
const feeds = React.useMemo(() => {
|
||||||
|
const items: AppBskyFeedDefs.GeneratorView[] = []
|
||||||
|
|
||||||
|
if (!data) return items
|
||||||
|
|
||||||
|
for (const page of data.pages) {
|
||||||
|
for (const feed of page.feeds) {
|
||||||
|
items.push(feed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return items
|
||||||
|
}, [data])
|
||||||
|
|
||||||
|
const content = isLoading ? (
|
||||||
|
Array(numFeedsToDisplay)
|
||||||
|
.fill(0)
|
||||||
|
.map((_, i) => <SuggestedFeedsCardPlaceholder key={i} />)
|
||||||
|
) : error || !feeds ? null : (
|
||||||
|
<>
|
||||||
|
{feeds.slice(0, numFeedsToDisplay).map(feed => (
|
||||||
|
<FeedCard.Link
|
||||||
|
key={feed.uri}
|
||||||
|
view={feed}
|
||||||
|
onPress={() => {
|
||||||
|
logEvent('feed:interstitial:feedCard:press', {})
|
||||||
|
}}>
|
||||||
|
{({hovered, pressed}) => (
|
||||||
|
<CardOuter
|
||||||
|
style={[
|
||||||
|
a.flex_1,
|
||||||
|
(hovered || pressed) && t.atoms.border_contrast_high,
|
||||||
|
]}>
|
||||||
|
<FeedCard.Outer>
|
||||||
|
<FeedCard.Header>
|
||||||
|
<FeedCard.Avatar src={feed.avatar} />
|
||||||
|
<FeedCard.TitleAndByline
|
||||||
|
title={feed.displayName}
|
||||||
|
creator={feed.creator}
|
||||||
|
/>
|
||||||
|
</FeedCard.Header>
|
||||||
|
<FeedCard.Description
|
||||||
|
description={feed.description}
|
||||||
|
numberOfLines={3}
|
||||||
|
/>
|
||||||
|
</FeedCard.Outer>
|
||||||
|
</CardOuter>
|
||||||
|
)}
|
||||||
|
</FeedCard.Link>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
|
return error ? null : (
|
||||||
|
<View
|
||||||
|
style={[a.border_t, t.atoms.border_contrast_low, t.atoms.bg_contrast_25]}>
|
||||||
|
<View style={[a.pt_2xl, a.px_lg, a.flex_row, a.pb_xs]}>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.flex_1,
|
||||||
|
a.text_lg,
|
||||||
|
a.font_bold,
|
||||||
|
t.atoms.text_contrast_medium,
|
||||||
|
]}>
|
||||||
|
<Trans>Some other feeds you might like</Trans>
|
||||||
|
</Text>
|
||||||
|
<Hashtag fill={t.atoms.text_contrast_low.color} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{gtMobile ? (
|
||||||
|
<View style={[a.flex_1, a.px_lg, a.pt_md, a.pb_xl, a.gap_md]}>
|
||||||
|
{content}
|
||||||
|
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.flex_row,
|
||||||
|
a.justify_end,
|
||||||
|
a.align_center,
|
||||||
|
a.pt_xs,
|
||||||
|
a.gap_md,
|
||||||
|
]}>
|
||||||
|
<InlineLinkText to="/search" style={[t.atoms.text_contrast_medium]}>
|
||||||
|
<Trans>Browse more suggestions</Trans>
|
||||||
|
</InlineLinkText>
|
||||||
|
<Arrow size="sm" fill={t.atoms.text_contrast_medium.color} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||||
|
<View style={[a.px_lg, a.pt_md, a.pb_xl, a.flex_row, a.gap_md]}>
|
||||||
|
{content}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
label={_(msg`Browse more feeds on the Explore page`)}
|
||||||
|
onPress={() => {
|
||||||
|
navigation.navigate('SearchTab')
|
||||||
|
}}
|
||||||
|
style={[a.flex_col]}>
|
||||||
|
<CardOuter style={[a.flex_1]}>
|
||||||
|
<View style={[a.flex_1, a.justify_center]}>
|
||||||
|
<View style={[a.flex_row, a.px_lg]}>
|
||||||
|
<Text style={[a.pr_xl, a.flex_1, a.leading_snug]}>
|
||||||
|
<Trans>Browse more suggestions on the Explore page</Trans>
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Arrow size="xl" />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</CardOuter>
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProgressGuide() {
|
||||||
|
const t = useTheme()
|
||||||
|
const {isDesktop} = useWebMediaQueries()
|
||||||
|
const guide = useProgressGuide('like-10-and-follow-7')
|
||||||
|
|
||||||
|
if (isDesktop) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return guide ? (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.border_t,
|
||||||
|
t.atoms.border_contrast_low,
|
||||||
|
a.px_lg,
|
||||||
|
a.py_lg,
|
||||||
|
a.pb_lg,
|
||||||
|
]}>
|
||||||
|
<ProgressGuideList />
|
||||||
|
</View>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import {StyleProp, ViewStyle} from 'react-native'
|
||||||
|
import {LinearGradient} from 'expo-linear-gradient'
|
||||||
|
|
||||||
|
import {gradients} from '#/alf/tokens'
|
||||||
|
|
||||||
|
export function LinearGradientBackground({
|
||||||
|
style,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
style: StyleProp<ViewStyle>
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
const gradient = gradients.sky.values.map(([_, color]) => {
|
||||||
|
return color
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LinearGradient colors={gradient} style={style}>
|
||||||
|
{children}
|
||||||
|
</LinearGradient>
|
||||||
|
)
|
||||||
|
}
|
||||||
+48
-1
@@ -1,5 +1,10 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {GestureResponderEvent} from 'react-native'
|
import {
|
||||||
|
GestureResponderEvent,
|
||||||
|
Pressable,
|
||||||
|
StyleProp,
|
||||||
|
ViewStyle,
|
||||||
|
} from 'react-native'
|
||||||
import {sanitizeUrl} from '@braintree/sanitize-url'
|
import {sanitizeUrl} from '@braintree/sanitize-url'
|
||||||
import {StackActions, useLinkProps} from '@react-navigation/native'
|
import {StackActions, useLinkProps} from '@react-navigation/native'
|
||||||
|
|
||||||
@@ -323,3 +328,45 @@ export function InlineLinkText({
|
|||||||
</Text>
|
</Text>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Pressable that uses useLink to handle navigation. It is unstyled, so can be used in cases where the Button styles
|
||||||
|
* in Link are not desired.
|
||||||
|
* @param displayText
|
||||||
|
* @param style
|
||||||
|
* @param children
|
||||||
|
* @param rest
|
||||||
|
* @constructor
|
||||||
|
*/
|
||||||
|
export function BaseLink({
|
||||||
|
displayText,
|
||||||
|
onPress: onPressOuter,
|
||||||
|
style,
|
||||||
|
children,
|
||||||
|
...rest
|
||||||
|
}: {
|
||||||
|
style?: StyleProp<ViewStyle>
|
||||||
|
children: React.ReactNode
|
||||||
|
to: string
|
||||||
|
action: 'push' | 'replace' | 'navigate'
|
||||||
|
onPress?: () => false | void
|
||||||
|
shareOnLongPress?: boolean
|
||||||
|
label: string
|
||||||
|
displayText?: string
|
||||||
|
}) {
|
||||||
|
const {onPress, ...btnProps} = useLink({
|
||||||
|
displayText: displayText ?? rest.to,
|
||||||
|
...rest,
|
||||||
|
})
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
style={style}
|
||||||
|
onPress={e => {
|
||||||
|
onPressOuter?.()
|
||||||
|
onPress(e)
|
||||||
|
}}
|
||||||
|
{...btnProps}>
|
||||||
|
{children}
|
||||||
|
</Pressable>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import {View} from 'react-native'
|
||||||
|
import {AppBskyActorDefs, AppBskyGraphDefs, AtUri} from '@atproto/api'
|
||||||
|
import {Trans} from '@lingui/macro'
|
||||||
|
import {useQueryClient} from '@tanstack/react-query'
|
||||||
|
|
||||||
|
import {sanitizeHandle} from 'lib/strings/handles'
|
||||||
|
import {precacheList} from 'state/queries/feed'
|
||||||
|
import {useTheme} from '#/alf'
|
||||||
|
import {atoms as a} from '#/alf'
|
||||||
|
import {
|
||||||
|
Avatar,
|
||||||
|
Description,
|
||||||
|
Header,
|
||||||
|
Outer,
|
||||||
|
SaveButton,
|
||||||
|
} from '#/components/FeedCard'
|
||||||
|
import {Link as InternalLink, LinkProps} from '#/components/Link'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This component is based on `FeedCard` and is tightly coupled with that
|
||||||
|
* component. Please refer to `FeedCard` for more context.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export {
|
||||||
|
Avatar,
|
||||||
|
AvatarPlaceholder,
|
||||||
|
Description,
|
||||||
|
Header,
|
||||||
|
Outer,
|
||||||
|
SaveButton,
|
||||||
|
TitleAndBylinePlaceholder,
|
||||||
|
} from '#/components/FeedCard'
|
||||||
|
|
||||||
|
const CURATELIST = 'app.bsky.graph.defs#curatelist'
|
||||||
|
const MODLIST = 'app.bsky.graph.defs#modlist'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
view: AppBskyGraphDefs.ListView
|
||||||
|
showPinButton?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Default(props: Props) {
|
||||||
|
const {view, showPinButton} = props
|
||||||
|
return (
|
||||||
|
<Link label={view.name} {...props}>
|
||||||
|
<Outer>
|
||||||
|
<Header>
|
||||||
|
<Avatar src={view.avatar} />
|
||||||
|
<TitleAndByline
|
||||||
|
title={view.name}
|
||||||
|
creator={view.creator}
|
||||||
|
purpose={view.purpose}
|
||||||
|
/>
|
||||||
|
{showPinButton && view.purpose === CURATELIST && (
|
||||||
|
<SaveButton view={view} pin />
|
||||||
|
)}
|
||||||
|
</Header>
|
||||||
|
<Description description={view.description} />
|
||||||
|
</Outer>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Link({
|
||||||
|
view,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: Props & Omit<LinkProps, 'to'>) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
const href = React.useMemo(() => {
|
||||||
|
return createProfileListHref({list: view})
|
||||||
|
}, [view])
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
precacheList(queryClient, view)
|
||||||
|
}, [view, queryClient])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<InternalLink to={href} {...props}>
|
||||||
|
{children}
|
||||||
|
</InternalLink>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TitleAndByline({
|
||||||
|
title,
|
||||||
|
creator,
|
||||||
|
purpose = CURATELIST,
|
||||||
|
}: {
|
||||||
|
title: string
|
||||||
|
creator?: AppBskyActorDefs.ProfileViewBasic
|
||||||
|
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}>
|
||||||
|
{purpose === MODLIST ? (
|
||||||
|
<Trans>
|
||||||
|
Moderation list by {sanitizeHandle(creator.handle, '@')}
|
||||||
|
</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>List by {sanitizeHandle(creator.handle, '@')}</Trans>
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createProfileListHref({
|
||||||
|
list,
|
||||||
|
}: {
|
||||||
|
list: AppBskyGraphDefs.ListView
|
||||||
|
}) {
|
||||||
|
const urip = new AtUri(list.uri)
|
||||||
|
const handleOrDid = list.creator.handle || list.creator.did
|
||||||
|
return `/profile/${handleOrDid}/lists/${urip.rkey}`
|
||||||
|
}
|
||||||
@@ -6,14 +6,17 @@ import {useLingui} from '@lingui/react'
|
|||||||
import {differenceInSeconds} from 'date-fns'
|
import {differenceInSeconds} from 'date-fns'
|
||||||
|
|
||||||
import {useGetTimeAgo} from '#/lib/hooks/useTimeAgo'
|
import {useGetTimeAgo} from '#/lib/hooks/useTimeAgo'
|
||||||
|
import {isNative} from '#/platform/detection'
|
||||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||||
import {HITSLOP_10} from 'lib/constants'
|
import {HITSLOP_10} from 'lib/constants'
|
||||||
import {sanitizeDisplayName} from 'lib/strings/display-names'
|
import {sanitizeDisplayName} from 'lib/strings/display-names'
|
||||||
import {atoms as a} from '#/alf'
|
import {useSession} from 'state/session'
|
||||||
import {Button} from '#/components/Button'
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
|
import {Button, ButtonText} from '#/components/Button'
|
||||||
import * as Dialog from '#/components/Dialog'
|
import * as Dialog from '#/components/Dialog'
|
||||||
import {useDialogControl} from '#/components/Dialog'
|
import {useDialogControl} from '#/components/Dialog'
|
||||||
import {Newskie} from '#/components/icons/Newskie'
|
import {Newskie} from '#/components/icons/Newskie'
|
||||||
|
import * as StarterPackCard from '#/components/StarterPack/StarterPackCard'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
export function NewskieDialog({
|
export function NewskieDialog({
|
||||||
@@ -24,17 +27,29 @@ export function NewskieDialog({
|
|||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
}) {
|
}) {
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
|
const t = useTheme()
|
||||||
const moderationOpts = useModerationOpts()
|
const moderationOpts = useModerationOpts()
|
||||||
|
const {currentAccount} = useSession()
|
||||||
|
const timeAgo = useGetTimeAgo()
|
||||||
const control = useDialogControl()
|
const control = useDialogControl()
|
||||||
|
|
||||||
|
const isMe = profile.did === currentAccount?.did
|
||||||
|
const createdAt = profile.createdAt as string | undefined
|
||||||
|
|
||||||
const profileName = React.useMemo(() => {
|
const profileName = React.useMemo(() => {
|
||||||
const name = profile.displayName || profile.handle
|
const name = profile.displayName || profile.handle
|
||||||
|
|
||||||
|
if (isMe) {
|
||||||
|
return _(msg`You`)
|
||||||
|
}
|
||||||
|
|
||||||
if (!moderationOpts) return name
|
if (!moderationOpts) return name
|
||||||
const moderation = moderateProfile(profile, moderationOpts)
|
const moderation = moderateProfile(profile, moderationOpts)
|
||||||
|
|
||||||
return sanitizeDisplayName(name, moderation.ui('displayName'))
|
return sanitizeDisplayName(name, moderation.ui('displayName'))
|
||||||
}, [moderationOpts, profile])
|
}, [_, isMe, moderationOpts, profile])
|
||||||
|
|
||||||
const [now] = React.useState(() => Date.now())
|
const [now] = React.useState(() => Date.now())
|
||||||
const timeAgo = useGetTimeAgo()
|
|
||||||
const createdAt = profile.createdAt as string | undefined
|
|
||||||
const daysOld = React.useMemo(() => {
|
const daysOld = React.useMemo(() => {
|
||||||
if (!createdAt) return Infinity
|
if (!createdAt) return Infinity
|
||||||
return differenceInSeconds(now, new Date(createdAt)) / 86400
|
return differenceInSeconds(now, new Date(createdAt)) / 86400
|
||||||
@@ -67,17 +82,81 @@ export function NewskieDialog({
|
|||||||
<Dialog.ScrollableInner
|
<Dialog.ScrollableInner
|
||||||
label={_(msg`New user info dialog`)}
|
label={_(msg`New user info dialog`)}
|
||||||
style={[{width: 'auto', maxWidth: 400, minWidth: 200}]}>
|
style={[{width: 'auto', maxWidth: 400, minWidth: 200}]}>
|
||||||
<View style={[a.gap_sm]}>
|
<View style={[a.gap_md]}>
|
||||||
|
<View style={[a.align_center]}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
{
|
||||||
|
height: 60,
|
||||||
|
width: 64,
|
||||||
|
},
|
||||||
|
]}>
|
||||||
|
<Newskie
|
||||||
|
width={64}
|
||||||
|
height={64}
|
||||||
|
fill="#FFC404"
|
||||||
|
style={[a.absolute, a.inset_0]}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
<Text style={[a.font_bold, a.text_xl]}>
|
<Text style={[a.font_bold, a.text_xl]}>
|
||||||
|
{isMe ? (
|
||||||
|
<Trans>Welcome, friend!</Trans>
|
||||||
|
) : (
|
||||||
<Trans>Say hello!</Trans>
|
<Trans>Say hello!</Trans>
|
||||||
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={[a.text_md]}>
|
</View>
|
||||||
|
<Text style={[a.text_md, a.text_center, a.leading_snug]}>
|
||||||
|
{profile.joinedViaStarterPack ? (
|
||||||
|
<Trans>
|
||||||
|
{profileName} joined Bluesky using a starter pack{' '}
|
||||||
|
{timeAgo(createdAt, now, {format: 'long'})} ago
|
||||||
|
</Trans>
|
||||||
|
) : (
|
||||||
<Trans>
|
<Trans>
|
||||||
{profileName} joined Bluesky{' '}
|
{profileName} joined Bluesky{' '}
|
||||||
{timeAgo(createdAt, now, {format: 'long'})} ago
|
{timeAgo(createdAt, now, {format: 'long'})} ago
|
||||||
</Trans>
|
</Trans>
|
||||||
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
|
{profile.joinedViaStarterPack ? (
|
||||||
|
<StarterPackCard.Link
|
||||||
|
starterPack={profile.joinedViaStarterPack}
|
||||||
|
onPress={() => {
|
||||||
|
control.close()
|
||||||
|
}}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.flex_1,
|
||||||
|
a.mt_sm,
|
||||||
|
a.p_lg,
|
||||||
|
a.border,
|
||||||
|
a.rounded_sm,
|
||||||
|
t.atoms.border_contrast_low,
|
||||||
|
]}>
|
||||||
|
<StarterPackCard.Card
|
||||||
|
starterPack={profile.joinedViaStarterPack}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
</StarterPackCard.Link>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{isNative && (
|
||||||
|
<Button
|
||||||
|
label={_(msg`Close`)}
|
||||||
|
variant="solid"
|
||||||
|
color="secondary"
|
||||||
|
size="small"
|
||||||
|
style={[a.mt_sm]}
|
||||||
|
onPress={() => control.close()}>
|
||||||
|
<ButtonText>
|
||||||
|
<Trans>Close</Trans>
|
||||||
|
</ButtonText>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Dialog.Close />
|
||||||
</Dialog.ScrollableInner>
|
</Dialog.ScrollableInner>
|
||||||
</Dialog.Outer>
|
</Dialog.Outer>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import {View} from 'react-native'
|
||||||
|
import {BSKY_LABELER_DID, ModerationCause} from '@atproto/api'
|
||||||
|
import {Trans} from '@lingui/macro'
|
||||||
|
|
||||||
|
import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription'
|
||||||
|
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||||
|
import {atoms as a, useTheme, ViewStyleProp} from '#/alf'
|
||||||
|
import {Button} from '#/components/Button'
|
||||||
|
import {
|
||||||
|
ModerationDetailsDialog,
|
||||||
|
useModerationDetailsDialogControl,
|
||||||
|
} from '#/components/moderation/ModerationDetailsDialog'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
|
export type CommonProps = {
|
||||||
|
size?: 'sm' | 'lg'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Row({
|
||||||
|
children,
|
||||||
|
style,
|
||||||
|
size = 'sm',
|
||||||
|
}: {children: React.ReactNode | React.ReactNode[]} & CommonProps &
|
||||||
|
ViewStyleProp) {
|
||||||
|
const styles = React.useMemo(() => {
|
||||||
|
switch (size) {
|
||||||
|
case 'lg':
|
||||||
|
return [{gap: 5}]
|
||||||
|
case 'sm':
|
||||||
|
default:
|
||||||
|
return [{gap: 3}]
|
||||||
|
}
|
||||||
|
}, [size])
|
||||||
|
return (
|
||||||
|
<View style={[a.flex_row, a.flex_wrap, a.gap_xs, styles, style]}>
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LabelProps = {
|
||||||
|
cause: ModerationCause
|
||||||
|
disableDetailsDialog?: boolean
|
||||||
|
noBg?: boolean
|
||||||
|
} & CommonProps
|
||||||
|
|
||||||
|
export function Label({
|
||||||
|
cause,
|
||||||
|
size = 'sm',
|
||||||
|
disableDetailsDialog,
|
||||||
|
noBg,
|
||||||
|
}: LabelProps) {
|
||||||
|
const t = useTheme()
|
||||||
|
const control = useModerationDetailsDialogControl()
|
||||||
|
const desc = useModerationCauseDescription(cause)
|
||||||
|
const isLabeler = Boolean(desc.sourceType && desc.sourceDid)
|
||||||
|
const isBlueskyLabel =
|
||||||
|
desc.sourceType === 'labeler' && desc.sourceDid === BSKY_LABELER_DID
|
||||||
|
|
||||||
|
const {outer, avi, text} = React.useMemo(() => {
|
||||||
|
switch (size) {
|
||||||
|
case 'lg': {
|
||||||
|
return {
|
||||||
|
outer: [
|
||||||
|
t.atoms.bg_contrast_25,
|
||||||
|
{
|
||||||
|
gap: 5,
|
||||||
|
paddingHorizontal: 5,
|
||||||
|
paddingVertical: 5,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
avi: 16,
|
||||||
|
text: [a.text_sm],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case 'sm':
|
||||||
|
default: {
|
||||||
|
return {
|
||||||
|
outer: [
|
||||||
|
!noBg && t.atoms.bg_contrast_25,
|
||||||
|
{
|
||||||
|
gap: 3,
|
||||||
|
paddingHorizontal: 3,
|
||||||
|
paddingVertical: 3,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
avi: 12,
|
||||||
|
text: [a.text_xs],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [t, size, noBg])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
disabled={disableDetailsDialog}
|
||||||
|
label={desc.name}
|
||||||
|
onPress={e => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
control.open()
|
||||||
|
}}>
|
||||||
|
{({hovered, pressed}) => (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.flex_row,
|
||||||
|
a.align_center,
|
||||||
|
a.rounded_full,
|
||||||
|
outer,
|
||||||
|
(hovered || pressed) && t.atoms.bg_contrast_50,
|
||||||
|
]}>
|
||||||
|
{isBlueskyLabel || !isLabeler ? (
|
||||||
|
<desc.icon
|
||||||
|
width={avi}
|
||||||
|
fill={t.atoms.text_contrast_medium.color}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<UserAvatar avatar={desc.sourceAvi} size={avi} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
text,
|
||||||
|
a.font_semibold,
|
||||||
|
a.leading_tight,
|
||||||
|
t.atoms.text_contrast_medium,
|
||||||
|
{paddingRight: 3},
|
||||||
|
]}>
|
||||||
|
{desc.name}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{!disableDetailsDialog && (
|
||||||
|
<ModerationDetailsDialog control={control} modcause={cause} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FollowsYou({size = 'sm'}: CommonProps) {
|
||||||
|
const t = useTheme()
|
||||||
|
|
||||||
|
const variantStyles = React.useMemo(() => {
|
||||||
|
switch (size) {
|
||||||
|
case 'sm':
|
||||||
|
case 'lg':
|
||||||
|
default:
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
paddingHorizontal: 6,
|
||||||
|
paddingVertical: 3,
|
||||||
|
borderRadius: 4,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}, [size])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[variantStyles, a.justify_center, t.atoms.bg_contrast_25]}>
|
||||||
|
<Text style={[a.text_xs, a.leading_tight]}>
|
||||||
|
<Trans>Follows You</Trans>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,359 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import {GestureResponderEvent, View} from 'react-native'
|
||||||
|
import {
|
||||||
|
AppBskyActorDefs,
|
||||||
|
moderateProfile,
|
||||||
|
ModerationOpts,
|
||||||
|
RichText as RichTextApi,
|
||||||
|
} from '@atproto/api'
|
||||||
|
import {msg} from '@lingui/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
|
import {LogEvents} from '#/lib/statsig/statsig'
|
||||||
|
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||||
|
import {useProfileFollowMutationQueue} from '#/state/queries/profile'
|
||||||
|
import {sanitizeHandle} from 'lib/strings/handles'
|
||||||
|
import {useProfileShadow} from 'state/cache/profile-shadow'
|
||||||
|
import {useSession} from 'state/session'
|
||||||
|
import * as Toast from '#/view/com/util/Toast'
|
||||||
|
import {ProfileCardPills} from 'view/com/profile/ProfileCard'
|
||||||
|
import {UserAvatar} from 'view/com/util/UserAvatar'
|
||||||
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
|
import {Button, ButtonIcon, ButtonProps, ButtonText} from '#/components/Button'
|
||||||
|
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
|
||||||
|
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
|
||||||
|
import {Link as InternalLink, LinkProps} from '#/components/Link'
|
||||||
|
import {RichText} from '#/components/RichText'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
|
export function Default({
|
||||||
|
profile,
|
||||||
|
moderationOpts,
|
||||||
|
logContext = 'ProfileCard',
|
||||||
|
}: {
|
||||||
|
profile: AppBskyActorDefs.ProfileViewDetailed
|
||||||
|
moderationOpts: ModerationOpts
|
||||||
|
logContext?: 'ProfileCard' | 'StarterPackProfilesList'
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Link did={profile.did}>
|
||||||
|
<Card
|
||||||
|
profile={profile}
|
||||||
|
moderationOpts={moderationOpts}
|
||||||
|
logContext={logContext}
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Card({
|
||||||
|
profile,
|
||||||
|
moderationOpts,
|
||||||
|
logContext = 'ProfileCard',
|
||||||
|
}: {
|
||||||
|
profile: AppBskyActorDefs.ProfileViewDetailed
|
||||||
|
moderationOpts: ModerationOpts
|
||||||
|
logContext?: 'ProfileCard' | 'StarterPackProfilesList'
|
||||||
|
}) {
|
||||||
|
const moderation = moderateProfile(profile, moderationOpts)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Outer>
|
||||||
|
<Header>
|
||||||
|
<Avatar profile={profile} moderationOpts={moderationOpts} />
|
||||||
|
<NameAndHandle profile={profile} moderationOpts={moderationOpts} />
|
||||||
|
<FollowButton profile={profile} logContext={logContext} />
|
||||||
|
</Header>
|
||||||
|
|
||||||
|
<ProfileCardPills
|
||||||
|
followedBy={Boolean(profile.viewer?.followedBy)}
|
||||||
|
moderation={moderation}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Description profile={profile} />
|
||||||
|
</Outer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Outer({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactElement | React.ReactElement[]
|
||||||
|
}) {
|
||||||
|
return <View style={[a.w_full, a.flex_1, a.gap_xs]}>{children}</View>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Header({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactElement | React.ReactElement[]
|
||||||
|
}) {
|
||||||
|
return <View style={[a.flex_row, a.align_center, a.gap_sm]}>{children}</View>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Link({
|
||||||
|
did,
|
||||||
|
children,
|
||||||
|
style,
|
||||||
|
...rest
|
||||||
|
}: {did: string} & Omit<LinkProps, 'to'>) {
|
||||||
|
return (
|
||||||
|
<InternalLink
|
||||||
|
to={{
|
||||||
|
screen: 'Profile',
|
||||||
|
params: {name: did},
|
||||||
|
}}
|
||||||
|
style={[a.flex_col, style]}
|
||||||
|
{...rest}>
|
||||||
|
{children}
|
||||||
|
</InternalLink>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Avatar({
|
||||||
|
profile,
|
||||||
|
moderationOpts,
|
||||||
|
}: {
|
||||||
|
profile: AppBskyActorDefs.ProfileViewDetailed
|
||||||
|
moderationOpts: ModerationOpts
|
||||||
|
}) {
|
||||||
|
const moderation = moderateProfile(profile, moderationOpts)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<UserAvatar
|
||||||
|
size={42}
|
||||||
|
avatar={profile.avatar}
|
||||||
|
type={profile.associated?.labeler ? 'labeler' : 'user'}
|
||||||
|
moderation={moderation.ui('avatar')}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AvatarPlaceholder() {
|
||||||
|
const t = useTheme()
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.rounded_full,
|
||||||
|
t.atoms.bg_contrast_50,
|
||||||
|
{
|
||||||
|
width: 42,
|
||||||
|
height: 42,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NameAndHandle({
|
||||||
|
profile,
|
||||||
|
moderationOpts,
|
||||||
|
}: {
|
||||||
|
profile: AppBskyActorDefs.ProfileViewDetailed
|
||||||
|
moderationOpts: ModerationOpts
|
||||||
|
}) {
|
||||||
|
const t = useTheme()
|
||||||
|
const moderation = moderateProfile(profile, moderationOpts)
|
||||||
|
const name = sanitizeDisplayName(
|
||||||
|
profile.displayName || sanitizeHandle(profile.handle),
|
||||||
|
moderation.ui('displayName'),
|
||||||
|
)
|
||||||
|
const handle = sanitizeHandle(profile.handle, '@')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[a.flex_1]}>
|
||||||
|
<Text style={[a.text_md, a.font_bold, a.leading_snug]} numberOfLines={1}>
|
||||||
|
{name}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={[a.leading_snug, t.atoms.text_contrast_medium]}
|
||||||
|
numberOfLines={1}>
|
||||||
|
{handle}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NameAndHandlePlaceholder() {
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.rounded_xs,
|
||||||
|
t.atoms.bg_contrast_50,
|
||||||
|
{
|
||||||
|
width: '40%',
|
||||||
|
height: 10,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Description({
|
||||||
|
profile: profileUnshadowed,
|
||||||
|
}: {
|
||||||
|
profile: AppBskyActorDefs.ProfileViewDetailed
|
||||||
|
}) {
|
||||||
|
const profile = useProfileShadow(profileUnshadowed)
|
||||||
|
const {description} = profile
|
||||||
|
const rt = React.useMemo(() => {
|
||||||
|
if (!description) return
|
||||||
|
const rt = new RichTextApi({text: description || ''})
|
||||||
|
rt.detectFacetsWithoutResolution()
|
||||||
|
return rt
|
||||||
|
}, [description])
|
||||||
|
if (!rt) return null
|
||||||
|
if (
|
||||||
|
profile.viewer &&
|
||||||
|
(profile.viewer.blockedBy ||
|
||||||
|
profile.viewer.blocking ||
|
||||||
|
profile.viewer.blockingByList)
|
||||||
|
)
|
||||||
|
return null
|
||||||
|
return (
|
||||||
|
<View style={[a.pt_xs]}>
|
||||||
|
<RichText
|
||||||
|
value={rt}
|
||||||
|
style={[a.leading_snug]}
|
||||||
|
numberOfLines={3}
|
||||||
|
disableLinks
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DescriptionPlaceholder() {
|
||||||
|
const t = useTheme()
|
||||||
|
return (
|
||||||
|
<View style={[a.gap_xs]}>
|
||||||
|
<View
|
||||||
|
style={[a.rounded_xs, a.w_full, t.atoms.bg_contrast_50, {height: 12}]}
|
||||||
|
/>
|
||||||
|
<View
|
||||||
|
style={[a.rounded_xs, a.w_full, t.atoms.bg_contrast_50, {height: 12}]}
|
||||||
|
/>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.rounded_xs,
|
||||||
|
a.w_full,
|
||||||
|
t.atoms.bg_contrast_50,
|
||||||
|
{height: 12, width: 100},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FollowButtonProps = {
|
||||||
|
profile: AppBskyActorDefs.ProfileViewBasic
|
||||||
|
logContext: LogEvents['profile:follow']['logContext'] &
|
||||||
|
LogEvents['profile:unfollow']['logContext']
|
||||||
|
} & Partial<ButtonProps>
|
||||||
|
|
||||||
|
export function FollowButton(props: FollowButtonProps) {
|
||||||
|
const {currentAccount, hasSession} = useSession()
|
||||||
|
const isMe = props.profile.did === currentAccount?.did
|
||||||
|
return hasSession && !isMe ? <FollowButtonInner {...props} /> : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FollowButtonInner({
|
||||||
|
profile: profileUnshadowed,
|
||||||
|
logContext,
|
||||||
|
...rest
|
||||||
|
}: FollowButtonProps) {
|
||||||
|
const {_} = useLingui()
|
||||||
|
const profile = useProfileShadow(profileUnshadowed)
|
||||||
|
const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(
|
||||||
|
profile,
|
||||||
|
logContext,
|
||||||
|
)
|
||||||
|
const isRound = Boolean(rest.shape && rest.shape === 'round')
|
||||||
|
|
||||||
|
const onPressFollow = async (e: GestureResponderEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
try {
|
||||||
|
await queueFollow()
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.name !== 'AbortError') {
|
||||||
|
Toast.show(_(msg`An issue occurred, please try again.`))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onPressUnfollow = async (e: GestureResponderEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
try {
|
||||||
|
await queueUnfollow()
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.name !== 'AbortError') {
|
||||||
|
Toast.show(_(msg`An issue occurred, please try again.`))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const unfollowLabel = _(
|
||||||
|
msg({
|
||||||
|
message: 'Following',
|
||||||
|
comment: 'User is following this account, click to unfollow',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const followLabel = _(
|
||||||
|
msg({
|
||||||
|
message: 'Follow',
|
||||||
|
comment: 'User is not following this account, click to follow',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!profile.viewer) return null
|
||||||
|
if (
|
||||||
|
profile.viewer.blockedBy ||
|
||||||
|
profile.viewer.blocking ||
|
||||||
|
profile.viewer.blockingByList
|
||||||
|
)
|
||||||
|
return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
{profile.viewer.following ? (
|
||||||
|
<Button
|
||||||
|
label={unfollowLabel}
|
||||||
|
size="small"
|
||||||
|
variant="solid"
|
||||||
|
color="secondary"
|
||||||
|
{...rest}
|
||||||
|
onPress={onPressUnfollow}>
|
||||||
|
<ButtonIcon icon={Check} position={isRound ? undefined : 'left'} />
|
||||||
|
{isRound ? null : <ButtonText>{unfollowLabel}</ButtonText>}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
label={followLabel}
|
||||||
|
size="small"
|
||||||
|
variant="solid"
|
||||||
|
color="primary"
|
||||||
|
{...rest}
|
||||||
|
onPress={onPressFollow}>
|
||||||
|
<ButtonIcon icon={Plus} position={isRound ? undefined : 'left'} />
|
||||||
|
{isRound ? null : <ButtonText>{followLabel}</ButtonText>}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user