Merge branch 'bluesky-social:main' into zh

zio/stable
Kuwa Lee 2024-06-22 11:33:58 +08:00 committed by GitHub
commit 21a7d47cdc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
74 changed files with 5611 additions and 770 deletions

View File

@ -4,6 +4,7 @@ on:
push:
branches:
- main
- divy/bskylink
env:
REGISTRY: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_REGISTRY }}

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -0,0 +1,41 @@
FROM node:20.11-alpine3.18 as build
# Move files into the image and install
WORKDIR /app
COPY ./bskyogcard/package.json ./
COPY ./bskyogcard/yarn.lock ./
RUN yarn install --frozen-lockfile
COPY ./bskyogcard ./
# build then prune dev deps
RUN yarn build
RUN yarn install --production --ignore-scripts --prefer-offline
# Uses assets from build stage to reduce build size
FROM node:20.11-alpine3.18
RUN apk add --update dumb-init
# Avoid zombie processes, handle signal forwarding
ENTRYPOINT ["dumb-init", "--"]
WORKDIR /app
COPY --from=build /app /app
RUN mkdir /app/data && chown node /app/data
VOLUME /app/data
EXPOSE 3000
ENV CARD_PORT=3000
ENV NODE_ENV=production
# potential perf issues w/ io_uring on this version of node
ENV UV_USE_IO_URING=0
# https://github.com/nodejs/docker-node/blob/master/docs/BestPractices.md#non-root-user
USER node
CMD ["node", "--heapsnapshot-signal=SIGUSR2", "--enable-source-maps", "dist/bin.js"]
LABEL org.opencontainers.image.source=https://github.com/bluesky-social/social-app
LABEL org.opencontainers.image.description="Bsky Card Service"
LABEL org.opencontainers.image.licenses=UNLICENSED

View File

@ -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"
}
}

View File

@ -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()

View File

@ -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,
}
}

View File

@ -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,
})
}
}

View File

@ -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')

View File

@ -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()
}

View File

@ -0,0 +1,5 @@
import * as init from './001-init.js'
export default {
'001': init,
}

View File

@ -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
}
}

View File

@ -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>

View File

@ -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()
}
}

View File

@ -0,0 +1,4 @@
import {subsystemLogger} from '@atproto/common'
export const httpLogger = subsystemLogger('bskylink')
export const dbLogger = subsystemLogger('bskylink:db')

View File

@ -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)
}

View File

@ -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'})
}
}),
)
}

View File

@ -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
}

View File

@ -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()
}),
)
}

View File

@ -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'],
},
})
})
}

View File

@ -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')
}

View File

@ -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')
}

View File

@ -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
}
})

View File

@ -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
}

View File

@ -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:

View File

@ -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 "$@"

View File

@ -0,0 +1,10 @@
{
"compilerOptions": {
"module": "NodeNext",
"esModuleInterop": true,
"moduleResolution": "NodeNext",
"outDir": "dist",
"lib": ["ES2021.String"]
},
"include": ["./src/index.ts", "./src/bin.ts"]
}

1027
bskylink/yarn.lock 100644

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,24 @@
{
"name": "bskyogcard",
"version": "0.0.0",
"type": "module",
"main": "src/index.ts",
"scripts": {
"start": "node --loader ts-node/esm ./src/bin.ts",
"build": "tsc && cp -r src/assets dist/assets"
},
"dependencies": {
"@atproto/api": "0.12.19-next.0",
"@atproto/common": "^0.4.0",
"@resvg/resvg-js": "^2.6.2",
"express": "^4.19.2",
"http-terminator": "^3.2.0",
"pino": "^9.2.0",
"react": "^18.3.1",
"satori": "^0.10.13"
},
"devDependencies": {
"@types/node": "^20.14.3",
"typescript": "^5.4.5"
}
}

Binary file not shown.

View File

@ -0,0 +1,48 @@
import cluster, {Worker} from 'node:cluster'
import {envInt} from '@atproto/common'
import {CardService, envToCfg, httpLogger, readEnv} from './index.js'
async function main() {
const env = readEnv()
const cfg = envToCfg(env)
const card = await CardService.create(cfg)
await card.start()
httpLogger.info('card service is running')
process.on('SIGTERM', async () => {
httpLogger.info('card service is stopping')
await card.destroy()
httpLogger.info('card service is stopped')
if (cluster.isWorker) process.exit(0)
})
}
const workerCount = envInt('CARD_CLUSTER_WORKER_COUNT')
if (workerCount) {
if (cluster.isPrimary) {
httpLogger.info(`primary ${process.pid} is running`)
const workers = new Set<Worker>()
for (let i = 0; i < workerCount; ++i) {
workers.add(cluster.fork())
}
let teardown = false
cluster.on('exit', worker => {
workers.delete(worker)
if (!teardown) {
workers.add(cluster.fork()) // restart on crash
}
})
process.on('SIGTERM', () => {
teardown = true
httpLogger.info('disconnecting workers')
workers.forEach(w => w.kill('SIGTERM'))
})
} else {
httpLogger.info(`worker ${process.pid} is running`)
main()
}
} else {
main() // non-clustering
}

View File

@ -0,0 +1,16 @@
import React from 'react'
export function Butterfly(props: React.SVGAttributes<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 568 501"
{...props}>
<path
fill="currentColor"
d="M123.121 33.664C188.241 82.553 258.281 181.68 284 234.873c25.719-53.192 95.759-152.32 160.879-201.21C491.866-1.611 568-28.906 568 57.947c0 17.346-9.945 145.713-15.778 166.555-20.275 72.453-94.155 90.933-159.875 79.748C507.222 323.8 536.444 388.56 473.333 453.32c-119.86 122.992-172.272-30.859-185.702-70.281-2.462-7.227-3.614-10.608-3.631-7.733-.017-2.875-1.169.506-3.631 7.733-13.43 39.422-65.842 193.273-185.702 70.281-63.111-64.76-33.89-129.52 80.986-149.071-65.72 11.185-139.6-7.295-159.875-79.748C9.945 203.659 0 75.291 0 57.946 0-28.906 76.135-1.612 123.121 33.664Z"
/>
</svg>
)
}

View File

@ -0,0 +1,10 @@
import React from 'react'
export function Img(
props: Omit<React.ImgHTMLAttributes<HTMLImageElement>, 'src'> & {src: Buffer},
) {
const {src, ...others} = props
return (
<img {...others} src={`data:image/jpeg;base64,${src.toString('base64')}`} />
)
}

View File

