Merge branch 'bluesky-social:main' into zh
commit
21a7d47cdc
|
@ -4,6 +4,7 @@ on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
- divy/bskylink
|
||||||
|
|
||||||
env:
|
env:
|
||||||
REGISTRY: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_REGISTRY }}
|
REGISTRY: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_REGISTRY }}
|
||||||
|
|
|
@ -0,0 +1,55 @@
|
||||||
|
name: build-and-push-link-aws
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- divy/bskylink
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_REGISTRY }}
|
||||||
|
USERNAME: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_USERNAME }}
|
||||||
|
PASSWORD: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_PASSWORD }}
|
||||||
|
IMAGE_NAME: bskylink
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
link-container-aws:
|
||||||
|
if: github.repository == 'bluesky-social/social-app'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
id-token: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Setup Docker buildx
|
||||||
|
uses: docker/setup-buildx-action@v1
|
||||||
|
|
||||||
|
- name: Log into registry ${{ env.REGISTRY }}
|
||||||
|
uses: docker/login-action@v2
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ env.USERNAME}}
|
||||||
|
password: ${{ env.PASSWORD }}
|
||||||
|
|
||||||
|
- name: Extract Docker metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v4
|
||||||
|
with:
|
||||||
|
images: |
|
||||||
|
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
|
tags: |
|
||||||
|
type=sha,enable=true,priority=100,prefix=,suffix=,format=long
|
||||||
|
|
||||||
|
- name: Build and push Docker image
|
||||||
|
id: build-and-push
|
||||||
|
uses: docker/build-push-action@v4
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
|
file: ./Dockerfile.bskylink
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
|
@ -0,0 +1,55 @@
|
||||||
|
name: build-and-push-ogcard-aws
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- divy/bskycard
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_REGISTRY }}
|
||||||
|
USERNAME: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_USERNAME }}
|
||||||
|
PASSWORD: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_PASSWORD }}
|
||||||
|
IMAGE_NAME: bskyogcard
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
ogcard-container-aws:
|
||||||
|
if: github.repository == 'bluesky-social/social-app'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
id-token: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Setup Docker buildx
|
||||||
|
uses: docker/setup-buildx-action@v1
|
||||||
|
|
||||||
|
- name: Log into registry ${{ env.REGISTRY }}
|
||||||
|
uses: docker/login-action@v2
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ env.USERNAME}}
|
||||||
|
password: ${{ env.PASSWORD }}
|
||||||
|
|
||||||
|
- name: Extract Docker metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v4
|
||||||
|
with:
|
||||||
|
images: |
|
||||||
|
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
|
tags: |
|
||||||
|
type=sha,enable=true,priority=100,prefix=,suffix=,format=long
|
||||||
|
|
||||||
|
- name: Build and push Docker image
|
||||||
|
id: build-and-push
|
||||||
|
uses: docker/build-push-action@v4
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
|
file: ./Dockerfile.bskyogcard
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
|
@ -0,0 +1,41 @@
|
||||||
|
FROM node:20.11-alpine3.18 as build
|
||||||
|
|
||||||
|
# Move files into the image and install
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY ./bskylink/package.json ./
|
||||||
|
COPY ./bskylink/yarn.lock ./
|
||||||
|
RUN yarn install --frozen-lockfile
|
||||||
|
|
||||||
|
COPY ./bskylink ./
|
||||||
|
|
||||||
|
# build then prune dev deps
|
||||||
|
RUN yarn build
|
||||||
|
RUN yarn install --production --ignore-scripts --prefer-offline
|
||||||
|
|
||||||
|
# Uses assets from build stage to reduce build size
|
||||||
|
FROM node:20.11-alpine3.18
|
||||||
|
|
||||||
|
RUN apk add --update dumb-init
|
||||||
|
|
||||||
|
# Avoid zombie processes, handle signal forwarding
|
||||||
|
ENTRYPOINT ["dumb-init", "--"]
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=build /app /app
|
||||||
|
RUN mkdir /app/data && chown node /app/data
|
||||||
|
|
||||||
|
VOLUME /app/data
|
||||||
|
EXPOSE 3000
|
||||||
|
ENV LINK_PORT=3000
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
# potential perf issues w/ io_uring on this version of node
|
||||||
|
ENV UV_USE_IO_URING=0
|
||||||
|
|
||||||
|
# https://github.com/nodejs/docker-node/blob/master/docs/BestPractices.md#non-root-user
|
||||||
|
USER node
|
||||||
|
CMD ["node", "--heapsnapshot-signal=SIGUSR2", "--enable-source-maps", "dist/bin.js"]
|
||||||
|
|
||||||
|
LABEL org.opencontainers.image.source=https://github.com/bluesky-social/social-app
|
||||||
|
LABEL org.opencontainers.image.description="Bsky Link Service"
|
||||||
|
LABEL org.opencontainers.image.licenses=UNLICENSED
|
|
@ -0,0 +1,41 @@
|
||||||
|
FROM node:20.11-alpine3.18 as build
|
||||||
|
|
||||||
|
# Move files into the image and install
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY ./bskyogcard/package.json ./
|
||||||
|
COPY ./bskyogcard/yarn.lock ./
|
||||||
|
RUN yarn install --frozen-lockfile
|
||||||
|
|
||||||
|
COPY ./bskyogcard ./
|
||||||
|
|
||||||
|
# build then prune dev deps
|
||||||
|
RUN yarn build
|
||||||
|
RUN yarn install --production --ignore-scripts --prefer-offline
|
||||||
|
|
||||||
|
# Uses assets from build stage to reduce build size
|
||||||
|
FROM node:20.11-alpine3.18
|
||||||
|
|
||||||
|
RUN apk add --update dumb-init
|
||||||
|
|
||||||
|
# Avoid zombie processes, handle signal forwarding
|
||||||
|
ENTRYPOINT ["dumb-init", "--"]
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=build /app /app
|
||||||
|
RUN mkdir /app/data && chown node /app/data
|
||||||
|
|
||||||
|
VOLUME /app/data
|
||||||
|
EXPOSE 3000
|
||||||
|
ENV CARD_PORT=3000
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
# potential perf issues w/ io_uring on this version of node
|
||||||
|
ENV UV_USE_IO_URING=0
|
||||||
|
|
||||||
|
# https://github.com/nodejs/docker-node/blob/master/docs/BestPractices.md#non-root-user
|
||||||
|
USER node
|
||||||
|
CMD ["node", "--heapsnapshot-signal=SIGUSR2", "--enable-source-maps", "dist/bin.js"]
|
||||||
|
|
||||||
|
LABEL org.opencontainers.image.source=https://github.com/bluesky-social/social-app
|
||||||
|
LABEL org.opencontainers.image.description="Bsky Card Service"
|
||||||
|
LABEL org.opencontainers.image.licenses=UNLICENSED
|
|
@ -0,0 +1,26 @@
|
||||||
|
{
|
||||||
|
"name": "bskylink",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"main": "index.ts",
|
||||||
|
"scripts": {
|
||||||
|
"test": "./tests/infra/with-test-db.sh node --loader ts-node/esm --test ./tests/index.ts",
|
||||||
|
"build": "tsc"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@atproto/common": "^0.4.0",
|
||||||
|
"body-parser": "^1.20.2",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"express": "^4.19.2",
|
||||||
|
"http-terminator": "^3.2.0",
|
||||||
|
"kysely": "^0.27.3",
|
||||||
|
"pg": "^8.12.0",
|
||||||
|
"pino": "^9.2.0",
|
||||||
|
"uint8arrays": "^5.1.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/cors": "^2.8.17",
|
||||||
|
"@types/pg": "^8.11.6",
|
||||||
|
"typescript": "^5.4.5"
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,24 @@
|
||||||
|
import {Database, envToCfg, httpLogger, LinkService, readEnv} from './index.js'
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const env = readEnv()
|
||||||
|
const cfg = envToCfg(env)
|
||||||
|
if (cfg.db.migrationUrl) {
|
||||||
|
const migrateDb = Database.postgres({
|
||||||
|
url: cfg.db.migrationUrl,
|
||||||
|
schema: cfg.db.schema,
|
||||||
|
})
|
||||||
|
await migrateDb.migrateToLatestOrThrow()
|
||||||
|
await migrateDb.close()
|
||||||
|
}
|
||||||
|
const link = await LinkService.create(cfg)
|
||||||
|
await link.start()
|
||||||
|
httpLogger.info('link service is running')
|
||||||
|
process.on('SIGTERM', async () => {
|
||||||
|
httpLogger.info('link service is stopping')
|
||||||
|
await link.destroy()
|
||||||
|
httpLogger.info('link service is stopped')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
|
@ -0,0 +1,82 @@
|
||||||
|
import {envInt, envList, envStr} from '@atproto/common'
|
||||||
|
|
||||||
|
export type Config = {
|
||||||
|
service: ServiceConfig
|
||||||
|
db: DbConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ServiceConfig = {
|
||||||
|
port: number
|
||||||
|
version?: string
|
||||||
|
hostnames: string[]
|
||||||
|
appHostname: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DbConfig = {
|
||||||
|
url: string
|
||||||
|
migrationUrl?: string
|
||||||
|
pool: DbPoolConfig
|
||||||
|
schema?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DbPoolConfig = {
|
||||||
|
size: number
|
||||||
|
maxUses: number
|
||||||
|
idleTimeoutMs: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Environment = {
|
||||||
|
port?: number
|
||||||
|
version?: string
|
||||||
|
hostnames: string[]
|
||||||
|
appHostname?: string
|
||||||
|
dbPostgresUrl?: string
|
||||||
|
dbPostgresMigrationUrl?: string
|
||||||
|
dbPostgresSchema?: string
|
||||||
|
dbPostgresPoolSize?: number
|
||||||
|
dbPostgresPoolMaxUses?: number
|
||||||
|
dbPostgresPoolIdleTimeoutMs?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const readEnv = (): Environment => {
|
||||||
|
return {
|
||||||
|
port: envInt('LINK_PORT'),
|
||||||
|
version: envStr('LINK_VERSION'),
|
||||||
|
hostnames: envList('LINK_HOSTNAMES'),
|
||||||
|
appHostname: envStr('LINK_APP_HOSTNAME'),
|
||||||
|
dbPostgresUrl: envStr('LINK_DB_POSTGRES_URL'),
|
||||||
|
dbPostgresMigrationUrl: envStr('LINK_DB_POSTGRES_MIGRATION_URL'),
|
||||||
|
dbPostgresSchema: envStr('LINK_DB_POSTGRES_SCHEMA'),
|
||||||
|
dbPostgresPoolSize: envInt('LINK_DB_POSTGRES_POOL_SIZE'),
|
||||||
|
dbPostgresPoolMaxUses: envInt('LINK_DB_POSTGRES_POOL_MAX_USES'),
|
||||||
|
dbPostgresPoolIdleTimeoutMs: envInt(
|
||||||
|
'LINK_DB_POSTGRES_POOL_IDLE_TIMEOUT_MS',
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const envToCfg = (env: Environment): Config => {
|
||||||
|
const serviceCfg: ServiceConfig = {
|
||||||
|
port: env.port ?? 3000,
|
||||||
|
version: env.version,
|
||||||
|
hostnames: env.hostnames,
|
||||||
|
appHostname: env.appHostname || 'bsky.app',
|
||||||
|
}
|
||||||
|
if (!env.dbPostgresUrl) {
|
||||||
|
throw new Error('Must configure postgres url (LINK_DB_POSTGRES_URL)')
|
||||||
|
}
|
||||||
|
const dbCfg: DbConfig = {
|
||||||
|
url: env.dbPostgresUrl,
|
||||||
|
migrationUrl: env.dbPostgresMigrationUrl,
|
||||||
|
schema: env.dbPostgresSchema,
|
||||||
|
pool: {
|
||||||
|
idleTimeoutMs: env.dbPostgresPoolIdleTimeoutMs ?? 10000,
|
||||||
|
maxUses: env.dbPostgresPoolMaxUses ?? Infinity,
|
||||||
|
size: env.dbPostgresPoolSize ?? 10,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
service: serviceCfg,
|
||||||
|
db: dbCfg,
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,33 @@
|
||||||
|
import {Config} from './config.js'
|
||||||
|
import Database from './db/index.js'
|
||||||
|
|
||||||
|
export type AppContextOptions = {
|
||||||
|
cfg: Config
|
||||||
|
db: Database
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AppContext {
|
||||||
|
cfg: Config
|
||||||
|
db: Database
|
||||||
|
abortController = new AbortController()
|
||||||
|
|
||||||
|
constructor(private opts: AppContextOptions) {
|
||||||
|
this.cfg = this.opts.cfg
|
||||||
|
this.db = this.opts.db
|
||||||
|
}
|
||||||
|
|
||||||
|
static async fromConfig(cfg: Config, overrides?: Partial<AppContextOptions>) {
|
||||||
|
const db = Database.postgres({
|
||||||
|
url: cfg.db.url,
|
||||||
|
schema: cfg.db.schema,
|
||||||
|
poolSize: cfg.db.pool.size,
|
||||||
|
poolMaxUses: cfg.db.pool.maxUses,
|
||||||
|
poolIdleTimeoutMs: cfg.db.pool.idleTimeoutMs,
|
||||||
|
})
|
||||||
|
return new AppContext({
|
||||||
|
cfg,
|
||||||
|
db,
|
||||||
|
...overrides,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,174 @@
|
||||||
|
import assert from 'assert'
|
||||||
|
import {
|
||||||
|
Kysely,
|
||||||
|
KyselyPlugin,
|
||||||
|
Migrator,
|
||||||
|
PluginTransformQueryArgs,
|
||||||
|
PluginTransformResultArgs,
|
||||||
|
PostgresDialect,
|
||||||
|
QueryResult,
|
||||||
|
RootOperationNode,
|
||||||
|
UnknownRow,
|
||||||
|
} from 'kysely'
|
||||||
|
import {default as Pg} from 'pg'
|
||||||
|
|
||||||
|
import {dbLogger as log} from '../logger.js'
|
||||||
|
import {default as migrations} from './migrations/index.js'
|
||||||
|
import {DbMigrationProvider} from './migrations/provider.js'
|
||||||
|
import {DbSchema} from './schema.js'
|
||||||
|
|
||||||
|
export class Database {
|
||||||
|
migrator: Migrator
|
||||||
|
destroyed = false
|
||||||
|
|
||||||
|
constructor(public db: Kysely<DbSchema>, public cfg: PgConfig) {
|
||||||
|
this.migrator = new Migrator({
|
||||||
|
db,
|
||||||
|
migrationTableSchema: cfg.schema,
|
||||||
|
provider: new DbMigrationProvider(migrations),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
static postgres(opts: PgOptions): Database {
|
||||||
|
const {schema, url, txLockNonce} = opts
|
||||||
|
const pool =
|
||||||
|
opts.pool ??
|
||||||
|
new Pg.Pool({
|
||||||
|
connectionString: url,
|
||||||
|
max: opts.poolSize,
|
||||||
|
maxUses: opts.poolMaxUses,
|
||||||
|
idleTimeoutMillis: opts.poolIdleTimeoutMs,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Select count(*) and other pg bigints as js integer
|
||||||
|
Pg.types.setTypeParser(Pg.types.builtins.INT8, n => parseInt(n, 10))
|
||||||
|
|
||||||
|
// Setup schema usage, primarily for test parallelism (each test suite runs in its own pg schema)
|
||||||
|
if (schema && !/^[a-z_]+$/i.test(schema)) {
|
||||||
|
throw new Error(`Postgres schema must only contain [A-Za-z_]: ${schema}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
pool.on('error', onPoolError)
|
||||||
|
|
||||||
|
const db = new Kysely<DbSchema>({
|
||||||
|
dialect: new PostgresDialect({pool}),
|
||||||
|
})
|
||||||
|
|
||||||
|
return new Database(db, {
|
||||||
|
pool,
|
||||||
|
schema,
|
||||||
|
url,
|
||||||
|
txLockNonce,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async transaction<T>(fn: (db: Database) => Promise<T>): Promise<T> {
|
||||||
|
const leakyTxPlugin = new LeakyTxPlugin()
|
||||||
|
return this.db
|
||||||
|
.withPlugin(leakyTxPlugin)
|
||||||
|
.transaction()
|
||||||
|
.execute(txn => {
|
||||||
|
const dbTxn = new Database(txn, this.cfg)
|
||||||
|
return fn(dbTxn)
|
||||||
|
.catch(async err => {
|
||||||
|
leakyTxPlugin.endTx()
|
||||||
|
// ensure that all in-flight queries are flushed & the connection is open
|
||||||
|
await dbTxn.db.getExecutor().provideConnection(async () => {})
|
||||||
|
throw err
|
||||||
|
})
|
||||||
|
.finally(() => leakyTxPlugin.endTx())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
get schema(): string | undefined {
|
||||||
|
return this.cfg.schema
|
||||||
|
}
|
||||||
|
|
||||||
|
get isTransaction() {
|
||||||
|
return this.db.isTransaction
|
||||||
|
}
|
||||||
|
|
||||||
|
assertTransaction() {
|
||||||
|
assert(this.isTransaction, 'Transaction required')
|
||||||
|
}
|
||||||
|
|
||||||
|
assertNotTransaction() {
|
||||||
|
assert(!this.isTransaction, 'Cannot be in a transaction')
|
||||||
|
}
|
||||||
|
|
||||||
|
async close(): Promise<void> {
|
||||||
|
if (this.destroyed) return
|
||||||
|
await this.db.destroy()
|
||||||
|
this.destroyed = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async migrateToOrThrow(migration: string) {
|
||||||
|
if (this.schema) {
|
||||||
|
await this.db.schema.createSchema(this.schema).ifNotExists().execute()
|
||||||
|
}
|
||||||
|
const {error, results} = await this.migrator.migrateTo(migration)
|
||||||
|
if (error) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
if (!results) {
|
||||||
|
throw new Error('An unknown failure occurred while migrating')
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
async migrateToLatestOrThrow() {
|
||||||
|
if (this.schema) {
|
||||||
|
await this.db.schema.createSchema(this.schema).ifNotExists().execute()
|
||||||
|
}
|
||||||
|
const {error, results} = await this.migrator.migrateToLatest()
|
||||||
|
if (error) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
if (!results) {
|
||||||
|
throw new Error('An unknown failure occurred while migrating')
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Database
|
||||||
|
|
||||||
|
export type PgConfig = {
|
||||||
|
pool: Pg.Pool
|
||||||
|
url: string
|
||||||
|
schema?: string
|
||||||
|
txLockNonce?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type PgOptions = {
|
||||||
|
url: string
|
||||||
|
pool?: Pg.Pool
|
||||||
|
schema?: string
|
||||||
|
poolSize?: number
|
||||||
|
poolMaxUses?: number
|
||||||
|
poolIdleTimeoutMs?: number
|
||||||
|
txLockNonce?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
class LeakyTxPlugin implements KyselyPlugin {
|
||||||
|
private txOver = false
|
||||||
|
|
||||||
|
endTx() {
|
||||||
|
this.txOver = true
|
||||||
|
}
|
||||||
|
|
||||||
|
transformQuery(args: PluginTransformQueryArgs): RootOperationNode {
|
||||||
|
if (this.txOver) {
|
||||||
|
throw new Error('tx already failed')
|
||||||
|
}
|
||||||
|
return args.node
|
||||||
|
}
|
||||||
|
|
||||||
|
async transformResult(
|
||||||
|
args: PluginTransformResultArgs,
|
||||||
|
): Promise<QueryResult<UnknownRow>> {
|
||||||
|
return args.result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onPoolError = (err: Error) => log.error({err}, 'db pool error')
|
|
@ -0,0 +1,15 @@
|
||||||
|
import {Kysely} from 'kysely'
|
||||||
|
|
||||||
|
export async function up(db: Kysely<unknown>): Promise<void> {
|
||||||
|
await db.schema
|
||||||
|
.createTable('link')
|
||||||
|
.addColumn('id', 'varchar', col => col.primaryKey())
|
||||||
|
.addColumn('type', 'smallint', col => col.notNull()) // integer enum: 1->starterpack
|
||||||
|
.addColumn('path', 'varchar', col => col.notNull())
|
||||||
|
.addUniqueConstraint('link_path_unique', ['path'])
|
||||||
|
.execute()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(db: Kysely<unknown>): Promise<void> {
|
||||||
|
await db.schema.dropTable('link').execute()
|
||||||
|
}
|
|
@ -0,0 +1,5 @@
|
||||||
|
import * as init from './001-init.js'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
'001': init,
|
||||||
|
}
|
|
@ -0,0 +1,8 @@
|
||||||
|
import {Migration, MigrationProvider} from 'kysely'
|
||||||
|
|
||||||
|
export class DbMigrationProvider implements MigrationProvider {
|
||||||
|
constructor(private migrations: Record<string, Migration>) {}
|
||||||
|
async getMigrations(): Promise<Record<string, Migration>> {
|
||||||
|
return this.migrations
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,17 @@
|
||||||
|
import {Selectable} from 'kysely'
|
||||||
|
|
||||||
|
export type DbSchema = {
|
||||||
|
link: Link
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Link {
|
||||||
|
id: string
|
||||||
|
type: LinkType
|
||||||
|
path: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum LinkType {
|
||||||
|
StarterPack = 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LinkEntry = Selectable<Link>
|
|
@ -0,0 +1,45 @@
|
||||||
|
import events from 'node:events'
|
||||||
|
import http from 'node:http'
|
||||||
|
|
||||||
|
import cors from 'cors'
|
||||||
|
import express from 'express'
|
||||||
|
import {createHttpTerminator, HttpTerminator} from 'http-terminator'
|
||||||
|
|
||||||
|
import {Config} from './config.js'
|
||||||
|
import {AppContext} from './context.js'
|
||||||
|
import {default as routes, errorHandler} from './routes/index.js'
|
||||||
|
|
||||||
|
export * from './config.js'
|
||||||
|
export * from './db/index.js'
|
||||||
|
export * from './logger.js'
|
||||||
|
|
||||||
|
export class LinkService {
|
||||||
|
public server?: http.Server
|
||||||
|
private terminator?: HttpTerminator
|
||||||
|
|
||||||
|
constructor(public app: express.Application, public ctx: AppContext) {}
|
||||||
|
|
||||||
|
static async create(cfg: Config): Promise<LinkService> {
|
||||||
|
let app = express()
|
||||||
|
app.use(cors())
|
||||||
|
|
||||||
|
const ctx = await AppContext.fromConfig(cfg)
|
||||||
|
app = routes(ctx, app)
|
||||||
|
app.use(errorHandler)
|
||||||
|
|
||||||
|
return new LinkService(app, ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
async start() {
|
||||||
|
this.server = this.app.listen(this.ctx.cfg.service.port)
|
||||||
|
this.server.keepAliveTimeout = 90000
|
||||||
|
this.terminator = createHttpTerminator({server: this.server})
|
||||||
|
await events.once(this.server, 'listening')
|
||||||
|
}
|
||||||
|
|
||||||
|
async destroy() {
|
||||||
|
this.ctx.abortController.abort()
|
||||||
|
await this.terminator?.terminate()
|
||||||
|
await this.ctx.db.close()
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,4 @@
|
||||||
|
import {subsystemLogger} from '@atproto/common'
|
||||||
|
|
||||||
|
export const httpLogger = subsystemLogger('bskylink')
|
||||||
|
export const dbLogger = subsystemLogger('bskylink:db')
|
|
@ -0,0 +1,111 @@
|
||||||
|
import assert from 'node:assert'
|
||||||
|
|
||||||
|
import bodyParser from 'body-parser'
|
||||||
|
import {Express, Request} from 'express'
|
||||||
|
|
||||||
|
import {AppContext} from '../context.js'
|
||||||
|
import {LinkType} from '../db/schema.js'
|
||||||
|
import {randomId} from '../util.js'
|
||||||
|
import {handler} from './util.js'
|
||||||
|
|
||||||
|
export default function (ctx: AppContext, app: Express) {
|
||||||
|
return app.post(
|
||||||
|
'/link',
|
||||||
|
bodyParser.json(),
|
||||||
|
handler(async (req, res) => {
|
||||||
|
let path: string
|
||||||
|
if (typeof req.body?.path === 'string') {
|
||||||
|
path = req.body.path
|
||||||
|
} else {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'InvalidPath',
|
||||||
|
message: '"path" parameter is missing or not a string',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (!path.startsWith('/')) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'InvalidPath',
|
||||||
|
message:
|
||||||
|
'"path" parameter must be formatted as a path, starting with a "/"',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const parts = getPathParts(path)
|
||||||
|
if (parts.length === 3 && parts[0] === 'start') {
|
||||||
|
// link pattern: /start/{did}/{rkey}
|
||||||
|
if (!parts[1].startsWith('did:')) {
|
||||||
|
// enforce strong links
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'InvalidPath',
|
||||||
|
message:
|
||||||
|
'"path" parameter for starter pack must contain the actor\'s DID',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const id = await ensureLink(ctx, LinkType.StarterPack, parts)
|
||||||
|
return res.json({url: getUrl(ctx, req, id)})
|
||||||
|
}
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'InvalidPath',
|
||||||
|
message: '"path" parameter does not have a known format',
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ensureLink = async (ctx: AppContext, type: LinkType, parts: string[]) => {
|
||||||
|
const normalizedPath = normalizedPathFromParts(parts)
|
||||||
|
const created = await ctx.db.db
|
||||||
|
.insertInto('link')
|
||||||
|
.values({
|
||||||
|
id: randomId(),
|
||||||
|
type,
|
||||||
|
path: normalizedPath,
|
||||||
|
})
|
||||||
|
.onConflict(oc => oc.column('path').doNothing())
|
||||||
|
.returningAll()
|
||||||
|
.executeTakeFirst()
|
||||||
|
if (created) {
|
||||||
|
return created.id
|
||||||
|
}
|
||||||
|
const found = await ctx.db.db
|
||||||
|
.selectFrom('link')
|
||||||
|
.selectAll()
|
||||||
|
.where('path', '=', normalizedPath)
|
||||||
|
.executeTakeFirstOrThrow()
|
||||||
|
return found.id
|
||||||
|
}
|
||||||
|
|
||||||
|
const getUrl = (ctx: AppContext, req: Request, id: string) => {
|
||||||
|
if (!ctx.cfg.service.hostnames.length) {
|
||||||
|
assert(req.headers.host, 'request must be made with host header')
|
||||||
|
const baseUrl =
|
||||||
|
req.protocol === 'http' && req.headers.host.startsWith('localhost:')
|
||||||
|
? `http://${req.headers.host}`
|
||||||
|
: `https://${req.headers.host}`
|
||||||
|
return `${baseUrl}/${id}`
|
||||||
|
}
|
||||||
|
const baseUrl = ctx.cfg.service.hostnames.includes(req.headers.host)
|
||||||
|
? `https://${req.headers.host}`
|
||||||
|
: `https://${ctx.cfg.service.hostnames[0]}`
|
||||||
|
return `${baseUrl}/${id}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedPathFromParts = (parts: string[]): string => {
|
||||||
|
return (
|
||||||
|
'/' +
|
||||||
|
parts
|
||||||
|
.map(encodeURIComponent)
|
||||||
|
.map(part => part.replaceAll('%3A', ':')) // preserve colons
|
||||||
|
.join('/')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getPathParts = (path: string): string[] => {
|
||||||
|
if (path === '/') return []
|
||||||
|
if (path.endsWith('/')) {
|
||||||
|
path = path.slice(0, -1) // ignore trailing slash
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
.slice(1) // remove leading slash
|
||||||
|
.split('/')
|
||||||
|
.map(decodeURIComponent)
|
||||||
|
}
|
|
@ -0,0 +1,20 @@
|
||||||
|
import {Express} from 'express'
|
||||||
|
import {sql} from 'kysely'
|
||||||
|
|
||||||
|
import {AppContext} from '../context.js'
|
||||||
|
import {handler} from './util.js'
|
||||||
|
|
||||||
|
export default function (ctx: AppContext, app: Express) {
|
||||||
|
return app.get(
|
||||||
|
'/_health',
|
||||||
|
handler(async (_req, res) => {
|
||||||
|
const {version} = ctx.cfg.service
|
||||||
|
try {
|
||||||
|
await sql`select 1`.execute(ctx.db.db)
|
||||||
|
return res.send({version})
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(503).send({version, error: 'Service Unavailable'})
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,17 @@
|
||||||
|
import {Express} from 'express'
|
||||||
|
|
||||||
|
import {AppContext} from '../context.js'
|
||||||
|
import {default as create} from './create.js'
|
||||||
|
import {default as health} from './health.js'
|
||||||
|
import {default as redirect} from './redirect.js'
|
||||||
|
import {default as siteAssociation} from './siteAssociation.js'
|
||||||
|
|
||||||
|
export * from './util.js'
|
||||||
|
|
||||||
|
export default function (ctx: AppContext, app: Express) {
|
||||||
|
app = health(ctx, app) // GET /_health
|
||||||
|
app = siteAssociation(ctx, app) // GET /.well-known/apple-app-site-association
|
||||||
|
app = create(ctx, app) // POST /link
|
||||||
|
app = redirect(ctx, app) // GET /:linkId (should go last due to permissive matching)
|
||||||
|
return app
|
||||||
|
}
|
|
@ -0,0 +1,40 @@
|
||||||
|
import assert from 'node:assert'
|
||||||
|
|
||||||
|
import {DAY, SECOND} from '@atproto/common'
|
||||||
|
import {Express} from 'express'
|
||||||
|
|
||||||
|
import {AppContext} from '../context.js'
|
||||||
|
import {handler} from './util.js'
|
||||||
|
|
||||||
|
export default function (ctx: AppContext, app: Express) {
|
||||||
|
return app.get(
|
||||||
|
'/:linkId',
|
||||||
|
handler(async (req, res) => {
|
||||||
|
const linkId = req.params.linkId
|
||||||
|
assert(
|
||||||
|
typeof linkId === 'string',
|
||||||
|
'express guarantees id parameter is a string',
|
||||||
|
)
|
||||||
|
const found = await ctx.db.db
|
||||||
|
.selectFrom('link')
|
||||||
|
.selectAll()
|
||||||
|
.where('id', '=', linkId)
|
||||||
|
.executeTakeFirst()
|
||||||
|
if (!found) {
|
||||||
|
// potentially broken or mistyped link— send user to the app
|
||||||
|
res.setHeader('Location', `https://${ctx.cfg.service.appHostname}`)
|
||||||
|
res.setHeader('Cache-Control', 'no-store')
|
||||||
|
return res.status(302).end()
|
||||||
|
}
|
||||||
|
// build url from original url in order to preserve query params
|
||||||
|
const url = new URL(
|
||||||
|
req.originalUrl,
|
||||||
|
`https://${ctx.cfg.service.appHostname}`,
|
||||||
|
)
|
||||||
|
url.pathname = found.path
|
||||||
|
res.setHeader('Location', url.href)
|
||||||
|
res.setHeader('Cache-Control', `max-age=${(7 * DAY) / SECOND}`)
|
||||||
|
return res.status(301).end()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,13 @@
|
||||||
|
import {Express} from 'express'
|
||||||
|
|
||||||
|
import {AppContext} from '../context.js'
|
||||||
|
|
||||||
|
export default function (ctx: AppContext, app: Express) {
|
||||||
|
return app.get('/.well-known/apple-app-site-association', (req, res) => {
|
||||||
|
res.json({
|
||||||
|
appclips: {
|
||||||
|
apps: ['B3LX46C5HS.xyz.blueskyweb.app.AppClip'],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
|
@ -0,0 +1,23 @@
|
||||||
|
import {ErrorRequestHandler, Request, RequestHandler, Response} from 'express'
|
||||||
|
|
||||||
|
import {httpLogger} from '../logger.js'
|
||||||
|
|
||||||
|
export type Handler = (req: Request, res: Response) => Awaited<void>
|
||||||
|
|
||||||
|
export const handler = (runHandler: Handler): RequestHandler => {
|
||||||
|
return async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
await runHandler(req, res)
|
||||||
|
} catch (err) {
|
||||||
|
next(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const errorHandler: ErrorRequestHandler = (err, _req, res, next) => {
|
||||||
|
httpLogger.error({err}, 'request error')
|
||||||
|
if (res.headersSent) {
|
||||||
|
return next(err)
|
||||||
|
}
|
||||||
|
return res.status(500).end('server error')
|
||||||
|
}
|
|
@ -0,0 +1,8 @@
|
||||||
|
import {randomBytes} from 'node:crypto'
|
||||||
|
|
||||||
|
import {toString} from 'uint8arrays'
|
||||||
|
|
||||||
|
// 40bit random id of 5-7 characters
|
||||||
|
export const randomId = () => {
|
||||||
|
return toString(randomBytes(5), 'base58btc')
|
||||||
|
}
|
|
@ -0,0 +1,84 @@
|
||||||
|
import assert from 'node:assert'
|
||||||
|
import {AddressInfo} from 'node:net'
|
||||||
|
import {after, before, describe, it} from 'node:test'
|
||||||
|
|
||||||
|
import {Database, envToCfg, LinkService, readEnv} from '../src/index.js'
|
||||||
|
|
||||||
|
describe('link service', async () => {
|
||||||
|
let linkService: LinkService
|
||||||
|
let baseUrl: string
|
||||||
|
before(async () => {
|
||||||
|
const env = readEnv()
|
||||||
|
const cfg = envToCfg({
|
||||||
|
...env,
|
||||||
|
hostnames: ['test.bsky.link'],
|
||||||
|
appHostname: 'test.bsky.app',
|
||||||
|
dbPostgresSchema: 'link_test',
|
||||||
|
dbPostgresUrl: process.env.DB_POSTGRES_URL,
|
||||||
|
})
|
||||||
|
const migrateDb = Database.postgres({
|
||||||
|
url: cfg.db.url,
|
||||||
|
schema: cfg.db.schema,
|
||||||
|
})
|
||||||
|
await migrateDb.migrateToLatestOrThrow()
|
||||||
|
await migrateDb.close()
|
||||||
|
linkService = await LinkService.create(cfg)
|
||||||
|
await linkService.start()
|
||||||
|
const {port} = linkService.server?.address() as AddressInfo
|
||||||
|
baseUrl = `http://localhost:${port}`
|
||||||
|
})
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
await linkService?.destroy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('creates a starter pack link', async () => {
|
||||||
|
const link = await getLink('/start/did:example:alice/xxx')
|
||||||
|
const url = new URL(link)
|
||||||
|
assert.strictEqual(url.origin, 'https://test.bsky.link')
|
||||||
|
assert.match(url.pathname, /^\/[a-z0-9]+$/i)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('normalizes input paths and provides same link each time.', async () => {
|
||||||
|
const link1 = await getLink('/start/did%3Aexample%3Abob/yyy')
|
||||||
|
const link2 = await getLink('/start/did:example:bob/yyy/')
|
||||||
|
assert.strictEqual(link1, link2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('serves permanent redirect, preserving query params.', async () => {
|
||||||
|
const link = await getLink('/start/did:example:carol/zzz/')
|
||||||
|
const [status, location] = await getRedirect(`${link}?a=b`)
|
||||||
|
assert.strictEqual(status, 301)
|
||||||
|
const locationUrl = new URL(location)
|
||||||
|
assert.strictEqual(
|
||||||
|
locationUrl.pathname + locationUrl.search,
|
||||||
|
'/start/did:example:carol/zzz?a=b',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
async function getRedirect(link: string): Promise<[number, string]> {
|
||||||
|
const url = new URL(link)
|
||||||
|
const base = new URL(baseUrl)
|
||||||
|
url.protocol = base.protocol
|
||||||
|
url.host = base.host
|
||||||
|
const res = await fetch(url, {redirect: 'manual'})
|
||||||
|
await res.arrayBuffer() // drain
|
||||||
|
assert(
|
||||||
|
res.status === 301 || res.status === 303,
|
||||||
|
'response was not a redirect',
|
||||||
|
)
|
||||||
|
return [res.status, res.headers.get('location') ?? '']
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getLink(path: string): Promise<string> {
|
||||||
|
const res = await fetch(new URL('/link', baseUrl), {
|
||||||
|
method: 'post',
|
||||||
|
headers: {'content-type': 'application/json'},
|
||||||
|
body: JSON.stringify({path}),
|
||||||
|
})
|
||||||
|
assert.strictEqual(res.status, 200)
|
||||||
|
const payload = await res.json()
|
||||||
|
assert(typeof payload.url === 'string')
|
||||||
|
return payload.url
|
||||||
|
}
|
||||||
|
})
|
|
@ -0,0 +1,157 @@
|
||||||
|
#!/usr/bin/env sh
|
||||||
|
|
||||||
|
# Exit if any command fails
|
||||||
|
set -e
|
||||||
|
|
||||||
|
get_container_id() {
|
||||||
|
local compose_file=$1
|
||||||
|
local service=$2
|
||||||
|
if [ -z "${compose_file}" ] || [ -z "${service}" ]; then
|
||||||
|
echo "usage: get_container_id <compose_file> <service>"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# first line of jq normalizes for docker compose breaking change, see docker/compose#10958
|
||||||
|
docker compose --file $compose_file ps --format json --status running \
|
||||||
|
| jq -sc '.[] | if type=="array" then .[] else . end' | jq -s \
|
||||||
|
| jq -r '.[]? | select(.Service == "'${service}'") | .ID'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Exports all environment variables
|
||||||
|
export_env() {
|
||||||
|
export_pg_env
|
||||||
|
}
|
||||||
|
|
||||||
|
# Exports postgres environment variables
|
||||||
|
export_pg_env() {
|
||||||
|
# Based on creds in compose.yaml
|
||||||
|
export PGPORT=5433
|
||||||
|
export PGHOST=localhost
|
||||||
|
export PGUSER=pg
|
||||||
|
export PGPASSWORD=password
|
||||||
|
export PGDATABASE=postgres
|
||||||
|
export DB_POSTGRES_URL="postgresql://pg:password@127.0.0.1:5433/postgres"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
pg_clear() {
|
||||||
|
local pg_uri=$1
|
||||||
|
|
||||||
|
for schema_name in `psql "${pg_uri}" -c "SELECT schema_name FROM information_schema.schemata WHERE schema_name NOT LIKE 'pg_%' AND schema_name NOT LIKE 'information_schema';" -t`; do
|
||||||
|
psql "${pg_uri}" -c "DROP SCHEMA \"${schema_name}\" CASCADE;"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
pg_init() {
|
||||||
|
local pg_uri=$1
|
||||||
|
|
||||||
|
psql "${pg_uri}" -c "CREATE SCHEMA IF NOT EXISTS \"public\";"
|
||||||
|
}
|
||||||
|
|
||||||
|
main_native() {
|
||||||
|
local services=${SERVICES}
|
||||||
|
local postgres_url_env_var=`[[ $services == *"db_test"* ]] && echo "DB_TEST_POSTGRES_URL" || echo "DB_POSTGRES_URL"`
|
||||||
|
|
||||||
|
postgres_url="${!postgres_url_env_var}"
|
||||||
|
|
||||||
|
if [ -n "${postgres_url}" ]; then
|
||||||
|
echo "Using ${postgres_url_env_var} (${postgres_url}) to connect to postgres."
|
||||||
|
pg_init "${postgres_url}"
|
||||||
|
else
|
||||||
|
echo "Postgres connection string missing did you set ${postgres_url_env_var}?"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
local services=$@
|
||||||
|
|
||||||
|
if [ -n "${postgres_url}" ] && [[ $services == *"db_test"* ]]; then
|
||||||
|
pg_clear "${postgres_url}" &> /dev/null
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# trap SIGINT and performs cleanup
|
||||||
|
trap "on_sigint ${services}" INT
|
||||||
|
on_sigint() {
|
||||||
|
cleanup $@
|
||||||
|
exit $?
|
||||||
|
}
|
||||||
|
|
||||||
|
# Run the arguments as a command
|
||||||
|
DB_POSTGRES_URL="${postgres_url}" \
|
||||||
|
"$@"
|
||||||
|
code=$?
|
||||||
|
|
||||||
|
cleanup ${services}
|
||||||
|
|
||||||
|
exit ${code}
|
||||||
|
}
|
||||||
|
|
||||||
|
main_docker() {
|
||||||
|
# Expect a SERVICES env var to be set with the docker service names
|
||||||
|
local services=${SERVICES}
|
||||||
|
|
||||||
|
dir=$(dirname $0)
|
||||||
|
compose_file="${dir}/docker-compose.yaml"
|
||||||
|
|
||||||
|
# whether this particular script started the container(s)
|
||||||
|
started_container=false
|
||||||
|
|
||||||
|
# performs cleanup as necessary, i.e. taking down containers
|
||||||
|
# if this script started them
|
||||||
|
cleanup() {
|
||||||
|
local services=$@
|
||||||
|
echo # newline
|
||||||
|
if $started_container; then
|
||||||
|
docker compose --file $compose_file rm --force --stop --volumes ${services}
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# trap SIGINT and performs cleanup
|
||||||
|
trap "on_sigint ${services}" INT
|
||||||
|
on_sigint() {
|
||||||
|
cleanup $@
|
||||||
|
exit $?
|
||||||
|
}
|
||||||
|
|
||||||
|
# check if all services are running already
|
||||||
|
not_running=false
|
||||||
|
for service in $services; do
|
||||||
|
container_id=$(get_container_id $compose_file $service)
|
||||||
|
if [ -z $container_id ]; then
|
||||||
|
not_running=true
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# if any are missing, recreate all services
|
||||||
|
if $not_running; then
|
||||||
|
started_container=true
|
||||||
|
docker compose --file $compose_file up --wait --force-recreate ${services}
|
||||||
|
else
|
||||||
|
echo "all services ${services} are already running"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# do not exit when following commands fail, so we can intercept exit code & tear down docker
|
||||||
|
set +e
|
||||||
|
|
||||||
|
# setup environment variables and run args
|
||||||
|
export_env
|
||||||
|
"$@"
|
||||||
|
# save return code for later
|
||||||
|
code=$?
|
||||||
|
|
||||||
|
# performs cleanup as necessary
|
||||||
|
cleanup ${services}
|
||||||
|
exit ${code}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Main entry point
|
||||||
|
main() {
|
||||||
|
if ! docker ps >/dev/null 2>&1; then
|
||||||
|
echo "Docker unavailable. Running on host."
|
||||||
|
main_native $@
|
||||||
|
else
|
||||||
|
main_docker $@
|
||||||
|
fi
|
||||||
|
}
|
|
@ -0,0 +1,27 @@
|
||||||
|
version: '3.8'
|
||||||
|
services:
|
||||||
|
# An ephermerally-stored postgres database for single-use test runs
|
||||||
|
db_test: &db_test
|
||||||
|
image: postgres:14.11-alpine
|
||||||
|
environment:
|
||||||
|
- POSTGRES_USER=pg
|
||||||
|
- POSTGRES_PASSWORD=password
|
||||||
|
ports:
|
||||||
|
- '5433:5432'
|
||||||
|
# Healthcheck ensures db is queryable when `docker-compose up --wait` completes
|
||||||
|
healthcheck:
|
||||||
|
test: 'pg_isready -U pg'
|
||||||
|
interval: 500ms
|
||||||
|
timeout: 10s
|
||||||
|
retries: 20
|
||||||
|
# A persistently-stored postgres database
|
||||||
|
db:
|
||||||
|
<<: *db_test
|
||||||
|
ports:
|
||||||
|
- '5432:5432'
|
||||||
|
healthcheck:
|
||||||
|
disable: true
|
||||||
|
volumes:
|
||||||
|
- link_db:/var/lib/postgresql/data
|
||||||
|
volumes:
|
||||||
|
link_db:
|
|
@ -0,0 +1,9 @@
|
||||||
|
#!/usr/bin/env sh
|
||||||
|
|
||||||
|
# Example usage:
|
||||||
|
# ./with-test-db.sh psql postgresql://pg:password@localhost:5433/postgres -c 'select 1;'
|
||||||
|
|
||||||
|
dir=$(dirname $0)
|
||||||
|
. ${dir}/_common.sh
|
||||||
|
|
||||||
|
SERVICES="db_test" main "$@"
|
|
@ -0,0 +1,10 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "NodeNext",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"outDir": "dist",
|
||||||
|
"lib": ["ES2021.String"]
|
||||||
|
},
|
||||||
|
"include": ["./src/index.ts", "./src/bin.ts"]
|
||||||
|
}
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,24 @@
|
||||||
|
{
|
||||||
|
"name": "bskyogcard",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"main": "src/index.ts",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node --loader ts-node/esm ./src/bin.ts",
|
||||||
|
"build": "tsc && cp -r src/assets dist/assets"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@atproto/api": "0.12.19-next.0",
|
||||||
|
"@atproto/common": "^0.4.0",
|
||||||
|
"@resvg/resvg-js": "^2.6.2",
|
||||||
|
"express": "^4.19.2",
|
||||||
|
"http-terminator": "^3.2.0",
|
||||||
|
"pino": "^9.2.0",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"satori": "^0.10.13"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^20.14.3",
|
||||||
|
"typescript": "^5.4.5"
|
||||||
|
}
|
||||||
|
}
|
Binary file not shown.
|
@ -0,0 +1,48 @@
|
||||||
|
import cluster, {Worker} from 'node:cluster'
|
||||||
|
|
||||||
|
import {envInt} from '@atproto/common'
|
||||||
|
|
||||||
|
import {CardService, envToCfg, httpLogger, readEnv} from './index.js'
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const env = readEnv()
|
||||||
|
const cfg = envToCfg(env)
|
||||||
|
const card = await CardService.create(cfg)
|
||||||
|
await card.start()
|
||||||
|
httpLogger.info('card service is running')
|
||||||
|
process.on('SIGTERM', async () => {
|
||||||
|
httpLogger.info('card service is stopping')
|
||||||
|
await card.destroy()
|
||||||
|
httpLogger.info('card service is stopped')
|
||||||
|
if (cluster.isWorker) process.exit(0)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const workerCount = envInt('CARD_CLUSTER_WORKER_COUNT')
|
||||||
|
|
||||||
|
if (workerCount) {
|
||||||
|
if (cluster.isPrimary) {
|
||||||
|
httpLogger.info(`primary ${process.pid} is running`)
|
||||||
|
const workers = new Set<Worker>()
|
||||||
|
for (let i = 0; i < workerCount; ++i) {
|
||||||
|
workers.add(cluster.fork())
|
||||||
|
}
|
||||||
|
let teardown = false
|
||||||
|
cluster.on('exit', worker => {
|
||||||
|
workers.delete(worker)
|
||||||
|
if (!teardown) {
|
||||||
|
workers.add(cluster.fork()) // restart on crash
|
||||||
|
}
|
||||||
|
})
|
||||||
|
process.on('SIGTERM', () => {
|
||||||
|
teardown = true
|
||||||
|
httpLogger.info('disconnecting workers')
|
||||||
|
workers.forEach(w => w.kill('SIGTERM'))
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
httpLogger.info(`worker ${process.pid} is running`)
|
||||||
|
main()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
main() // non-clustering
|
||||||
|
}
|
|
@ -0,0 +1,16 @@
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
export function Butterfly(props: React.SVGAttributes<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 568 501"
|
||||||
|
{...props}>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M123.121 33.664C188.241 82.553 258.281 181.68 284 234.873c25.719-53.192 95.759-152.32 160.879-201.21C491.866-1.611 568-28.906 568 57.947c0 17.346-9.945 145.713-15.778 166.555-20.275 72.453-94.155 90.933-159.875 79.748C507.222 323.8 536.444 388.56 473.333 453.32c-119.86 122.992-172.272-30.859-185.702-70.281-2.462-7.227-3.614-10.608-3.631-7.733-.017-2.875-1.169.506-3.631 7.733-13.43 39.422-65.842 193.273-185.702 70.281-63.111-64.76-33.89-129.52 80.986-149.071-65.72 11.185-139.6-7.295-159.875-79.748C9.945 203.659 0 75.291 0 57.946 0-28.906 76.135-1.612 123.121 33.664Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,10 @@
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
export function Img(
|
||||||
|
props: Omit<React.ImgHTMLAttributes<HTMLImageElement>, 'src'> & {src: Buffer},
|
||||||
|
) {
|
||||||
|
const {src, ...others} = props
|
||||||
|
return (
|
||||||
|
<img {...others} src={`data:image/jpeg;base64,${src.toString('base64')}`} />
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,149 @@
|
||||||
|
/* eslint-disable bsky-internal/avoid-unwrapped-text */
|
||||||
|
import React from 'react'
|
||||||
|
import {AppBskyGraphDefs, AppBskyGraphStarterpack} from '@atproto/api'
|
||||||
|
|
||||||
|
import {Butterfly} from './Butterfly.js'
|
||||||
|
import {Img} from './Img.js'
|
||||||
|
|
||||||
|
export const STARTERPACK_HEIGHT = 630
|
||||||
|
export const STARTERPACK_WIDTH = 1200
|
||||||
|
export const TILE_SIZE = STARTERPACK_HEIGHT / 3
|
||||||
|
|
||||||
|
const GRADIENT_TOP = '#0A7AFF'
|
||||||
|
const GRADIENT_BOTTOM = '#59B9FF'
|
||||||
|
const IMAGE_STROKE = '#359CFF'
|
||||||
|
|
||||||
|
export function StarterPack(props: {
|
||||||
|
starterPack: AppBskyGraphDefs.StarterPackView
|
||||||
|
images: Map<string, Buffer>
|
||||||
|
}) {
|
||||||
|
const {starterPack, images} = props
|
||||||
|
const record = AppBskyGraphStarterpack.isRecord(starterPack.record)
|
||||||
|
? starterPack.record
|
||||||
|
: null
|
||||||
|
const imagesArray = [...images.values()]
|
||||||
|
const imageOfCreator = images.get(starterPack.creator.did)
|
||||||
|
const imagesExceptCreator = [...images.entries()]
|
||||||
|
.filter(([did]) => did !== starterPack.creator.did)
|
||||||
|
.map(([, image]) => image)
|
||||||
|
const imagesAcross: Buffer[] = []
|
||||||
|
if (imageOfCreator) {
|
||||||
|
if (imagesExceptCreator.length >= 6) {
|
||||||
|
imagesAcross.push(...imagesExceptCreator.slice(0, 3))
|
||||||
|
imagesAcross.push(imageOfCreator)
|
||||||
|
imagesAcross.push(...imagesExceptCreator.slice(3, 6))
|
||||||
|
} else {
|
||||||
|
const firstHalf = Math.floor(imagesExceptCreator.length / 2)
|
||||||
|
imagesAcross.push(...imagesExceptCreator.slice(0, firstHalf))
|
||||||
|
imagesAcross.push(imageOfCreator)
|
||||||
|
imagesAcross.push(
|
||||||
|
...imagesExceptCreator.slice(firstHalf, imagesExceptCreator.length),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
imagesAcross.push(...imagesExceptCreator.slice(0, 7))
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: STARTERPACK_WIDTH,
|
||||||
|
height: STARTERPACK_HEIGHT,
|
||||||
|
backgroundColor: 'black',
|
||||||
|
color: 'white',
|
||||||
|
fontFamily: 'Inter',
|
||||||
|
}}>
|
||||||
|
{/* image tiles */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
alignItems: 'stretch',
|
||||||
|
width: TILE_SIZE * 6,
|
||||||
|
height: TILE_SIZE * 3,
|
||||||
|
}}>
|
||||||
|
{[...Array(18)].map((_, i) => {
|
||||||
|
const image = imagesArray.at(i % imagesArray.length)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
height: TILE_SIZE,
|
||||||
|
width: TILE_SIZE,
|
||||||
|
}}>
|
||||||
|
{image && <Img height="100%" width="100%" src={image} />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{/* background overlay */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
position: 'absolute',
|
||||||
|
backgroundImage: `linear-gradient(to bottom, ${GRADIENT_TOP}, ${GRADIENT_BOTTOM})`,
|
||||||
|
opacity: 0.9,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/* foreground text & images */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
flexDirection: 'column',
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
position: 'absolute',
|
||||||
|
color: 'white',
|
||||||
|
}}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
color: 'white',
|
||||||
|
padding: 60,
|
||||||
|
fontSize: 40,
|
||||||
|
}}>
|
||||||
|
JOIN THE CONVERSATION
|
||||||
|
</div>
|
||||||
|
<div style={{display: 'flex'}}>
|
||||||
|
{imagesAcross.map((image, i) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
height: 172 + 15 * 2,
|
||||||
|
width: 172 + 15 * 2,
|
||||||
|
margin: -15,
|
||||||
|
border: `15px solid ${IMAGE_STROKE}`,
|
||||||
|
borderRadius: '50%',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}>
|
||||||
|
<Img height="100%" width="100%" src={image} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '75px 30px 0px',
|
||||||
|
fontSize: 65,
|
||||||
|
}}>
|
||||||
|
{record?.name || 'Starter Pack'}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
fontSize: 40,
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: '30px 30px 10px',
|
||||||
|
}}>
|
||||||
|
on <Butterfly width="65" style={{margin: '-7px 10px 0'}} /> Bluesky
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,40 @@
|
||||||
|
import {envInt, envStr} from '@atproto/common'
|
||||||
|
|
||||||
|
export type Config = {
|
||||||
|
service: ServiceConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ServiceConfig = {
|
||||||
|
port: number
|
||||||
|
version?: string
|
||||||
|
appviewUrl: string
|
||||||
|
originVerify?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Environment = {
|
||||||
|
port?: number
|
||||||
|
version?: string
|
||||||
|
appviewUrl?: string
|
||||||
|
originVerify?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const readEnv = (): Environment => {
|
||||||
|
return {
|
||||||
|
port: envInt('CARD_PORT'),
|
||||||
|
version: envStr('CARD_VERSION'),
|
||||||
|
appviewUrl: envStr('CARD_APPVIEW_URL'),
|
||||||
|
originVerify: envStr('CARD_ORIGIN_VERIFY'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const envToCfg = (env: Environment): Config => {
|
||||||
|
const serviceCfg: ServiceConfig = {
|
||||||
|
port: env.port ?? 3000,
|
||||||
|
version: env.version,
|
||||||
|
appviewUrl: env.appviewUrl ?? 'https://api.bsky.app',
|
||||||
|
originVerify: env.originVerify,
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
service: serviceCfg,
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,44 @@
|
||||||
|
import {readFileSync} from 'node:fs'
|
||||||
|
|
||||||
|
import {AtpAgent} from '@atproto/api'
|
||||||
|
import * as path from 'path'
|
||||||
|
import {fileURLToPath} from 'url'
|
||||||
|
|
||||||
|
import {Config} from './config.js'
|
||||||
|
|
||||||
|
const __DIRNAME = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
|
||||||
|
export type AppContextOptions = {
|
||||||
|
cfg: Config
|
||||||
|
appviewAgent: AtpAgent
|
||||||
|
fonts: {name: string; data: Buffer}[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AppContext {
|
||||||
|
cfg: Config
|
||||||
|
appviewAgent: AtpAgent
|
||||||
|
fonts: {name: string; data: Buffer}[]
|
||||||
|
abortController = new AbortController()
|
||||||
|
|
||||||
|
constructor(private opts: AppContextOptions) {
|
||||||
|
this.cfg = this.opts.cfg
|
||||||
|
this.appviewAgent = this.opts.appviewAgent
|
||||||
|
this.fonts = this.opts.fonts
|
||||||
|
}
|
||||||
|
|
||||||
|
static async fromConfig(cfg: Config, overrides?: Partial<AppContextOptions>) {
|
||||||
|
const appviewAgent = new AtpAgent({service: cfg.service.appviewUrl})
|
||||||
|
const fonts = [
|
||||||
|
{
|
||||||
|
name: 'Inter',
|
||||||
|
data: readFileSync(path.join(__DIRNAME, 'assets', 'Inter-Bold.ttf')),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
return new AppContext({
|
||||||
|
cfg,
|
||||||
|
appviewAgent,
|
||||||
|
fonts,
|
||||||
|
...overrides,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,41 @@
|
||||||
|
import events from 'node:events'
|
||||||
|
import http from 'node:http'
|
||||||
|
|
||||||
|
import express from 'express'
|
||||||
|
import {createHttpTerminator, HttpTerminator} from 'http-terminator'
|
||||||
|
|
||||||
|
import {Config} from './config.js'
|
||||||
|
import {AppContext} from './context.js'
|
||||||
|
import {default as routes, errorHandler} from './routes/index.js'
|
||||||
|
|
||||||
|
export * from './config.js'
|
||||||
|
export * from './logger.js'
|
||||||
|
|
||||||
|
export class CardService {
|
||||||
|
public server?: http.Server
|
||||||
|
private terminator?: HttpTerminator
|
||||||
|
|
||||||
|
constructor(public app: express.Application, public ctx: AppContext) {}
|
||||||
|
|
||||||
|
static async create(cfg: Config): Promise<CardService> {
|
||||||
|
let app = express()
|
||||||
|
|
||||||
|
const ctx = await AppContext.fromConfig(cfg)
|
||||||
|
app = routes(ctx, app)
|
||||||
|
app.use(errorHandler)
|
||||||
|
|
||||||
|
return new CardService(app, ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
async start() {
|
||||||
|
this.server = this.app.listen(this.ctx.cfg.service.port)
|
||||||
|
this.server.keepAliveTimeout = 90000
|
||||||
|
this.terminator = createHttpTerminator({server: this.server})
|
||||||
|
await events.once(this.server, 'listening')
|
||||||
|
}
|
||||||
|
|
||||||
|
async destroy() {
|
||||||
|
this.ctx.abortController.abort()
|
||||||
|
await this.terminator?.terminate()
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,3 @@
|
||||||
|
import {subsystemLogger} from '@atproto/common'
|
||||||
|
|
||||||
|
export const httpLogger = subsystemLogger('bskyogcard')
|
|
@ -0,0 +1,14 @@
|
||||||
|
import {Express} from 'express'
|
||||||
|
|
||||||
|
import {AppContext} from '../context.js'
|
||||||
|
import {handler} from './util.js'
|
||||||
|
|
||||||
|
export default function (ctx: AppContext, app: Express) {
|
||||||
|
return app.get(
|
||||||
|
'/_health',
|
||||||
|
handler(async (_req, res) => {
|
||||||
|
const {version} = ctx.cfg.service
|
||||||
|
return res.send({version})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,13 @@
|
||||||
|
import {Express} from 'express'
|
||||||
|
|
||||||
|
import {AppContext} from '../context.js'
|
||||||
|
import {default as health} from './health.js'
|
||||||
|
import {default as starterPack} from './starter-pack.js'
|
||||||
|
|
||||||
|
export * from './util.js'
|
||||||
|
|
||||||
|
export default function (ctx: AppContext, app: Express) {
|
||||||
|
app = health(ctx, app) // GET /_health
|
||||||
|
app = starterPack(ctx, app) // GET /start/:actor/:rkey
|
||||||
|
return app
|
||||||
|
}
|
|
@ -0,0 +1,102 @@
|
||||||
|
import assert from 'node:assert'
|
||||||
|
|
||||||
|
import React from 'react'
|
||||||
|
import {AppBskyGraphDefs, AtUri} from '@atproto/api'
|
||||||
|
import resvg from '@resvg/resvg-js'
|
||||||
|
import {Express} from 'express'
|
||||||
|
import satori from 'satori'
|
||||||
|
|
||||||
|
import {
|
||||||
|
StarterPack,
|
||||||
|
STARTERPACK_HEIGHT,
|
||||||
|
STARTERPACK_WIDTH,
|
||||||
|
} from '../components/StarterPack.js'
|
||||||
|
import {AppContext} from '../context.js'
|
||||||
|
import {httpLogger} from '../logger.js'
|
||||||
|
import {handler, originVerifyMiddleware} from './util.js'
|
||||||
|
|
||||||
|
export default function (ctx: AppContext, app: Express) {
|
||||||
|
return app.get(
|
||||||
|
'/start/:actor/:rkey',
|
||||||
|
originVerifyMiddleware(ctx),
|
||||||
|
handler(async (req, res) => {
|
||||||
|
const {actor, rkey} = req.params
|
||||||
|
const uri = AtUri.make(actor, 'app.bsky.graph.starterpack', rkey)
|
||||||
|
let starterPack: AppBskyGraphDefs.StarterPackView
|
||||||
|
try {
|
||||||
|
const result = await ctx.appviewAgent.api.app.bsky.graph.getStarterPack(
|
||||||
|
{starterPack: uri.toString()},
|
||||||
|
)
|
||||||
|
starterPack = result.data.starterPack
|
||||||
|
} catch (err) {
|
||||||
|
httpLogger.warn(
|
||||||
|
{err, uri: uri.toString()},
|
||||||
|
'could not fetch starter pack',
|
||||||
|
)
|
||||||
|
return res.status(404).end('not found')
|
||||||
|
}
|
||||||
|
const imageEntries = await Promise.all(
|
||||||
|
[starterPack.creator]
|
||||||
|
.concat(starterPack.listItemsSample.map(li => li.subject))
|
||||||
|
// has avatar
|
||||||
|
.filter(p => p.avatar)
|
||||||
|
// no sensitive labels
|
||||||
|
.filter(p => !p.labels.some(l => hideAvatarLabels.has(l.val)))
|
||||||
|
.map(async p => {
|
||||||
|
try {
|
||||||
|
assert(p.avatar)
|
||||||
|
const image = await getImage(p.avatar)
|
||||||
|
return [p.did, image] as const
|
||||||
|
} catch (err) {
|
||||||
|
httpLogger.warn(
|
||||||
|
{err, uri: uri.toString(), did: p.did},
|
||||||
|
'could not fetch image',
|
||||||
|
)
|
||||||
|
return [p.did, null] as const
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const images = new Map(
|
||||||
|
imageEntries.filter(([_, image]) => image !== null).slice(0, 7),
|
||||||
|
)
|
||||||
|
const svg = await satori(
|
||||||
|
<StarterPack starterPack={starterPack} images={images} />,
|
||||||
|
{
|
||||||
|
fonts: ctx.fonts,
|
||||||
|
height: STARTERPACK_HEIGHT,
|
||||||
|
width: STARTERPACK_WIDTH,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
const output = await resvg.renderAsync(svg)
|
||||||
|
res.statusCode = 200
|
||||||
|
res.setHeader('content-type', 'image/png')
|
||||||
|
res.setHeader('cdn-tag', [...images.keys()].join(','))
|
||||||
|
return res.end(output.asPng())
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getImage(url: string) {
|
||||||
|
const response = await fetch(url)
|
||||||
|
const arrayBuf = await response.arrayBuffer() // must drain body even if it will be discarded
|
||||||
|
if (response.status !== 200) return null
|
||||||
|
return Buffer.from(arrayBuf)
|
||||||
|
}
|
||||||
|
|
||||||
|
const hideAvatarLabels = new Set([
|
||||||
|
'!hide',
|
||||||
|
'!warn',
|
||||||
|
'porn',
|
||||||
|
'sexual',
|
||||||
|
'nudity',
|
||||||
|
'sexual-figurative',
|
||||||
|
'graphic-media',
|
||||||
|
'self-harm',
|
||||||
|
'sensitive',
|
||||||
|
'security',
|
||||||
|
'impersonation',
|
||||||
|
'scam',
|
||||||
|
'spam',
|
||||||
|
'misleading',
|
||||||
|
'inauthentic',
|
||||||
|
])
|
|
@ -0,0 +1,36 @@
|
||||||
|
import {ErrorRequestHandler, Request, RequestHandler, Response} from 'express'
|
||||||
|
|
||||||
|
import {AppContext} from '../context.js'
|
||||||
|
import {httpLogger} from '../logger.js'
|
||||||
|
|
||||||
|
export type Handler = (req: Request, res: Response) => Awaited<void>
|
||||||
|
|
||||||
|
export const handler = (runHandler: Handler): RequestHandler => {
|
||||||
|
return async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
await runHandler(req, res)
|
||||||
|
} catch (err) {
|
||||||
|
next(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function originVerifyMiddleware(ctx: AppContext): RequestHandler {
|
||||||
|
const {originVerify} = ctx.cfg.service
|
||||||
|
if (!originVerify) return (_req, _res, next) => next()
|
||||||
|
return (req, res, next) => {
|
||||||
|
const verifyHeader = req.headers['x-origin-verify']
|
||||||
|
if (verifyHeader !== originVerify) {
|
||||||
|
return res.status(404).end('not found')
|
||||||
|
}
|
||||||
|
next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const errorHandler: ErrorRequestHandler = (err, req, res, next) => {
|
||||||
|
httpLogger.error({err}, 'request error')
|
||||||
|
if (res.headersSent) {
|
||||||
|
return next(err)
|
||||||
|
}
|
||||||
|
return res.status(500).end('server error')
|
||||||
|
}
|
|
@ -0,0 +1,11 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ESNext",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"outDir": "dist"
|
||||||
|
},
|
||||||
|
"include": ["./src/index.ts", "./src/bin.ts"]
|
||||||
|
}
|
File diff suppressed because it is too large
Load Diff
|
@ -35,7 +35,7 @@ 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"},
|
||||||
|
@ -47,6 +47,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"
|
||||||
|
@ -36,6 +37,7 @@ 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")
|
||||||
|
linkHost := cctx.String("link-host")
|
||||||
|
|
||||||
// Echo
|
// Echo
|
||||||
e := echo.New()
|
e := echo.New()
|
||||||
|
@ -221,6 +223,14 @@ 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)
|
||||||
|
|
||||||
|
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 +302,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{}
|
||||||
|
|
|
@ -24,6 +24,7 @@ import {
|
||||||
import {s} from '#/lib/styles'
|
import {s} from '#/lib/styles'
|
||||||
import {ThemeProvider} from '#/lib/ThemeContext'
|
import {ThemeProvider} from '#/lib/ThemeContext'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
|
import {Provider as A11yProvider} from '#/state/a11y'
|
||||||
import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes'
|
import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes'
|
||||||
import {Provider as DialogStateProvider} from '#/state/dialogs'
|
import {Provider as DialogStateProvider} from '#/state/dialogs'
|
||||||
import {Provider as InvitesStateProvider} from '#/state/invites'
|
import {Provider as InvitesStateProvider} from '#/state/invites'
|
||||||
|
@ -152,6 +153,7 @@ function App() {
|
||||||
* that is set up in the InnerApp component above.
|
* that is set up in the InnerApp component above.
|
||||||
*/
|
*/
|
||||||
return (
|
return (
|
||||||
|
<A11yProvider>
|
||||||
<KeyboardProvider enabled={false} statusBarTranslucent={true}>
|
<KeyboardProvider enabled={false} statusBarTranslucent={true}>
|
||||||
<SessionProvider>
|
<SessionProvider>
|
||||||
<ShellStateProvider>
|
<ShellStateProvider>
|
||||||
|
@ -173,6 +175,7 @@ function App() {
|
||||||
</ShellStateProvider>
|
</ShellStateProvider>
|
||||||
</SessionProvider>
|
</SessionProvider>
|
||||||
</KeyboardProvider>
|
</KeyboardProvider>
|
||||||
|
</A11yProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -13,6 +13,7 @@ import {QueryProvider} from '#/lib/react-query'
|
||||||
import {Provider as StatsigProvider} from '#/lib/statsig/statsig'
|
import {Provider as StatsigProvider} from '#/lib/statsig/statsig'
|
||||||
import {ThemeProvider} from '#/lib/ThemeContext'
|
import {ThemeProvider} from '#/lib/ThemeContext'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
|
import {Provider as A11yProvider} from '#/state/a11y'
|
||||||
import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes'
|
import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes'
|
||||||
import {Provider as DialogStateProvider} from '#/state/dialogs'
|
import {Provider as DialogStateProvider} from '#/state/dialogs'
|
||||||
import {Provider as InvitesStateProvider} from '#/state/invites'
|
import {Provider as InvitesStateProvider} from '#/state/invites'
|
||||||
|
@ -135,6 +136,7 @@ function App() {
|
||||||
* that is set up in the InnerApp component above.
|
* that is set up in the InnerApp component above.
|
||||||
*/
|
*/
|
||||||
return (
|
return (
|
||||||
|
<A11yProvider>
|
||||||
<SessionProvider>
|
<SessionProvider>
|
||||||
<ShellStateProvider>
|
<ShellStateProvider>
|
||||||
<PrefsStateProvider>
|
<PrefsStateProvider>
|
||||||
|
@ -154,6 +156,7 @@ function App() {
|
||||||
</PrefsStateProvider>
|
</PrefsStateProvider>
|
||||||
</ShellStateProvider>
|
</ShellStateProvider>
|
||||||
</SessionProvider>
|
</SessionProvider>
|
||||||
|
</A11yProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -312,7 +312,11 @@ 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`)}}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,8 +1,14 @@
|
||||||
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,
|
||||||
|
} 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,6 +17,7 @@ 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, precacheList} 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'
|
||||||
|
@ -20,41 +27,72 @@ import {Button, ButtonIcon} from '#/components/Button'
|
||||||
import {useRichText} from '#/components/hooks/useRichText'
|
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} from '#/components/RichText'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
export function Default({feed}: {feed: AppBskyFeedDefs.GeneratorView}) {
|
type Props =
|
||||||
|
| {
|
||||||
|
type: 'feed'
|
||||||
|
view: AppBskyFeedDefs.GeneratorView
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'list'
|
||||||
|
view: AppBskyGraphDefs.ListView
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Default(props: Props) {
|
||||||
|
const {type, view} = props
|
||||||
|
const displayName = type === 'feed' ? view.displayName : view.name
|
||||||
|
const purpose = type === 'list' ? view.purpose : undefined
|
||||||
return (
|
return (
|
||||||
<Link feed={feed}>
|
<Link label={displayName} {...props}>
|
||||||
<Outer>
|
<Outer>
|
||||||
<Header>
|
<Header>
|
||||||
<Avatar src={feed.avatar} />
|
<Avatar src={view.avatar} />
|
||||||
<TitleAndByline title={feed.displayName} creator={feed.creator} />
|
<TitleAndByline
|
||||||
<Action uri={feed.uri} pin />
|
title={displayName}
|
||||||
|
creator={view.creator}
|
||||||
|
type={type}
|
||||||
|
purpose={purpose}
|
||||||
|
/>
|
||||||
|
<Action uri={view.uri} pin type={type} purpose={purpose} />
|
||||||
</Header>
|
</Header>
|
||||||
<Description description={feed.description} />
|
<Description description={view.description} />
|
||||||
<Likes count={feed.likeCount || 0} />
|
{type === 'feed' && <Likes count={view.likeCount || 0} />}
|
||||||
</Outer>
|
</Outer>
|
||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Link({
|
export function Link({
|
||||||
|
type,
|
||||||
|
view,
|
||||||
|
label,
|
||||||
children,
|
children,
|
||||||
feed,
|
}: Props & Omit<LinkProps, 'to'>) {
|
||||||
}: {
|
const queryClient = useQueryClient()
|
||||||
children: React.ReactElement
|
|
||||||
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])
|
return (
|
||||||
return <InternalLink to={href}>{children}</InternalLink>
|
<InternalLink
|
||||||
|
to={href}
|
||||||
|
label={label}
|
||||||
|
onPress={() => {
|
||||||
|
if (type === 'feed') {
|
||||||
|
precacheFeedFromGeneratorView(queryClient, view)
|
||||||
|
} else {
|
||||||
|
precacheList(queryClient, view)
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
{children}
|
||||||
|
</InternalLink>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Outer({children}: {children: React.ReactNode}) {
|
export function Outer({children}: {children: React.ReactNode}) {
|
||||||
|
@ -62,34 +100,100 @@ export function Outer({children}: {children: React.ReactNode}) {
|
||||||
}
|
}
|
||||||
|
|
||||||
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_1, 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({
|
||||||
title,
|
title,
|
||||||
creator,
|
creator,
|
||||||
|
type,
|
||||||
|
purpose,
|
||||||
}: {
|
}: {
|
||||||
title: string
|
title: string
|
||||||
creator: AppBskyActorDefs.ProfileViewBasic
|
creator?: AppBskyActorDefs.ProfileViewBasic
|
||||||
|
type: 'feed' | 'list'
|
||||||
|
purpose?: AppBskyGraphDefs.ListView['purpose']
|
||||||
}) {
|
}) {
|
||||||
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}>
|
||||||
|
{type === 'list' && purpose === 'app.bsky.graph.defs#curatelist' ? (
|
||||||
|
<Trans>List by {sanitizeHandle(creator.handle, '@')}</Trans>
|
||||||
|
) : type === 'list' && purpose === 'app.bsky.graph.defs#modlist' ? (
|
||||||
|
<Trans>
|
||||||
|
Moderation list by {sanitizeHandle(creator.handle, '@')}
|
||||||
|
</Trans>
|
||||||
|
) : (
|
||||||
<Trans>Feed by {sanitizeHandle(creator.handle, '@')}</Trans>
|
<Trans>Feed by {sanitizeHandle(creator.handle, '@')}</Trans>
|
||||||
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TitleAndBylinePlaceholder({creator}: {creator?: boolean}) {
|
||||||
|
const t = useTheme()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[a.flex_1, a.gap_xs]}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.rounded_xs,
|
||||||
|
t.atoms.bg_contrast_50,
|
||||||
|
{
|
||||||
|
width: '60%',
|
||||||
|
height: 14,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{creator && (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.rounded_xs,
|
||||||
|
t.atoms.bg_contrast_25,
|
||||||
|
{
|
||||||
|
width: '40%',
|
||||||
|
height: 10,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -116,13 +220,31 @@ export function Likes({count}: {count: number}) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Action({uri, pin}: {uri: string; pin?: boolean}) {
|
export function Action({
|
||||||
|
uri,
|
||||||
|
pin,
|
||||||
|
type,
|
||||||
|
purpose,
|
||||||
|
}: {
|
||||||
|
uri: string
|
||||||
|
pin?: boolean
|
||||||
|
type: 'feed' | 'list'
|
||||||
|
purpose?: AppBskyGraphDefs.ListView['purpose']
|
||||||
|
}) {
|
||||||
const {hasSession} = useSession()
|
const {hasSession} = useSession()
|
||||||
if (!hasSession) return null
|
if (!hasSession || purpose !== 'app.bsky.graph.defs#curatelist') return null
|
||||||
return <ActionInner uri={uri} pin={pin} />
|
return <ActionInner uri={uri} pin={pin} type={type} />
|
||||||
}
|
}
|
||||||
|
|
||||||
function ActionInner({uri, pin}: {uri: string; pin?: boolean}) {
|
function ActionInner({
|
||||||
|
uri,
|
||||||
|
pin,
|
||||||
|
type,
|
||||||
|
}: {
|
||||||
|
uri: string
|
||||||
|
pin?: boolean
|
||||||
|
type: 'feed' | 'list'
|
||||||
|
}) {
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const {data: preferences} = usePreferencesQuery()
|
const {data: preferences} = usePreferencesQuery()
|
||||||
const {isPending: isAddSavedFeedPending, mutateAsync: saveFeeds} =
|
const {isPending: isAddSavedFeedPending, mutateAsync: saveFeeds} =
|
||||||
|
@ -130,9 +252,7 @@ function ActionInner({uri, pin}: {uri: string; pin?: boolean}) {
|
||||||
const {isPending: isRemovePending, mutateAsync: removeFeed} =
|
const {isPending: isRemovePending, mutateAsync: removeFeed} =
|
||||||
useRemoveFeedMutation()
|
useRemoveFeedMutation()
|
||||||
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 +268,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 +280,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 +323,16 @@ function ActionInner({uri, pin}: {uri: string; pin?: boolean}) {
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createProfileFeedHref({
|
||||||
|
feed,
|
||||||
|
}: {
|
||||||
|
feed: AppBskyFeedDefs.GeneratorView | AppBskyGraphDefs.ListView
|
||||||
|
}) {
|
||||||
|
const urip = new AtUri(feed.uri)
|
||||||
|
const type = urip.collection === 'app.bsky.feed.generator' ? 'feed' : 'list'
|
||||||
|
const handleOrDid = feed.creator.handle || feed.creator.did
|
||||||
|
return `/profile/${handleOrDid}/${type === 'feed' ? 'feed' : 'lists'}/${
|
||||||
|
urip.rkey
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
|
|
@ -64,7 +64,7 @@ export function ProfileHoverCard(props: ProfileHoverCardProps) {
|
||||||
return props.children
|
return props.children
|
||||||
} else {
|
} else {
|
||||||
return (
|
return (
|
||||||
<View onPointerMove={onPointerMove}>
|
<View onPointerMove={onPointerMove} style={[a.flex_shrink]}>
|
||||||
<ProfileHoverCardInner {...props} />
|
<ProfileHoverCardInner {...props} />
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
|
|
|
@ -73,6 +73,22 @@ export type LogEvents = {
|
||||||
feedType: string
|
feedType: string
|
||||||
reason: 'pull-to-refresh' | 'soft-reset' | 'load-latest'
|
reason: 'pull-to-refresh' | 'soft-reset' | 'load-latest'
|
||||||
}
|
}
|
||||||
|
'discover:showMore': {
|
||||||
|
feedContext: string
|
||||||
|
}
|
||||||
|
'discover:showLess': {
|
||||||
|
feedContext: string
|
||||||
|
}
|
||||||
|
'discover:clickthrough:sampled': {
|
||||||
|
count: number
|
||||||
|
}
|
||||||
|
'discover:engaged:sampled': {
|
||||||
|
count: number
|
||||||
|
}
|
||||||
|
'discover:seen:sampled': {
|
||||||
|
count: number
|
||||||
|
}
|
||||||
|
|
||||||
'composer:gif:open': {}
|
'composer:gif:open': {}
|
||||||
'composer:gif:select': {}
|
'composer:gif:select': {}
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
export type Gate =
|
export type Gate =
|
||||||
// Keep this alphabetic please.
|
// Keep this alphabetic please.
|
||||||
|
| 'debug_show_feedcontext'
|
||||||
| 'native_pwi_disabled'
|
| 'native_pwi_disabled'
|
||||||
| 'request_notifications_permission_after_onboarding_v2'
|
| 'request_notifications_permission_after_onboarding_v2'
|
||||||
| 'show_avi_follow_button'
|
| 'show_avi_follow_button'
|
||||||
|
|
|
@ -115,6 +115,9 @@ const DOWNSAMPLED_EVENTS: Set<keyof LogEvents> = new Set([
|
||||||
'home:feedDisplayed:sampled',
|
'home:feedDisplayed:sampled',
|
||||||
'feed:endReached:sampled',
|
'feed:endReached:sampled',
|
||||||
'feed:refresh:sampled',
|
'feed:refresh:sampled',
|
||||||
|
'discover:clickthrough:sampled',
|
||||||
|
'discover:engaged:sampled',
|
||||||
|
'discover:seen:sampled',
|
||||||
])
|
])
|
||||||
const isDownsampledSession = Math.random() < 0.9 // 90% likely
|
const isDownsampledSession = Math.random() < 0.9 // 90% likely
|
||||||
|
|
||||||
|
|
|
@ -165,7 +165,7 @@ msgstr ""
|
||||||
#~ msgstr "<0>{following} </0><1>following</1>"
|
#~ msgstr "<0>{following} </0><1>following</1>"
|
||||||
|
|
||||||
#~ msgid "<0>Choose your</0><1>Recommended</1><2>Feeds</2>"
|
#~ msgid "<0>Choose your</0><1>Recommended</1><2>Feeds</2>"
|
||||||
#~ msgstr "<0>Scegli i tuoi</0><1>feeds</1><2>consigliati</2>"
|
#~ msgstr "<0>Scegli i tuoi</0><1>feed/1><2>consigliati</2>"
|
||||||
|
|
||||||
#~ msgid "<0>Follow some</0><1>Recommended</1><2>Users</2>"
|
#~ msgid "<0>Follow some</0><1>Recommended</1><2>Users</2>"
|
||||||
#~ msgstr "<0>Segui alcuni</0><1>utenti</1><2>consigliati</2>"
|
#~ msgstr "<0>Segui alcuni</0><1>utenti</1><2>consigliati</2>"
|
||||||
|
@ -356,7 +356,7 @@ msgstr "Aggiunto alla lista"
|
||||||
|
|
||||||
#: src/view/com/feeds/FeedSourceCard.tsx:126
|
#: src/view/com/feeds/FeedSourceCard.tsx:126
|
||||||
msgid "Added to my feeds"
|
msgid "Added to my feeds"
|
||||||
msgstr "Aggiunto ai miei feeds"
|
msgstr "Aggiunto ai miei feed"
|
||||||
|
|
||||||
#: src/view/screens/PreferencesFollowingFeed.tsx:172
|
#: src/view/screens/PreferencesFollowingFeed.tsx:172
|
||||||
msgid "Adjust the number of likes a reply must have to be shown in your feed."
|
msgid "Adjust the number of likes a reply must have to be shown in your feed."
|
||||||
|
@ -395,7 +395,7 @@ msgstr ""
|
||||||
#: src/screens/Messages/Settings.tsx:62
|
#: src/screens/Messages/Settings.tsx:62
|
||||||
#: src/screens/Messages/Settings.tsx:65
|
#: src/screens/Messages/Settings.tsx:65
|
||||||
msgid "Allow new messages from"
|
msgid "Allow new messages from"
|
||||||
msgstr ""
|
msgstr "Consenti nuovi messaggi da"
|
||||||
|
|
||||||
#: src/screens/Login/ForgotPasswordForm.tsx:178
|
#: src/screens/Login/ForgotPasswordForm.tsx:178
|
||||||
#: src/view/com/modals/ChangePassword.tsx:171
|
#: src/view/com/modals/ChangePassword.tsx:171
|
||||||
|
@ -936,12 +936,12 @@ msgstr "Conversazione silenziata"
|
||||||
#: src/screens/Messages/List/index.tsx:88
|
#: src/screens/Messages/List/index.tsx:88
|
||||||
#: src/view/screens/Settings/index.tsx:638
|
#: src/view/screens/Settings/index.tsx:638
|
||||||
msgid "Chat settings"
|
msgid "Chat settings"
|
||||||
msgstr ""
|
msgstr "Impostazioni messaggi"
|
||||||
|
|
||||||
#: src/screens/Messages/Settings.tsx:59
|
#: src/screens/Messages/Settings.tsx:59
|
||||||
#: src/view/screens/Settings/index.tsx:647
|
#: src/view/screens/Settings/index.tsx:647
|
||||||
msgid "Chat Settings"
|
msgid "Chat Settings"
|
||||||
msgstr ""
|
msgstr "Impostazioni messaggi"
|
||||||
|
|
||||||
#: src/components/dms/ConvoMenu.tsx:84
|
#: src/components/dms/ConvoMenu.tsx:84
|
||||||
msgid "Chat unmuted"
|
msgid "Chat unmuted"
|
||||||
|
@ -1043,7 +1043,7 @@ msgstr ""
|
||||||
|
|
||||||
#: src/screens/Feeds/NoFollowingFeed.tsx:46
|
#: src/screens/Feeds/NoFollowingFeed.tsx:46
|
||||||
#~ msgid "Click here to add one."
|
#~ msgid "Click here to add one."
|
||||||
#~ msgstr ""
|
#~ msgstr "Clicca qui per aggiungerne uno."
|
||||||
|
|
||||||
#: src/components/TagMenu/index.web.tsx:138
|
#: src/components/TagMenu/index.web.tsx:138
|
||||||
msgid "Click here to open tag menu for {tag}"
|
msgid "Click here to open tag menu for {tag}"
|
||||||
|
@ -1665,14 +1665,14 @@ msgstr "Scoraggia le app dal mostrare il mio account agli utenti disconnessi"
|
||||||
#: src/view/com/posts/FollowingEmptyState.tsx:70
|
#: src/view/com/posts/FollowingEmptyState.tsx:70
|
||||||
#: src/view/com/posts/FollowingEndOfFeed.tsx:71
|
#: src/view/com/posts/FollowingEndOfFeed.tsx:71
|
||||||
msgid "Discover new custom feeds"
|
msgid "Discover new custom feeds"
|
||||||
msgstr "Scopri nuovi feeds personalizzati"
|
msgstr "Scopri nuovi feed personalizzati"
|
||||||
|
|
||||||
#~ msgid "Discover new feeds"
|
#~ msgid "Discover new feeds"
|
||||||
#~ msgstr "Scopri nuovi feeds"
|
#~ msgstr "Scopri nuovi feed"
|
||||||
|
|
||||||
#: src/view/screens/Feeds.tsx:794
|
#: src/view/screens/Feeds.tsx:794
|
||||||
msgid "Discover New Feeds"
|
msgid "Discover New Feeds"
|
||||||
msgstr "Scopri nuovi feeds"
|
msgstr "Scopri nuovi feed"
|
||||||
|
|
||||||
#: src/view/com/modals/EditProfile.tsx:193
|
#: src/view/com/modals/EditProfile.tsx:193
|
||||||
msgid "Display name"
|
msgid "Display name"
|
||||||
|
@ -1831,7 +1831,7 @@ msgstr "Modifica l'elenco di moderazione"
|
||||||
#: src/view/screens/Feeds.tsx:469
|
#: src/view/screens/Feeds.tsx:469
|
||||||
#: src/view/screens/SavedFeeds.tsx:93
|
#: src/view/screens/SavedFeeds.tsx:93
|
||||||
msgid "Edit My Feeds"
|
msgid "Edit My Feeds"
|
||||||
msgstr "Modifica i miei feeds"
|
msgstr "Modifica i miei feed"
|
||||||
|
|
||||||
#: src/view/com/modals/EditProfile.tsx:153
|
#: src/view/com/modals/EditProfile.tsx:153
|
||||||
msgid "Edit my profile"
|
msgid "Edit my profile"
|
||||||
|
@ -1850,7 +1850,7 @@ msgstr "Modifica il Profilo"
|
||||||
#: src/view/com/home/HomeHeaderLayout.web.tsx:76
|
#: src/view/com/home/HomeHeaderLayout.web.tsx:76
|
||||||
#: src/view/screens/Feeds.tsx:416
|
#: src/view/screens/Feeds.tsx:416
|
||||||
#~ msgid "Edit Saved Feeds"
|
#~ msgid "Edit Saved Feeds"
|
||||||
#~ msgstr "Modifica i feeds memorizzati"
|
#~ msgstr "Modifica i feed memorizzati"
|
||||||
|
|
||||||
#: src/view/com/modals/CreateOrEditList.tsx:234
|
#: src/view/com/modals/CreateOrEditList.tsx:234
|
||||||
msgid "Edit User List"
|
msgid "Edit User List"
|
||||||
|
@ -1927,7 +1927,7 @@ msgstr "Attiva il contenuto per adulti"
|
||||||
#: src/screens/Onboarding/StepModeration/AdultContentEnabledPref.tsx:78
|
#: src/screens/Onboarding/StepModeration/AdultContentEnabledPref.tsx:78
|
||||||
#: src/screens/Onboarding/StepModeration/AdultContentEnabledPref.tsx:79
|
#: src/screens/Onboarding/StepModeration/AdultContentEnabledPref.tsx:79
|
||||||
#~ msgid "Enable adult content in your feeds"
|
#~ msgid "Enable adult content in your feeds"
|
||||||
#~ msgstr "Abilita i contenuti per adulti nei tuoi feeds"
|
#~ msgstr "Abilita i contenuti per adulti nei tuoi feed"
|
||||||
|
|
||||||
#: src/components/dialogs/EmbedConsent.tsx:82
|
#: src/components/dialogs/EmbedConsent.tsx:82
|
||||||
#: src/components/dialogs/EmbedConsent.tsx:89
|
#: src/components/dialogs/EmbedConsent.tsx:89
|
||||||
|
@ -2202,7 +2202,7 @@ msgstr "Commenti"
|
||||||
#: src/view/shell/Drawer.tsx:493
|
#: src/view/shell/Drawer.tsx:493
|
||||||
#: src/view/shell/Drawer.tsx:494
|
#: src/view/shell/Drawer.tsx:494
|
||||||
msgid "Feeds"
|
msgid "Feeds"
|
||||||
msgstr "Feeds"
|
msgstr "Feed"
|
||||||
|
|
||||||
#~ msgid "Feeds are created by users to curate content. Choose some feeds that you find interesting."
|
#~ msgid "Feeds are created by users to curate content. Choose some feeds that you find interesting."
|
||||||
#~ msgstr "I feed vengono creati dagli utenti per curare i contenuti. Scegli alcuni feed che ritieni interessanti."
|
#~ msgstr "I feed vengono creati dagli utenti per curare i contenuti. Scegli alcuni feed che ritieni interessanti."
|
||||||
|
@ -2213,7 +2213,7 @@ msgstr "I feed sono algoritmi personalizzati che gli utenti creano con un minimo
|
||||||
|
|
||||||
#: src/screens/Onboarding/StepTopicalFeeds.tsx:80
|
#: src/screens/Onboarding/StepTopicalFeeds.tsx:80
|
||||||
#~ msgid "Feeds can be topical as well!"
|
#~ msgid "Feeds can be topical as well!"
|
||||||
#~ msgstr "I feeds possono anche avere tematiche!"
|
#~ msgstr "I feed possono anche avere tematiche!"
|
||||||
|
|
||||||
#: src/view/com/modals/ChangeHandle.tsx:475
|
#: src/view/com/modals/ChangeHandle.tsx:475
|
||||||
msgid "File Contents"
|
msgid "File Contents"
|
||||||
|
@ -3214,7 +3214,7 @@ msgstr "Il messaggio è troppo lungo"
|
||||||
|
|
||||||
#: src/screens/Messages/List/index.tsx:321
|
#: src/screens/Messages/List/index.tsx:321
|
||||||
msgid "Message settings"
|
msgid "Message settings"
|
||||||
msgstr "Impostazione messaggio"
|
msgstr "Impostazioni messaggio"
|
||||||
|
|
||||||
#: src/Navigation.tsx:504
|
#: src/Navigation.tsx:504
|
||||||
#: src/screens/Messages/List/index.tsx:164
|
#: src/screens/Messages/List/index.tsx:164
|
||||||
|
@ -3412,7 +3412,7 @@ msgstr "Il mio Compleanno"
|
||||||
|
|
||||||
#: src/view/screens/Feeds.tsx:768
|
#: src/view/screens/Feeds.tsx:768
|
||||||
msgid "My Feeds"
|
msgid "My Feeds"
|
||||||
msgstr "I miei Feeds"
|
msgstr "I miei Feed"
|
||||||
|
|
||||||
#: src/view/shell/desktop/LeftNav.tsx:84
|
#: src/view/shell/desktop/LeftNav.tsx:84
|
||||||
msgid "My Profile"
|
msgid "My Profile"
|
||||||
|
@ -3424,7 +3424,7 @@ msgstr "I miei feed salvati"
|
||||||
|
|
||||||
#: src/view/screens/Settings/index.tsx:622
|
#: src/view/screens/Settings/index.tsx:622
|
||||||
msgid "My Saved Feeds"
|
msgid "My Saved Feeds"
|
||||||
msgstr "I miei Feeds Salvati"
|
msgstr "I miei Feed Salvati"
|
||||||
|
|
||||||
#~ msgid "my-server.com"
|
#~ msgid "my-server.com"
|
||||||
#~ msgstr "my-server.com"
|
#~ msgstr "my-server.com"
|
||||||
|
@ -3862,7 +3862,7 @@ msgstr "Apre la fotocamera sul dispositivo"
|
||||||
|
|
||||||
#: src/view/screens/Settings/index.tsx:639
|
#: src/view/screens/Settings/index.tsx:639
|
||||||
msgid "Opens chat settings"
|
msgid "Opens chat settings"
|
||||||
msgstr ""
|
msgstr "Apre impostazioni messaggi"
|
||||||
|
|
||||||
#: src/view/com/composer/Prompt.tsx:27
|
#: src/view/com/composer/Prompt.tsx:27
|
||||||
msgid "Opens composer"
|
msgid "Opens composer"
|
||||||
|
@ -4112,7 +4112,7 @@ msgstr "Fissa su Home"
|
||||||
|
|
||||||
#: src/view/screens/SavedFeeds.tsx:103
|
#: src/view/screens/SavedFeeds.tsx:103
|
||||||
msgid "Pinned Feeds"
|
msgid "Pinned Feeds"
|
||||||
msgstr "Feeds Fissi"
|
msgstr "Feed Fissi"
|
||||||
|
|
||||||
#: src/view/screens/ProfileList.tsx:289
|
#: src/view/screens/ProfileList.tsx:289
|
||||||
msgid "Pinned to your feeds"
|
msgid "Pinned to your feeds"
|
||||||
|
@ -4380,7 +4380,7 @@ msgstr "Elenchi pubblici e condivisibili di utenti da disattivare o bloccare in
|
||||||
|
|
||||||
#: src/view/screens/Lists.tsx:66
|
#: src/view/screens/Lists.tsx:66
|
||||||
msgid "Public, shareable lists which can drive feeds."
|
msgid "Public, shareable lists which can drive feeds."
|
||||||
msgstr "Liste pubbliche e condivisibili che possono impulsare i feeds."
|
msgstr "Liste pubbliche e condivisibili che possono impulsare i feed."
|
||||||
|
|
||||||
#: src/view/com/composer/Composer.tsx:462
|
#: src/view/com/composer/Composer.tsx:462
|
||||||
msgid "Publish post"
|
msgid "Publish post"
|
||||||
|
@ -4431,7 +4431,7 @@ msgid "Recent Searches"
|
||||||
msgstr "Ricerche recenti"
|
msgstr "Ricerche recenti"
|
||||||
|
|
||||||
#~ msgid "Recommended Feeds"
|
#~ msgid "Recommended Feeds"
|
||||||
#~ msgstr "Feeds consigliati"
|
#~ msgstr "Feed consigliati"
|
||||||
|
|
||||||
#~ msgid "Recommended Users"
|
#~ msgid "Recommended Users"
|
||||||
#~ msgstr "Utenti consigliati"
|
#~ msgstr "Utenti consigliati"
|
||||||
|
@ -4454,7 +4454,7 @@ msgid "Remove"
|
||||||
msgstr "Rimuovi"
|
msgstr "Rimuovi"
|
||||||
|
|
||||||
#~ msgid "Remove {0} from my feeds?"
|
#~ msgid "Remove {0} from my feeds?"
|
||||||
#~ msgstr "Rimuovere {0} dai miei feeds?"
|
#~ msgstr "Rimuovere {0} dai miei feed?"
|
||||||
|
|
||||||
#: src/view/com/util/AccountDropdownBtn.tsx:22
|
#: src/view/com/util/AccountDropdownBtn.tsx:22
|
||||||
msgid "Remove account"
|
msgid "Remove account"
|
||||||
|
@ -4524,14 +4524,14 @@ msgid "Remove repost"
|
||||||
msgstr "Rimuovi la ripubblicazione"
|
msgstr "Rimuovi la ripubblicazione"
|
||||||
|
|
||||||
#~ msgid "Remove this feed from my feeds?"
|
#~ msgid "Remove this feed from my feeds?"
|
||||||
#~ msgstr "Rimuovere questo feed dai miei feeds?"
|
#~ msgstr "Rimuovere questo feed dai miei feed?"
|
||||||
|
|
||||||
#: src/view/com/posts/FeedErrorMessage.tsx:210
|
#: src/view/com/posts/FeedErrorMessage.tsx:210
|
||||||
msgid "Remove this feed from your saved feeds"
|
msgid "Remove this feed from your saved feeds"
|
||||||
msgstr "Rimuovi questo feed dai feed salvati"
|
msgstr "Rimuovi questo feed dai feed salvati"
|
||||||
|
|
||||||
#~ msgid "Remove this feed from your saved feeds?"
|
#~ msgid "Remove this feed from your saved feeds?"
|
||||||
#~ msgstr "Elimina questo feed dai feeds salvati?"
|
#~ msgstr "Elimina questo feed dai feed salvati?"
|
||||||
|
|
||||||
#: src/view/com/modals/ListAddRemoveUsers.tsx:199
|
#: src/view/com/modals/ListAddRemoveUsers.tsx:199
|
||||||
#: src/view/com/modals/UserAddRemoveLists.tsx:165
|
#: src/view/com/modals/UserAddRemoveLists.tsx:165
|
||||||
|
@ -4540,7 +4540,7 @@ msgstr "Elimina dalla lista"
|
||||||
|
|
||||||
#: src/view/com/feeds/FeedSourceCard.tsx:139
|
#: src/view/com/feeds/FeedSourceCard.tsx:139
|
||||||
msgid "Removed from my feeds"
|
msgid "Removed from my feeds"
|
||||||
msgstr "Rimuovere dai miei feeds"
|
msgstr "Rimuovere dai miei feed"
|
||||||
|
|
||||||
#: src/view/com/posts/FeedShutdownMsg.tsx:44
|
#: src/view/com/posts/FeedShutdownMsg.tsx:44
|
||||||
#: src/view/screens/ProfileFeed.tsx:191
|
#: src/view/screens/ProfileFeed.tsx:191
|
||||||
|
@ -5047,7 +5047,7 @@ msgstr "Seleziona il servizio che ospita i tuoi dati."
|
||||||
|
|
||||||
#: src/screens/Onboarding/StepTopicalFeeds.tsx:100
|
#: src/screens/Onboarding/StepTopicalFeeds.tsx:100
|
||||||
#~ msgid "Select topical feeds to follow from the list below"
|
#~ msgid "Select topical feeds to follow from the list below"
|
||||||
#~ msgstr "Seleziona i feeds con temi da seguire dal seguente elenco"
|
#~ msgstr "Seleziona i feed con temi da seguire dal seguente elenco"
|
||||||
|
|
||||||
#: src/screens/Onboarding/StepModeration/index.tsx:63
|
#: src/screens/Onboarding/StepModeration/index.tsx:63
|
||||||
#~ msgid "Select what you want to see (or not see), and we’ll handle the rest."
|
#~ msgid "Select what you want to see (or not see), and we’ll handle the rest."
|
||||||
|
@ -6154,7 +6154,7 @@ msgid "This will delete {0} from your muted words. You can always add it back la
|
||||||
msgstr "Questo eliminerà {0} dalle parole disattivate. Puoi sempre aggiungerla nuovamente in seguito."
|
msgstr "Questo eliminerà {0} dalle parole disattivate. Puoi sempre aggiungerla nuovamente in seguito."
|
||||||
|
|
||||||
#~ msgid "This will hide this post from your feeds."
|
#~ msgid "This will hide this post from your feeds."
|
||||||
#~ msgstr "Questo nasconderà il post dai tuoi feeds."
|
#~ msgstr "Questo nasconderà il post dai tuoi feed."
|
||||||
|
|
||||||
#: src/view/screens/Settings/index.tsx:594
|
#: src/view/screens/Settings/index.tsx:594
|
||||||
msgid "Thread preferences"
|
msgid "Thread preferences"
|
||||||
|
@ -6783,7 +6783,7 @@ msgstr "Che lingue sono utilizzate in questo post?"
|
||||||
|
|
||||||
#: src/view/com/modals/lang-settings/ContentLanguagesSettings.tsx:77
|
#: src/view/com/modals/lang-settings/ContentLanguagesSettings.tsx:77
|
||||||
msgid "Which languages would you like to see in your algorithmic feeds?"
|
msgid "Which languages would you like to see in your algorithmic feeds?"
|
||||||
msgstr "Quali lingue vorresti vedere negli algoritmi dei tuoi feeds?"
|
msgstr "Quali lingue vorresti vedere negli algoritmi dei tuoi feed?"
|
||||||
|
|
||||||
#: src/components/dms/MessagesNUX.tsx:110
|
#: src/components/dms/MessagesNUX.tsx:110
|
||||||
#: src/components/dms/MessagesNUX.tsx:124
|
#: src/components/dms/MessagesNUX.tsx:124
|
||||||
|
@ -6901,7 +6901,7 @@ msgstr "Puoi modificarlo in qualsiasi momento."
|
||||||
|
|
||||||
#: src/screens/Messages/Settings.tsx:111
|
#: src/screens/Messages/Settings.tsx:111
|
||||||
msgid "You can continue ongoing conversations regardless of which setting you choose."
|
msgid "You can continue ongoing conversations regardless of which setting you choose."
|
||||||
msgstr ""
|
msgstr "Puoi proseguire le conversazioni in corso indipendentemente da quale settaggio scegli."
|
||||||
|
|
||||||
#: src/screens/Login/index.tsx:158
|
#: src/screens/Login/index.tsx:158
|
||||||
#: src/screens/Login/PasswordUpdatedForm.tsx:33
|
#: src/screens/Login/PasswordUpdatedForm.tsx:33
|
||||||
|
@ -6982,7 +6982,7 @@ msgstr "Non hai ancora nessuna conversazione. Avviane una!"
|
||||||
|
|
||||||
#: src/view/com/feeds/ProfileFeedgens.tsx:141
|
#: src/view/com/feeds/ProfileFeedgens.tsx:141
|
||||||
msgid "You have no feeds."
|
msgid "You have no feeds."
|
||||||
msgstr "Non hai feeds."
|
msgstr "Non hai feed."
|
||||||
|
|
||||||
#: src/view/com/lists/MyLists.tsx:90
|
#: src/view/com/lists/MyLists.tsx:90
|
||||||
#: src/view/com/lists/ProfileLists.tsx:145
|
#: src/view/com/lists/ProfileLists.tsx:145
|
||||||
|
|
|
@ -8,7 +8,7 @@ msgstr ""
|
||||||
"Language: ja\n"
|
"Language: ja\n"
|
||||||
"Project-Id-Version: \n"
|
"Project-Id-Version: \n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"PO-Revision-Date: 2024-06-05 11:06+0900\n"
|
"PO-Revision-Date: 2024-06-19 11:10+0900\n"
|
||||||
"Last-Translator: tkusano\n"
|
"Last-Translator: tkusano\n"
|
||||||
"Language-Team: Hima-Zinn, tkusano, dolciss, oboenikui, noritada, middlingphys, hibiki, reindex-ot, haoyayoi, vyv03354\n"
|
"Language-Team: Hima-Zinn, tkusano, dolciss, oboenikui, noritada, middlingphys, hibiki, reindex-ot, haoyayoi, vyv03354\n"
|
||||||
"Plural-Forms: \n"
|
"Plural-Forms: \n"
|
||||||
|
@ -37,10 +37,6 @@ msgstr "{0, plural, other {#個のラベルがこのコンテンツに適用さ
|
||||||
msgid "{0, plural, one {# repost} other {# reposts}}"
|
msgid "{0, plural, one {# repost} other {# reposts}}"
|
||||||
msgstr "{0, plural, other {#回のリポスト}}"
|
msgstr "{0, plural, other {#回のリポスト}}"
|
||||||
|
|
||||||
#: src/components/KnownFollowers.tsx:179
|
|
||||||
msgid "{0, plural, one {and # other} other {and # others}}"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: src/components/ProfileHoverCard/index.web.tsx:376
|
#: src/components/ProfileHoverCard/index.web.tsx:376
|
||||||
#: src/screens/Profile/Header/Metrics.tsx:23
|
#: src/screens/Profile/Header/Metrics.tsx:23
|
||||||
msgid "{0, plural, one {follower} other {followers}}"
|
msgid "{0, plural, one {follower} other {followers}}"
|
||||||
|
@ -87,6 +83,26 @@ msgstr "{0}のアバター"
|
||||||
msgid "{count, plural, one {Liked by # user} other {Liked by # users}}"
|
msgid "{count, plural, one {Liked by # user} other {Liked by # users}}"
|
||||||
msgstr "{count, plural, other {#人のユーザーがいいね}}"
|
msgstr "{count, plural, other {#人のユーザーがいいね}}"
|
||||||
|
|
||||||
|
#: src/lib/hooks/useTimeAgo.ts:69
|
||||||
|
msgid "{diff, plural, one {day} other {days}}"
|
||||||
|
msgstr "{diff, plural, other {日}}"
|
||||||
|
|
||||||
|
#: src/lib/hooks/useTimeAgo.ts:64
|
||||||
|
msgid "{diff, plural, one {hour} other {hours}}"
|
||||||
|
msgstr "{diff, plural, other {時間}}"
|
||||||
|
|
||||||
|
#: src/lib/hooks/useTimeAgo.ts:59
|
||||||
|
msgid "{diff, plural, one {minute} other {minutes}}"
|
||||||
|
msgstr "{diff, plural, other {分}}"
|
||||||
|
|
||||||
|
#: src/lib/hooks/useTimeAgo.ts:75
|
||||||
|
msgid "{diff, plural, one {month} other {months}}"
|
||||||
|
msgstr "{diff, plural, other {ヶ月}}"
|
||||||
|
|
||||||
|
#: src/lib/hooks/useTimeAgo.ts:54
|
||||||
|
msgid "{diffSeconds, plural, one {second} other {seconds}}"
|
||||||
|
msgstr "{diffSeconds, plural, other {秒}}"
|
||||||
|
|
||||||
#: src/screens/SignupQueued.tsx:207
|
#: src/screens/SignupQueued.tsx:207
|
||||||
msgid "{estimatedTimeHrs, plural, one {hour} other {hours}}"
|
msgid "{estimatedTimeHrs, plural, one {hour} other {hours}}"
|
||||||
msgstr "{estimatedTimeHrs, plural, other {時間}}"
|
msgstr "{estimatedTimeHrs, plural, other {時間}}"
|
||||||
|
@ -114,6 +130,10 @@ msgstr "{likeCount, plural, other {#人のユーザーがいいね}}"
|
||||||
msgid "{numUnreadNotifications} unread"
|
msgid "{numUnreadNotifications} unread"
|
||||||
msgstr "{numUnreadNotifications}件の未読"
|
msgstr "{numUnreadNotifications}件の未読"
|
||||||
|
|
||||||
|
#: src/components/NewskieDialog.tsx:75
|
||||||
|
msgid "{profileName} joined Bluesky {0} ago"
|
||||||
|
msgstr "{profileName}はBlueskyに{0}前に参加しました"
|
||||||
|
|
||||||
#: src/view/screens/PreferencesFollowingFeed.tsx:67
|
#: src/view/screens/PreferencesFollowingFeed.tsx:67
|
||||||
msgid "{value, plural, =0 {Show all replies} one {Show replies with at least # like} other {Show replies with at least # likes}}"
|
msgid "{value, plural, =0 {Show all replies} one {Show replies with at least # like} other {Show replies with at least # likes}}"
|
||||||
msgstr "{value, plural, =0 {すべての返信を表示} other {#個以上のいいねがついた返信を表示}}"
|
msgstr "{value, plural, =0 {すべての返信を表示} other {#個以上のいいねがついた返信を表示}}"
|
||||||
|
@ -270,6 +290,10 @@ msgstr "フォローしているユーザーのみのデフォルトのフィー
|
||||||
msgid "Add the following DNS record to your domain:"
|
msgid "Add the following DNS record to your domain:"
|
||||||
msgstr "次のDNSレコードをドメインに追加してください:"
|
msgstr "次のDNSレコードをドメインに追加してください:"
|
||||||
|
|
||||||
|
#: src/components/FeedCard.tsx:173
|
||||||
|
msgid "Add this feed to your feeds"
|
||||||
|
msgstr "このフィードをあなたのフィードに追加する"
|
||||||
|
|
||||||
#: src/view/com/profile/ProfileMenu.tsx:265
|
#: src/view/com/profile/ProfileMenu.tsx:265
|
||||||
#: src/view/com/profile/ProfileMenu.tsx:268
|
#: src/view/com/profile/ProfileMenu.tsx:268
|
||||||
msgid "Add to Lists"
|
msgid "Add to Lists"
|
||||||
|
@ -469,6 +493,10 @@ msgstr "この会話から退出しますか?あなたのメッセージはあ
|
||||||
msgid "Are you sure you want to remove {0} from your feeds?"
|
msgid "Are you sure you want to remove {0} from your feeds?"
|
||||||
msgstr "あなたのフィードから{0}を削除してもよろしいですか?"
|
msgstr "あなたのフィードから{0}を削除してもよろしいですか?"
|
||||||
|
|
||||||
|
#: src/components/FeedCard.tsx:190
|
||||||
|
msgid "Are you sure you want to remove this from your feeds?"
|
||||||
|
msgstr "本当にこのフィードをあなたのフィードから削除したいですか?"
|
||||||
|
|
||||||
#: src/view/com/composer/Composer.tsx:630
|
#: src/view/com/composer/Composer.tsx:630
|
||||||
msgid "Are you sure you'd like to discard this draft?"
|
msgid "Are you sure you'd like to discard this draft?"
|
||||||
msgstr "本当にこの下書きを破棄しますか?"
|
msgstr "本当にこの下書きを破棄しますか?"
|
||||||
|
@ -1092,7 +1120,7 @@ msgstr "{0}として続行(現在サインイン中)"
|
||||||
|
|
||||||
#: src/view/com/post-thread/PostThreadLoadMore.tsx:52
|
#: src/view/com/post-thread/PostThreadLoadMore.tsx:52
|
||||||
msgid "Continue thread..."
|
msgid "Continue thread..."
|
||||||
msgstr ""
|
msgstr "スレッドの続き…"
|
||||||
|
|
||||||
#: src/screens/Onboarding/StepInterests/index.tsx:250
|
#: src/screens/Onboarding/StepInterests/index.tsx:250
|
||||||
#: src/screens/Onboarding/StepProfile/index.tsx:266
|
#: src/screens/Onboarding/StepProfile/index.tsx:266
|
||||||
|
@ -1419,6 +1447,10 @@ msgstr "アプリがログアウトしたユーザーに自分のアカウント
|
||||||
msgid "Discover new custom feeds"
|
msgid "Discover new custom feeds"
|
||||||
msgstr "新しいカスタムフィードを見つける"
|
msgstr "新しいカスタムフィードを見つける"
|
||||||
|
|
||||||
|
#: src/view/screens/Search/Explore.tsx:378
|
||||||
|
msgid "Discover new feeds"
|
||||||
|
msgstr "新しいフィードを探す"
|
||||||
|
|
||||||
#: src/view/screens/Feeds.tsx:794
|
#: src/view/screens/Feeds.tsx:794
|
||||||
msgid "Discover New Feeds"
|
msgid "Discover New Feeds"
|
||||||
msgstr "新しいフィードを探す"
|
msgstr "新しいフィードを探す"
|
||||||
|
@ -1534,16 +1566,16 @@ msgstr "例:返信として広告を繰り返し送ってくるユーザー。
|
||||||
msgid "Each code works once. You'll receive more invite codes periodically."
|
msgid "Each code works once. You'll receive more invite codes periodically."
|
||||||
msgstr "それぞれのコードは一回限り有効です。定期的に追加の招待コードをお送りします。"
|
msgstr "それぞれのコードは一回限り有効です。定期的に追加の招待コードをお送りします。"
|
||||||
|
|
||||||
#: src/view/screens/Feeds.tsx:400
|
|
||||||
#: src/view/screens/Feeds.tsx:471
|
|
||||||
msgid "Edit"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: src/view/com/lists/ListMembers.tsx:149
|
#: src/view/com/lists/ListMembers.tsx:149
|
||||||
msgctxt "action"
|
msgctxt "action"
|
||||||
msgid "Edit"
|
msgid "Edit"
|
||||||
msgstr "編集"
|
msgstr "編集"
|
||||||
|
|
||||||
|
#: src/view/screens/Feeds.tsx:400
|
||||||
|
#: src/view/screens/Feeds.tsx:471
|
||||||
|
msgid "Edit"
|
||||||
|
msgstr "編集"
|
||||||
|
|
||||||
#: src/view/com/util/UserAvatar.tsx:312
|
#: src/view/com/util/UserAvatar.tsx:312
|
||||||
#: src/view/com/util/UserBanner.tsx:92
|
#: src/view/com/util/UserBanner.tsx:92
|
||||||
msgid "Edit avatar"
|
msgid "Edit avatar"
|
||||||
|
@ -1583,11 +1615,6 @@ msgstr "プロフィールを編集"
|
||||||
msgid "Edit Profile"
|
msgid "Edit Profile"
|
||||||
msgstr "プロフィールを編集"
|
msgstr "プロフィールを編集"
|
||||||
|
|
||||||
#: src/view/com/home/HomeHeaderLayout.web.tsx:76
|
|
||||||
#: src/view/screens/Feeds.tsx:416
|
|
||||||
#~ msgid "Edit Saved Feeds"
|
|
||||||
#~ msgstr "保存されたフィードを編集"
|
|
||||||
|
|
||||||
#: src/view/com/modals/CreateOrEditList.tsx:234
|
#: src/view/com/modals/CreateOrEditList.tsx:234
|
||||||
msgid "Edit User List"
|
msgid "Edit User List"
|
||||||
msgstr "ユーザーリストを編集"
|
msgstr "ユーザーリストを編集"
|
||||||
|
@ -1754,6 +1781,10 @@ msgstr "全員"
|
||||||
msgid "Everybody can reply"
|
msgid "Everybody can reply"
|
||||||
msgstr "誰でも返信可能"
|
msgstr "誰でも返信可能"
|
||||||
|
|
||||||
|
#: src/view/com/threadgate/WhoCanReply.tsx:129
|
||||||
|
msgid "Everybody can reply."
|
||||||
|
msgstr "誰でも返信可能です。"
|
||||||
|
|
||||||
#: src/components/dms/MessagesNUX.tsx:131
|
#: src/components/dms/MessagesNUX.tsx:131
|
||||||
#: src/components/dms/MessagesNUX.tsx:134
|
#: src/components/dms/MessagesNUX.tsx:134
|
||||||
#: src/screens/Messages/Settings.tsx:75
|
#: src/screens/Messages/Settings.tsx:75
|
||||||
|
@ -1857,6 +1888,11 @@ msgstr "メッセージの削除に失敗しました"
|
||||||
msgid "Failed to delete post, please try again"
|
msgid "Failed to delete post, please try again"
|
||||||
msgstr "投稿の削除に失敗しました。もう一度お試しください。"
|
msgstr "投稿の削除に失敗しました。もう一度お試しください。"
|
||||||
|
|
||||||
|
#: src/view/screens/Search/Explore.tsx:414
|
||||||
|
#: src/view/screens/Search/Explore.tsx:438
|
||||||
|
msgid "Failed to load feeds preferences"
|
||||||
|
msgstr "フィードの設定の読み込みに失敗しました"
|
||||||
|
|
||||||
#: src/components/dialogs/GifSelect.ios.tsx:196
|
#: src/components/dialogs/GifSelect.ios.tsx:196
|
||||||
#: src/components/dialogs/GifSelect.tsx:212
|
#: src/components/dialogs/GifSelect.tsx:212
|
||||||
msgid "Failed to load GIFs"
|
msgid "Failed to load GIFs"
|
||||||
|
@ -1866,6 +1902,15 @@ msgstr "GIFの読み込みに失敗しました"
|
||||||
msgid "Failed to load past messages"
|
msgid "Failed to load past messages"
|
||||||
msgstr "過去のメッセージの読み込みに失敗しました"
|
msgstr "過去のメッセージの読み込みに失敗しました"
|
||||||
|
|
||||||
|
#: src/view/screens/Search/Explore.tsx:407
|
||||||
|
#: src/view/screens/Search/Explore.tsx:431
|
||||||
|
msgid "Failed to load suggested feeds"
|
||||||
|
msgstr "おすすめのフィードの読み込みに失敗しました"
|
||||||
|
|
||||||
|
#: src/view/screens/Search/Explore.tsx:367
|
||||||
|
msgid "Failed to load suggested follows"
|
||||||
|
msgstr "おすすめのフォローの読み込みに失敗しました"
|
||||||
|
|
||||||
#: src/view/com/lightbox/Lightbox.tsx:84
|
#: src/view/com/lightbox/Lightbox.tsx:84
|
||||||
msgid "Failed to save image: {0}"
|
msgid "Failed to save image: {0}"
|
||||||
msgstr "画像の保存に失敗しました:{0}"
|
msgstr "画像の保存に失敗しました:{0}"
|
||||||
|
@ -1879,6 +1924,14 @@ msgstr "送信に失敗"
|
||||||
msgid "Failed to submit appeal, please try again."
|
msgid "Failed to submit appeal, please try again."
|
||||||
msgstr "異議申し立ての送信に失敗しました。再度試してください。"
|
msgstr "異議申し立ての送信に失敗しました。再度試してください。"
|
||||||
|
|
||||||
|
#: src/view/com/util/forms/PostDropdownBtn.tsx:180
|
||||||
|
msgid "Failed to toggle thread mute, please try again"
|
||||||
|
msgstr "スレッドのミュートの切り替えに失敗しました。再度試してください"
|
||||||
|
|
||||||
|
#: src/components/FeedCard.tsx:153
|
||||||
|
msgid "Failed to update feeds"
|
||||||
|
msgstr "フィードの更新に失敗しました"
|
||||||
|
|
||||||
#: src/components/dms/MessagesNUX.tsx:60
|
#: src/components/dms/MessagesNUX.tsx:60
|
||||||
#: src/screens/Messages/Settings.tsx:35
|
#: src/screens/Messages/Settings.tsx:35
|
||||||
msgid "Failed to update settings"
|
msgid "Failed to update settings"
|
||||||
|
@ -1914,6 +1967,10 @@ msgstr "フィード"
|
||||||
msgid "Feeds are custom algorithms that users build with a little coding expertise. <0/> for more information."
|
msgid "Feeds are custom algorithms that users build with a little coding expertise. <0/> for more information."
|
||||||
msgstr "フィードはユーザーがプログラミングの専門知識を持って構築するカスタムアルゴリズムです。詳細については、<0/>を参照してください。"
|
msgstr "フィードはユーザーがプログラミングの専門知識を持って構築するカスタムアルゴリズムです。詳細については、<0/>を参照してください。"
|
||||||
|
|
||||||
|
#: src/components/FeedCard.tsx:150
|
||||||
|
msgid "Feeds updated!"
|
||||||
|
msgstr "フィードを更新しました!"
|
||||||
|
|
||||||
#: src/view/com/modals/ChangeHandle.tsx:475
|
#: src/view/com/modals/ChangeHandle.tsx:475
|
||||||
msgid "File Contents"
|
msgid "File Contents"
|
||||||
msgstr "ファイルのコンテンツ"
|
msgstr "ファイルのコンテンツ"
|
||||||
|
@ -1996,14 +2053,30 @@ msgstr "アカウントをフォロー"
|
||||||
msgid "Follow Back"
|
msgid "Follow Back"
|
||||||
msgstr "フォローバック"
|
msgstr "フォローバック"
|
||||||
|
|
||||||
#: src/components/KnownFollowers.tsx:169
|
#: src/view/screens/Search/Explore.tsx:332
|
||||||
msgid "Followed by"
|
msgid "Follow more accounts to get connected to your interests and build your network."
|
||||||
msgstr ""
|
msgstr "もっとたくさんのアカウントをフォローして、興味あることにつながり、ネットワークを広げましょう。"
|
||||||
|
|
||||||
#: src/view/com/profile/ProfileCard.tsx:227
|
#: src/view/com/profile/ProfileCard.tsx:227
|
||||||
msgid "Followed by {0}"
|
msgid "Followed by {0}"
|
||||||
msgstr "{0}がフォロー中"
|
msgstr "{0}がフォロー中"
|
||||||
|
|
||||||
|
#: src/components/KnownFollowers.tsx:192
|
||||||
|
msgid "Followed by <0>{0}</0>"
|
||||||
|
msgstr "<0>{0}</0>がフォロー中"
|
||||||
|
|
||||||
|
#: src/components/KnownFollowers.tsx:209
|
||||||
|
msgid "Followed by <0>{0}</0> and {1, plural, one {# other} other {# others}}"
|
||||||
|
msgstr "<0>{0}</0>および{1, plural, other {他#人}}がフォロー中"
|
||||||
|
|
||||||
|
#: src/components/KnownFollowers.tsx:181
|
||||||
|
msgid "Followed by <0>{0}</0> and <1>{1}</1>"
|
||||||
|
msgstr "<0>{0}</0>と<1>{1}</1>がフォロー中"
|
||||||
|
|
||||||
|
#: src/components/KnownFollowers.tsx:168
|
||||||
|
msgid "Followed by <0>{0}</0>, <1>{1}</1>, and {2, plural, one {# other} other {# others}}"
|
||||||
|
msgstr "<0>{0}</0>、<1>{1}</1>および{2, plural, other {他#人}}がフォロー中"
|
||||||
|
|
||||||
#: src/view/com/modals/Threadgate.tsx:99
|
#: src/view/com/modals/Threadgate.tsx:99
|
||||||
msgid "Followed users"
|
msgid "Followed users"
|
||||||
msgstr "自分がフォローしているユーザー"
|
msgstr "自分がフォローしているユーザー"
|
||||||
|
@ -2023,12 +2096,12 @@ msgstr "フォロワー"
|
||||||
|
|
||||||
#: src/Navigation.tsx:177
|
#: src/Navigation.tsx:177
|
||||||
msgid "Followers of @{0} that you know"
|
msgid "Followers of @{0} that you know"
|
||||||
msgstr ""
|
msgstr "あなたが知っている@{0}のフォロワー"
|
||||||
|
|
||||||
#: src/screens/Profile/KnownFollowers.tsx:108
|
#: src/screens/Profile/KnownFollowers.tsx:108
|
||||||
#: src/screens/Profile/KnownFollowers.tsx:118
|
#: src/screens/Profile/KnownFollowers.tsx:118
|
||||||
msgid "Followers you know"
|
msgid "Followers you know"
|
||||||
msgstr ""
|
msgstr "あなたが知っているフォロワー"
|
||||||
|
|
||||||
#: src/components/ProfileHoverCard/index.web.tsx:411
|
#: src/components/ProfileHoverCard/index.web.tsx:411
|
||||||
#: src/components/ProfileHoverCard/index.web.tsx:422
|
#: src/components/ProfileHoverCard/index.web.tsx:422
|
||||||
|
@ -2118,6 +2191,10 @@ msgstr "始める"
|
||||||
msgid "Get Started"
|
msgid "Get Started"
|
||||||
msgstr "開始"
|
msgstr "開始"
|
||||||
|
|
||||||
|
#: src/view/com/util/images/ImageHorzList.tsx:35
|
||||||
|
msgid "GIF"
|
||||||
|
msgstr "GIF"
|
||||||
|
|
||||||
#: src/screens/Onboarding/StepProfile/index.tsx:225
|
#: src/screens/Onboarding/StepProfile/index.tsx:225
|
||||||
msgid "Give your profile a face"
|
msgid "Give your profile a face"
|
||||||
msgstr "プロフィールに顔をつける"
|
msgstr "プロフィールに顔をつける"
|
||||||
|
@ -2658,6 +2735,18 @@ msgstr "リスト"
|
||||||
msgid "Lists blocking this user:"
|
msgid "Lists blocking this user:"
|
||||||
msgstr "このユーザーをブロックしているリスト:"
|
msgstr "このユーザーをブロックしているリスト:"
|
||||||
|
|
||||||
|
#: src/view/screens/Search/Explore.tsx:128
|
||||||
|
msgid "Load more"
|
||||||
|
msgstr "さらに読み込む"
|
||||||
|
|
||||||
|
#: src/view/screens/Search/Explore.tsx:216
|
||||||
|
msgid "Load more suggested feeds"
|
||||||
|
msgstr "おすすめのフィードをさらに読み込む"
|
||||||
|
|
||||||
|
#: src/view/screens/Search/Explore.tsx:214
|
||||||
|
msgid "Load more suggested follows"
|
||||||
|
msgstr "おすすめのフォローをさらに読み込む"
|
||||||
|
|
||||||
#: src/view/screens/Notifications.tsx:184
|
#: src/view/screens/Notifications.tsx:184
|
||||||
msgid "Load new notifications"
|
msgid "Load new notifications"
|
||||||
msgstr "最新の通知を読み込む"
|
msgstr "最新の通知を読み込む"
|
||||||
|
@ -3062,6 +3151,10 @@ msgctxt "action"
|
||||||
msgid "New Post"
|
msgid "New Post"
|
||||||
msgstr "新しい投稿"
|
msgstr "新しい投稿"
|
||||||
|
|
||||||
|
#: src/components/NewskieDialog.tsx:68
|
||||||
|
msgid "New user info dialog"
|
||||||
|
msgstr "新しいユーザー情報ダイアログ"
|
||||||
|
|
||||||
#: src/view/com/modals/CreateOrEditList.tsx:236
|
#: src/view/com/modals/CreateOrEditList.tsx:236
|
||||||
msgid "New User List"
|
msgid "New User List"
|
||||||
msgstr "新しいユーザーリスト"
|
msgstr "新しいユーザーリスト"
|
||||||
|
@ -3142,7 +3235,7 @@ msgstr "誰からも受け取らない"
|
||||||
|
|
||||||
#: src/screens/Profile/Sections/Feed.tsx:59
|
#: src/screens/Profile/Sections/Feed.tsx:59
|
||||||
msgid "No posts yet."
|
msgid "No posts yet."
|
||||||
msgstr ""
|
msgstr "まだ投稿がありません。"
|
||||||
|
|
||||||
#: src/view/com/composer/text-input/mobile/Autocomplete.tsx:101
|
#: src/view/com/composer/text-input/mobile/Autocomplete.tsx:101
|
||||||
#: src/view/com/composer/text-input/web/Autocomplete.tsx:195
|
#: src/view/com/composer/text-input/web/Autocomplete.tsx:195
|
||||||
|
@ -3236,6 +3329,10 @@ msgstr "通知音"
|
||||||
msgid "Notifications"
|
msgid "Notifications"
|
||||||
msgstr "通知"
|
msgstr "通知"
|
||||||
|
|
||||||
|
#: src/lib/hooks/useTimeAgo.ts:51
|
||||||
|
msgid "now"
|
||||||
|
msgstr "今"
|
||||||
|
|
||||||
#: src/components/dms/MessageItem.tsx:175
|
#: src/components/dms/MessageItem.tsx:175
|
||||||
msgid "Now"
|
msgid "Now"
|
||||||
msgstr "今"
|
msgstr "今"
|
||||||
|
@ -3274,6 +3371,10 @@ msgstr "OK"
|
||||||
msgid "Oldest replies first"
|
msgid "Oldest replies first"
|
||||||
msgstr "古い順に返信を表示"
|
msgstr "古い順に返信を表示"
|
||||||
|
|
||||||
|
#: src/lib/hooks/useTimeAgo.ts:81
|
||||||
|
msgid "on {str}"
|
||||||
|
msgstr "{str}"
|
||||||
|
|
||||||
#: src/view/screens/Settings/index.tsx:256
|
#: src/view/screens/Settings/index.tsx:256
|
||||||
msgid "Onboarding reset"
|
msgid "Onboarding reset"
|
||||||
msgstr "オンボーディングのリセット"
|
msgstr "オンボーディングのリセット"
|
||||||
|
@ -3449,11 +3550,6 @@ msgstr "モデレーションの設定を開く"
|
||||||
msgid "Opens password reset form"
|
msgid "Opens password reset form"
|
||||||
msgstr "パスワードリセットのフォームを開く"
|
msgstr "パスワードリセットのフォームを開く"
|
||||||
|
|
||||||
#: src/view/com/home/HomeHeaderLayout.web.tsx:77
|
|
||||||
#: src/view/screens/Feeds.tsx:417
|
|
||||||
#~ msgid "Opens screen to edit Saved Feeds"
|
|
||||||
#~ msgstr "保存されたフィードの編集画面を開く"
|
|
||||||
|
|
||||||
#: src/view/screens/Settings/index.tsx:617
|
#: src/view/screens/Settings/index.tsx:617
|
||||||
msgid "Opens screen with all saved feeds"
|
msgid "Opens screen with all saved feeds"
|
||||||
msgstr "保存されたすべてのフィードで画面を開く"
|
msgstr "保存されたすべてのフィードで画面を開く"
|
||||||
|
@ -3777,7 +3873,7 @@ msgstr "再実行する"
|
||||||
|
|
||||||
#: src/components/KnownFollowers.tsx:111
|
#: src/components/KnownFollowers.tsx:111
|
||||||
msgid "Press to view followers of this account that you also follow"
|
msgid "Press to view followers of this account that you also follow"
|
||||||
msgstr ""
|
msgstr "あなたもフォローしているこのアカウントのフォロワーを見る"
|
||||||
|
|
||||||
#: src/view/com/lightbox/Lightbox.web.tsx:150
|
#: src/view/com/lightbox/Lightbox.web.tsx:150
|
||||||
msgid "Previous image"
|
msgid "Previous image"
|
||||||
|
@ -3975,7 +4071,7 @@ msgstr "リストから削除されました"
|
||||||
|
|
||||||
#: src/view/com/feeds/FeedSourceCard.tsx:139
|
#: src/view/com/feeds/FeedSourceCard.tsx:139
|
||||||
msgid "Removed from my feeds"
|
msgid "Removed from my feeds"
|
||||||
msgstr "フィードから削除しました"
|
msgstr "マイフィードから削除しました"
|
||||||
|
|
||||||
#: src/view/com/posts/FeedShutdownMsg.tsx:44
|
#: src/view/com/posts/FeedShutdownMsg.tsx:44
|
||||||
#: src/view/screens/ProfileFeed.tsx:191
|
#: src/view/screens/ProfileFeed.tsx:191
|
||||||
|
@ -4000,9 +4096,9 @@ msgstr "Discoverで置き換える"
|
||||||
msgid "Replies"
|
msgid "Replies"
|
||||||
msgstr "返信"
|
msgstr "返信"
|
||||||
|
|
||||||
#: src/view/com/threadgate/WhoCanReply.tsx:98
|
#: src/view/com/threadgate/WhoCanReply.tsx:131
|
||||||
msgid "Replies to this thread are disabled"
|
msgid "Replies to this thread are disabled."
|
||||||
msgstr "このスレッドへの返信はできません"
|
msgstr "このスレッドへの返信はできません。"
|
||||||
|
|
||||||
#: src/view/com/composer/Composer.tsx:475
|
#: src/view/com/composer/Composer.tsx:475
|
||||||
msgctxt "action"
|
msgctxt "action"
|
||||||
|
@ -4019,6 +4115,11 @@ msgctxt "description"
|
||||||
msgid "Reply to <0><1/></0>"
|
msgid "Reply to <0><1/></0>"
|
||||||
msgstr "<0><1/></0>に返信"
|
msgstr "<0><1/></0>に返信"
|
||||||
|
|
||||||
|
#: src/view/com/posts/FeedItem.tsx:437
|
||||||
|
msgctxt "description"
|
||||||
|
msgid "Reply to a blocked post"
|
||||||
|
msgstr "ブロックした投稿への返信"
|
||||||
|
|
||||||
#: src/components/dms/MessageMenu.tsx:132
|
#: src/components/dms/MessageMenu.tsx:132
|
||||||
#: src/components/dms/MessagesListBlockedFooter.tsx:77
|
#: src/components/dms/MessagesListBlockedFooter.tsx:77
|
||||||
#: src/components/dms/MessagesListBlockedFooter.tsx:84
|
#: src/components/dms/MessagesListBlockedFooter.tsx:84
|
||||||
|
@ -4926,9 +5027,9 @@ msgstr "このラベラーを登録"
|
||||||
msgid "Subscribe to this list"
|
msgid "Subscribe to this list"
|
||||||
msgstr "このリストに登録"
|
msgstr "このリストに登録"
|
||||||
|
|
||||||
#: src/view/screens/Search/Search.tsx:425
|
#: src/view/screens/Search/Explore.tsx:330
|
||||||
msgid "Suggested Follows"
|
msgid "Suggested accounts"
|
||||||
msgstr "おすすめのフォロー"
|
msgstr "おすすめのアカウント"
|
||||||
|
|
||||||
#: src/view/com/profile/ProfileHeaderSuggestedFollows.tsx:65
|
#: src/view/com/profile/ProfileHeaderSuggestedFollows.tsx:65
|
||||||
msgid "Suggested for you"
|
msgid "Suggested for you"
|
||||||
|
@ -5077,7 +5178,7 @@ msgstr "サービス規約は移動しました"
|
||||||
|
|
||||||
#: src/screens/Settings/components/DeactivateAccountDialog.tsx:86
|
#: src/screens/Settings/components/DeactivateAccountDialog.tsx:86
|
||||||
msgid "There is no time limit for account deactivation, come back any time."
|
msgid "There is no time limit for account deactivation, come back any time."
|
||||||
msgstr "アカウントの無効化に期限はありません。いつでも戻ってこれます。"
|
msgstr "アカウントの無効化に期限はありません。いつでも戻ってこられます。"
|
||||||
|
|
||||||
#: src/screens/Profile/Header/ProfileHeaderLabeler.tsx:115
|
#: src/screens/Profile/Header/ProfileHeaderLabeler.tsx:115
|
||||||
#: src/view/screens/ProfileFeed.tsx:541
|
#: src/view/screens/ProfileFeed.tsx:541
|
||||||
|
@ -5217,7 +5318,7 @@ msgstr "このコンテンツはBlueskyのアカウントがないと閲覧で
|
||||||
|
|
||||||
#: src/screens/Messages/List/ChatListItem.tsx:213
|
#: src/screens/Messages/List/ChatListItem.tsx:213
|
||||||
msgid "This conversation is with a deleted or a deactivated account. Press for options."
|
msgid "This conversation is with a deleted or a deactivated account. Press for options."
|
||||||
msgstr ""
|
msgstr "削除あるいは無効化されたアカウントとの会話です。押すと選択肢が表示されます。"
|
||||||
|
|
||||||
#: src/view/screens/Settings/ExportCarDialog.tsx:93
|
#: src/view/screens/Settings/ExportCarDialog.tsx:93
|
||||||
msgid "This feature is in beta. You can read more about repository exports in <0>this blogpost</0>."
|
msgid "This feature is in beta. You can read more about repository exports in <0>this blogpost</0>."
|
||||||
|
@ -5227,12 +5328,6 @@ msgstr "この機能はベータ版です。リポジトリのエクスポート
|
||||||
msgid "This feed is currently receiving high traffic and is temporarily unavailable. Please try again later."
|
msgid "This feed is currently receiving high traffic and is temporarily unavailable. Please try again later."
|
||||||
msgstr "現在このフィードにはアクセスが集中しており、一時的にご利用いただけません。時間をおいてもう一度お試しください。"
|
msgstr "現在このフィードにはアクセスが集中しており、一時的にご利用いただけません。時間をおいてもう一度お試しください。"
|
||||||
|
|
||||||
#: src/screens/Profile/Sections/Feed.tsx:59
|
|
||||||
#: src/view/screens/ProfileFeed.tsx:471
|
|
||||||
#: src/view/screens/ProfileList.tsx:729
|
|
||||||
#~ msgid "This feed is empty!"
|
|
||||||
#~ msgstr "このフィードは空です!"
|
|
||||||
|
|
||||||
#: src/view/com/posts/CustomFeedEmptyState.tsx:37
|
#: src/view/com/posts/CustomFeedEmptyState.tsx:37
|
||||||
msgid "This feed is empty! You may need to follow more users or tune your language settings."
|
msgid "This feed is empty! You may need to follow more users or tune your language settings."
|
||||||
msgstr "このフィードは空です!もっと多くのユーザーをフォローするか、言語の設定を調整する必要があるかもしれません。"
|
msgstr "このフィードは空です!もっと多くのユーザーをフォローするか、言語の設定を調整する必要があるかもしれません。"
|
||||||
|
@ -5240,7 +5335,7 @@ msgstr "このフィードは空です!もっと多くのユーザーをフォ
|
||||||
#: src/view/screens/ProfileFeed.tsx:471
|
#: src/view/screens/ProfileFeed.tsx:471
|
||||||
#: src/view/screens/ProfileList.tsx:729
|
#: src/view/screens/ProfileList.tsx:729
|
||||||
msgid "This feed is empty."
|
msgid "This feed is empty."
|
||||||
msgstr ""
|
msgstr "このフィードは空です。"
|
||||||
|
|
||||||
#: src/view/com/posts/FeedShutdownMsg.tsx:97
|
#: src/view/com/posts/FeedShutdownMsg.tsx:97
|
||||||
msgid "This feed is no longer online. We are showing <0>Discover</0> instead."
|
msgid "This feed is no longer online. We are showing <0>Discover</0> instead."
|
||||||
|
@ -5336,6 +5431,10 @@ msgstr "このユーザーはブロックした<0>{0}</0>リストに含まれ
|
||||||
msgid "This user is included in the <0>{0}</0> list which you have muted."
|
msgid "This user is included in the <0>{0}</0> list which you have muted."
|
||||||
msgstr "このユーザーはミュートした<0>{0}</0>リストに含まれています。"
|
msgstr "このユーザーはミュートした<0>{0}</0>リストに含まれています。"
|
||||||
|
|
||||||
|
#: src/components/NewskieDialog.tsx:50
|
||||||
|
msgid "This user is new here. Press for more info about when they joined."
|
||||||
|
msgstr "新しいユーザーです。ここを押すといつ参加したかの情報が表示されます。"
|
||||||
|
|
||||||
#: src/view/com/profile/ProfileFollows.tsx:87
|
#: src/view/com/profile/ProfileFollows.tsx:87
|
||||||
msgid "This user isn't following anyone."
|
msgid "This user isn't following anyone."
|
||||||
msgstr "このユーザーは誰もフォローしていません。"
|
msgstr "このユーザーは誰もフォローしていません。"
|
||||||
|
@ -5762,6 +5861,10 @@ msgstr "{0}のアバターを表示"
|
||||||
msgid "View {0}'s profile"
|
msgid "View {0}'s profile"
|
||||||
msgstr "{0}のプロフィールを表示"
|
msgstr "{0}のプロフィールを表示"
|
||||||
|
|
||||||
|
#: src/components/ProfileHoverCard/index.web.tsx:417
|
||||||
|
msgid "View blocked user's profile"
|
||||||
|
msgstr "ブロック中のユーザーのプロフィールを表示"
|
||||||
|
|
||||||
#: src/view/screens/Log.tsx:52
|
#: src/view/screens/Log.tsx:52
|
||||||
msgid "View debug entry"
|
msgid "View debug entry"
|
||||||
msgstr "デバッグエントリーを表示"
|
msgstr "デバッグエントリーを表示"
|
||||||
|
@ -5804,7 +5907,7 @@ msgstr "このフィードにいいねしたユーザーを見る"
|
||||||
#: src/view/com/home/HomeHeaderLayout.web.tsx:78
|
#: src/view/com/home/HomeHeaderLayout.web.tsx:78
|
||||||
#: src/view/com/home/HomeHeaderLayoutMobile.tsx:84
|
#: src/view/com/home/HomeHeaderLayoutMobile.tsx:84
|
||||||
msgid "View your feeds and explore more"
|
msgid "View your feeds and explore more"
|
||||||
msgstr ""
|
msgstr "フィードを表示し、さらにフィードを探す"
|
||||||
|
|
||||||
#: src/view/com/modals/LinkWarning.tsx:89
|
#: src/view/com/modals/LinkWarning.tsx:89
|
||||||
#: src/view/com/modals/LinkWarning.tsx:95
|
#: src/view/com/modals/LinkWarning.tsx:95
|
||||||
|
@ -5891,7 +5994,7 @@ msgstr "大変申し訳ありませんが、検索を完了できませんでし
|
||||||
|
|
||||||
#: src/view/com/composer/Composer.tsx:318
|
#: src/view/com/composer/Composer.tsx:318
|
||||||
msgid "We're sorry! The post you are replying to has been deleted."
|
msgid "We're sorry! The post you are replying to has been deleted."
|
||||||
msgstr ""
|
msgstr "大変申し訳ありません!返信しようとしている投稿は削除されました。"
|
||||||
|
|
||||||
#: src/components/Lists.tsx:212
|
#: src/components/Lists.tsx:212
|
||||||
#: src/view/screens/NotFound.tsx:48
|
#: src/view/screens/NotFound.tsx:48
|
||||||
|
@ -5899,8 +6002,8 @@ msgid "We're sorry! We can't find the page you were looking for."
|
||||||
msgstr "大変申し訳ありません!お探しのページは見つかりません。"
|
msgstr "大変申し訳ありません!お探しのページは見つかりません。"
|
||||||
|
|
||||||
#: src/screens/Profile/Header/ProfileHeaderLabeler.tsx:330
|
#: src/screens/Profile/Header/ProfileHeaderLabeler.tsx:330
|
||||||
msgid "We're sorry! You can only subscribe to ten labelers, and you've reached your limit of ten."
|
msgid "We're sorry! You can only subscribe to twenty labelers, and you've reached your limit of twenty."
|
||||||
msgstr "大変申し訳ありません!ラベラーは10までしか登録できず、すでに上限に達しています。"
|
msgstr "大変申し訳ありません!ラベラーは20までしか登録できず、すでに上限に達しています。"
|
||||||
|
|
||||||
#: src/screens/Deactivated.tsx:128
|
#: src/screens/Deactivated.tsx:128
|
||||||
msgid "Welcome back!"
|
msgid "Welcome back!"
|
||||||
|
@ -6047,7 +6150,7 @@ msgstr "あなたはまだだれもフォロワーがいません。"
|
||||||
|
|
||||||
#: src/screens/Profile/KnownFollowers.tsx:99
|
#: src/screens/Profile/KnownFollowers.tsx:99
|
||||||
msgid "You don't follow any users who follow @{name}."
|
msgid "You don't follow any users who follow @{name}."
|
||||||
msgstr ""
|
msgstr "@{name}をフォローしているユーザーを誰もフォローしていません。"
|
||||||
|
|
||||||
#: src/view/com/modals/InviteCodes.tsx:67
|
#: src/view/com/modals/InviteCodes.tsx:67
|
||||||
msgid "You don't have any invite codes yet! We'll send you some when you've been on Bluesky for a little longer."
|
msgid "You don't have any invite codes yet! We'll send you some when you've been on Bluesky for a little longer."
|
||||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,65 @@
|
||||||
|
import React from 'react'
|
||||||
|
import {AccessibilityInfo} from 'react-native'
|
||||||
|
import {isReducedMotion} from 'react-native-reanimated'
|
||||||
|
|
||||||
|
import {isWeb} from '#/platform/detection'
|
||||||
|
|
||||||
|
const Context = React.createContext({
|
||||||
|
reduceMotionEnabled: false,
|
||||||
|
screenReaderEnabled: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
export function useA11y() {
|
||||||
|
return React.useContext(Context)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||||
|
const [reduceMotionEnabled, setReduceMotionEnabled] = React.useState(() =>
|
||||||
|
isReducedMotion(),
|
||||||
|
)
|
||||||
|
const [screenReaderEnabled, setScreenReaderEnabled] = React.useState(false)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const reduceMotionChangedSubscription = AccessibilityInfo.addEventListener(
|
||||||
|
'reduceMotionChanged',
|
||||||
|
enabled => {
|
||||||
|
setReduceMotionEnabled(enabled)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
const screenReaderChangedSubscription = AccessibilityInfo.addEventListener(
|
||||||
|
'screenReaderChanged',
|
||||||
|
enabled => {
|
||||||
|
setScreenReaderEnabled(enabled)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
;(async () => {
|
||||||
|
const [_reduceMotionEnabled, _screenReaderEnabled] = await Promise.all([
|
||||||
|
AccessibilityInfo.isReduceMotionEnabled(),
|
||||||
|
AccessibilityInfo.isScreenReaderEnabled(),
|
||||||
|
])
|
||||||
|
setReduceMotionEnabled(_reduceMotionEnabled)
|
||||||
|
setScreenReaderEnabled(_screenReaderEnabled)
|
||||||
|
})()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
reduceMotionChangedSubscription.remove()
|
||||||
|
screenReaderChangedSubscription.remove()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const ctx = React.useMemo(() => {
|
||||||
|
return {
|
||||||
|
reduceMotionEnabled,
|
||||||
|
/**
|
||||||
|
* Always returns true on web. For now, we're using this for mobile a11y,
|
||||||
|
* so we reset to false on web.
|
||||||
|
*
|
||||||
|
* @see https://github.com/necolas/react-native-web/discussions/2072
|
||||||
|
*/
|
||||||
|
screenReaderEnabled: isWeb ? false : screenReaderEnabled,
|
||||||
|
}
|
||||||
|
}, [reduceMotionEnabled, screenReaderEnabled])
|
||||||
|
|
||||||
|
return <Context.Provider value={ctx}>{children}</Context.Provider>
|
||||||
|
}
|
|
@ -4,6 +4,7 @@ import {AppBskyFeedDefs, BskyAgent} from '@atproto/api'
|
||||||
import throttle from 'lodash.throttle'
|
import throttle from 'lodash.throttle'
|
||||||
|
|
||||||
import {PROD_DEFAULT_FEED} from '#/lib/constants'
|
import {PROD_DEFAULT_FEED} from '#/lib/constants'
|
||||||
|
import {logEvent} from '#/lib/statsig/statsig'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {
|
import {
|
||||||
FeedDescriptor,
|
FeedDescriptor,
|
||||||
|
@ -34,6 +35,16 @@ export function useFeedFeedback(feed: FeedDescriptor, hasSession: boolean) {
|
||||||
WeakSet<FeedPostSliceItem | AppBskyFeedDefs.Interaction>
|
WeakSet<FeedPostSliceItem | AppBskyFeedDefs.Interaction>
|
||||||
>(new WeakSet())
|
>(new WeakSet())
|
||||||
|
|
||||||
|
const aggregatedStats = React.useRef<AggregatedStats | null>(null)
|
||||||
|
const throttledFlushAggregatedStats = React.useMemo(
|
||||||
|
() =>
|
||||||
|
throttle(() => flushToStatsig(aggregatedStats.current), 45e3, {
|
||||||
|
leading: true, // The outer call is already throttled somewhat.
|
||||||
|
trailing: true,
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
const sendToFeedNoDelay = React.useCallback(() => {
|
const sendToFeedNoDelay = React.useCallback(() => {
|
||||||
const proxyAgent = agent.withProxy(
|
const proxyAgent = agent.withProxy(
|
||||||
// @ts-ignore TODO need to update withProxy() to support this key -prf
|
// @ts-ignore TODO need to update withProxy() to support this key -prf
|
||||||
|
@ -45,12 +56,20 @@ export function useFeedFeedback(feed: FeedDescriptor, hasSession: boolean) {
|
||||||
const interactions = Array.from(queue.current).map(toInteraction)
|
const interactions = Array.from(queue.current).map(toInteraction)
|
||||||
queue.current.clear()
|
queue.current.clear()
|
||||||
|
|
||||||
|
// Send to the feed
|
||||||
proxyAgent.app.bsky.feed
|
proxyAgent.app.bsky.feed
|
||||||
.sendInteractions({interactions})
|
.sendInteractions({interactions})
|
||||||
.catch((e: any) => {
|
.catch((e: any) => {
|
||||||
logger.warn('Failed to send feed interactions', {error: e})
|
logger.warn('Failed to send feed interactions', {error: e})
|
||||||
})
|
})
|
||||||
}, [agent])
|
|
||||||
|
// Send to Statsig
|
||||||
|
if (aggregatedStats.current === null) {
|
||||||
|
aggregatedStats.current = createAggregatedStats()
|
||||||
|
}
|
||||||
|
sendOrAggregateInteractionsForStats(aggregatedStats.current, interactions)
|
||||||
|
throttledFlushAggregatedStats()
|
||||||
|
}, [agent, throttledFlushAggregatedStats])
|
||||||
|
|
||||||
const sendToFeed = React.useMemo(
|
const sendToFeed = React.useMemo(
|
||||||
() =>
|
() =>
|
||||||
|
@ -149,3 +168,89 @@ function toInteraction(str: string): AppBskyFeedDefs.Interaction {
|
||||||
const [item, event, feedContext] = str.split('|')
|
const [item, event, feedContext] = str.split('|')
|
||||||
return {item, event, feedContext}
|
return {item, event, feedContext}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AggregatedStats = {
|
||||||
|
clickthroughCount: number
|
||||||
|
engagedCount: number
|
||||||
|
seenCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function createAggregatedStats(): AggregatedStats {
|
||||||
|
return {
|
||||||
|
clickthroughCount: 0,
|
||||||
|
engagedCount: 0,
|
||||||
|
seenCount: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendOrAggregateInteractionsForStats(
|
||||||
|
stats: AggregatedStats,
|
||||||
|
interactions: AppBskyFeedDefs.Interaction[],
|
||||||
|
) {
|
||||||
|
for (let interaction of interactions) {
|
||||||
|
switch (interaction.event) {
|
||||||
|
// Pressing "Show more" / "Show less" is relatively uncommon so we won't aggregate them.
|
||||||
|
// This lets us send the feed context together with them.
|
||||||
|
case 'app.bsky.feed.defs#requestLess': {
|
||||||
|
logEvent('discover:showLess', {
|
||||||
|
feedContext: interaction.feedContext ?? '',
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'app.bsky.feed.defs#requestMore': {
|
||||||
|
logEvent('discover:showMore', {
|
||||||
|
feedContext: interaction.feedContext ?? '',
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// The rest of the events are aggregated and sent later in batches.
|
||||||
|
case 'app.bsky.feed.defs#clickthroughAuthor':
|
||||||
|
case 'app.bsky.feed.defs#clickthroughEmbed':
|
||||||
|
case 'app.bsky.feed.defs#clickthroughItem':
|
||||||
|
case 'app.bsky.feed.defs#clickthroughReposter': {
|
||||||
|
stats.clickthroughCount++
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'app.bsky.feed.defs#interactionLike':
|
||||||
|
case 'app.bsky.feed.defs#interactionQuote':
|
||||||
|
case 'app.bsky.feed.defs#interactionReply':
|
||||||
|
case 'app.bsky.feed.defs#interactionRepost':
|
||||||
|
case 'app.bsky.feed.defs#interactionShare': {
|
||||||
|
stats.engagedCount++
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'app.bsky.feed.defs#interactionSeen': {
|
||||||
|
stats.seenCount++
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function flushToStatsig(stats: AggregatedStats | null) {
|
||||||
|
if (stats === null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stats.clickthroughCount > 0) {
|
||||||
|
logEvent('discover:clickthrough:sampled', {
|
||||||
|
count: stats.clickthroughCount,
|
||||||
|
})
|
||||||
|
stats.clickthroughCount = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stats.engagedCount > 0) {
|
||||||
|
logEvent('discover:engaged:sampled', {
|
||||||
|
count: stats.engagedCount,
|
||||||
|
})
|
||||||
|
stats.engagedCount = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stats.seenCount > 0) {
|
||||||
|
logEvent('discover:seen:sampled', {
|
||||||
|
count: stats.seenCount,
|
||||||
|
})
|
||||||
|
stats.seenCount = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -9,20 +9,24 @@ import {
|
||||||
} from '@atproto/api'
|
} from '@atproto/api'
|
||||||
import {
|
import {
|
||||||
InfiniteData,
|
InfiniteData,
|
||||||
|
QueryClient,
|
||||||
QueryKey,
|
QueryKey,
|
||||||
useInfiniteQuery,
|
useInfiniteQuery,
|
||||||
useMutation,
|
useMutation,
|
||||||
useQuery,
|
useQuery,
|
||||||
|
useQueryClient,
|
||||||
} from '@tanstack/react-query'
|
} from '@tanstack/react-query'
|
||||||
|
|
||||||
import {DISCOVER_FEED_URI, DISCOVER_SAVED_FEED} from '#/lib/constants'
|
import {DISCOVER_FEED_URI, DISCOVER_SAVED_FEED} from '#/lib/constants'
|
||||||
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||||
import {STALE} from '#/state/queries'
|
import {STALE} from '#/state/queries'
|
||||||
|
import {RQKEY as listQueryKey} from '#/state/queries/list'
|
||||||
import {usePreferencesQuery} from '#/state/queries/preferences'
|
import {usePreferencesQuery} from '#/state/queries/preferences'
|
||||||
import {useAgent, useSession} from '#/state/session'
|
import {useAgent, useSession} from '#/state/session'
|
||||||
import {router} from '#/routes'
|
import {router} from '#/routes'
|
||||||
import {FeedDescriptor} from './post-feed'
|
import {FeedDescriptor} from './post-feed'
|
||||||
|
import {precacheResolvedUri} from './resolve-uri'
|
||||||
|
|
||||||
export type FeedSourceFeedInfo = {
|
export type FeedSourceFeedInfo = {
|
||||||
type: 'feed'
|
type: 'feed'
|
||||||
|
@ -201,6 +205,7 @@ export function useGetPopularFeedsQuery(options?: GetPopularFeedsOptions) {
|
||||||
const agent = useAgent()
|
const agent = useAgent()
|
||||||
const limit = options?.limit || 10
|
const limit = options?.limit || 10
|
||||||
const {data: preferences} = usePreferencesQuery()
|
const {data: preferences} = usePreferencesQuery()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
// Make sure this doesn't invalidate unless really needed.
|
// Make sure this doesn't invalidate unless really needed.
|
||||||
const selectArgs = useMemo(
|
const selectArgs = useMemo(
|
||||||
|
@ -225,6 +230,13 @@ export function useGetPopularFeedsQuery(options?: GetPopularFeedsOptions) {
|
||||||
limit,
|
limit,
|
||||||
cursor: pageParam,
|
cursor: pageParam,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// precache feeds
|
||||||
|
for (const feed of res.data.feeds) {
|
||||||
|
const hydratedFeed = hydrateFeedGenerator(feed)
|
||||||
|
precacheFeed(queryClient, hydratedFeed)
|
||||||
|
}
|
||||||
|
|
||||||
return res.data
|
return res.data
|
||||||
},
|
},
|
||||||
initialPageParam: undefined,
|
initialPageParam: undefined,
|
||||||
|
@ -449,3 +461,138 @@ export function usePinnedFeedsInfos() {
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SavedFeedItem =
|
||||||
|
| {
|
||||||
|
type: 'feed'
|
||||||
|
config: AppBskyActorDefs.SavedFeed
|
||||||
|
view: AppBskyFeedDefs.GeneratorView
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'list'
|
||||||
|
config: AppBskyActorDefs.SavedFeed
|
||||||
|
view: AppBskyGraphDefs.ListView
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'timeline'
|
||||||
|
config: AppBskyActorDefs.SavedFeed
|
||||||
|
view: undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSavedFeeds() {
|
||||||
|
const agent = useAgent()
|
||||||
|
const {data: preferences, isLoading: isLoadingPrefs} = usePreferencesQuery()
|
||||||
|
const savedItems = preferences?.savedFeeds ?? []
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
return useQuery({
|
||||||
|
staleTime: STALE.INFINITY,
|
||||||
|
enabled: !isLoadingPrefs,
|
||||||
|
queryKey: [pinnedFeedInfosQueryKeyRoot, ...savedItems],
|
||||||
|
placeholderData: previousData => {
|
||||||
|
return (
|
||||||
|
previousData || {
|
||||||
|
count: savedItems.length,
|
||||||
|
feeds: [],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
queryFn: async () => {
|
||||||
|
const resolvedFeeds = new Map<string, AppBskyFeedDefs.GeneratorView>()
|
||||||
|
const resolvedLists = new Map<string, AppBskyGraphDefs.ListView>()
|
||||||
|
|
||||||
|
const savedFeeds = savedItems.filter(feed => feed.type === 'feed')
|
||||||
|
const savedLists = savedItems.filter(feed => feed.type === 'list')
|
||||||
|
|
||||||
|
let feedsPromise = Promise.resolve()
|
||||||
|
if (savedFeeds.length > 0) {
|
||||||
|
feedsPromise = agent.app.bsky.feed
|
||||||
|
.getFeedGenerators({
|
||||||
|
feeds: savedFeeds.map(f => f.value),
|
||||||
|
})
|
||||||
|
.then(res => {
|
||||||
|
res.data.feeds.forEach(f => {
|
||||||
|
resolvedFeeds.set(f.uri, f)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const listsPromises = savedLists.map(list =>
|
||||||
|
agent.app.bsky.graph
|
||||||
|
.getList({
|
||||||
|
list: list.value,
|
||||||
|
limit: 1,
|
||||||
|
})
|
||||||
|
.then(res => {
|
||||||
|
const listView = res.data.list
|
||||||
|
resolvedLists.set(listView.uri, listView)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await Promise.allSettled([feedsPromise, ...listsPromises])
|
||||||
|
|
||||||
|
resolvedFeeds.forEach(feed => {
|
||||||
|
const hydratedFeed = hydrateFeedGenerator(feed)
|
||||||
|
precacheFeed(queryClient, hydratedFeed)
|
||||||
|
})
|
||||||
|
resolvedLists.forEach(list => {
|
||||||
|
precacheList(queryClient, list)
|
||||||
|
})
|
||||||
|
|
||||||
|
const res: SavedFeedItem[] = savedItems.map(s => {
|
||||||
|
if (s.type === 'timeline') {
|
||||||
|
return {
|
||||||
|
type: 'timeline',
|
||||||
|
config: s,
|
||||||
|
view: undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: s.type,
|
||||||
|
config: s,
|
||||||
|
view:
|
||||||
|
s.type === 'feed'
|
||||||
|
? resolvedFeeds.get(s.value)
|
||||||
|
: resolvedLists.get(s.value),
|
||||||
|
}
|
||||||
|
}) as SavedFeedItem[]
|
||||||
|
|
||||||
|
return {
|
||||||
|
count: savedItems.length,
|
||||||
|
feeds: res,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function precacheFeed(queryClient: QueryClient, hydratedFeed: FeedSourceInfo) {
|
||||||
|
precacheResolvedUri(
|
||||||
|
queryClient,
|
||||||
|
hydratedFeed.creatorHandle,
|
||||||
|
hydratedFeed.creatorDid,
|
||||||
|
)
|
||||||
|
queryClient.setQueryData<FeedSourceInfo>(
|
||||||
|
feedSourceInfoQueryKey({uri: hydratedFeed.uri}),
|
||||||
|
hydratedFeed,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function precacheList(
|
||||||
|
queryClient: QueryClient,
|
||||||
|
list: AppBskyGraphDefs.ListView,
|
||||||
|
) {
|
||||||
|
precacheResolvedUri(queryClient, list.creator.handle, list.creator.did)
|
||||||
|
queryClient.setQueryData<AppBskyGraphDefs.ListView>(
|
||||||
|
listQueryKey(list.uri),
|
||||||
|
list,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function precacheFeedFromGeneratorView(
|
||||||
|
queryClient: QueryClient,
|
||||||
|
view: AppBskyFeedDefs.GeneratorView,
|
||||||
|
) {
|
||||||
|
const hydratedFeed = hydrateFeedGenerator(view)
|
||||||
|
precacheFeed(queryClient, hydratedFeed)
|
||||||
|
}
|
||||||
|
|
|
@ -1,5 +1,10 @@
|
||||||
import {AppBskyActorDefs, AtUri} from '@atproto/api'
|
import {AppBskyActorDefs, AtUri} from '@atproto/api'
|
||||||
import {useQuery, useQueryClient, UseQueryResult} from '@tanstack/react-query'
|
import {
|
||||||
|
QueryClient,
|
||||||
|
useQuery,
|
||||||
|
useQueryClient,
|
||||||
|
UseQueryResult,
|
||||||
|
} from '@tanstack/react-query'
|
||||||
|
|
||||||
import {STALE} from '#/state/queries'
|
import {STALE} from '#/state/queries'
|
||||||
import {useAgent} from '#/state/session'
|
import {useAgent} from '#/state/session'
|
||||||
|
@ -50,3 +55,11 @@ export function useResolveDidQuery(didOrHandle: string | undefined) {
|
||||||
enabled: !!didOrHandle,
|
enabled: !!didOrHandle,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function precacheResolvedUri(
|
||||||
|
queryClient: QueryClient,
|
||||||
|
handle: string,
|
||||||
|
did: string,
|
||||||
|
) {
|
||||||
|
queryClient.setQueryData<string>(RQKEY(handle), did)
|
||||||
|
}
|
||||||
|
|
|
@ -34,13 +34,14 @@ const suggestedFollowsByActorQueryKey = (did: string) => [
|
||||||
did,
|
did,
|
||||||
]
|
]
|
||||||
|
|
||||||
type SuggestedFollowsOptions = {limit?: number}
|
type SuggestedFollowsOptions = {limit?: number; subsequentPageLimit?: number}
|
||||||
|
|
||||||
export function useSuggestedFollowsQuery(options?: SuggestedFollowsOptions) {
|
export function useSuggestedFollowsQuery(options?: SuggestedFollowsOptions) {
|
||||||
const {currentAccount} = useSession()
|
const {currentAccount} = useSession()
|
||||||
const agent = useAgent()
|
const agent = useAgent()
|
||||||
const moderationOpts = useModerationOpts()
|
const moderationOpts = useModerationOpts()
|
||||||
const {data: preferences} = usePreferencesQuery()
|
const {data: preferences} = usePreferencesQuery()
|
||||||
|
const limit = options?.limit || 25
|
||||||
|
|
||||||
return useInfiniteQuery<
|
return useInfiniteQuery<
|
||||||
AppBskyActorGetSuggestions.OutputSchema,
|
AppBskyActorGetSuggestions.OutputSchema,
|
||||||
|
@ -54,9 +55,13 @@ export function useSuggestedFollowsQuery(options?: SuggestedFollowsOptions) {
|
||||||
queryKey: suggestedFollowsQueryKey(options),
|
queryKey: suggestedFollowsQueryKey(options),
|
||||||
queryFn: async ({pageParam}) => {
|
queryFn: async ({pageParam}) => {
|
||||||
const contentLangs = getContentLanguages().join(',')
|
const contentLangs = getContentLanguages().join(',')
|
||||||
|
const maybeDifferentLimit =
|
||||||
|
options?.subsequentPageLimit && pageParam
|
||||||
|
? options.subsequentPageLimit
|
||||||
|
: limit
|
||||||
const res = await agent.app.bsky.actor.getSuggestions(
|
const res = await agent.app.bsky.actor.getSuggestions(
|
||||||
{
|
{
|
||||||
limit: options?.limit || 25,
|
limit: maybeDifferentLimit,
|
||||||
cursor: pageParam,
|
cursor: pageParam,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
@ -19,6 +19,7 @@ import {
|
||||||
import {getInitialState, reducer} from './reducer'
|
import {getInitialState, reducer} from './reducer'
|
||||||
|
|
||||||
export {isSignupQueued} from './util'
|
export {isSignupQueued} from './util'
|
||||||
|
import {addSessionDebugLog} from './logging'
|
||||||
export type {SessionAccount} from '#/state/session/types'
|
export type {SessionAccount} from '#/state/session/types'
|
||||||
import {SessionApiContext, SessionStateContext} from '#/state/session/types'
|
import {SessionApiContext, SessionStateContext} from '#/state/session/types'
|
||||||
|
|
||||||
|
@ -40,9 +41,11 @@ const ApiContext = React.createContext<SessionApiContext>({
|
||||||
|
|
||||||
export function Provider({children}: React.PropsWithChildren<{}>) {
|
export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||||
const cancelPendingTask = useOneTaskAtATime()
|
const cancelPendingTask = useOneTaskAtATime()
|
||||||
const [state, dispatch] = React.useReducer(reducer, null, () =>
|
const [state, dispatch] = React.useReducer(reducer, null, () => {
|
||||||
getInitialState(persisted.get('session').accounts),
|
const initialState = getInitialState(persisted.get('session').accounts)
|
||||||
)
|
addSessionDebugLog({type: 'reducer:init', state: initialState})
|
||||||
|
return initialState
|
||||||
|
})
|
||||||
|
|
||||||
const onAgentSessionChange = React.useCallback(
|
const onAgentSessionChange = React.useCallback(
|
||||||
(agent: BskyAgent, accountDid: string, sessionEvent: AtpSessionEvent) => {
|
(agent: BskyAgent, accountDid: string, sessionEvent: AtpSessionEvent) => {
|
||||||
|
@ -63,6 +66,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||||
|
|
||||||
const createAccount = React.useCallback<SessionApiContext['createAccount']>(
|
const createAccount = React.useCallback<SessionApiContext['createAccount']>(
|
||||||
async params => {
|
async params => {
|
||||||
|
addSessionDebugLog({type: 'method:start', method: 'createAccount'})
|
||||||
const signal = cancelPendingTask()
|
const signal = cancelPendingTask()
|
||||||
track('Try Create Account')
|
track('Try Create Account')
|
||||||
logEvent('account:create:begin', {})
|
logEvent('account:create:begin', {})
|
||||||
|
@ -81,12 +85,14 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||||
})
|
})
|
||||||
track('Create Account')
|
track('Create Account')
|
||||||
logEvent('account:create:success', {})
|
logEvent('account:create:success', {})
|
||||||
|
addSessionDebugLog({type: 'method:end', method: 'createAccount', account})
|
||||||
},
|
},
|
||||||
[onAgentSessionChange, cancelPendingTask],
|
[onAgentSessionChange, cancelPendingTask],
|
||||||
)
|
)
|
||||||
|
|
||||||
const login = React.useCallback<SessionApiContext['login']>(
|
const login = React.useCallback<SessionApiContext['login']>(
|
||||||
async (params, logContext) => {
|
async (params, logContext) => {
|
||||||
|
addSessionDebugLog({type: 'method:start', method: 'login'})
|
||||||
const signal = cancelPendingTask()
|
const signal = cancelPendingTask()
|
||||||
const {agent, account} = await createAgentAndLogin(
|
const {agent, account} = await createAgentAndLogin(
|
||||||
params,
|
params,
|
||||||
|
@ -103,23 +109,31 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||||
})
|
})
|
||||||
track('Sign In', {resumedSession: false})
|
track('Sign In', {resumedSession: false})
|
||||||
logEvent('account:loggedIn', {logContext, withPassword: true})
|
logEvent('account:loggedIn', {logContext, withPassword: true})
|
||||||
|
addSessionDebugLog({type: 'method:end', method: 'login', account})
|
||||||
},
|
},
|
||||||
[onAgentSessionChange, cancelPendingTask],
|
[onAgentSessionChange, cancelPendingTask],
|
||||||
)
|
)
|
||||||
|
|
||||||
const logout = React.useCallback<SessionApiContext['logout']>(
|
const logout = React.useCallback<SessionApiContext['logout']>(
|
||||||
logContext => {
|
logContext => {
|
||||||
|
addSessionDebugLog({type: 'method:start', method: 'logout'})
|
||||||
cancelPendingTask()
|
cancelPendingTask()
|
||||||
dispatch({
|
dispatch({
|
||||||
type: 'logged-out',
|
type: 'logged-out',
|
||||||
})
|
})
|
||||||
logEvent('account:loggedOut', {logContext})
|
logEvent('account:loggedOut', {logContext})
|
||||||
|
addSessionDebugLog({type: 'method:end', method: 'logout'})
|
||||||
},
|
},
|
||||||
[cancelPendingTask],
|
[cancelPendingTask],
|
||||||
)
|
)
|
||||||
|
|
||||||
const resumeSession = React.useCallback<SessionApiContext['resumeSession']>(
|
const resumeSession = React.useCallback<SessionApiContext['resumeSession']>(
|
||||||
async storedAccount => {
|
async storedAccount => {
|
||||||
|
addSessionDebugLog({
|
||||||
|
type: 'method:start',
|
||||||
|
method: 'resumeSession',
|
||||||
|
account: storedAccount,
|
||||||
|
})
|
||||||
const signal = cancelPendingTask()
|
const signal = cancelPendingTask()
|
||||||
const {agent, account} = await createAgentAndResume(
|
const {agent, account} = await createAgentAndResume(
|
||||||
storedAccount,
|
storedAccount,
|
||||||
|
@ -134,17 +148,24 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||||
newAgent: agent,
|
newAgent: agent,
|
||||||
newAccount: account,
|
newAccount: account,
|
||||||
})
|
})
|
||||||
|
addSessionDebugLog({type: 'method:end', method: 'resumeSession', account})
|
||||||
},
|
},
|
||||||
[onAgentSessionChange, cancelPendingTask],
|
[onAgentSessionChange, cancelPendingTask],
|
||||||
)
|
)
|
||||||
|
|
||||||
const removeAccount = React.useCallback<SessionApiContext['removeAccount']>(
|
const removeAccount = React.useCallback<SessionApiContext['removeAccount']>(
|
||||||
account => {
|
account => {
|
||||||
|
addSessionDebugLog({
|
||||||
|
type: 'method:start',
|
||||||
|
method: 'removeAccount',
|
||||||
|
account,
|
||||||
|
})
|
||||||
cancelPendingTask()
|
cancelPendingTask()
|
||||||
dispatch({
|
dispatch({
|
||||||
type: 'removed-account',
|
type: 'removed-account',
|
||||||
accountDid: account.did,
|
accountDid: account.did,
|
||||||
})
|
})
|
||||||
|
addSessionDebugLog({type: 'method:end', method: 'removeAccount', account})
|
||||||
},
|
},
|
||||||
[cancelPendingTask],
|
[cancelPendingTask],
|
||||||
)
|
)
|
||||||
|
@ -152,18 +173,21 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (state.needsPersist) {
|
if (state.needsPersist) {
|
||||||
state.needsPersist = false
|
state.needsPersist = false
|
||||||
persisted.write('session', {
|
const persistedData = {
|
||||||
accounts: state.accounts,
|
accounts: state.accounts,
|
||||||
currentAccount: state.accounts.find(
|
currentAccount: state.accounts.find(
|
||||||
a => a.did === state.currentAgentState.did,
|
a => a.did === state.currentAgentState.did,
|
||||||
),
|
),
|
||||||
})
|
}
|
||||||
|
addSessionDebugLog({type: 'persisted:broadcast', data: persistedData})
|
||||||
|
persisted.write('session', persistedData)
|
||||||
}
|
}
|
||||||
}, [state])
|
}, [state])
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
return persisted.onUpdate(() => {
|
return persisted.onUpdate(() => {
|
||||||
const synced = persisted.get('session')
|
const synced = persisted.get('session')
|
||||||
|
addSessionDebugLog({type: 'persisted:receive', data: synced})
|
||||||
dispatch({
|
dispatch({
|
||||||
type: 'synced-accounts',
|
type: 'synced-accounts',
|
||||||
syncedAccounts: synced.accounts,
|
syncedAccounts: synced.accounts,
|
||||||
|
@ -177,7 +201,14 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||||
resumeSession(syncedAccount)
|
resumeSession(syncedAccount)
|
||||||
} else {
|
} else {
|
||||||
const agent = state.currentAgentState.agent as BskyAgent
|
const agent = state.currentAgentState.agent as BskyAgent
|
||||||
|
const prevSession = agent.session
|
||||||
agent.session = sessionAccountToSession(syncedAccount)
|
agent.session = sessionAccountToSession(syncedAccount)
|
||||||
|
addSessionDebugLog({
|
||||||
|
type: 'agent:patch',
|
||||||
|
agent,
|
||||||
|
prevSession,
|
||||||
|
nextSession: agent.session,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
@ -215,6 +246,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||||
// Read the previous value and immediately advance the pointer.
|
// Read the previous value and immediately advance the pointer.
|
||||||
const prevAgent = currentAgentRef.current
|
const prevAgent = currentAgentRef.current
|
||||||
currentAgentRef.current = agent
|
currentAgentRef.current = agent
|
||||||
|
addSessionDebugLog({type: 'agent:switch', prevAgent, nextAgent: agent})
|
||||||
// We never reuse agents so let's fully neutralize the previous one.
|
// We never reuse agents so let's fully neutralize the previous one.
|
||||||
// This ensures it won't try to consume any refresh tokens.
|
// This ensures it won't try to consume any refresh tokens.
|
||||||
prevAgent.session = undefined
|
prevAgent.session = undefined
|
||||||
|
|
|
@ -0,0 +1,137 @@
|
||||||
|
import {AtpSessionData} from '@atproto/api'
|
||||||
|
import {sha256} from 'js-sha256'
|
||||||
|
import {Statsig} from 'statsig-react-native-expo'
|
||||||
|
|
||||||
|
import {Schema} from '../persisted'
|
||||||
|
import {Action, State} from './reducer'
|
||||||
|
import {SessionAccount} from './types'
|
||||||
|
|
||||||
|
type Reducer = (state: State, action: Action) => State
|
||||||
|
|
||||||
|
type Log =
|
||||||
|
| {
|
||||||
|
type: 'reducer:init'
|
||||||
|
state: State
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'reducer:call'
|
||||||
|
action: Action
|
||||||
|
prevState: State
|
||||||
|
nextState: State
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'method:start'
|
||||||
|
method:
|
||||||
|
| 'createAccount'
|
||||||
|
| 'login'
|
||||||
|
| 'logout'
|
||||||
|
| 'resumeSession'
|
||||||
|
| 'removeAccount'
|
||||||
|
account?: SessionAccount
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'method:end'
|
||||||
|
method:
|
||||||
|
| 'createAccount'
|
||||||
|
| 'login'
|
||||||
|
| 'logout'
|
||||||
|
| 'resumeSession'
|
||||||
|
| 'removeAccount'
|
||||||
|
account?: SessionAccount
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'persisted:broadcast'
|
||||||
|
data: Schema['session']
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'persisted:receive'
|
||||||
|
data: Schema['session']
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'agent:switch'
|
||||||
|
prevAgent: object
|
||||||
|
nextAgent: object
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'agent:patch'
|
||||||
|
agent: object
|
||||||
|
prevSession: AtpSessionData | undefined
|
||||||
|
nextSession: AtpSessionData
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wrapSessionReducerForLogging(reducer: Reducer): Reducer {
|
||||||
|
return function loggingWrapper(prevState: State, action: Action): State {
|
||||||
|
const nextState = reducer(prevState, action)
|
||||||
|
addSessionDebugLog({type: 'reducer:call', prevState, action, nextState})
|
||||||
|
return nextState
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextMessageIndex = 0
|
||||||
|
const MAX_SLICE_LENGTH = 1000
|
||||||
|
|
||||||
|
export function addSessionDebugLog(log: Log) {
|
||||||
|
try {
|
||||||
|
if (!Statsig.initializeCalled() || !Statsig.getStableID()) {
|
||||||
|
// Drop these logs for now.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!Statsig.checkGate('debug_session')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const messageIndex = nextMessageIndex++
|
||||||
|
const {type, ...content} = log
|
||||||
|
let payload = JSON.stringify(content, replacer)
|
||||||
|
|
||||||
|
let nextSliceIndex = 0
|
||||||
|
while (payload.length > 0) {
|
||||||
|
const sliceIndex = nextSliceIndex++
|
||||||
|
const slice = payload.slice(0, MAX_SLICE_LENGTH)
|
||||||
|
payload = payload.slice(MAX_SLICE_LENGTH)
|
||||||
|
Statsig.logEvent('session:debug', null, {
|
||||||
|
realmId,
|
||||||
|
messageIndex: String(messageIndex),
|
||||||
|
messageType: type,
|
||||||
|
sliceIndex: String(sliceIndex),
|
||||||
|
slice,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let agentIds = new WeakMap<object, string>()
|
||||||
|
let realmId = Math.random().toString(36).slice(2)
|
||||||
|
let nextAgentId = 1
|
||||||
|
|
||||||
|
function getAgentId(agent: object) {
|
||||||
|
let id = agentIds.get(agent)
|
||||||
|
if (id === undefined) {
|
||||||
|
id = realmId + '::' + nextAgentId++
|
||||||
|
agentIds.set(agent, id)
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
function replacer(key: string, value: unknown) {
|
||||||
|
if (typeof value === 'object' && value != null && 'api' in value) {
|
||||||
|
return getAgentId(value)
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
key === 'service' ||
|
||||||
|
key === 'email' ||
|
||||||
|
key === 'emailConfirmed' ||
|
||||||
|
key === 'emailAuthFactor' ||
|
||||||
|
key === 'pdsUrl'
|
||||||
|
) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
typeof value === 'string' &&
|
||||||
|
(key === 'refreshJwt' || key === 'accessJwt')
|
||||||
|
) {
|
||||||
|
return sha256(value)
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
|
@ -1,6 +1,7 @@
|
||||||
import {AtpSessionEvent} from '@atproto/api'
|
import {AtpSessionEvent} from '@atproto/api'
|
||||||
|
|
||||||
import {createPublicAgent} from './agent'
|
import {createPublicAgent} from './agent'
|
||||||
|
import {wrapSessionReducerForLogging} from './logging'
|
||||||
import {SessionAccount} from './types'
|
import {SessionAccount} from './types'
|
||||||
|
|
||||||
// A hack so that the reducer can't read anything from the agent.
|
// A hack so that the reducer can't read anything from the agent.
|
||||||
|
@ -64,7 +65,7 @@ export function getInitialState(persistedAccounts: SessionAccount[]): State {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function reducer(state: State, action: Action): State {
|
let reducer = (state: State, action: Action): State => {
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
case 'received-agent-event': {
|
case 'received-agent-event': {
|
||||||
const {agent, accountDid, refreshedAccount, sessionEvent} = action
|
const {agent, accountDid, refreshedAccount, sessionEvent} = action
|
||||||
|
@ -166,3 +167,5 @@ export function reducer(state: State, action: Action): State {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
reducer = wrapSessionReducerForLogging(reducer)
|
||||||
|
export {reducer}
|
||||||
|
|
|
@ -24,12 +24,18 @@ import Animated, {
|
||||||
} from 'react-native-reanimated'
|
} from 'react-native-reanimated'
|
||||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||||
import {LinearGradient} from 'expo-linear-gradient'
|
import {LinearGradient} from 'expo-linear-gradient'
|
||||||
|
import {
|
||||||
|
AppBskyFeedDefs,
|
||||||
|
AppBskyFeedGetPostThread,
|
||||||
|
BskyAgent,
|
||||||
|
} from '@atproto/api'
|
||||||
import {RichText} from '@atproto/api'
|
import {RichText} from '@atproto/api'
|
||||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||||
import {msg, Trans} from '@lingui/macro'
|
import {msg, Trans} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
import {observer} from 'mobx-react-lite'
|
import {observer} from 'mobx-react-lite'
|
||||||
|
|
||||||
|
import {until} from '#/lib/async/until'
|
||||||
import {
|
import {
|
||||||
createGIFDescription,
|
createGIFDescription,
|
||||||
parseAltFromGIFDescription,
|
parseAltFromGIFDescription,
|
||||||
|
@ -299,6 +305,17 @@ export const ComposePost = observer(function ComposePost({
|
||||||
langs: toPostLanguages(langPrefs.postLanguage),
|
langs: toPostLanguages(langPrefs.postLanguage),
|
||||||
})
|
})
|
||||||
).uri
|
).uri
|
||||||
|
try {
|
||||||
|
await whenAppViewReady(agent, postUri, res => {
|
||||||
|
const thread = res.data.thread
|
||||||
|
return AppBskyFeedDefs.isThreadViewPost(thread)
|
||||||
|
})
|
||||||
|
} catch (waitErr: any) {
|
||||||
|
logger.error(waitErr, {
|
||||||
|
message: `Waiting for app view failed`,
|
||||||
|
})
|
||||||
|
// Keep going because the post *was* published.
|
||||||
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
logger.error(e, {
|
logger.error(e, {
|
||||||
message: `Composer: create post failed`,
|
message: `Composer: create post failed`,
|
||||||
|
@ -756,6 +773,23 @@ function useKeyboardVerticalOffset() {
|
||||||
return top + 10
|
return top + 10
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function whenAppViewReady(
|
||||||
|
agent: BskyAgent,
|
||||||
|
uri: string,
|
||||||
|
fn: (res: AppBskyFeedGetPostThread.Response) => boolean,
|
||||||
|
) {
|
||||||
|
await until(
|
||||||
|
5, // 5 tries
|
||||||
|
1e3, // 1s delay between tries
|
||||||
|
fn,
|
||||||
|
() =>
|
||||||
|
agent.app.bsky.feed.getPostThread({
|
||||||
|
uri,
|
||||||
|
depth: 0,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
topbarInner: {
|
topbarInner: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
|
|
|
@ -3,7 +3,6 @@ import {
|
||||||
findNodeHandle,
|
findNodeHandle,
|
||||||
ListRenderItemInfo,
|
ListRenderItemInfo,
|
||||||
StyleProp,
|
StyleProp,
|
||||||
StyleSheet,
|
|
||||||
View,
|
View,
|
||||||
ViewStyle,
|
ViewStyle,
|
||||||
} from 'react-native'
|
} from 'react-native'
|
||||||
|
@ -12,18 +11,17 @@ import {useLingui} from '@lingui/react'
|
||||||
import {useQueryClient} from '@tanstack/react-query'
|
import {useQueryClient} from '@tanstack/react-query'
|
||||||
|
|
||||||
import {cleanError} from '#/lib/strings/errors'
|
import {cleanError} from '#/lib/strings/errors'
|
||||||
import {useTheme} from '#/lib/ThemeContext'
|
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {isNative, isWeb} from '#/platform/detection'
|
import {isNative, isWeb} from '#/platform/detection'
|
||||||
import {hydrateFeedGenerator} from '#/state/queries/feed'
|
|
||||||
import {usePreferencesQuery} from '#/state/queries/preferences'
|
import {usePreferencesQuery} from '#/state/queries/preferences'
|
||||||
import {RQKEY, useProfileFeedgensQuery} from '#/state/queries/profile-feedgens'
|
import {RQKEY, useProfileFeedgensQuery} from '#/state/queries/profile-feedgens'
|
||||||
import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
|
import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
|
||||||
import {EmptyState} from 'view/com/util/EmptyState'
|
import {EmptyState} from 'view/com/util/EmptyState'
|
||||||
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
|
import * as FeedCard from '#/components/FeedCard'
|
||||||
import {ErrorMessage} from '../util/error/ErrorMessage'
|
import {ErrorMessage} from '../util/error/ErrorMessage'
|
||||||
import {List, ListRef} from '../util/List'
|
import {List, ListRef} from '../util/List'
|
||||||
import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn'
|
import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn'
|
||||||
import {FeedSourceCardLoaded} from './FeedSourceCard'
|
|
||||||
|
|
||||||
const LOADING = {_reactKey: '__loading__'}
|
const LOADING = {_reactKey: '__loading__'}
|
||||||
const EMPTY = {_reactKey: '__empty__'}
|
const EMPTY = {_reactKey: '__empty__'}
|
||||||
|
@ -52,7 +50,7 @@ export const ProfileFeedgens = React.forwardRef<
|
||||||
ref,
|
ref,
|
||||||
) {
|
) {
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const theme = useTheme()
|
const t = useTheme()
|
||||||
const [isPTRing, setIsPTRing] = React.useState(false)
|
const [isPTRing, setIsPTRing] = React.useState(false)
|
||||||
const opts = React.useMemo(() => ({enabled}), [enabled])
|
const opts = React.useMemo(() => ({enabled}), [enabled])
|
||||||
const {
|
const {
|
||||||
|
@ -79,10 +77,9 @@ export const ProfileFeedgens = React.forwardRef<
|
||||||
items = items.concat([EMPTY])
|
items = items.concat([EMPTY])
|
||||||
} else if (data?.pages) {
|
} else if (data?.pages) {
|
||||||
for (const page of data?.pages) {
|
for (const page of data?.pages) {
|
||||||
items = items.concat(page.feeds.map(feed => hydrateFeedGenerator(feed)))
|
items = items.concat(page.feeds)
|
||||||
}
|
}
|
||||||
}
|
} else if (isError && !isEmpty) {
|
||||||
if (isError && !isEmpty) {
|
|
||||||
items = items.concat([LOAD_MORE_ERROR_ITEM])
|
items = items.concat([LOAD_MORE_ERROR_ITEM])
|
||||||
}
|
}
|
||||||
return items
|
return items
|
||||||
|
@ -132,8 +129,7 @@ export const ProfileFeedgens = React.forwardRef<
|
||||||
// rendering
|
// rendering
|
||||||
// =
|
// =
|
||||||
|
|
||||||
const renderItemInner = React.useCallback(
|
const renderItem = ({item, index}: ListRenderItemInfo<any>) => {
|
||||||
({item, index}: ListRenderItemInfo<any>) => {
|
|
||||||
if (item === EMPTY) {
|
if (item === EMPTY) {
|
||||||
return (
|
return (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
|
@ -160,20 +156,19 @@ export const ProfileFeedgens = React.forwardRef<
|
||||||
}
|
}
|
||||||
if (preferences) {
|
if (preferences) {
|
||||||
return (
|
return (
|
||||||
<FeedSourceCardLoaded
|
<View
|
||||||
feedUri={item.uri}
|
style={[
|
||||||
feed={item}
|
(index !== 0 || isWeb) && a.border_t,
|
||||||
preferences={preferences}
|
t.atoms.border_contrast_low,
|
||||||
style={styles.item}
|
a.px_lg,
|
||||||
showLikes
|
a.py_lg,
|
||||||
hideTopBorder={index === 0 && !isWeb}
|
]}>
|
||||||
/>
|
<FeedCard.Default type="feed" view={item} />
|
||||||
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
},
|
}
|
||||||
[error, refetch, onPressRetryLoadMore, preferences, _],
|
|
||||||
)
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (enabled && scrollElRef.current) {
|
if (enabled && scrollElRef.current) {
|
||||||
|
@ -189,12 +184,12 @@ export const ProfileFeedgens = React.forwardRef<
|
||||||
ref={scrollElRef}
|
ref={scrollElRef}
|
||||||
data={items}
|
data={items}
|
||||||
keyExtractor={(item: any) => item._reactKey || item.uri}
|
keyExtractor={(item: any) => item._reactKey || item.uri}
|
||||||
renderItem={renderItemInner}
|
renderItem={renderItem}
|
||||||
refreshing={isPTRing}
|
refreshing={isPTRing}
|
||||||
onRefresh={onRefresh}
|
onRefresh={onRefresh}
|
||||||
headerOffset={headerOffset}
|
headerOffset={headerOffset}
|
||||||
contentContainerStyle={isNative && {paddingBottom: headerOffset + 100}}
|
contentContainerStyle={isNative && {paddingBottom: headerOffset + 100}}
|
||||||
indicatorStyle={theme.colorScheme === 'dark' ? 'white' : 'black'}
|
indicatorStyle={t.name === 'light' ? 'black' : 'white'}
|
||||||
removeClippedSubviews={true}
|
removeClippedSubviews={true}
|
||||||
// @ts-ignore our .web version only -prf
|
// @ts-ignore our .web version only -prf
|
||||||
desktopFixedHeight
|
desktopFixedHeight
|
||||||
|
@ -203,9 +198,3 @@ export const ProfileFeedgens = React.forwardRef<
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
item: {
|
|
||||||
paddingHorizontal: 18,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
|
@ -3,7 +3,6 @@ import {
|
||||||
findNodeHandle,
|
findNodeHandle,
|
||||||
ListRenderItemInfo,
|
ListRenderItemInfo,
|
||||||
StyleProp,
|
StyleProp,
|
||||||
StyleSheet,
|
|
||||||
View,
|
View,
|
||||||
ViewStyle,
|
ViewStyle,
|
||||||
} from 'react-native'
|
} from 'react-native'
|
||||||
|
@ -12,17 +11,17 @@ import {useLingui} from '@lingui/react'
|
||||||
import {useQueryClient} from '@tanstack/react-query'
|
import {useQueryClient} from '@tanstack/react-query'
|
||||||
|
|
||||||
import {cleanError} from '#/lib/strings/errors'
|
import {cleanError} from '#/lib/strings/errors'
|
||||||
import {useTheme} from '#/lib/ThemeContext'
|
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {isNative, isWeb} from '#/platform/detection'
|
import {isNative, isWeb} from '#/platform/detection'
|
||||||
import {RQKEY, useProfileListsQuery} from '#/state/queries/profile-lists'
|
import {RQKEY, useProfileListsQuery} from '#/state/queries/profile-lists'
|
||||||
import {useAnalytics} from 'lib/analytics/analytics'
|
import {useAnalytics} from 'lib/analytics/analytics'
|
||||||
import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
|
import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
|
||||||
import {EmptyState} from 'view/com/util/EmptyState'
|
import {EmptyState} from 'view/com/util/EmptyState'
|
||||||
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
|
import * as FeedCard from '#/components/FeedCard'
|
||||||
import {ErrorMessage} from '../util/error/ErrorMessage'
|
import {ErrorMessage} from '../util/error/ErrorMessage'
|
||||||
import {List, ListRef} from '../util/List'
|
import {List, ListRef} from '../util/List'
|
||||||
import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn'
|
import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn'
|
||||||
import {ListCard} from './ListCard'
|
|
||||||
|
|
||||||
const LOADING = {_reactKey: '__loading__'}
|
const LOADING = {_reactKey: '__loading__'}
|
||||||
const EMPTY = {_reactKey: '__empty__'}
|
const EMPTY = {_reactKey: '__empty__'}
|
||||||
|
@ -48,7 +47,7 @@ export const ProfileLists = React.forwardRef<SectionRef, ProfileListsProps>(
|
||||||
{did, scrollElRef, headerOffset, enabled, style, testID, setScrollViewTag},
|
{did, scrollElRef, headerOffset, enabled, style, testID, setScrollViewTag},
|
||||||
ref,
|
ref,
|
||||||
) {
|
) {
|
||||||
const theme = useTheme()
|
const t = useTheme()
|
||||||
const {track} = useAnalytics()
|
const {track} = useAnalytics()
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const [isPTRing, setIsPTRing] = React.useState(false)
|
const [isPTRing, setIsPTRing] = React.useState(false)
|
||||||
|
@ -166,15 +165,18 @@ export const ProfileLists = React.forwardRef<SectionRef, ProfileListsProps>(
|
||||||
return <FeedLoadingPlaceholder />
|
return <FeedLoadingPlaceholder />
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<ListCard
|
<View
|
||||||
list={item}
|
style={[
|
||||||
testID={`list-${item.name}`}
|
(index !== 0 || isWeb) && a.border_t,
|
||||||
style={styles.item}
|
t.atoms.border_contrast_low,
|
||||||
noBorder={index === 0 && !isWeb}
|
a.px_lg,
|
||||||
/>
|
a.py_lg,
|
||||||
|
]}>
|
||||||
|
<FeedCard.Default type="list" view={item} />
|
||||||
|
</View>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
[error, refetch, onPressRetryLoadMore, _],
|
[error, refetch, onPressRetryLoadMore, _, t.atoms.border_contrast_low],
|
||||||
)
|
)
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
@ -198,7 +200,7 @@ export const ProfileLists = React.forwardRef<SectionRef, ProfileListsProps>(
|
||||||
contentContainerStyle={
|
contentContainerStyle={
|
||||||
isNative && {paddingBottom: headerOffset + 100}
|
isNative && {paddingBottom: headerOffset + 100}
|
||||||
}
|
}
|
||||||
indicatorStyle={theme.colorScheme === 'dark' ? 'white' : 'black'}
|
indicatorStyle={t.name === 'light' ? 'black' : 'white'}
|
||||||
removeClippedSubviews={true}
|
removeClippedSubviews={true}
|
||||||
// @ts-ignore our .web version only -prf
|
// @ts-ignore our .web version only -prf
|
||||||
desktopFixedHeight
|
desktopFixedHeight
|
||||||
|
@ -208,9 +210,3 @@ export const ProfileLists = React.forwardRef<SectionRef, ProfileListsProps>(
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
item: {
|
|
||||||
paddingHorizontal: 18,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
|
@ -328,6 +328,7 @@ const styles = StyleSheet.create({
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
paddingHorizontal: 6,
|
paddingHorizontal: 6,
|
||||||
paddingVertical: 2,
|
paddingVertical: 2,
|
||||||
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
btn: {
|
btn: {
|
||||||
paddingVertical: 7,
|
paddingVertical: 7,
|
||||||
|
|
|
@ -6,6 +6,7 @@ import {
|
||||||
View,
|
View,
|
||||||
type ViewStyle,
|
type ViewStyle,
|
||||||
} from 'react-native'
|
} from 'react-native'
|
||||||
|
import * as Clipboard from 'expo-clipboard'
|
||||||
import {
|
import {
|
||||||
AppBskyFeedDefs,
|
AppBskyFeedDefs,
|
||||||
AppBskyFeedPost,
|
AppBskyFeedPost,
|
||||||
|
@ -19,6 +20,7 @@ import {POST_CTRL_HITSLOP} from '#/lib/constants'
|
||||||
import {useHaptics} from '#/lib/haptics'
|
import {useHaptics} from '#/lib/haptics'
|
||||||
import {makeProfileLink} from '#/lib/routes/links'
|
import {makeProfileLink} from '#/lib/routes/links'
|
||||||
import {shareUrl} from '#/lib/sharing'
|
import {shareUrl} from '#/lib/sharing'
|
||||||
|
import {useGate} from '#/lib/statsig/statsig'
|
||||||
import {toShareUrl} from '#/lib/strings/url-helpers'
|
import {toShareUrl} from '#/lib/strings/url-helpers'
|
||||||
import {s} from '#/lib/styles'
|
import {s} from '#/lib/styles'
|
||||||
import {Shadow} from '#/state/cache/types'
|
import {Shadow} from '#/state/cache/types'
|
||||||
|
@ -41,6 +43,7 @@ import * as Prompt from '#/components/Prompt'
|
||||||
import {PostDropdownBtn} from '../forms/PostDropdownBtn'
|
import {PostDropdownBtn} from '../forms/PostDropdownBtn'
|
||||||
import {formatCount} from '../numeric/format'
|
import {formatCount} from '../numeric/format'
|
||||||
import {Text} from '../text/Text'
|
import {Text} from '../text/Text'
|
||||||
|
import * as Toast from '../Toast'
|
||||||
import {RepostButton} from './RepostButton'
|
import {RepostButton} from './RepostButton'
|
||||||
|
|
||||||
let PostCtrls = ({
|
let PostCtrls = ({
|
||||||
|
@ -75,6 +78,7 @@ let PostCtrls = ({
|
||||||
const loggedOutWarningPromptControl = useDialogControl()
|
const loggedOutWarningPromptControl = useDialogControl()
|
||||||
const {sendInteraction} = useFeedFeedbackContext()
|
const {sendInteraction} = useFeedFeedbackContext()
|
||||||
const playHaptic = useHaptics()
|
const playHaptic = useHaptics()
|
||||||
|
const gate = useGate()
|
||||||
|
|
||||||
const shouldShowLoggedOutWarning = React.useMemo(() => {
|
const shouldShowLoggedOutWarning = React.useMemo(() => {
|
||||||
return (
|
return (
|
||||||
|
@ -329,6 +333,31 @@ let PostCtrls = ({
|
||||||
timestamp={post.indexedAt}
|
timestamp={post.indexedAt}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
{gate('debug_show_feedcontext') && feedContext && (
|
||||||
|
<Pressable
|
||||||
|
accessible={false}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
right: 0,
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
onPress={e => {
|
||||||
|
e.stopPropagation()
|
||||||
|
Clipboard.setStringAsync(feedContext)
|
||||||
|
Toast.show(_(msg`Copied to clipboard`))
|
||||||
|
}}>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: t.palette.contrast_400,
|
||||||
|
fontSize: 7,
|
||||||
|
}}>
|
||||||
|
{feedContext}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,8 +1,6 @@
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {ActivityIndicator, type FlatList, StyleSheet, View} from 'react-native'
|
import {ActivityIndicator, type FlatList, StyleSheet, View} from 'react-native'
|
||||||
import {AppBskyActorDefs, AppBskyFeedDefs} from '@atproto/api'
|
import {AppBskyFeedDefs} from '@atproto/api'
|
||||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
|
||||||
import {FontAwesomeIconStyle} from '@fortawesome/react-native-fontawesome'
|
|
||||||
import {msg, Trans} from '@lingui/macro'
|
import {msg, Trans} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
import {useFocusEffect} from '@react-navigation/native'
|
import {useFocusEffect} from '@react-navigation/native'
|
||||||
|
@ -10,12 +8,11 @@ import debounce from 'lodash.debounce'
|
||||||
|
|
||||||
import {isNative, isWeb} from '#/platform/detection'
|
import {isNative, isWeb} from '#/platform/detection'
|
||||||
import {
|
import {
|
||||||
getAvatarTypeFromUri,
|
SavedFeedItem,
|
||||||
useFeedSourceInfoQuery,
|
|
||||||
useGetPopularFeedsQuery,
|
useGetPopularFeedsQuery,
|
||||||
|
useSavedFeeds,
|
||||||
useSearchPopularFeedsMutation,
|
useSearchPopularFeedsMutation,
|
||||||
} from '#/state/queries/feed'
|
} from '#/state/queries/feed'
|
||||||
import {usePreferencesQuery} from '#/state/queries/preferences'
|
|
||||||
import {useSession} from '#/state/session'
|
import {useSession} from '#/state/session'
|
||||||
import {useSetMinimalShellMode} from '#/state/shell'
|
import {useSetMinimalShellMode} from '#/state/shell'
|
||||||
import {useComposerControls} from '#/state/shell/composer'
|
import {useComposerControls} from '#/state/shell/composer'
|
||||||
|
@ -28,14 +25,10 @@ import {s} from 'lib/styles'
|
||||||
import {ErrorMessage} from 'view/com/util/error/ErrorMessage'
|
import {ErrorMessage} from 'view/com/util/error/ErrorMessage'
|
||||||
import {FAB} from 'view/com/util/fab/FAB'
|
import {FAB} from 'view/com/util/fab/FAB'
|
||||||
import {SearchInput} from 'view/com/util/forms/SearchInput'
|
import {SearchInput} from 'view/com/util/forms/SearchInput'
|
||||||
import {Link, TextLink} from 'view/com/util/Link'
|
import {TextLink} from 'view/com/util/Link'
|
||||||
import {List} from 'view/com/util/List'
|
import {List} from 'view/com/util/List'
|
||||||
import {
|
import {FeedFeedLoadingPlaceholder} from 'view/com/util/LoadingPlaceholder'
|
||||||
FeedFeedLoadingPlaceholder,
|
|
||||||
LoadingPlaceholder,
|
|
||||||
} from 'view/com/util/LoadingPlaceholder'
|
|
||||||
import {Text} from 'view/com/util/text/Text'
|
import {Text} from 'view/com/util/text/Text'
|
||||||
import {UserAvatar} from 'view/com/util/UserAvatar'
|
|
||||||
import {ViewHeader} from 'view/com/util/ViewHeader'
|
import {ViewHeader} from 'view/com/util/ViewHeader'
|
||||||
import {NoFollowingFeed} from '#/screens/Feeds/NoFollowingFeed'
|
import {NoFollowingFeed} from '#/screens/Feeds/NoFollowingFeed'
|
||||||
import {NoSavedFeedsOfAnyType} from '#/screens/Feeds/NoSavedFeedsOfAnyType'
|
import {NoSavedFeedsOfAnyType} from '#/screens/Feeds/NoSavedFeedsOfAnyType'
|
||||||
|
@ -47,6 +40,7 @@ import {ListSparkle_Stroke2_Corner0_Rounded} from '#/components/icons/ListSparkl
|
||||||
import hairlineWidth = StyleSheet.hairlineWidth
|
import hairlineWidth = StyleSheet.hairlineWidth
|
||||||
import {Divider} from '#/components/Divider'
|
import {Divider} from '#/components/Divider'
|
||||||
import * as FeedCard from '#/components/FeedCard'
|
import * as FeedCard from '#/components/FeedCard'
|
||||||
|
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
|
||||||
|
|
||||||
type Props = NativeStackScreenProps<CommonNavigatorParams, 'Feeds'>
|
type Props = NativeStackScreenProps<CommonNavigatorParams, 'Feeds'>
|
||||||
|
|
||||||
|
@ -61,9 +55,8 @@ type FlatlistSlice =
|
||||||
key: string
|
key: string
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'savedFeedsLoading'
|
type: 'savedFeedPlaceholder'
|
||||||
key: string
|
key: string
|
||||||
// pendingItems: number,
|
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'savedFeedNoResults'
|
type: 'savedFeedNoResults'
|
||||||
|
@ -72,8 +65,7 @@ type FlatlistSlice =
|
||||||
| {
|
| {
|
||||||
type: 'savedFeed'
|
type: 'savedFeed'
|
||||||
key: string
|
key: string
|
||||||
feedUri: string
|
savedFeed: SavedFeedItem
|
||||||
savedFeedConfig: AppBskyActorDefs.SavedFeed
|
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'savedFeedsLoadMore'
|
type: 'savedFeedsLoadMore'
|
||||||
|
@ -113,11 +105,11 @@ export function FeedsScreen(_props: Props) {
|
||||||
const [query, setQuery] = React.useState('')
|
const [query, setQuery] = React.useState('')
|
||||||
const [isPTR, setIsPTR] = React.useState(false)
|
const [isPTR, setIsPTR] = React.useState(false)
|
||||||
const {
|
const {
|
||||||
data: preferences,
|
data: savedFeeds,
|
||||||
isLoading: isPreferencesLoading,
|
isPlaceholderData: isSavedFeedsPlaceholder,
|
||||||
error: preferencesError,
|
error: savedFeedsError,
|
||||||
refetch: refetchPreferences,
|
refetch: refetchSavedFeeds,
|
||||||
} = usePreferencesQuery()
|
} = useSavedFeeds()
|
||||||
const {
|
const {
|
||||||
data: popularFeeds,
|
data: popularFeeds,
|
||||||
isFetching: isPopularFeedsFetching,
|
isFetching: isPopularFeedsFetching,
|
||||||
|
@ -173,11 +165,11 @@ export function FeedsScreen(_props: Props) {
|
||||||
const onPullToRefresh = React.useCallback(async () => {
|
const onPullToRefresh = React.useCallback(async () => {
|
||||||
setIsPTR(true)
|
setIsPTR(true)
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
refetchPreferences().catch(_e => undefined),
|
refetchSavedFeeds().catch(_e => undefined),
|
||||||
refetchPopularFeeds().catch(_e => undefined),
|
refetchPopularFeeds().catch(_e => undefined),
|
||||||
])
|
])
|
||||||
setIsPTR(false)
|
setIsPTR(false)
|
||||||
}, [setIsPTR, refetchPreferences, refetchPopularFeeds])
|
}, [setIsPTR, refetchSavedFeeds, refetchPopularFeeds])
|
||||||
const onEndReached = React.useCallback(() => {
|
const onEndReached = React.useCallback(() => {
|
||||||
if (
|
if (
|
||||||
isPopularFeedsFetching ||
|
isPopularFeedsFetching ||
|
||||||
|
@ -203,6 +195,11 @@ export function FeedsScreen(_props: Props) {
|
||||||
|
|
||||||
const items = React.useMemo(() => {
|
const items = React.useMemo(() => {
|
||||||
let slices: FlatlistSlice[] = []
|
let slices: FlatlistSlice[] = []
|
||||||
|
const hasActualSavedCount =
|
||||||
|
!isSavedFeedsPlaceholder ||
|
||||||
|
(isSavedFeedsPlaceholder && (savedFeeds?.count || 0) > 0)
|
||||||
|
const canShowDiscoverSection =
|
||||||
|
!hasSession || (hasSession && hasActualSavedCount)
|
||||||
|
|
||||||
if (hasSession) {
|
if (hasSession) {
|
||||||
slices.push({
|
slices.push({
|
||||||
|
@ -210,47 +207,63 @@ export function FeedsScreen(_props: Props) {
|
||||||
type: 'savedFeedsHeader',
|
type: 'savedFeedsHeader',
|
||||||
})
|
})
|
||||||
|
|
||||||
if (preferencesError) {
|
if (savedFeedsError) {
|
||||||
slices.push({
|
slices.push({
|
||||||
key: 'savedFeedsError',
|
key: 'savedFeedsError',
|
||||||
type: 'error',
|
type: 'error',
|
||||||
error: cleanError(preferencesError.toString()),
|
error: cleanError(savedFeedsError.toString()),
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
if (isPreferencesLoading || !preferences?.savedFeeds) {
|
if (isSavedFeedsPlaceholder && !savedFeeds?.feeds.length) {
|
||||||
|
/*
|
||||||
|
* Initial render in placeholder state is 0 on a cold page load,
|
||||||
|
* because preferences haven't loaded yet.
|
||||||
|
*
|
||||||
|
* In practice, `savedFeeds` is always defined, but we check for TS
|
||||||
|
* and for safety.
|
||||||
|
*
|
||||||
|
* In both cases, we show 4 as the the loading state.
|
||||||
|
*/
|
||||||
|
const min = 8
|
||||||
|
const count = savedFeeds
|
||||||
|
? savedFeeds.count === 0
|
||||||
|
? min
|
||||||
|
: savedFeeds.count
|
||||||
|
: min
|
||||||
|
Array(count)
|
||||||
|
.fill(0)
|
||||||
|
.forEach((_, i) => {
|
||||||
slices.push({
|
slices.push({
|
||||||
key: 'savedFeedsLoading',
|
key: 'savedFeedPlaceholder' + i,
|
||||||
type: 'savedFeedsLoading',
|
type: 'savedFeedPlaceholder',
|
||||||
// pendingItems: this.rootStore.preferences.savedFeeds.length || 3,
|
})
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
if (preferences.savedFeeds?.length) {
|
if (savedFeeds?.feeds?.length) {
|
||||||
const noFollowingFeed = preferences.savedFeeds.every(
|
const noFollowingFeed = savedFeeds.feeds.every(
|
||||||
f => f.type !== 'timeline',
|
f => f.type !== 'timeline',
|
||||||
)
|
)
|
||||||
|
|
||||||
slices = slices.concat(
|
slices = slices.concat(
|
||||||
preferences.savedFeeds
|
savedFeeds.feeds
|
||||||
.filter(f => {
|
.filter(s => {
|
||||||
return f.pinned
|
return s.config.pinned
|
||||||
})
|
})
|
||||||
.map(feed => ({
|
.map(s => ({
|
||||||
key: `savedFeed:${feed.value}:${feed.id}`,
|
key: `savedFeed:${s.view?.uri}:${s.config.id}`,
|
||||||
type: 'savedFeed',
|
type: 'savedFeed',
|
||||||
feedUri: feed.value,
|
savedFeed: s,
|
||||||
savedFeedConfig: feed,
|
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
slices = slices.concat(
|
slices = slices.concat(
|
||||||
preferences.savedFeeds
|
savedFeeds.feeds
|
||||||
.filter(f => {
|
.filter(s => {
|
||||||
return !f.pinned
|
return !s.config.pinned
|
||||||
})
|
})
|
||||||
.map(feed => ({
|
.map(s => ({
|
||||||
key: `savedFeed:${feed.value}:${feed.id}`,
|
key: `savedFeed:${s.view?.uri}:${s.config.id}`,
|
||||||
type: 'savedFeed',
|
type: 'savedFeed',
|
||||||
feedUri: feed.value,
|
savedFeed: s,
|
||||||
savedFeedConfig: feed,
|
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -270,6 +283,7 @@ export function FeedsScreen(_props: Props) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!hasSession || (hasSession && canShowDiscoverSection)) {
|
||||||
slices.push({
|
slices.push({
|
||||||
key: 'popularFeedsHeader',
|
key: 'popularFeedsHeader',
|
||||||
type: 'popularFeedsHeader',
|
type: 'popularFeedsHeader',
|
||||||
|
@ -341,13 +355,14 @@ export function FeedsScreen(_props: Props) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return slices
|
return slices
|
||||||
}, [
|
}, [
|
||||||
hasSession,
|
hasSession,
|
||||||
preferences,
|
savedFeeds,
|
||||||
isPreferencesLoading,
|
isSavedFeedsPlaceholder,
|
||||||
preferencesError,
|
savedFeedsError,
|
||||||
popularFeeds,
|
popularFeeds,
|
||||||
isPopularFeedsFetching,
|
isPopularFeedsFetching,
|
||||||
popularFeedsError,
|
popularFeedsError,
|
||||||
|
@ -407,10 +422,7 @@ export function FeedsScreen(_props: Props) {
|
||||||
({item}: {item: FlatlistSlice}) => {
|
({item}: {item: FlatlistSlice}) => {
|
||||||
if (item.type === 'error') {
|
if (item.type === 'error') {
|
||||||
return <ErrorMessage message={item.error} />
|
return <ErrorMessage message={item.error} />
|
||||||
} else if (
|
} else if (item.type === 'popularFeedsLoadingMore') {
|
||||||
item.type === 'popularFeedsLoadingMore' ||
|
|
||||||
item.type === 'savedFeedsLoading'
|
|
||||||
) {
|
|
||||||
return (
|
return (
|
||||||
<View style={s.p10}>
|
<View style={s.p10}>
|
||||||
<ActivityIndicator size="large" />
|
<ActivityIndicator size="large" />
|
||||||
|
@ -459,8 +471,10 @@ export function FeedsScreen(_props: Props) {
|
||||||
<NoSavedFeedsOfAnyType />
|
<NoSavedFeedsOfAnyType />
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
|
} else if (item.type === 'savedFeedPlaceholder') {
|
||||||
|
return <SavedFeedPlaceholder />
|
||||||
} else if (item.type === 'savedFeed') {
|
} else if (item.type === 'savedFeed') {
|
||||||
return <FeedOrFollowing savedFeedConfig={item.savedFeedConfig} />
|
return <FeedOrFollowing savedFeed={item.savedFeed} />
|
||||||
} else if (item.type === 'popularFeedsHeader') {
|
} else if (item.type === 'popularFeedsHeader') {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
@ -481,7 +495,7 @@ export function FeedsScreen(_props: Props) {
|
||||||
} else if (item.type === 'popularFeed') {
|
} else if (item.type === 'popularFeed') {
|
||||||
return (
|
return (
|
||||||
<View style={[a.px_lg, a.pt_lg, a.gap_lg]}>
|
<View style={[a.px_lg, a.pt_lg, a.gap_lg]}>
|
||||||
<FeedCard.Default feed={item.feed} />
|
<FeedCard.Default type="feed" view={item.feed} />
|
||||||
<Divider />
|
<Divider />
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
|
@ -571,30 +585,27 @@ export function FeedsScreen(_props: Props) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function FeedOrFollowing({
|
function FeedOrFollowing({savedFeed}: {savedFeed: SavedFeedItem}) {
|
||||||
savedFeedConfig: feed,
|
return savedFeed.type === 'timeline' ? (
|
||||||
}: {
|
|
||||||
savedFeedConfig: AppBskyActorDefs.SavedFeed
|
|
||||||
}) {
|
|
||||||
return feed.type === 'timeline' ? (
|
|
||||||
<FollowingFeed />
|
<FollowingFeed />
|
||||||
) : (
|
) : (
|
||||||
<SavedFeed savedFeedConfig={feed} />
|
<SavedFeed savedFeed={savedFeed} />
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function FollowingFeed() {
|
function FollowingFeed() {
|
||||||
const pal = usePalette('default')
|
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {isMobile} = useWebMediaQueries()
|
const {_} = useLingui()
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
testID={`saved-feed-timeline`}
|
|
||||||
style={[
|
style={[
|
||||||
pal.border,
|
a.flex_1,
|
||||||
styles.savedFeed,
|
a.px_lg,
|
||||||
isMobile && styles.savedFeedMobile,
|
a.py_md,
|
||||||
|
a.border_b,
|
||||||
|
t.atoms.border_contrast_low,
|
||||||
]}>
|
]}>
|
||||||
|
<FeedCard.Header>
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
a.align_center,
|
a.align_center,
|
||||||
|
@ -616,91 +627,64 @@ function FollowingFeed() {
|
||||||
fill={t.palette.white}
|
fill={t.palette.white}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
<View
|
<FeedCard.TitleAndByline title={_(msg`Following`)} type="feed" />
|
||||||
style={{flex: 1, flexDirection: 'row', gap: 8, alignItems: 'center'}}>
|
</FeedCard.Header>
|
||||||
<Text type="lg-medium" style={pal.text} numberOfLines={1}>
|
|
||||||
<Trans>Following</Trans>
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function SavedFeed({
|
function SavedFeed({
|
||||||
savedFeedConfig: feed,
|
savedFeed,
|
||||||
}: {
|
}: {
|
||||||
savedFeedConfig: AppBskyActorDefs.SavedFeed
|
savedFeed: SavedFeedItem & {type: 'feed' | 'list'}
|
||||||
}) {
|
}) {
|
||||||
const pal = usePalette('default')
|
const t = useTheme()
|
||||||
const {isMobile} = useWebMediaQueries()
|
const {view: feed} = savedFeed
|
||||||
const {data: info, error} = useFeedSourceInfoQuery({uri: feed.value})
|
const displayName =
|
||||||
const typeAvatar = getAvatarTypeFromUri(feed.value)
|
savedFeed.type === 'feed' ? savedFeed.view.displayName : savedFeed.view.name
|
||||||
|
|
||||||
if (!info)
|
|
||||||
return (
|
|
||||||
<SavedFeedLoadingPlaceholder
|
|
||||||
key={`savedFeedLoadingPlaceholder:${feed.value}`}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<FeedCard.Link testID={`saved-feed-${feed.displayName}`} {...savedFeed}>
|
||||||
testID={`saved-feed-${info.displayName}`}
|
{({hovered, pressed}) => (
|
||||||
href={info.route.href}
|
|
||||||
style={[pal.border, styles.savedFeed, isMobile && styles.savedFeedMobile]}
|
|
||||||
hoverStyle={pal.viewLight}
|
|
||||||
accessibilityLabel={info.displayName}
|
|
||||||
accessibilityHint=""
|
|
||||||
asAnchor
|
|
||||||
anchorNoUnderline>
|
|
||||||
{error ? (
|
|
||||||
<View
|
<View
|
||||||
style={{width: 28, flexDirection: 'row', justifyContent: 'center'}}>
|
style={[
|
||||||
<FontAwesomeIcon
|
a.flex_1,
|
||||||
icon="exclamation-circle"
|
a.px_lg,
|
||||||
color={pal.colors.textLight}
|
a.py_md,
|
||||||
|
a.border_b,
|
||||||
|
t.atoms.border_contrast_low,
|
||||||
|
(hovered || pressed) && t.atoms.bg_contrast_25,
|
||||||
|
]}>
|
||||||
|
<FeedCard.Header>
|
||||||
|
<FeedCard.Avatar src={feed.avatar} size={28} />
|
||||||
|
<FeedCard.TitleAndByline
|
||||||
|
title={displayName}
|
||||||
|
type={savedFeed.type}
|
||||||
/>
|
/>
|
||||||
</View>
|
|
||||||
) : (
|
|
||||||
<UserAvatar type={typeAvatar} size={28} avatar={info.avatar} />
|
|
||||||
)}
|
|
||||||
<View
|
|
||||||
style={{flex: 1, flexDirection: 'row', gap: 8, alignItems: 'center'}}>
|
|
||||||
<Text type="lg-medium" style={pal.text} numberOfLines={1}>
|
|
||||||
{info.displayName}
|
|
||||||
</Text>
|
|
||||||
{error ? (
|
|
||||||
<View style={[styles.offlineSlug, pal.borderDark]}>
|
|
||||||
<Text type="xs" style={pal.textLight}>
|
|
||||||
<Trans>Feed offline</Trans>
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{isMobile && (
|
<ChevronRight size="sm" fill={t.atoms.text_contrast_low.color} />
|
||||||
<FontAwesomeIcon
|
</FeedCard.Header>
|
||||||
icon="chevron-right"
|
</View>
|
||||||
size={14}
|
|
||||||
style={pal.textLight as FontAwesomeIconStyle}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</Link>
|
</FeedCard.Link>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function SavedFeedLoadingPlaceholder() {
|
function SavedFeedPlaceholder() {
|
||||||
const pal = usePalette('default')
|
const t = useTheme()
|
||||||
const {isMobile} = useWebMediaQueries()
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
pal.border,
|
a.flex_1,
|
||||||
styles.savedFeed,
|
a.px_lg,
|
||||||
isMobile && styles.savedFeedMobile,
|
a.py_md,
|
||||||
|
a.border_b,
|
||||||
|
t.atoms.border_contrast_low,
|
||||||
]}>
|
]}>
|
||||||
<LoadingPlaceholder width={28} height={28} style={{borderRadius: 4}} />
|
<FeedCard.Header>
|
||||||
<LoadingPlaceholder width={140} height={12} />
|
<FeedCard.AvatarPlaceholder size={28} />
|
||||||
|
<FeedCard.TitleAndBylinePlaceholder />
|
||||||
|
</FeedCard.Header>
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -282,7 +282,7 @@ export function Explore() {
|
||||||
isFetchingNextPage: isFetchingNextProfilesPage,
|
isFetchingNextPage: isFetchingNextProfilesPage,
|
||||||
error: profilesError,
|
error: profilesError,
|
||||||
fetchNextPage: fetchNextProfilesPage,
|
fetchNextPage: fetchNextProfilesPage,
|
||||||
} = useSuggestedFollowsQuery({limit: 3})
|
} = useSuggestedFollowsQuery({limit: 6, subsequentPageLimit: 10})
|
||||||
const {
|
const {
|
||||||
data: feeds,
|
data: feeds,
|
||||||
hasNextPage: hasNextFeedsPage,
|
hasNextPage: hasNextFeedsPage,
|
||||||
|
@ -290,7 +290,7 @@ export function Explore() {
|
||||||
isFetchingNextPage: isFetchingNextFeedsPage,
|
isFetchingNextPage: isFetchingNextFeedsPage,
|
||||||
error: feedsError,
|
error: feedsError,
|
||||||
fetchNextPage: fetchNextFeedsPage,
|
fetchNextPage: fetchNextFeedsPage,
|
||||||
} = useGetPopularFeedsQuery({limit: 3})
|
} = useGetPopularFeedsQuery({limit: 10})
|
||||||
|
|
||||||
const isLoadingMoreProfiles = isFetchingNextProfilesPage && !isLoadingProfiles
|
const isLoadingMoreProfiles = isFetchingNextProfilesPage && !isLoadingProfiles
|
||||||
const onLoadMoreProfiles = React.useCallback(async () => {
|
const onLoadMoreProfiles = React.useCallback(async () => {
|
||||||
|
@ -340,11 +340,12 @@ export function Explore() {
|
||||||
// Currently the responses contain duplicate items.
|
// Currently the responses contain duplicate items.
|
||||||
// Needs to be fixed on backend, but let's dedupe to be safe.
|
// Needs to be fixed on backend, but let's dedupe to be safe.
|
||||||
let seen = new Set()
|
let seen = new Set()
|
||||||
|
const profileItems: ExploreScreenItems[] = []
|
||||||
for (const page of profiles.pages) {
|
for (const page of profiles.pages) {
|
||||||
for (const actor of page.actors) {
|
for (const actor of page.actors) {
|
||||||
if (!seen.has(actor.did)) {
|
if (!seen.has(actor.did)) {
|
||||||
seen.add(actor.did)
|
seen.add(actor.did)
|
||||||
i.push({
|
profileItems.push({
|
||||||
type: 'profile',
|
type: 'profile',
|
||||||
key: actor.did,
|
key: actor.did,
|
||||||
profile: actor,
|
profile: actor,
|
||||||
|
@ -354,13 +355,19 @@ export function Explore() {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasNextProfilesPage) {
|
if (hasNextProfilesPage) {
|
||||||
|
// splice off 3 as previews if we have a next page
|
||||||
|
const previews = profileItems.splice(-3)
|
||||||
|
// push remainder
|
||||||
|
i.push(...profileItems)
|
||||||
i.push({
|
i.push({
|
||||||
type: 'loadMore',
|
type: 'loadMore',
|
||||||
key: 'loadMoreProfiles',
|
key: 'loadMoreProfiles',
|
||||||
isLoadingMore: isLoadingMoreProfiles,
|
isLoadingMore: isLoadingMoreProfiles,
|
||||||
onLoadMore: onLoadMoreProfiles,
|
onLoadMore: onLoadMoreProfiles,
|
||||||
items: i.filter(item => item.type === 'profile').slice(-3),
|
items: previews,
|
||||||
})
|
})
|
||||||
|
} else {
|
||||||
|
i.push(...profileItems)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (profilesError) {
|
if (profilesError) {
|
||||||
|
@ -390,11 +397,12 @@ export function Explore() {
|
||||||
// Currently the responses contain duplicate items.
|
// Currently the responses contain duplicate items.
|
||||||
// Needs to be fixed on backend, but let's dedupe to be safe.
|
// Needs to be fixed on backend, but let's dedupe to be safe.
|
||||||
let seen = new Set()
|
let seen = new Set()
|
||||||
|
const feedItems: ExploreScreenItems[] = []
|
||||||
for (const page of feeds.pages) {
|
for (const page of feeds.pages) {
|
||||||
for (const feed of page.feeds) {
|
for (const feed of page.feeds) {
|
||||||
if (!seen.has(feed.uri)) {
|
if (!seen.has(feed.uri)) {
|
||||||
seen.add(feed.uri)
|
seen.add(feed.uri)
|
||||||
i.push({
|
feedItems.push({
|
||||||
type: 'feed',
|
type: 'feed',
|
||||||
key: feed.uri,
|
key: feed.uri,
|
||||||
feed,
|
feed,
|
||||||
|
@ -403,6 +411,7 @@ export function Explore() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// feeds errors can occur during pagination, so feeds is truthy
|
||||||
if (feedsError) {
|
if (feedsError) {
|
||||||
i.push({
|
i.push({
|
||||||
type: 'error',
|
type: 'error',
|
||||||
|
@ -418,13 +427,17 @@ export function Explore() {
|
||||||
error: cleanError(preferencesError),
|
error: cleanError(preferencesError),
|
||||||
})
|
})
|
||||||
} else if (hasNextFeedsPage) {
|
} else if (hasNextFeedsPage) {
|
||||||
|
const preview = feedItems.splice(-3)
|
||||||
|
i.push(...feedItems)
|
||||||
i.push({
|
i.push({
|
||||||
type: 'loadMore',
|
type: 'loadMore',
|
||||||
key: 'loadMoreFeeds',
|
key: 'loadMoreFeeds',
|
||||||
isLoadingMore: isLoadingMoreFeeds,
|
isLoadingMore: isLoadingMoreFeeds,
|
||||||
onLoadMore: onLoadMoreFeeds,
|
onLoadMore: onLoadMoreFeeds,
|
||||||
items: i.filter(item => item.type === 'feed').slice(-3),
|
items: preview,
|
||||||
})
|
})
|
||||||
|
} else {
|
||||||
|
i.push(...feedItems)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (feedsError) {
|
if (feedsError) {
|
||||||
|
@ -492,7 +505,7 @@ export function Explore() {
|
||||||
a.px_lg,
|
a.px_lg,
|
||||||
a.py_lg,
|
a.py_lg,
|
||||||
]}>
|
]}>
|
||||||
<FeedCard.Default feed={item.feed} />
|
<FeedCard.Default type="feed" view={item.feed} />
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -306,7 +306,7 @@ let SearchScreenFeedsResults = ({
|
||||||
a.px_lg,
|
a.px_lg,
|
||||||
a.py_lg,
|
a.py_lg,
|
||||||
]}>
|
]}>
|
||||||
<FeedCard.Default feed={item} />
|
<FeedCard.Default type="feed" view={item} />
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
keyExtractor={item => item.uri}
|
keyExtractor={item => item.uri}
|
||||||
|
|
Loading…
Reference in New Issue