@ -0,0 +1,149 @@
/* eslint-disable bsky-internal/avoid-unwrapped-text */
import React from 'react'
import {AppBskyGraphDefs, AppBskyGraphStarterpack} from '@atproto/api'
import {Butterfly} from './Butterfly.js'
import {Img} from './Img.js'
export const STARTERPACK_HEIGHT = 630
export const STARTERPACK_WIDTH = 1200
export const TILE_SIZE = STARTERPACK_HEIGHT / 3
const GRADIENT_TOP = '#0A7AFF'
const GRADIENT_BOTTOM = '#59B9FF'
const IMAGE_STROKE = '#359CFF'
export function StarterPack(props: {
starterPack: AppBskyGraphDefs.StarterPackView
images: Map<string, Buffer>
}) {
const {starterPack, images} = props
const record = AppBskyGraphStarterpack.isRecord(starterPack.record)
? starterPack.record
: null
const imagesArray = [...images.values()]
const imageOfCreator = images.get(starterPack.creator.did)
const imagesExceptCreator = [...images.entries()]
.filter(([did]) => did !== starterPack.creator.did)
.map(([, image]) => image)
const imagesAcross: Buffer[] = []
if (imageOfCreator) {
if (imagesExceptCreator.length >= 6) {
imagesAcross.push(...imagesExceptCreator.slice(0, 3))
imagesAcross.push(imageOfCreator)
imagesAcross.push(...imagesExceptCreator.slice(3, 6))
} else {
const firstHalf = Math.floor(imagesExceptCreator.length / 2)
imagesAcross.push(...imagesExceptCreator.slice(0, firstHalf))
imagesAcross.push(imageOfCreator)
imagesAcross.push(
...imagesExceptCreator.slice(firstHalf, imagesExceptCreator.length),
)
}
} else {
imagesAcross.push(...imagesExceptCreator.slice(0, 7))
}
return (
<div
style={{
display: 'flex',
justifyContent: 'center',
width: STARTERPACK_WIDTH,
height: STARTERPACK_HEIGHT,
backgroundColor: 'black',
color: 'white',
fontFamily: 'Inter',
}}>
{/* image tiles */}
<div
style={{
display: 'flex',
flexWrap: 'wrap',
alignItems: 'stretch',
width: TILE_SIZE * 6,
height: TILE_SIZE * 3,
}}>
{[...Array(18)].map((_, i) => {
const image = imagesArray.at(i % imagesArray.length)
return (
<div
key={i}
style={{
display: 'flex',
height: TILE_SIZE,
width: TILE_SIZE,
}}>
{image && <Img height="100%" width="100%" src={image} />}
</div>
)
})}
{/* background overlay */}
<div
style={{
display: 'flex',
width: '100%',
height: '100%',
position: 'absolute',
backgroundImage: `linear-gradient(to bottom, ${GRADIENT_TOP}, ${GRADIENT_BOTTOM})`,
opacity: 0.9,
}}
/>
</div>
{/* foreground text & images */}
<div
style={{
display: 'flex',
alignItems: 'center',
flexDirection: 'column',
width: '100%',
height: '100%',
position: 'absolute',
color: 'white',
}}>
<div
style={{
color: 'white',
padding: 60,
fontSize: 40,
}}>
JOIN THE CONVERSATION
</div>
<div style={{display: 'flex'}}>
{imagesAcross.map((image, i) => {
return (
<div
key={i}
style={{
display: 'flex',
height: 172 + 15 * 2,
width: 172 + 15 * 2,
margin: -15,
border: `15px solid ${IMAGE_STROKE}`,
borderRadius: '50%',
overflow: 'hidden',
}}>
<Img height="100%" width="100%" src={image} />
</div>
)
})}
</div>
<div
style={{
padding: '75px 30px 0px',
fontSize: 65,
}}>
{record?.name || 'Starter Pack'}
</div>
<div
style={{
display: 'flex',
fontSize: 40,
justifyContent: 'center',
padding: '30px 30px 10px',
}}>
on <Butterfly width="65" style={{margin: '-7px 10px 0'}} /> Bluesky
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,40 @@
import {envInt, envStr} from '@atproto/common'
export type Config = {
service: ServiceConfig
}
export type ServiceConfig = {
port: number
version?: string
appviewUrl: string
originVerify?: string
}
export type Environment = {
port?: number
version?: string
appviewUrl?: string
originVerify?: string
}
export const readEnv = (): Environment => {
return {
port: envInt('CARD_PORT'),
version: envStr('CARD_VERSION'),
appviewUrl: envStr('CARD_APPVIEW_URL'),
originVerify: envStr('CARD_ORIGIN_VERIFY'),
}
}
export const envToCfg = (env: Environment): Config => {
const serviceCfg: ServiceConfig = {
port: env.port ?? 3000,
version: env.version,
appviewUrl: env.appviewUrl ?? 'https://api.bsky.app',
originVerify: env.originVerify,
}
return {
service: serviceCfg,
}
}

View File

@ -0,0 +1,44 @@
import {readFileSync} from 'node:fs'
import {AtpAgent} from '@atproto/api'
import * as path from 'path'
import {fileURLToPath} from 'url'
import {Config} from './config.js'
const __DIRNAME = path.dirname(fileURLToPath(import.meta.url))
export type AppContextOptions = {
cfg: Config
appviewAgent: AtpAgent
fonts: {name: string; data: Buffer}[]
}
export class AppContext {
cfg: Config
appviewAgent: AtpAgent
fonts: {name: string; data: Buffer}[]
abortController = new AbortController()
constructor(private opts: AppContextOptions) {
this.cfg = this.opts.cfg
this.appviewAgent = this.opts.appviewAgent
this.fonts = this.opts.fonts
}
static async fromConfig(cfg: Config, overrides?: Partial<AppContextOptions>) {
const appviewAgent = new AtpAgent({service: cfg.service.appviewUrl})
const fonts = [
{
name: 'Inter',
data: readFileSync(path.join(__DIRNAME, 'assets', 'Inter-Bold.ttf')),
},
]
return new AppContext({
cfg,
appviewAgent,
fonts,
...overrides,
})
}
}

View File

@ -0,0 +1,41 @@
import events from 'node:events'
import http from 'node:http'
import express from 'express'
import {createHttpTerminator, HttpTerminator} from 'http-terminator'
import {Config} from './config.js'
import {AppContext} from './context.js'
import {default as routes, errorHandler} from './routes/index.js'
export * from './config.js'
export * from './logger.js'
export class CardService {
public server?: http.Server
private terminator?: HttpTerminator
constructor(public app: express.Application, public ctx: AppContext) {}
static async create(cfg: Config): Promise<CardService> {
let app = express()
const ctx = await AppContext.fromConfig(cfg)
app = routes(ctx, app)
app.use(errorHandler)
return new CardService(app, ctx)
}
async start() {
this.server = this.app.listen(this.ctx.cfg.service.port)
this.server.keepAliveTimeout = 90000
this.terminator = createHttpTerminator({server: this.server})
await events.once(this.server, 'listening')
}
async destroy() {
this.ctx.abortController.abort()
await this.terminator?.terminate()
}
}

View File

@ -0,0 +1,3 @@
import {subsystemLogger} from '@atproto/common'
export const httpLogger = subsystemLogger('bskyogcard')

View File

@ -0,0 +1,14 @@
import {Express} from 'express'
import {AppContext} from '../context.js'
import {handler} from './util.js'
export default function (ctx: AppContext, app: Express) {
return app.get(
'/_health',
handler(async (_req, res) => {
const {version} = ctx.cfg.service
return res.send({version})
}),
)
}

View File

@ -0,0 +1,13 @@
import {Express} from 'express'
import {AppContext} from '../context.js'
import {default as health} from './health.js'
import {default as starterPack} from './starter-pack.js'
export * from './util.js'
export default function (ctx: AppContext, app: Express) {
app = health(ctx, app) // GET /_health
app = starterPack(ctx, app) // GET /start/:actor/:rkey
return app
}

View File

@ -0,0 +1,102 @@
import assert from 'node:assert'
import React from 'react'
import {AppBskyGraphDefs, AtUri} from '@atproto/api'
import resvg from '@resvg/resvg-js'
import {Express} from 'express'
import satori from 'satori'
import {
StarterPack,
STARTERPACK_HEIGHT,
STARTERPACK_WIDTH,
} from '../components/StarterPack.js'
import {AppContext} from '../context.js'
import {httpLogger} from '../logger.js'
import {handler, originVerifyMiddleware} from './util.js'
export default function (ctx: AppContext, app: Express) {
return app.get(
'/start/:actor/:rkey',
originVerifyMiddleware(ctx),
handler(async (req, res) => {
const {actor, rkey} = req.params
const uri = AtUri.make(actor, 'app.bsky.graph.starterpack', rkey)
let starterPack: AppBskyGraphDefs.StarterPackView
try {
const result = await ctx.appviewAgent.api.app.bsky.graph.getStarterPack(
{starterPack: uri.toString()},
)
starterPack = result.data.starterPack
} catch (err) {
httpLogger.warn(
{err, uri: uri.toString()},
'could not fetch starter pack',
)
return res.status(404).end('not found')
}
const imageEntries = await Promise.all(
[starterPack.creator]
.concat(starterPack.listItemsSample.map(li => li.subject))
// has avatar
.filter(p => p.avatar)
// no sensitive labels
.filter(p => !p.labels.some(l => hideAvatarLabels.has(l.val)))
.map(async p => {
try {
assert(p.avatar)
const image = await getImage(p.avatar)
return [p.did, image] as const
} catch (err) {
httpLogger.warn(
{err, uri: uri.toString(), did: p.did},
'could not fetch image',
)
return [p.did, null] as const
}
}),
)
const images = new Map(
imageEntries.filter(([_, image]) => image !== null).slice(0, 7),
)
const svg = await satori(
<StarterPack starterPack={starterPack} images={images} />,
{
fonts: ctx.fonts,
height: STARTERPACK_HEIGHT,
width: STARTERPACK_WIDTH,
},
)
const output = await resvg.renderAsync(svg)
res.statusCode = 200
res.setHeader('content-type', 'image/png')
res.setHeader('cdn-tag', [...images.keys()].join(','))
return res.end(output.asPng())
}),
)
}
async function getImage(url: string) {
const response = await fetch(url)
const arrayBuf = await response.arrayBuffer() // must drain body even if it will be discarded
if (response.status !== 200) return null
return Buffer.from(arrayBuf)
}
const hideAvatarLabels = new Set([
'!hide',
'!warn',
'porn',
'sexual',
'nudity',
'sexual-figurative',
'graphic-media',
'self-harm',
'sensitive',
'security',
'impersonation',
'scam',
'spam',
'misleading',
'inauthentic',
])

View File

@ -0,0 +1,36 @@
import {ErrorRequestHandler, Request, RequestHandler, Response} from 'express'
import {AppContext} from '../context.js'
import {httpLogger} from '../logger.js'
export type Handler = (req: Request, res: Response) => Awaited<void>
export const handler = (runHandler: Handler): RequestHandler => {
return async (req, res, next) => {
try {
await runHandler(req, res)
} catch (err) {
next(err)
}
}
}
export function originVerifyMiddleware(ctx: AppContext): RequestHandler {
const {originVerify} = ctx.cfg.service
if (!originVerify) return (_req, _res, next) => next()
return (req, res, next) => {
const verifyHeader = req.headers['x-origin-verify']
if (verifyHeader !== originVerify) {
return res.status(404).end('not found')
}
next()
}
}
export const errorHandler: ErrorRequestHandler = (err, req, res, next) => {
httpLogger.error({err}, 'request error')
if (res.headersSent) {
return next(err)
}
return res.status(500).end('server error')
}

View File

@ -0,0 +1,11 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "NodeNext",
"esModuleInterop": true,
"moduleResolution": "NodeNext",
"jsx": "react-jsx",
"outDir": "dist"
},
"include": ["./src/index.ts", "./src/bin.ts"]
}

1113
bskyogcard/yarn.lock 100644

File diff suppressed because it is too large Load Diff

View File

@ -35,7 +35,7 @@ func run(args []string) {
Flags: []cli.Flag{
&cli.StringFlag{
Name: "appview-host",
Usage: "method, hostname, and port of PDS instance",
Usage: "scheme, hostname, and port of PDS instance",
Value: "http://localhost:2584",
// retain old PDS env var for easy transition
EnvVars: []string{"ATP_APPVIEW_HOST", "ATP_PDS_HOST"},
@ -47,6 +47,13 @@ func run(args []string) {
Value: ":8100",
EnvVars: []string{"HTTP_ADDRESS"},
},
&cli.StringFlag{
Name: "link-host",
Usage: "scheme, hostname, and port of link service",
Required: false,
Value: "",
EnvVars: []string{"LINK_HOST"},
},
&cli.BoolFlag{
Name: "debug",
Usage: "Enable debug mode",

View File

@ -6,6 +6,7 @@ import (
"fmt"
"io/fs"
"net/http"
"net/url"
"os"
"os/signal"
"strings"
@ -36,6 +37,7 @@ func serve(cctx *cli.Context) error {
debug := cctx.Bool("debug")
httpAddress := cctx.String("http-address")
appviewHost := cctx.String("appview-host")
linkHost := cctx.String("link-host")
// Echo
e := echo.New()
@ -221,6 +223,14 @@ func serve(cctx *cli.Context) error {
e.GET("/profile/:handleOrDID/post/:rkey/liked-by", server.WebGeneric)
e.GET("/profile/:handleOrDID/post/:rkey/reposted-by", server.WebGeneric)
if linkHost != "" {
linkUrl, err := url.Parse(linkHost)
if err != nil {
return err
}
e.Group("/:linkId", server.LinkProxyMiddleware(linkUrl))
}
// Start the server.
log.Infof("starting server address=%s", httpAddress)
go func() {
@ -292,6 +302,30 @@ func (srv *Server) Download(c echo.Context) error {
return c.Redirect(http.StatusFound, "/")
}
// Handler for proxying top-level paths to link service, which ends up serving a redirect
func (srv *Server) LinkProxyMiddleware(url *url.URL) echo.MiddlewareFunc {
return middleware.ProxyWithConfig(
middleware.ProxyConfig{
Balancer: middleware.NewRoundRobinBalancer(
[]*middleware.ProxyTarget{{URL: url}},
),
Skipper: func(c echo.Context) bool {
req := c.Request()
if req.Method == "GET" &&
strings.LastIndex(strings.TrimRight(req.URL.Path, "/"), "/") == 0 && // top-level path
!strings.HasPrefix(req.URL.Path, "/_") { // e.g. /_health endpoint
return false
}
return true
},
RetryCount: 2,
ErrorHandler: func(c echo.Context, err error) error {
return c.Redirect(302, "/")
},
},
)
}
// handler for endpoint that have no specific server-side handling
func (srv *Server) WebGeneric(c echo.Context) error {
data := pongo2.Context{}

View File

@ -24,6 +24,7 @@ import {
import {s} from '#/lib/styles'
import {ThemeProvider} from '#/lib/ThemeContext'
import {logger} from '#/logger'
import {Provider as A11yProvider} from '#/state/a11y'
import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes'
import {Provider as DialogStateProvider} from '#/state/dialogs'
import {Provider as InvitesStateProvider} from '#/state/invites'
@ -152,6 +153,7 @@ function App() {
* that is set up in the InnerApp component above.
*/
return (
<A11yProvider>
<KeyboardProvider enabled={false} statusBarTranslucent={true}>
<SessionProvider>
<ShellStateProvider>
@ -173,6 +175,7 @@ function App() {
</ShellStateProvider>
</SessionProvider>
</KeyboardProvider>
</A11yProvider>
)
}

View File

@ -13,6 +13,7 @@ import {QueryProvider} from '#/lib/react-query'
import {Provider as StatsigProvider} from '#/lib/statsig/statsig'
import {ThemeProvider} from '#/lib/ThemeContext'
import {logger} from '#/logger'
import {Provider as A11yProvider} from '#/state/a11y'
import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes'
import {Provider as DialogStateProvider} from '#/state/dialogs'
import {Provider as InvitesStateProvider} from '#/state/invites'
@ -135,6 +136,7 @@ function App() {
* that is set up in the InnerApp component above.
*/
return (
<A11yProvider>
<SessionProvider>
<ShellStateProvider>
<PrefsStateProvider>
@ -154,6 +156,7 @@ function App() {
</PrefsStateProvider>
</ShellStateProvider>
</SessionProvider>
</A11yProvider>
)
}

View File

@ -312,7 +312,11 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
getComponent={() => MessagesSettingsScreen}
options={{title: title(msg`Chat settings`), requireAuth: true}}
/>
<Stack.Screen name="Feeds" getComponent={() => FeedsScreen} />
<Stack.Screen
name="Feeds"
getComponent={() => FeedsScreen}
options={{title: title(msg`Feeds`)}}
/>
</>
)
}

View File

@ -1,8 +1,14 @@
import React from 'react'
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 {useLingui} from '@lingui/react'
import {useQueryClient} from '@tanstack/react-query'
import {logger} from '#/logger'
import {
@ -11,6 +17,7 @@ import {
useRemoveFeedMutation,
} from '#/state/queries/preferences'
import {sanitizeHandle} from 'lib/strings/handles'
import {precacheFeedFromGeneratorView, precacheList} from 'state/queries/feed'
import {useSession} from 'state/session'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import * as Toast from 'view/com/util/Toast'
@ -20,41 +27,72 @@ import {Button, ButtonIcon} from '#/components/Button'
import {useRichText} from '#/components/hooks/useRichText'
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
import {Link as InternalLink} from '#/components/Link'
import {Link as InternalLink, LinkProps} from '#/components/Link'
import {Loader} from '#/components/Loader'
import * as Prompt from '#/components/Prompt'
import {RichText} from '#/components/RichText'
import {Text} from '#/components/Typography'
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 (
<Link feed={feed}>
<Link label={displayName} {...props}>
<Outer>
<Header>
<Avatar src={feed.avatar} />
<TitleAndByline title={feed.displayName} creator={feed.creator} />
<Action uri={feed.uri} pin />
<Avatar src={view.avatar} />
<TitleAndByline
title={displayName}
creator={view.creator}
type={type}
purpose={purpose}
/>
<Action uri={view.uri} pin type={type} purpose={purpose} />
</Header>
<Description description={feed.description} />
<Likes count={feed.likeCount || 0} />
<Description description={view.description} />
{type === 'feed' && <Likes count={view.likeCount || 0} />}
</Outer>
</Link>
)
}
export function Link({
type,
view,
label,
children,
feed,
}: {
children: React.ReactElement
feed: AppBskyFeedDefs.GeneratorView
}) {
}: Props & Omit<LinkProps, 'to'>) {
const queryClient = useQueryClient()
const href = React.useMemo(() => {
const urip = new AtUri(feed.uri)
const handleOrDid = feed.creator.handle || feed.creator.did
return `/profile/${handleOrDid}/feed/${urip.rkey}`
}, [feed])
return <InternalLink to={href}>{children}</InternalLink>
return createProfileFeedHref({feed: view})
}, [view])
return (
<InternalLink
to={href}
label={label}
onPress={() => {
if (type === 'feed') {
precacheFeedFromGeneratorView(queryClient, view)
} else {
precacheList(queryClient, view)
}
}}>
{children}
</InternalLink>
)
}
export function Outer({children}: {children: React.ReactNode}) {
@ -62,34 +100,100 @@ export function Outer({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}) {
return <UserAvatar type="algo" size={40} avatar={src} />
export type AvatarProps = {src: string | undefined; size?: number}
export function Avatar({src, size = 40}: AvatarProps) {
return <UserAvatar type="algo" size={size} avatar={src} />
}
export function AvatarPlaceholder({size = 40}: Omit<AvatarProps, 'src'>) {
const t = useTheme()
return (
<View
style={[
t.atoms.bg_contrast_25,
{
width: size,
height: size,
borderRadius: 8,
},
]}
/>
)
}
export function TitleAndByline({
title,
creator,
type,
purpose,
}: {
title: string
creator: AppBskyActorDefs.ProfileViewBasic
creator?: AppBskyActorDefs.ProfileViewBasic
type: 'feed' | 'list'
purpose?: AppBskyGraphDefs.ListView['purpose']
}) {
const t = useTheme()
return (
<View style={[a.flex_1]}>
<Text
style={[a.text_md, a.font_bold, a.flex_1, a.leading_snug]}
numberOfLines={1}>
<Text style={[a.text_md, a.font_bold, a.leading_snug]} numberOfLines={1}>
{title}
</Text>
{creator && (
<Text
style={[a.flex_1, a.leading_snug, t.atoms.text_contrast_medium]}
style={[a.leading_snug, t.atoms.text_contrast_medium]}
numberOfLines={1}>
{type === 'list' && purpose === 'app.bsky.graph.defs#curatelist' ? (
<Trans>List by {sanitizeHandle(creator.handle, '@')}</Trans>
) : type === 'list' && purpose === 'app.bsky.graph.defs#modlist' ? (
<Trans>
Moderation list by {sanitizeHandle(creator.handle, '@')}
</Trans>
) : (
<Trans>Feed by {sanitizeHandle(creator.handle, '@')}</Trans>
)}
</Text>
)}
</View>
)
}
export function TitleAndBylinePlaceholder({creator}: {creator?: boolean}) {
const t = useTheme()
return (
<View style={[a.flex_1, a.gap_xs]}>
<View
style={[
a.rounded_xs,
t.atoms.bg_contrast_50,
{
width: '60%',
height: 14,
},
]}
/>
{creator && (
<View
style={[
a.rounded_xs,
t.atoms.bg_contrast_25,
{
width: '40%',
height: 10,
},
]}
/>
)}
</View>
)
}
@ -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()
if (!hasSession) return null
return <ActionInner uri={uri} pin={pin} />
if (!hasSession || purpose !== 'app.bsky.graph.defs#curatelist') return null
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 {data: preferences} = usePreferencesQuery()
const {isPending: isAddSavedFeedPending, mutateAsync: saveFeeds} =
@ -130,9 +252,7 @@ function ActionInner({uri, pin}: {uri: string; pin?: boolean}) {
const {isPending: isRemovePending, mutateAsync: removeFeed} =
useRemoveFeedMutation()
const savedFeedConfig = React.useMemo(() => {
return preferences?.savedFeeds?.find(
feed => feed.type === 'feed' && feed.value === uri,
)
return preferences?.savedFeeds?.find(feed => feed.value === uri)
}, [preferences?.savedFeeds, uri])
const removePromptControl = Prompt.usePromptControl()
const isPending = isAddSavedFeedPending || isRemovePending
@ -148,7 +268,7 @@ function ActionInner({uri, pin}: {uri: string; pin?: boolean}) {
} else {
await saveFeeds([
{
type: 'feed',
type,
value: uri,
pinned: pin || false,
},
@ -160,7 +280,7 @@ function ActionInner({uri, pin}: {uri: string; pin?: boolean}) {
Toast.show(_(msg`Failed to update feeds`))
}
},
[_, pin, saveFeeds, removeFeed, uri, savedFeedConfig],
[_, pin, saveFeeds, removeFeed, uri, savedFeedConfig, type],
)
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
}`
}

View File

@ -64,7 +64,7 @@ export function ProfileHoverCard(props: ProfileHoverCardProps) {
return props.children
} else {
return (
<View onPointerMove={onPointerMove}>
<View onPointerMove={onPointerMove} style={[a.flex_shrink]}>
<ProfileHoverCardInner {...props} />
</View>
)

View File

@ -73,6 +73,22 @@ export type LogEvents = {
feedType: string
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:select': {}

View File

@ -1,5 +1,6 @@
export type Gate =
// Keep this alphabetic please.
| 'debug_show_feedcontext'
| 'native_pwi_disabled'
| 'request_notifications_permission_after_onboarding_v2'
| 'show_avi_follow_button'

View File

@ -115,6 +115,9 @@ const DOWNSAMPLED_EVENTS: Set<keyof LogEvents> = new Set([
'home:feedDisplayed:sampled',
'feed:endReached:sampled',
'feed:refresh:sampled',
'discover:clickthrough:sampled',
'discover:engaged:sampled',
'discover:seen:sampled',
])
const isDownsampledSession = Math.random() < 0.9 // 90% likely

View File

@ -165,7 +165,7 @@ msgstr ""
#~ msgstr "<0>{following} </0><1>following</1>"
#~ 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>"
#~ 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
msgid "Added to my feeds"
msgstr "Aggiunto ai miei feeds"
msgstr "Aggiunto ai miei feed"
#: src/view/screens/PreferencesFollowingFeed.tsx:172
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:65
msgid "Allow new messages from"
msgstr ""
msgstr "Consenti nuovi messaggi da"
#: src/screens/Login/ForgotPasswordForm.tsx:178
#: src/view/com/modals/ChangePassword.tsx:171
@ -936,12 +936,12 @@ msgstr "Conversazione silenziata"
#: src/screens/Messages/List/index.tsx:88
#: src/view/screens/Settings/index.tsx:638
msgid "Chat settings"
msgstr ""
msgstr "Impostazioni messaggi"
#: src/screens/Messages/Settings.tsx:59
#: src/view/screens/Settings/index.tsx:647
msgid "Chat Settings"
msgstr ""
msgstr "Impostazioni messaggi"
#: src/components/dms/ConvoMenu.tsx:84
msgid "Chat unmuted"
@ -1043,7 +1043,7 @@ msgstr ""
#: src/screens/Feeds/NoFollowingFeed.tsx:46
#~ msgid "Click here to add one."
#~ msgstr ""
#~ msgstr "Clicca qui per aggiungerne uno."
#: src/components/TagMenu/index.web.tsx:138
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/FollowingEndOfFeed.tsx:71
msgid "Discover new custom feeds"
msgstr "Scopri nuovi feeds personalizzati"
msgstr "Scopri nuovi feed personalizzati"
#~ msgid "Discover new feeds"
#~ msgstr "Scopri nuovi feeds"
#~ msgstr "Scopri nuovi feed"
#: src/view/screens/Feeds.tsx:794
msgid "Discover New Feeds"
msgstr "Scopri nuovi feeds"
msgstr "Scopri nuovi feed"
#: src/view/com/modals/EditProfile.tsx:193
msgid "Display name"
@ -1831,7 +1831,7 @@ msgstr "Modifica l'elenco di moderazione"
#: src/view/screens/Feeds.tsx:469
#: src/view/screens/SavedFeeds.tsx:93
msgid "Edit My Feeds"
msgstr "Modifica i miei feeds"
msgstr "Modifica i miei feed"
#: src/view/com/modals/EditProfile.tsx:153
msgid "Edit my profile"
@ -1850,7 +1850,7 @@ msgstr "Modifica il Profilo"
#: src/view/com/home/HomeHeaderLayout.web.tsx:76
#: src/view/screens/Feeds.tsx:416
#~ msgid "Edit Saved Feeds"
#~ msgstr "Modifica i feeds memorizzati"
#~ msgstr "Modifica i feed memorizzati"
#: src/view/com/modals/CreateOrEditList.tsx:234
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:79
#~ 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:89
@ -2202,7 +2202,7 @@ msgstr "Commenti"
#: src/view/shell/Drawer.tsx:493
#: src/view/shell/Drawer.tsx:494
msgid "Feeds"
msgstr "Feeds"
msgstr "Feed"
#~ 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."
@ -2213,7 +2213,7 @@ msgstr "I feed sono algoritmi personalizzati che gli utenti creano con un minimo
#: src/screens/Onboarding/StepTopicalFeeds.tsx:80
#~ 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
msgid "File Contents"
@ -3214,7 +3214,7 @@ msgstr "Il messaggio è troppo lungo"
#: src/screens/Messages/List/index.tsx:321
msgid "Message settings"
msgstr "Impostazione messaggio"
msgstr "Impostazioni messaggio"
#: src/Navigation.tsx:504
#: src/screens/Messages/List/index.tsx:164
@ -3412,7 +3412,7 @@ msgstr "Il mio Compleanno"
#: src/view/screens/Feeds.tsx:768
msgid "My Feeds"
msgstr "I miei Feeds"
msgstr "I miei Feed"
#: src/view/shell/desktop/LeftNav.tsx:84
msgid "My Profile"
@ -3424,7 +3424,7 @@ msgstr "I miei feed salvati"
#: src/view/screens/Settings/index.tsx:622
msgid "My Saved Feeds"
msgstr "I miei Feeds Salvati"
msgstr "I miei Feed Salvati"
#~ msgid "my-server.com"
#~ msgstr "my-server.com"
@ -3862,7 +3862,7 @@ msgstr "Apre la fotocamera sul dispositivo"
#: src/view/screens/Settings/index.tsx:639
msgid "Opens chat settings"
msgstr ""
msgstr "Apre impostazioni messaggi"
#: src/view/com/composer/Prompt.tsx:27
msgid "Opens composer"
@ -4112,7 +4112,7 @@ msgstr "Fissa su Home"
#: src/view/screens/SavedFeeds.tsx:103
msgid "Pinned Feeds"
msgstr "Feeds Fissi"
msgstr "Feed Fissi"
#: src/view/screens/ProfileList.tsx:289
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
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
msgid "Publish post"
@ -4431,7 +4431,7 @@ msgid "Recent Searches"
msgstr "Ricerche recenti"
#~ msgid "Recommended Feeds"
#~ msgstr "Feeds consigliati"
#~ msgstr "Feed consigliati"
#~ msgid "Recommended Users"
#~ msgstr "Utenti consigliati"
@ -4454,7 +4454,7 @@ msgid "Remove"
msgstr "Rimuovi"
#~ 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
msgid "Remove account"
@ -4524,14 +4524,14 @@ msgid "Remove repost"
msgstr "Rimuovi la ripubblicazione"
#~ 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
msgid "Remove this feed from your saved feeds"
msgstr "Rimuovi questo feed dai feed salvati"
#~ 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/UserAddRemoveLists.tsx:165
@ -4540,7 +4540,7 @@ msgstr "Elimina dalla lista"
#: src/view/com/feeds/FeedSourceCard.tsx:139
msgid "Removed from my feeds"
msgstr "Rimuovere dai miei feeds"
msgstr "Rimuovere dai miei feed"
#: src/view/com/posts/FeedShutdownMsg.tsx:44
#: 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
#~ 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
#~ msgid "Select what you want to see (or not see), and well 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."
#~ 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
msgid "Thread preferences"
@ -6783,7 +6783,7 @@ msgstr "Che lingue sono utilizzate in questo post?"
#: src/view/com/modals/lang-settings/ContentLanguagesSettings.tsx:77
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:124
@ -6901,7 +6901,7 @@ msgstr "Puoi modificarlo in qualsiasi momento."
#: src/screens/Messages/Settings.tsx:111
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/PasswordUpdatedForm.tsx:33
@ -6982,7 +6982,7 @@ msgstr "Non hai ancora nessuna conversazione. Avviane una!"
#: src/view/com/feeds/ProfileFeedgens.tsx:141
msgid "You have no feeds."
msgstr "Non hai feeds."
msgstr "Non hai feed."
#: src/view/com/lists/MyLists.tsx:90
#: src/view/com/lists/ProfileLists.tsx:145

View File

@ -8,7 +8,7 @@ msgstr ""
"Language: ja\n"
"Project-Id-Version: \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"
"Language-Team: Hima-Zinn, tkusano, dolciss, oboenikui, noritada, middlingphys, hibiki, reindex-ot, haoyayoi, vyv03354\n"
"Plural-Forms: \n"
@ -37,10 +37,6 @@ msgstr "{0, plural, other {#個のラベルがこのコンテンツに適用さ
msgid "{0, plural, one {# repost} other {# reposts}}"
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/screens/Profile/Header/Metrics.tsx:23
msgid "{0, plural, one {follower} other {followers}}"
@ -87,6 +83,26 @@ msgstr "{0}のアバター"
msgid "{count, plural, one {Liked by # user} other {Liked by # users}}"
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
msgid "{estimatedTimeHrs, plural, one {hour} other {hours}}"
msgstr "{estimatedTimeHrs, plural, other {時間}}"
@ -114,6 +130,10 @@ msgstr "{likeCount, plural, other {#人のユーザーがいいね}}"
msgid "{numUnreadNotifications} unread"
msgstr "{numUnreadNotifications}件の未読"
#: src/components/NewskieDialog.tsx:75
msgid "{profileName} joined Bluesky {0} ago"
msgstr "{profileName}はBlueskyに{0}前に参加しました"
#: 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}}"
msgstr "{value, plural, =0 {すべての返信を表示} other {#個以上のいいねがついた返信を表示}}"
@ -270,6 +290,10 @@ msgstr "フォローしているユーザーのみのデフォルトのフィー
msgid "Add the following DNS record to your domain:"
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:268
msgid "Add to Lists"
@ -469,6 +493,10 @@ msgstr "この会話から退出しますか?あなたのメッセージはあ
msgid "Are you sure you want to remove {0} from your feeds?"
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
msgid "Are you sure you'd like to discard this draft?"
msgstr "本当にこの下書きを破棄しますか?"
@ -1092,7 +1120,7 @@ msgstr "{0}として続行(現在サインイン中)"
#: src/view/com/post-thread/PostThreadLoadMore.tsx:52
msgid "Continue thread..."
msgstr ""
msgstr "スレッドの続き…"
#: src/screens/Onboarding/StepInterests/index.tsx:250
#: src/screens/Onboarding/StepProfile/index.tsx:266
@ -1419,6 +1447,10 @@ msgstr "アプリがログアウトしたユーザーに自分のアカウント
msgid "Discover new custom feeds"
msgstr "新しいカスタムフィードを見つける"
#: src/view/screens/Search/Explore.tsx:378
msgid "Discover new feeds"
msgstr "新しいフィードを探す"
#: src/view/screens/Feeds.tsx:794
msgid "Discover New Feeds"
msgstr "新しいフィードを探す"
@ -1534,16 +1566,16 @@ msgstr "例:返信として広告を繰り返し送ってくるユーザー。
msgid "Each code works once. You'll receive more invite codes periodically."
msgstr "それぞれのコードは一回限り有効です。定期的に追加の招待コードをお送りします。"
#: src/view/screens/Feeds.tsx:400
#: src/view/screens/Feeds.tsx:471
msgid "Edit"
msgstr ""
#: src/view/com/lists/ListMembers.tsx:149
msgctxt "action"
msgid "Edit"
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/UserBanner.tsx:92
msgid "Edit avatar"
@ -1583,11 +1615,6 @@ msgstr "プロフィールを編集"
msgid "Edit Profile"
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
msgid "Edit User List"
msgstr "ユーザーリストを編集"
@ -1754,6 +1781,10 @@ msgstr "全員"
msgid "Everybody can reply"
msgstr "誰でも返信可能"
#: src/view/com/threadgate/WhoCanReply.tsx:129
msgid "Everybody can reply."
msgstr "誰でも返信可能です。"
#: src/components/dms/MessagesNUX.tsx:131
#: src/components/dms/MessagesNUX.tsx:134
#: src/screens/Messages/Settings.tsx:75
@ -1857,6 +1888,11 @@ msgstr "メッセージの削除に失敗しました"
msgid "Failed to delete post, please try again"
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.tsx:212
msgid "Failed to load GIFs"
@ -1866,6 +1902,15 @@ msgstr "GIFの読み込みに失敗しました"
msgid "Failed to load past messages"
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
msgid "Failed to save image: {0}"
msgstr "画像の保存に失敗しました:{0}"
@ -1879,6 +1924,14 @@ msgstr "送信に失敗"
msgid "Failed to submit appeal, please try again."
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/screens/Messages/Settings.tsx:35
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."
msgstr "フィードはユーザーがプログラミングの専門知識を持って構築するカスタムアルゴリズムです。詳細については、<0/>を参照してください。"
#: src/components/FeedCard.tsx:150
msgid "Feeds updated!"
msgstr "フィードを更新しました!"
#: src/view/com/modals/ChangeHandle.tsx:475
msgid "File Contents"
msgstr "ファイルのコンテンツ"
@ -1996,14 +2053,30 @@ msgstr "アカウントをフォロー"
msgid "Follow Back"
msgstr "フォローバック"
#: src/components/KnownFollowers.tsx:169
msgid "Followed by"
msgstr ""
#: src/view/screens/Search/Explore.tsx:332
msgid "Follow more accounts to get connected to your interests and build your network."
msgstr "もっとたくさんのアカウントをフォローして、興味あることにつながり、ネットワークを広げましょう。"
#: src/view/com/profile/ProfileCard.tsx:227
msgid "Followed by {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
msgid "Followed users"
msgstr "自分がフォローしているユーザー"
@ -2023,12 +2096,12 @@ msgstr "フォロワー"
#: src/Navigation.tsx:177
msgid "Followers of @{0} that you know"
msgstr ""
msgstr "あなたが知っている@{0}のフォロワー"
#: src/screens/Profile/KnownFollowers.tsx:108
#: src/screens/Profile/KnownFollowers.tsx:118
msgid "Followers you know"
msgstr ""
msgstr "あなたが知っているフォロワー"
#: src/components/ProfileHoverCard/index.web.tsx:411
#: src/components/ProfileHoverCard/index.web.tsx:422
@ -2118,6 +2191,10 @@ msgstr "始める"
msgid "Get Started"
msgstr "開始"
#: src/view/com/util/images/ImageHorzList.tsx:35
msgid "GIF"
msgstr "GIF"
#: src/screens/Onboarding/StepProfile/index.tsx:225
msgid "Give your profile a face"
msgstr "プロフィールに顔をつける"
@ -2658,6 +2735,18 @@ msgstr "リスト"
msgid "Lists blocking this user:"
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
msgid "Load new notifications"
msgstr "最新の通知を読み込む"
@ -3062,6 +3151,10 @@ msgctxt "action"
msgid "New Post"
msgstr "新しい投稿"
#: src/components/NewskieDialog.tsx:68
msgid "New user info dialog"
msgstr "新しいユーザー情報ダイアログ"
#: src/view/com/modals/CreateOrEditList.tsx:236
msgid "New User List"
msgstr "新しいユーザーリスト"
@ -3142,7 +3235,7 @@ msgstr "誰からも受け取らない"
#: src/screens/Profile/Sections/Feed.tsx:59
msgid "No posts yet."
msgstr ""
msgstr "まだ投稿がありません。"
#: src/view/com/composer/text-input/mobile/Autocomplete.tsx:101
#: src/view/com/composer/text-input/web/Autocomplete.tsx:195
@ -3236,6 +3329,10 @@ msgstr "通知音"
msgid "Notifications"
msgstr "通知"
#: src/lib/hooks/useTimeAgo.ts:51
msgid "now"
msgstr "今"
#: src/components/dms/MessageItem.tsx:175
msgid "Now"
msgstr "今"
@ -3274,6 +3371,10 @@ msgstr "OK"
msgid "Oldest replies first"
msgstr "古い順に返信を表示"
#: src/lib/hooks/useTimeAgo.ts:81
msgid "on {str}"
msgstr "{str}"
#: src/view/screens/Settings/index.tsx:256
msgid "Onboarding reset"
msgstr "オンボーディングのリセット"
@ -3449,11 +3550,6 @@ msgstr "モデレーションの設定を開く"
msgid "Opens password reset form"
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
msgid "Opens screen with all saved feeds"
msgstr "保存されたすべてのフィードで画面を開く"
@ -3777,7 +3873,7 @@ msgstr "再実行する"
#: src/components/KnownFollowers.tsx:111
msgid "Press to view followers of this account that you also follow"
msgstr ""
msgstr "あなたもフォローしているこのアカウントのフォロワーを見る"
#: src/view/com/lightbox/Lightbox.web.tsx:150
msgid "Previous image"
@ -3975,7 +4071,7 @@ msgstr "リストから削除されました"
#: src/view/com/feeds/FeedSourceCard.tsx:139
msgid "Removed from my feeds"
msgstr "フィードから削除しました"
msgstr "マイフィードから削除しました"
#: src/view/com/posts/FeedShutdownMsg.tsx:44
#: src/view/screens/ProfileFeed.tsx:191
@ -4000,9 +4096,9 @@ msgstr "Discoverで置き換える"
msgid "Replies"
msgstr "返信"
#: src/view/com/threadgate/WhoCanReply.tsx:98
msgid "Replies to this thread are disabled"
msgstr "このスレッドへの返信はできません"
#: src/view/com/threadgate/WhoCanReply.tsx:131
msgid "Replies to this thread are disabled."
msgstr "このスレッドへの返信はできません"
#: src/view/com/composer/Composer.tsx:475
msgctxt "action"
@ -4019,6 +4115,11 @@ msgctxt "description"
msgid "Reply to <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/MessagesListBlockedFooter.tsx:77
#: src/components/dms/MessagesListBlockedFooter.tsx:84
@ -4926,9 +5027,9 @@ msgstr "このラベラーを登録"
msgid "Subscribe to this list"
msgstr "このリストに登録"
#: src/view/screens/Search/Search.tsx:425
msgid "Suggested Follows"
msgstr "おすすめのフォロー"
#: src/view/screens/Search/Explore.tsx:330
msgid "Suggested accounts"
msgstr "おすすめのアカウント"
#: src/view/com/profile/ProfileHeaderSuggestedFollows.tsx:65
msgid "Suggested for you"
@ -5077,7 +5178,7 @@ msgstr "サービス規約は移動しました"
#: src/screens/Settings/components/DeactivateAccountDialog.tsx:86
msgid "There is no time limit for account deactivation, come back any time."
msgstr "アカウントの無効化に期限はありません。いつでも戻ってこれます。"
msgstr "アカウントの無効化に期限はありません。いつでも戻ってこれます。"
#: src/screens/Profile/Header/ProfileHeaderLabeler.tsx:115
#: src/view/screens/ProfileFeed.tsx:541
@ -5217,7 +5318,7 @@ msgstr "このコンテンツはBlueskyのアカウントがないと閲覧で
#: src/screens/Messages/List/ChatListItem.tsx:213
msgid "This conversation is with a deleted or a deactivated account. Press for options."
msgstr ""
msgstr "削除あるいは無効化されたアカウントとの会話です。押すと選択肢が表示されます。"
#: 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>."
@ -5227,12 +5328,6 @@ msgstr "この機能はベータ版です。リポジトリのエクスポート
msgid "This feed is currently receiving high traffic and is temporarily unavailable. Please try again later."
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
msgid "This feed is empty! You may need to follow more users or tune your language settings."
msgstr "このフィードは空です!もっと多くのユーザーをフォローするか、言語の設定を調整する必要があるかもしれません。"
@ -5240,7 +5335,7 @@ msgstr "このフィードは空です!もっと多くのユーザーをフォ
#: src/view/screens/ProfileFeed.tsx:471
#: src/view/screens/ProfileList.tsx:729
msgid "This feed is empty."
msgstr ""
msgstr "このフィードは空です。"
#: src/view/com/posts/FeedShutdownMsg.tsx:97
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."
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
msgid "This user isn't following anyone."
msgstr "このユーザーは誰もフォローしていません。"
@ -5762,6 +5861,10 @@ msgstr "{0}のアバターを表示"
msgid "View {0}'s profile"
msgstr "{0}のプロフィールを表示"
#: src/components/ProfileHoverCard/index.web.tsx:417
msgid "View blocked user's profile"
msgstr "ブロック中のユーザーのプロフィールを表示"
#: src/view/screens/Log.tsx:52
msgid "View debug entry"
msgstr "デバッグエントリーを表示"
@ -5804,7 +5907,7 @@ msgstr "このフィードにいいねしたユーザーを見る"
#: src/view/com/home/HomeHeaderLayout.web.tsx:78
#: src/view/com/home/HomeHeaderLayoutMobile.tsx:84
msgid "View your feeds and explore more"
msgstr ""
msgstr "フィードを表示し、さらにフィードを探す"
#: src/view/com/modals/LinkWarning.tsx:89
#: src/view/com/modals/LinkWarning.tsx:95
@ -5891,7 +5994,7 @@ msgstr "大変申し訳ありませんが、検索を完了できませんでし
#: src/view/com/composer/Composer.tsx:318
msgid "We're sorry! The post you are replying to has been deleted."
msgstr ""
msgstr "大変申し訳ありません!返信しようとしている投稿は削除されました。"
#: src/components/Lists.tsx:212
#: 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 "大変申し訳ありません!お探しのページは見つかりません。"
#: 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."
msgstr "大変申し訳ありません!ラベラーは10までしか登録できず、すでに上限に達しています。"
msgid "We're sorry! You can only subscribe to twenty labelers, and you've reached your limit of twenty."
msgstr "大変申し訳ありません!ラベラーは20までしか登録できず、すでに上限に達しています。"
#: src/screens/Deactivated.tsx:128
msgid "Welcome back!"
@ -6047,7 +6150,7 @@ msgstr "あなたはまだだれもフォロワーがいません。"
#: src/screens/Profile/KnownFollowers.tsx:99
msgid "You don't follow any users who follow @{name}."
msgstr ""
msgstr "@{name}をフォローしているユーザーを誰もフォローしていません。"
#: 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."

File diff suppressed because it is too large Load Diff

65
src/state/a11y.tsx 100644
View File

@ -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>
}

View File

@ -4,6 +4,7 @@ import {AppBskyFeedDefs, BskyAgent} from '@atproto/api'
import throttle from 'lodash.throttle'
import {PROD_DEFAULT_FEED} from '#/lib/constants'
import {logEvent} from '#/lib/statsig/statsig'
import {logger} from '#/logger'
import {
FeedDescriptor,
@ -34,6 +35,16 @@ export function useFeedFeedback(feed: FeedDescriptor, hasSession: boolean) {
WeakSet<FeedPostSliceItem | AppBskyFeedDefs.Interaction>
>(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 proxyAgent = agent.withProxy(
// @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)
queue.current.clear()
// Send to the feed
proxyAgent.app.bsky.feed
.sendInteractions({interactions})
.catch((e: any) => {
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(
() =>
@ -149,3 +168,89 @@ function toInteraction(str: string): AppBskyFeedDefs.Interaction {
const [item, event, feedContext] = str.split('|')
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
}
}

View File

@ -9,20 +9,24 @@ import {
} from '@atproto/api'
import {
InfiniteData,
QueryClient,
QueryKey,
useInfiniteQuery,
useMutation,
useQuery,
useQueryClient,
} from '@tanstack/react-query'
import {DISCOVER_FEED_URI, DISCOVER_SAVED_FEED} from '#/lib/constants'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
import {STALE} from '#/state/queries'
import {RQKEY as listQueryKey} from '#/state/queries/list'
import {usePreferencesQuery} from '#/state/queries/preferences'
import {useAgent, useSession} from '#/state/session'
import {router} from '#/routes'
import {FeedDescriptor} from './post-feed'
import {precacheResolvedUri} from './resolve-uri'
export type FeedSourceFeedInfo = {
type: 'feed'
@ -201,6 +205,7 @@ export function useGetPopularFeedsQuery(options?: GetPopularFeedsOptions) {
const agent = useAgent()
const limit = options?.limit || 10
const {data: preferences} = usePreferencesQuery()
const queryClient = useQueryClient()
// Make sure this doesn't invalidate unless really needed.
const selectArgs = useMemo(
@ -225,6 +230,13 @@ export function useGetPopularFeedsQuery(options?: GetPopularFeedsOptions) {
limit,
cursor: pageParam,
})
// precache feeds
for (const feed of res.data.feeds) {
const hydratedFeed = hydrateFeedGenerator(feed)
precacheFeed(queryClient, hydratedFeed)
}
return res.data
},
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)
}

View File

@ -1,5 +1,10 @@
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 {useAgent} from '#/state/session'
@ -50,3 +55,11 @@ export function useResolveDidQuery(didOrHandle: string | undefined) {
enabled: !!didOrHandle,
})
}
export function precacheResolvedUri(
queryClient: QueryClient,
handle: string,
did: string,
) {
queryClient.setQueryData<string>(RQKEY(handle), did)
}

View File

@ -34,13 +34,14 @@ const suggestedFollowsByActorQueryKey = (did: string) => [
did,
]
type SuggestedFollowsOptions = {limit?: number}
type SuggestedFollowsOptions = {limit?: number; subsequentPageLimit?: number}
export function useSuggestedFollowsQuery(options?: SuggestedFollowsOptions) {
const {currentAccount} = useSession()
const agent = useAgent()
const moderationOpts = useModerationOpts()
const {data: preferences} = usePreferencesQuery()
const limit = options?.limit || 25
return useInfiniteQuery<
AppBskyActorGetSuggestions.OutputSchema,
@ -54,9 +55,13 @@ export function useSuggestedFollowsQuery(options?: SuggestedFollowsOptions) {
queryKey: suggestedFollowsQueryKey(options),
queryFn: async ({pageParam}) => {
const contentLangs = getContentLanguages().join(',')
const maybeDifferentLimit =
options?.subsequentPageLimit && pageParam
? options.subsequentPageLimit
: limit
const res = await agent.app.bsky.actor.getSuggestions(
{
limit: options?.limit || 25,
limit: maybeDifferentLimit,
cursor: pageParam,
},
{

View File

@ -19,6 +19,7 @@ import {
import {getInitialState, reducer} from './reducer'
export {isSignupQueued} from './util'
import {addSessionDebugLog} from './logging'
export type {SessionAccount} 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<{}>) {
const cancelPendingTask = useOneTaskAtATime()
const [state, dispatch] = React.useReducer(reducer, null, () =>
getInitialState(persisted.get('session').accounts),
)
const [state, dispatch] = React.useReducer(reducer, null, () => {
const initialState = getInitialState(persisted.get('session').accounts)
addSessionDebugLog({type: 'reducer:init', state: initialState})
return initialState
})
const onAgentSessionChange = React.useCallback(
(agent: BskyAgent, accountDid: string, sessionEvent: AtpSessionEvent) => {
@ -63,6 +66,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
const createAccount = React.useCallback<SessionApiContext['createAccount']>(
async params => {
addSessionDebugLog({type: 'method:start', method: 'createAccount'})
const signal = cancelPendingTask()
track('Try Create Account')
logEvent('account:create:begin', {})
@ -81,12 +85,14 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
})
track('Create Account')
logEvent('account:create:success', {})
addSessionDebugLog({type: 'method:end', method: 'createAccount', account})
},
[onAgentSessionChange, cancelPendingTask],
)
const login = React.useCallback<SessionApiContext['login']>(
async (params, logContext) => {
addSessionDebugLog({type: 'method:start', method: 'login'})
const signal = cancelPendingTask()
const {agent, account} = await createAgentAndLogin(
params,
@ -103,23 +109,31 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
})
track('Sign In', {resumedSession: false})
logEvent('account:loggedIn', {logContext, withPassword: true})
addSessionDebugLog({type: 'method:end', method: 'login', account})
},
[onAgentSessionChange, cancelPendingTask],
)
const logout = React.useCallback<SessionApiContext['logout']>(
logContext => {
addSessionDebugLog({type: 'method:start', method: 'logout'})
cancelPendingTask()
dispatch({
type: 'logged-out',
})
logEvent('account:loggedOut', {logContext})
addSessionDebugLog({type: 'method:end', method: 'logout'})
},
[cancelPendingTask],
)
const resumeSession = React.useCallback<SessionApiContext['resumeSession']>(
async storedAccount => {
addSessionDebugLog({
type: 'method:start',
method: 'resumeSession',
account: storedAccount,
})
const signal = cancelPendingTask()
const {agent, account} = await createAgentAndResume(
storedAccount,
@ -134,17 +148,24 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
newAgent: agent,
newAccount: account,
})
addSessionDebugLog({type: 'method:end', method: 'resumeSession', account})
},
[onAgentSessionChange, cancelPendingTask],
)
const removeAccount = React.useCallback<SessionApiContext['removeAccount']>(
account => {
addSessionDebugLog({
type: 'method:start',
method: 'removeAccount',
account,
})
cancelPendingTask()
dispatch({
type: 'removed-account',
accountDid: account.did,
})
addSessionDebugLog({type: 'method:end', method: 'removeAccount', account})
},
[cancelPendingTask],
)
@ -152,18 +173,21 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
React.useEffect(() => {
if (state.needsPersist) {
state.needsPersist = false
persisted.write('session', {
const persistedData = {
accounts: state.accounts,
currentAccount: state.accounts.find(
a => a.did === state.currentAgentState.did,
),
})
}
addSessionDebugLog({type: 'persisted:broadcast', data: persistedData})
persisted.write('session', persistedData)
}
}, [state])
React.useEffect(() => {
return persisted.onUpdate(() => {
const synced = persisted.get('session')
addSessionDebugLog({type: 'persisted:receive', data: synced})
dispatch({
type: 'synced-accounts',
syncedAccounts: synced.accounts,
@ -177,7 +201,14 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
resumeSession(syncedAccount)
} else {
const agent = state.currentAgentState.agent as BskyAgent
const prevSession = agent.session
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.
const prevAgent = currentAgentRef.current
currentAgentRef.current = agent
addSessionDebugLog({type: 'agent:switch', prevAgent, nextAgent: agent})
// We never reuse agents so let's fully neutralize the previous one.
// This ensures it won't try to consume any refresh tokens.
prevAgent.session = undefined

View File

@ -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
}

View File

@ -1,6 +1,7 @@
import {AtpSessionEvent} from '@atproto/api'
import {createPublicAgent} from './agent'
import {wrapSessionReducerForLogging} from './logging'
import {SessionAccount} from './types'
// 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) {
case 'received-agent-event': {
const {agent, accountDid, refreshedAccount, sessionEvent} = action
@ -166,3 +167,5 @@ export function reducer(state: State, action: Action): State {
}
}
}
reducer = wrapSessionReducerForLogging(reducer)
export {reducer}

View File

@ -24,12 +24,18 @@ import Animated, {
} from 'react-native-reanimated'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {LinearGradient} from 'expo-linear-gradient'
import {
AppBskyFeedDefs,
AppBskyFeedGetPostThread,
BskyAgent,
} from '@atproto/api'
import {RichText} from '@atproto/api'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {observer} from 'mobx-react-lite'
import {until} from '#/lib/async/until'
import {
createGIFDescription,
parseAltFromGIFDescription,
@ -299,6 +305,17 @@ export const ComposePost = observer(function ComposePost({
langs: toPostLanguages(langPrefs.postLanguage),
})
).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) {
logger.error(e, {
message: `Composer: create post failed`,
@ -756,6 +773,23 @@ function useKeyboardVerticalOffset() {
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({
topbarInner: {
flexDirection: 'row',

View File

@ -3,7 +3,6 @@ import {
findNodeHandle,
ListRenderItemInfo,
StyleProp,
StyleSheet,
View,
ViewStyle,
} from 'react-native'
@ -12,18 +11,17 @@ import {useLingui} from '@lingui/react'
import {useQueryClient} from '@tanstack/react-query'
import {cleanError} from '#/lib/strings/errors'
import {useTheme} from '#/lib/ThemeContext'
import {logger} from '#/logger'
import {isNative, isWeb} from '#/platform/detection'
import {hydrateFeedGenerator} from '#/state/queries/feed'
import {usePreferencesQuery} from '#/state/queries/preferences'
import {RQKEY, useProfileFeedgensQuery} from '#/state/queries/profile-feedgens'
import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
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 {List, ListRef} from '../util/List'
import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn'
import {FeedSourceCardLoaded} from './FeedSourceCard'
const LOADING = {_reactKey: '__loading__'}
const EMPTY = {_reactKey: '__empty__'}
@ -52,7 +50,7 @@ export const ProfileFeedgens = React.forwardRef<
ref,
) {
const {_} = useLingui()
const theme = useTheme()
const t = useTheme()
const [isPTRing, setIsPTRing] = React.useState(false)
const opts = React.useMemo(() => ({enabled}), [enabled])
const {
@ -79,10 +77,9 @@ export const ProfileFeedgens = React.forwardRef<
items = items.concat([EMPTY])
} else if (data?.pages) {
for (const page of data?.pages) {
items = items.concat(page.feeds.map(feed => hydrateFeedGenerator(feed)))
items = items.concat(page.feeds)
}
}
if (isError && !isEmpty) {
} else if (isError && !isEmpty) {
items = items.concat([LOAD_MORE_ERROR_ITEM])
}
return items
@ -132,8 +129,7 @@ export const ProfileFeedgens = React.forwardRef<
// rendering
// =
const renderItemInner = React.useCallback(
({item, index}: ListRenderItemInfo<any>) => {
const renderItem = ({item, index}: ListRenderItemInfo<any>) => {
if (item === EMPTY) {
return (
<EmptyState
@ -160,20 +156,19 @@ export const ProfileFeedgens = React.forwardRef<
}
if (preferences) {
return (
<FeedSourceCardLoaded
feedUri={item.uri}
feed={item}
preferences={preferences}
style={styles.item}
showLikes
hideTopBorder={index === 0 && !isWeb}
/>
<View
style={[
(index !== 0 || isWeb) && a.border_t,
t.atoms.border_contrast_low,
a.px_lg,
a.py_lg,
]}>
<FeedCard.Default type="feed" view={item} />
</View>
)
}
return null
},
[error, refetch, onPressRetryLoadMore, preferences, _],
)
}
React.useEffect(() => {
if (enabled && scrollElRef.current) {
@ -189,12 +184,12 @@ export const ProfileFeedgens = React.forwardRef<
ref={scrollElRef}
data={items}
keyExtractor={(item: any) => item._reactKey || item.uri}
renderItem={renderItemInner}
renderItem={renderItem}
refreshing={isPTRing}
onRefresh={onRefresh}
headerOffset={headerOffset}
contentContainerStyle={isNative && {paddingBottom: headerOffset + 100}}
indicatorStyle={theme.colorScheme === 'dark' ? 'white' : 'black'}
indicatorStyle={t.name === 'light' ? 'black' : 'white'}
removeClippedSubviews={true}
// @ts-ignore our .web version only -prf
desktopFixedHeight
@ -203,9 +198,3 @@ export const ProfileFeedgens = React.forwardRef<
</View>
)
})
const styles = StyleSheet.create({
item: {
paddingHorizontal: 18,
},
})

View File

@ -3,7 +3,6 @@ import {
findNodeHandle,
ListRenderItemInfo,
StyleProp,
StyleSheet,
View,
ViewStyle,
} from 'react-native'
@ -12,17 +11,17 @@ import {useLingui} from '@lingui/react'
import {useQueryClient} from '@tanstack/react-query'
import {cleanError} from '#/lib/strings/errors'
import {useTheme} from '#/lib/ThemeContext'
import {logger} from '#/logger'
import {isNative, isWeb} from '#/platform/detection'
import {RQKEY, useProfileListsQuery} from '#/state/queries/profile-lists'
import {useAnalytics} from 'lib/analytics/analytics'
import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
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 {List, ListRef} from '../util/List'
import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn'
import {ListCard} from './ListCard'
const LOADING = {_reactKey: '__loading__'}
const EMPTY = {_reactKey: '__empty__'}
@ -48,7 +47,7 @@ export const ProfileLists = React.forwardRef<SectionRef, ProfileListsProps>(
{did, scrollElRef, headerOffset, enabled, style, testID, setScrollViewTag},
ref,
) {
const theme = useTheme()
const t = useTheme()
const {track} = useAnalytics()
const {_} = useLingui()
const [isPTRing, setIsPTRing] = React.useState(false)
@ -166,15 +165,18 @@ export const ProfileLists = React.forwardRef<SectionRef, ProfileListsProps>(
return <FeedLoadingPlaceholder />
}
return (
<ListCard
list={item}
testID={`list-${item.name}`}
style={styles.item}
noBorder={index === 0 && !isWeb}
/>
<View
style={[
(index !== 0 || isWeb) && a.border_t,
t.atoms.border_contrast_low,
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(() => {
@ -198,7 +200,7 @@ export const ProfileLists = React.forwardRef<SectionRef, ProfileListsProps>(
contentContainerStyle={
isNative && {paddingBottom: headerOffset + 100}
}
indicatorStyle={theme.colorScheme === 'dark' ? 'white' : 'black'}
indicatorStyle={t.name === 'light' ? 'black' : 'white'}
removeClippedSubviews={true}
// @ts-ignore our .web version only -prf
desktopFixedHeight
@ -208,9 +210,3 @@ export const ProfileLists = React.forwardRef<SectionRef, ProfileListsProps>(
)
},
)
const styles = StyleSheet.create({
item: {
paddingHorizontal: 18,
},
})

View File

@ -328,6 +328,7 @@ const styles = StyleSheet.create({
borderRadius: 4,
paddingHorizontal: 6,
paddingVertical: 2,
justifyContent: 'center',
},
btn: {
paddingVertical: 7,

View File

@ -6,6 +6,7 @@ import {
View,
type ViewStyle,
} from 'react-native'
import * as Clipboard from 'expo-clipboard'
import {
AppBskyFeedDefs,
AppBskyFeedPost,
@ -19,6 +20,7 @@ import {POST_CTRL_HITSLOP} from '#/lib/constants'
import {useHaptics} from '#/lib/haptics'
import {makeProfileLink} from '#/lib/routes/links'
import {shareUrl} from '#/lib/sharing'
import {useGate} from '#/lib/statsig/statsig'
import {toShareUrl} from '#/lib/strings/url-helpers'
import {s} from '#/lib/styles'
import {Shadow} from '#/state/cache/types'
@ -41,6 +43,7 @@ import * as Prompt from '#/components/Prompt'
import {PostDropdownBtn} from '../forms/PostDropdownBtn'
import {formatCount} from '../numeric/format'
import {Text} from '../text/Text'
import * as Toast from '../Toast'
import {RepostButton} from './RepostButton'
let PostCtrls = ({
@ -75,6 +78,7 @@ let PostCtrls = ({
const loggedOutWarningPromptControl = useDialogControl()
const {sendInteraction} = useFeedFeedbackContext()
const playHaptic = useHaptics()
const gate = useGate()
const shouldShowLoggedOutWarning = React.useMemo(() => {
return (
@ -329,6 +333,31 @@ let PostCtrls = ({
timestamp={post.indexedAt}
/>
</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 File

@ -1,8 +1,6 @@
import React from 'react'
import {ActivityIndicator, type FlatList, StyleSheet, View} from 'react-native'
import {AppBskyActorDefs, AppBskyFeedDefs} from '@atproto/api'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {FontAwesomeIconStyle} from '@fortawesome/react-native-fontawesome'
import {AppBskyFeedDefs} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useFocusEffect} from '@react-navigation/native'
@ -10,12 +8,11 @@ import debounce from 'lodash.debounce'
import {isNative, isWeb} from '#/platform/detection'
import {
getAvatarTypeFromUri,
useFeedSourceInfoQuery,
SavedFeedItem,
useGetPopularFeedsQuery,
useSavedFeeds,
useSearchPopularFeedsMutation,
} from '#/state/queries/feed'
import {usePreferencesQuery} from '#/state/queries/preferences'
import {useSession} from '#/state/session'
import {useSetMinimalShellMode} from '#/state/shell'
import {useComposerControls} from '#/state/shell/composer'
@ -28,14 +25,10 @@ import {s} from 'lib/styles'
import {ErrorMessage} from 'view/com/util/error/ErrorMessage'
import {FAB} from 'view/com/util/fab/FAB'
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 {
FeedFeedLoadingPlaceholder,
LoadingPlaceholder,
} from 'view/com/util/LoadingPlaceholder'
import {FeedFeedLoadingPlaceholder} from 'view/com/util/LoadingPlaceholder'
import {Text} from 'view/com/util/text/Text'
import {UserAvatar} from 'view/com/util/UserAvatar'
import {ViewHeader} from 'view/com/util/ViewHeader'
import {NoFollowingFeed} from '#/screens/Feeds/NoFollowingFeed'
import {NoSavedFeedsOfAnyType} from '#/screens/Feeds/NoSavedFeedsOfAnyType'
@ -47,6 +40,7 @@ import {ListSparkle_Stroke2_Corner0_Rounded} from '#/components/icons/ListSparkl
import hairlineWidth = StyleSheet.hairlineWidth
import {Divider} from '#/components/Divider'
import * as FeedCard from '#/components/FeedCard'
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'Feeds'>
@ -61,9 +55,8 @@ type FlatlistSlice =
key: string
}
| {
type: 'savedFeedsLoading'
type: 'savedFeedPlaceholder'
key: string
// pendingItems: number,
}
| {
type: 'savedFeedNoResults'
@ -72,8 +65,7 @@ type FlatlistSlice =
| {
type: 'savedFeed'
key: string
feedUri: string
savedFeedConfig: AppBskyActorDefs.SavedFeed
savedFeed: SavedFeedItem
}
| {
type: 'savedFeedsLoadMore'
@ -113,11 +105,11 @@ export function FeedsScreen(_props: Props) {
const [query, setQuery] = React.useState('')
const [isPTR, setIsPTR] = React.useState(false)
const {
data: preferences,
isLoading: isPreferencesLoading,
error: preferencesError,
refetch: refetchPreferences,
} = usePreferencesQuery()
data: savedFeeds,
isPlaceholderData: isSavedFeedsPlaceholder,
error: savedFeedsError,
refetch: refetchSavedFeeds,
} = useSavedFeeds()
const {
data: popularFeeds,
isFetching: isPopularFeedsFetching,
@ -173,11 +165,11 @@ export function FeedsScreen(_props: Props) {
const onPullToRefresh = React.useCallback(async () => {
setIsPTR(true)
await Promise.all([
refetchPreferences().catch(_e => undefined),
refetchSavedFeeds().catch(_e => undefined),
refetchPopularFeeds().catch(_e => undefined),
])
setIsPTR(false)
}, [setIsPTR, refetchPreferences, refetchPopularFeeds])
}, [setIsPTR, refetchSavedFeeds, refetchPopularFeeds])
const onEndReached = React.useCallback(() => {
if (
isPopularFeedsFetching ||
@ -203,6 +195,11 @@ export function FeedsScreen(_props: Props) {
const items = React.useMemo(() => {
let slices: FlatlistSlice[] = []
const hasActualSavedCount =
!isSavedFeedsPlaceholder ||
(isSavedFeedsPlaceholder && (savedFeeds?.count || 0) > 0)
const canShowDiscoverSection =
!hasSession || (hasSession && hasActualSavedCount)
if (hasSession) {
slices.push({
@ -210,47 +207,63 @@ export function FeedsScreen(_props: Props) {
type: 'savedFeedsHeader',
})
if (preferencesError) {
if (savedFeedsError) {
slices.push({
key: 'savedFeedsError',
type: 'error',
error: cleanError(preferencesError.toString()),
error: cleanError(savedFeedsError.toString()),
})
} 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({
key: 'savedFeedsLoading',
type: 'savedFeedsLoading',
// pendingItems: this.rootStore.preferences.savedFeeds.length || 3,
key: 'savedFeedPlaceholder' + i,
type: 'savedFeedPlaceholder',
})
})
} else {
if (preferences.savedFeeds?.length) {
const noFollowingFeed = preferences.savedFeeds.every(
if (savedFeeds?.feeds?.length) {
const noFollowingFeed = savedFeeds.feeds.every(
f => f.type !== 'timeline',
)
slices = slices.concat(
preferences.savedFeeds
.filter(f => {
return f.pinned
savedFeeds.feeds
.filter(s => {
return s.config.pinned
})
.map(feed => ({
key: `savedFeed:${feed.value}:${feed.id}`,
.map(s => ({
key: `savedFeed:${s.view?.uri}:${s.config.id}`,
type: 'savedFeed',
feedUri: feed.value,
savedFeedConfig: feed,
savedFeed: s,
})),
)
slices = slices.concat(
preferences.savedFeeds
.filter(f => {
return !f.pinned
savedFeeds.feeds
.filter(s => {
return !s.config.pinned
})
.map(feed => ({
key: `savedFeed:${feed.value}:${feed.id}`,
.map(s => ({
key: `savedFeed:${s.view?.uri}:${s.config.id}`,
type: 'savedFeed',
feedUri: feed.value,
savedFeedConfig: feed,
savedFeed: s,
})),
)
@ -270,6 +283,7 @@ export function FeedsScreen(_props: Props) {
}
}
if (!hasSession || (hasSession && canShowDiscoverSection)) {
slices.push({
key: 'popularFeedsHeader',
type: 'popularFeedsHeader',
@ -341,13 +355,14 @@ export function FeedsScreen(_props: Props) {
}
}
}
}
return slices
}, [
hasSession,
preferences,
isPreferencesLoading,
preferencesError,
savedFeeds,
isSavedFeedsPlaceholder,
savedFeedsError,
popularFeeds,
isPopularFeedsFetching,
popularFeedsError,
@ -407,10 +422,7 @@ export function FeedsScreen(_props: Props) {
({item}: {item: FlatlistSlice}) => {
if (item.type === 'error') {
return <ErrorMessage message={item.error} />
} else if (
item.type === 'popularFeedsLoadingMore' ||
item.type === 'savedFeedsLoading'
) {
} else if (item.type === 'popularFeedsLoadingMore') {
return (
<View style={s.p10}>
<ActivityIndicator size="large" />
@ -459,8 +471,10 @@ export function FeedsScreen(_props: Props) {
<NoSavedFeedsOfAnyType />
</View>
)
} else if (item.type === 'savedFeedPlaceholder') {
return <SavedFeedPlaceholder />
} else if (item.type === 'savedFeed') {
return <FeedOrFollowing savedFeedConfig={item.savedFeedConfig} />
return <FeedOrFollowing savedFeed={item.savedFeed} />
} else if (item.type === 'popularFeedsHeader') {
return (
<>
@ -481,7 +495,7 @@ export function FeedsScreen(_props: Props) {
} else if (item.type === 'popularFeed') {
return (
<View style={[a.px_lg, a.pt_lg, a.gap_lg]}>
<FeedCard.Default feed={item.feed} />
<FeedCard.Default type="feed" view={item.feed} />
<Divider />
</View>
)
@ -571,30 +585,27 @@ export function FeedsScreen(_props: Props) {
)
}
function FeedOrFollowing({
savedFeedConfig: feed,
}: {
savedFeedConfig: AppBskyActorDefs.SavedFeed
}) {
return feed.type === 'timeline' ? (
function FeedOrFollowing({savedFeed}: {savedFeed: SavedFeedItem}) {
return savedFeed.type === 'timeline' ? (
<FollowingFeed />
) : (
<SavedFeed savedFeedConfig={feed} />
<SavedFeed savedFeed={savedFeed} />
)
}
function FollowingFeed() {
const pal = usePalette('default')
const t = useTheme()
const {isMobile} = useWebMediaQueries()
const {_} = useLingui()
return (
<View
testID={`saved-feed-timeline`}
style={[
pal.border,
styles.savedFeed,
isMobile && styles.savedFeedMobile,
a.flex_1,
a.px_lg,
a.py_md,
a.border_b,
t.atoms.border_contrast_low,
]}>
<FeedCard.Header>
<View
style={[
a.align_center,
@ -616,91 +627,64 @@ function FollowingFeed() {
fill={t.palette.white}
/>
</View>
<View
style={{flex: 1, flexDirection: 'row', gap: 8, alignItems: 'center'}}>
<Text type="lg-medium" style={pal.text} numberOfLines={1}>
<Trans>Following</Trans>
</Text>
</View>
<FeedCard.TitleAndByline title={_(msg`Following`)} type="feed" />
</FeedCard.Header>
</View>
)
}
function SavedFeed({
savedFeedConfig: feed,
savedFeed,
}: {
savedFeedConfig: AppBskyActorDefs.SavedFeed
savedFeed: SavedFeedItem & {type: 'feed' | 'list'}
}) {
const pal = usePalette('default')
const {isMobile} = useWebMediaQueries()
const {data: info, error} = useFeedSourceInfoQuery({uri: feed.value})
const typeAvatar = getAvatarTypeFromUri(feed.value)
if (!info)
return (
<SavedFeedLoadingPlaceholder
key={`savedFeedLoadingPlaceholder:${feed.value}`}
/>
)
const t = useTheme()
const {view: feed} = savedFeed
const displayName =
savedFeed.type === 'feed' ? savedFeed.view.displayName : savedFeed.view.name
return (
<Link
testID={`saved-feed-${info.displayName}`}
href={info.route.href}
style={[pal.border, styles.savedFeed, isMobile && styles.savedFeedMobile]}
hoverStyle={pal.viewLight}
accessibilityLabel={info.displayName}
accessibilityHint=""
asAnchor
anchorNoUnderline>
{error ? (
<FeedCard.Link testID={`saved-feed-${feed.displayName}`} {...savedFeed}>
{({hovered, pressed}) => (
<View
style={{width: 28, flexDirection: 'row', justifyContent: 'center'}}>
<FontAwesomeIcon
icon="exclamation-circle"
color={pal.colors.textLight}
style={[
a.flex_1,
a.px_lg,
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 && (
<FontAwesomeIcon
icon="chevron-right"
size={14}
style={pal.textLight as FontAwesomeIconStyle}
/>
<ChevronRight size="sm" fill={t.atoms.text_contrast_low.color} />
</FeedCard.Header>
</View>
)}
</Link>
</FeedCard.Link>
)
}
function SavedFeedLoadingPlaceholder() {
const pal = usePalette('default')
const {isMobile} = useWebMediaQueries()
function SavedFeedPlaceholder() {
const t = useTheme()
return (
<View
style={[
pal.border,
styles.savedFeed,
isMobile && styles.savedFeedMobile,
a.flex_1,
a.px_lg,
a.py_md,
a.border_b,
t.atoms.border_contrast_low,
]}>
<LoadingPlaceholder width={28} height={28} style={{borderRadius: 4}} />
<LoadingPlaceholder width={140} height={12} />
<FeedCard.Header>
<FeedCard.AvatarPlaceholder size={28} />
<FeedCard.TitleAndBylinePlaceholder />
</FeedCard.Header>
</View>
)
}

View File

@ -282,7 +282,7 @@ export function Explore() {
isFetchingNextPage: isFetchingNextProfilesPage,
error: profilesError,
fetchNextPage: fetchNextProfilesPage,
} = useSuggestedFollowsQuery({limit: 3})
} = useSuggestedFollowsQuery({limit: 6, subsequentPageLimit: 10})
const {
data: feeds,
hasNextPage: hasNextFeedsPage,
@ -290,7 +290,7 @@ export function Explore() {
isFetchingNextPage: isFetchingNextFeedsPage,
error: feedsError,
fetchNextPage: fetchNextFeedsPage,
} = useGetPopularFeedsQuery({limit: 3})
} = useGetPopularFeedsQuery({limit: 10})
const isLoadingMoreProfiles = isFetchingNextProfilesPage && !isLoadingProfiles
const onLoadMoreProfiles = React.useCallback(async () => {
@ -340,11 +340,12 @@ export function Explore() {
// Currently the responses contain duplicate items.
// Needs to be fixed on backend, but let's dedupe to be safe.
let seen = new Set()
const profileItems: ExploreScreenItems[] = []
for (const page of profiles.pages) {
for (const actor of page.actors) {
if (!seen.has(actor.did)) {
seen.add(actor.did)
i.push({
profileItems.push({
type: 'profile',
key: actor.did,
profile: actor,
@ -354,13 +355,19 @@ export function Explore() {
}
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({
type: 'loadMore',
key: 'loadMoreProfiles',
isLoadingMore: isLoadingMoreProfiles,
onLoadMore: onLoadMoreProfiles,
items: i.filter(item => item.type === 'profile').slice(-3),
items: previews,
})
} else {
i.push(...profileItems)
}
} else {
if (profilesError) {
@ -390,11 +397,12 @@ export function Explore() {
// Currently the responses contain duplicate items.
// Needs to be fixed on backend, but let's dedupe to be safe.
let seen = new Set()
const feedItems: ExploreScreenItems[] = []
for (const page of feeds.pages) {
for (const feed of page.feeds) {
if (!seen.has(feed.uri)) {
seen.add(feed.uri)
i.push({
feedItems.push({
type: 'feed',
key: feed.uri,
feed,
@ -403,6 +411,7 @@ export function Explore() {
}
}
// feeds errors can occur during pagination, so feeds is truthy
if (feedsError) {
i.push({
type: 'error',
@ -418,13 +427,17 @@ export function Explore() {
error: cleanError(preferencesError),
})
} else if (hasNextFeedsPage) {
const preview = feedItems.splice(-3)
i.push(...feedItems)
i.push({
type: 'loadMore',
key: 'loadMoreFeeds',
isLoadingMore: isLoadingMoreFeeds,
onLoadMore: onLoadMoreFeeds,
items: i.filter(item => item.type === 'feed').slice(-3),
items: preview,
})
} else {
i.push(...feedItems)
}
} else {
if (feedsError) {
@ -492,7 +505,7 @@ export function Explore() {
a.px_lg,
a.py_lg,
]}>
<FeedCard.Default feed={item.feed} />
<FeedCard.Default type="feed" view={item.feed} />
</View>
)
}

View File

@ -306,7 +306,7 @@ let SearchScreenFeedsResults = ({
a.px_lg,
a.py_lg,
]}>
<FeedCard.Default feed={item} />
<FeedCard.Default type="feed" view={item} />
</View>
)}
keyExtractor={item => item.uri}