Compare commits

..

73 Commits

Author SHA1 Message Date
Samuel Newman 57ce8a739e Reenable tanstack query devtools 2026-04-09 11:41:25 +03:00
pfrazee 987a656904 Nightly source-language update 2026-04-09 03:13:30 +00:00
DS Boyce d0d00ba9f8 Address lint warnings (#10188) 2026-04-08 15:54:43 -07:00
Eric Bailey e4b4e48acb Prep 1.120.0 release (#10207) 2026-04-08 13:58:16 -05:00
RetroSunstar 925ae29b5f Bump expo-paste-input to ^0.1.15 (#10203) 2026-04-08 13:50:12 -05:00
Eric Bailey f6b8854a9b Bump xcode in workflows (#10201) 2026-04-07 21:37:05 -05:00
Eric Bailey 3d0fbc5030 Prep 1.120.0 release (#10200) 2026-04-07 21:06:19 -05:00
Eric Bailey 0a5ae17738 New rich text composer + autocomplete (#10159) 2026-04-07 20:37:50 -05:00
Samuel Newman 02b849996c Double the framerate (#8446) 2026-04-07 14:13:24 -07:00
Samuel Newman f231b67b76 Tweak missing feed copy (#10133) 2026-04-07 15:25:22 -05:00
Samuel Newman d2519a4f67 RM unneeded config plugin, update primary color (#10153) 2026-04-07 15:21:03 -05:00
DS Boyce a049a6538c Use new endpoints for suggested users (#10185) 2026-04-07 15:10:23 -05:00
DS Boyce 3c21fee6c2 Don't call getProfiles if there are no actors (#10196) 2026-04-07 11:55:03 -07:00
DS Boyce e74b57ab78 Update browserslist (#10195) 2026-04-07 11:43:58 -07:00
Samuel Newman a169bd862f Fix layout shift when liking a post on Android (#10190) 2026-04-07 09:48:11 -07:00
Eric Bailey 8bd6d9d135 Revert PostThread parent chunk size for native (#10193) 2026-04-07 10:58:47 -05:00
pfrazee 5868804d3b Nightly source-language update 2026-04-07 03:13:28 +00:00
Eric Bailey bdce8e8ecd Bump expo-paste-input (#10187) 2026-04-06 18:50:28 -05:00
Eric Bailey 99257f2816 Add some more clarity to the RQ docs (#10120) 2026-04-06 18:45:40 -05:00
Spence Pope 011d8d2f7c [APP-2038] fix feeds not refreshing (#10167)
Co-authored-by: Eric Bailey <git@esb.lol>
2026-04-06 17:34:01 -05:00
Spence Pope c0d3010f3e [APP-1999] Fix ReportDialog Android hang by narrowing dependency arrays (#10171)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 16:46:31 -05:00
Samuel Newman 3685439ffb Minor hotkeys nit (#10184) 2026-04-06 07:48:45 -07:00
RetroSunstar 165dd5a779 Fixes Hotkeys causing native to crash immediately on start (#10180) 2026-04-06 09:50:14 -04:00
pfrazee 84b026efb7 Nightly source-language update 2026-04-04 03:09:51 +00:00
Samuel Newman be56066ee9 Fix OOM crash - disable tanstack extension integration (#10169) 2026-04-03 11:31:55 -07:00
DS Boyce cca3326b21 Create global keyboard shortcut handler (#10145) 2026-04-03 10:06:51 -07:00
DS Boyce e0ea778e58 Warn when useState get/set names mismatch (#10166) 2026-04-02 15:47:56 -07:00
Samuel Newman 9fe808f8a8 Fix bottom bar badge text padding (#10162) 2026-04-02 11:51:54 -07:00
Samuel Newman b9f3d04d65 Scale profile badge icons with font size (#10161) 2026-04-02 11:51:38 -07:00
Samuel Newman 68a4d73d61 Add no-extraneous-dependencies and no-nodejs-modules eslint rules (#10151) 2026-04-02 08:34:12 -07:00
Samuel Newman eb566c5fcc Fix useResolveUriQuery (#10163) 2026-04-02 06:39:40 -07:00
pfrazee 512e550c2e Nightly source-language update 2026-04-02 03:13:26 +00:00
Samuel Newman 500fc1c934 Update React Query to latest (#10126) 2026-04-01 15:39:26 -07:00
Arunabh Verma b196ddef73 Replace react-native-paste-input with expo-paste-input (#10158)
Co-authored-by: Eric Bailey <git@esb.lol>
2026-04-01 16:25:16 -05:00
DS Boyce 5a2135733f Handle more translation errors (#10160) 2026-04-01 13:39:05 -07:00
Samuel Newman 69164c640f Keep screen awake when videos are playing (#10155) 2026-04-01 13:07:02 -05:00
Samuel Newman f30acadc73 Hide alt text note after alt text is added (#10132) 2026-04-01 13:00:11 -05:00
DS Boyce 9b20023c86 Fix Safari layout shift issue with SubtleHover (#10135) 2026-04-01 09:12:11 -07:00
DS Boyce e8ee30398a Update Search to use ALF atoms (#10146)
Co-authored-by: Samuel Newman <mozzius@protonmail.com>
2026-04-01 09:11:27 -07:00
pfrazee 6897116625 Nightly source-language update 2026-04-01 03:13:49 +00:00
DS Boyce 2262040797 Display see more link for suggested users in profile header (#10134) 2026-03-31 14:54:51 -07:00
Samuel Newman 498d321bb1 Change other borderWidths from 2 to 1 (#10156) 2026-03-31 09:35:31 -07:00
Samuel Newman a697841a21 Update typescript-eslint, fix up eslint setup (#10150) 2026-03-31 06:00:56 -07:00
DS Boyce 8ac63d780d Update emoji regex to detect variations and sequences (#10138) 2026-03-30 05:30:33 -07:00
Eric Bailey 0419066f45 Add fmt patch to react-native (#10136) 2026-03-26 22:53:41 -05:00
pfrazee 1463323289 Nightly source-language update 2026-03-27 03:13:48 +00:00
DS Boyce f4e14626aa Add button for creating a new group clip clop (#10066)
Co-authored-by: Samuel Newman <mozzius@protonmail.com>
2026-03-26 10:23:49 -07:00
RetroSunstar ce000ada50 Fix 'Lists' & 'Feeds' not displaying their contents on muted profiles (#10101) 2026-03-26 02:49:36 -07:00
pfrazee 4d0774b75e Nightly source-language update 2026-03-26 03:14:00 +00:00
Eric Bailey bc7b6f1e13 Fix clip issue (#10131) 2026-03-25 17:17:47 -05:00
DS Boyce d44060a34e Pass correct log context to suggested user metrics (#10074) 2026-03-25 17:13:23 -05:00
DS Boyce 2c828b5755 Fix text overflow in post meta on web (#10122) 2026-03-25 12:59:04 -07:00
Samuel Newman c06312f09a PostThread perf: reduce time wasted on rendering skeletons (#10092) 2026-03-25 12:44:36 -07:00
Samuel Newman cc5580848c Upgrade TypeScript to 6.0 (#10113) 2026-03-25 00:50:26 -07:00
Samuel Newman fa84c451d0 Fix draft restore to calculate shortened grapheme length for links (#10103)
Co-authored-by: Claude <noreply@anthropic.com>
2026-03-24 15:31:19 -05:00
Paweł Fornagiel 894e2b89d4 fix: too many re-renders when toggling experimental feed sampling option (#10114) 2026-03-24 14:59:01 -05:00
Eric Bailey 3e37696d88 Add resolution downsizing to image compression (#10117) 2026-03-24 14:34:51 -05:00
Eric Bailey 6a15ca88b6 Update redirect service (#10116) 2026-03-24 10:10:35 -05:00
Eric Bailey 5a6942025c Add metrics to redirect service (#10112) 2026-03-24 08:45:08 -05:00
pfrazee 3866142ccd Nightly source-language update 2026-03-24 03:09:20 +00:00
Samuel Newman 7fd6f8f04b Update version to v1.120.0 (#10110) 2026-03-23 22:05:43 +02:00
DS Boyce 21d8b07bfe Use isTranslationSupported from expo-translate-text (#10108) 2026-03-23 09:56:35 -07:00
Samuel Newman f08bf5fef9 Fix report dialog flashing error state on open (#10099) 2026-03-23 09:19:57 -07:00
Samuel Newman 3e7e859c9c [Web] Fix Wordle score rendering - prevent Inter from overriding emoji presentation (#10104) 2026-03-23 09:19:33 -07:00
Samuel Newman 854ae60e7b [Web] Fix clipped text when using numberOfLines={1} (#10105) 2026-03-23 09:17:49 -07:00
Eric Bailey cc4a436e45 Bump version to 1.119.1 (#10088) 2026-03-20 12:04:12 -05:00
Eric Bailey 0532e120b8 Prep 1.119.1 (#10087) 2026-03-20 11:52:46 -05:00
Samuel Newman 288fad67f4 Fix bottom sheet crash on API <30 (#10084)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 11:41:39 -05:00
DS Boyce 0e1e790c34 Include recId with log event for suggested profiles (#10075) 2026-03-20 11:39:51 -05:00
Eric Bailey 74d8ca8fa7 Increase parent batch size to reduce onStartReached calls (#10086) 2026-03-20 11:38:52 -05:00
Eric Bailey 34d8c6fe58 [APP-1968] Increase image upload resolution (#10073) 2026-03-20 11:22:36 -05:00
Samuel Newman 5bcb909081 Remove hardcoded yellows (#10083) 2026-03-20 11:21:16 -05:00
Samuel Newman e28f6d2f37 Add bookmarks to thread placeholder candidates (#10080)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 11:20:27 -05:00
216 changed files with 22758 additions and 16221 deletions
+1 -1
View File
@@ -56,7 +56,7 @@ jobs:
- uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: "26.0"
xcode-version: "26.4"
- name: ☕️ Setup Cocoapods
uses: maxim-lobanov/setup-cocoapods@v1
@@ -197,7 +197,7 @@ jobs:
- uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: "26.0"
xcode-version: "26.4"
- name: ☕️ Setup Cocoapods
uses: maxim-lobanov/setup-cocoapods@v1
+60 -9
View File
@@ -431,16 +431,30 @@ yarn intl:compile # Compile translations for runtime
// src/state/queries/profile.ts
import {useQuery, useMutation, useQueryClient} from '@tanstack/react-query'
// Query key pattern
const RQKEY_ROOT = 'profile'
export const RQKEY = (did: string) => [RQKEY_ROOT, did]
import {createQueryKey} from '#/state/queries/util'
// Query hook
/*
* Query key name should match the query hook name for consistency
*/
const profileQueryKeyRoot = 'profile'
/*
* Use object params and createQueryKey helper for better readability and to
* avoid bugs with parameter order or types.
*/
export const createProfileQueryKey = (args: {did: string}) =>
createQueryKey(profileQueryKeyRoot, args)
/*
* Query hook should be named use[Name]Query, where [Name] describes the data
* being fetched. This is not a strict requirement, but it's a helpful
* convention for discoverability
*/
export function useProfileQuery({did}: {did: string}) {
const agent = useAgent()
return useQuery({
queryKey: RQKEY(did),
queryKey: createProfileQueryKey({did}),
queryFn: async () => {
const res = await agent.getProfile({actor: did})
return res.data
@@ -450,8 +464,12 @@ export function useProfileQuery({did}: {did: string}) {
})
}
// Mutation hook
export function useUpdateProfile() {
/*
* Mutation hook should match the name of the query hook, but with "Mutation"
* suffix. This is not a strict requirement, but it's a helpful convention for
* discoverability and consistency.
*/
export function useProfileMutation() {
const queryClient = useQueryClient()
return useMutation({
@@ -459,7 +477,9 @@ export function useUpdateProfile() {
// Update logic
},
onSuccess: (_, variables) => {
queryClient.invalidateQueries({queryKey: RQKEY(variables.did)})
queryClient.invalidateQueries({
queryKey: createProfileQueryKey({did: variables.did}),
})
},
onError: (error) => {
if (isNetworkError(error)) {
@@ -473,6 +493,24 @@ export function useUpdateProfile() {
}
})
}
/*
* If cache mutation is needed, include specific interfaces for the specific
* mutations you require adjacent to the source queries. Naming should be
* descriptive of the mutation's purpose, e.g. use[Name]CacheMutation. This is
* not a strict requirement, but it's a helpful convention for discoverability
* and consistency.
*/
export function useProfileCacheMutation() {
const queryClient = useQueryClient()
return (data: Partial<Profile>) => {
queryClient.setQueryData(createProfileQueryKey({did: data.did}), oldData => {
if (!oldData) return oldData
return {...oldData, ...data}
})
}
}
```
**Stale Time Constants** (from `src/state/queries/index.ts`):
@@ -491,7 +529,7 @@ export function useDraftsQuery() {
const agent = useAgent()
return useInfiniteQuery({
queryKey: ['drafts'],
queryKey: createQueryKey('drafts'),
queryFn: async ({pageParam}) => {
const res = await agent.app.bsky.draft.getDrafts({cursor: pageParam})
return res.data
@@ -504,6 +542,19 @@ export function useDraftsQuery() {
To get all items from pages: `data?.pages.flatMap(page => page.items) ?? []`
**Persisted Queries**
To persist query data across app restarts, `createQueryKey` supports a third
parameter called `options`, which has a `persistedVersion` property. When this
property is set to a number, the query will be persisted.
When this property is updated (e.g. incremented), the persisted data will be cleared and replaced with the new data from the query function. This is useful for cases where the shape of the data has changed and old persisted data would no longer be valid.
```tsx
export const createProfileQueryKey = (args: {did: string}) =>
createQueryKey(profileQueryKeyRoot, args, {persistedVersion: 1})
```
### Preferences (React Context)
```tsx
+2 -2
View File
@@ -54,7 +54,7 @@ module.exports = function (_config) {
},
icon: './assets/app-icons/ios_icon_default_next.png',
userInterfaceStyle: 'automatic',
primaryColor: '#1083fe',
primaryColor: '#006AFF',
newArchEnabled: false,
ios: {
supportsTablet: false,
@@ -64,6 +64,7 @@ module.exports = function (_config) {
},
icon: IOS_ICON_FILE,
infoPlist: {
CADisableMinimumFrameDurationOnPhone: true,
UIBackgroundModes: ['remote-notification'],
NSCameraUsageDescription:
'Used for profile pictures, posts, and other kinds of content.',
@@ -296,7 +297,6 @@ module.exports = function (_config) {
'./plugins/withAndroidManifestFCMIconPlugin.js',
'./plugins/withAndroidManifestIntentQueriesPlugin.js',
'./plugins/withAndroidStylesAccentColorPlugin.js',
'./plugins/withAndroidDayNightThemePlugin.js',
'./plugins/withAndroidNoJitpackPlugin.js',
'./plugins/shareExtension/withShareExtensions.js',
'./plugins/notificationsExtension/withNotificationsExtension.js',
+3 -1
View File
@@ -4,7 +4,9 @@
"type": "module",
"main": "index.ts",
"scripts": {
"test": "./tests/infra/with-test-db.sh node --loader ts-node/esm --test ./tests/index.ts",
"test": "npm run test:unit && npm run test:e2e",
"test:e2e": "./tests/infra/with-test-db.sh node --loader ts-node/esm --test ./tests/index.ts",
"test:unit": "node --loader ts-node/esm --test ./src/*.test.ts",
"build": "tsc"
},
"dependencies": {
+4
View File
@@ -15,6 +15,7 @@ export type ServiceConfig = {
safelinkPdsUrl?: string
safelinkAgentIdentifier?: string
safelinkAgentPass?: string
metricsApiHost?: string
}
export type DbConfig = {
@@ -45,6 +46,7 @@ export type Environment = {
safelinkPdsUrl?: string
safelinkAgentIdentifier?: string
safelinkAgentPass?: string
metricsApiHost?: string
}
export const readEnv = (): Environment => {
@@ -65,6 +67,7 @@ export const readEnv = (): Environment => {
safelinkPdsUrl: envStr('LINK_SAFELINK_PDS_URL'),
safelinkAgentIdentifier: envStr('LINK_SAFELINK_AGENT_IDENTIFIER'),
safelinkAgentPass: envStr('LINK_SAFELINK_AGENT_PASS'),
metricsApiHost: envStr('LINK_METRICS_API_HOST'),
}
}
@@ -79,6 +82,7 @@ export const envToCfg = (env: Environment): Config => {
safelinkPdsUrl: env.safelinkPdsUrl,
safelinkAgentIdentifier: env.safelinkAgentIdentifier,
safelinkAgentPass: env.safelinkAgentPass,
metricsApiHost: env.metricsApiHost,
}
if (!env.dbPostgresUrl) {
throw new Error('Must configure postgres url (LINK_DB_POSTGRES_URL)')
+5
View File
@@ -1,6 +1,7 @@
import {SafelinkClient} from './cache/safelinkClient.js'
import {type Config} from './config.js'
import Database from './db/index.js'
import {MetricsClient} from './metrics.js'
export type AppContextOptions = {
cfg: Config
@@ -12,6 +13,7 @@ export class AppContext {
db: Database
safelinkClient: SafelinkClient
abortController = new AbortController()
metrics: MetricsClient
constructor(private opts: AppContextOptions) {
this.cfg = this.opts.cfg
@@ -20,6 +22,9 @@ export class AppContext {
cfg: this.opts.cfg.service,
db: this.opts.db,
})
this.metrics = new MetricsClient({
trackingEndpoint: this.opts.cfg.service.metricsApiHost,
})
}
static async fromConfig(cfg: Config, overrides?: Partial<AppContextOptions>) {
+2
View File
@@ -36,6 +36,7 @@ export class LinkService {
}
async start() {
this.ctx.metrics.start()
this.server = this.app.listen(this.ctx.cfg.service.port)
this.server.keepAliveTimeout = 90000
this.terminator = createHttpTerminator({server: this.server})
@@ -46,5 +47,6 @@ export class LinkService {
this.ctx.abortController.abort()
await this.terminator?.terminate()
await this.ctx.db.close()
this.ctx.metrics.stop()
}
}
+183
View File
@@ -0,0 +1,183 @@
import assert from 'node:assert'
import {afterEach, beforeEach, describe, it, mock} from 'node:test'
import {httpLogger} from './logger.js'
import {MetricsClient} from './metrics.js'
type TestEvents = {
click: {button: string}
view: {screen: string}
}
describe('MetricsClient', () => {
let fetchMock: ReturnType<typeof mock.fn>
let fetchRequests: {body: any}[]
let client: MetricsClient<TestEvents>
let loggerErrorMock: ReturnType<typeof mock.fn>
beforeEach(() => {
mock.timers.enable({apis: ['setInterval', 'setTimeout']})
fetchRequests = []
fetchMock = mock.fn(async (_url: any, options: any) => {
const body = JSON.parse(options.body)
fetchRequests.push({body})
return {ok: true, status: 200, text: async () => ''}
})
;(globalThis as any).fetch = fetchMock
loggerErrorMock = mock.fn()
httpLogger.error = loggerErrorMock as any
})
afterEach(() => {
client?.stop()
mock.timers.reset()
mock.restoreAll()
})
it('flushes events on interval', async () => {
client = new MetricsClient<TestEvents>({
trackingEndpoint: 'https://test.metrics.api',
})
client.track('click', {button: 'submit'})
client.track('view', {screen: 'home'})
assert.strictEqual(fetchRequests.length, 0)
mock.timers.tick(10_000)
await flush()
assert.strictEqual(fetchRequests.length, 1)
assert.strictEqual(fetchRequests[0].body.events.length, 2)
assert.strictEqual(fetchRequests[0].body.events[0].event, 'click')
assert.strictEqual(fetchRequests[0].body.events[1].event, 'view')
})
it('flushes when maxBatchSize is exceeded', async () => {
client = new MetricsClient<TestEvents>({
trackingEndpoint: 'https://test.metrics.api',
})
client.maxBatchSize = 5
for (let i = 0; i < 5; i++) {
client.track('click', {button: `btn-${i}`})
}
assert.strictEqual(fetchRequests.length, 0)
client.track('click', {button: 'btn-trigger'})
await flush()
assert.strictEqual(fetchRequests.length, 1)
assert.strictEqual(fetchRequests[0].body.events.length, 6)
})
it('logs error on failed request', async () => {
fetchMock.mock.mockImplementation(async () => {
return {
ok: false,
status: 500,
text: async () => 'Internal Server Error',
}
})
client = new MetricsClient<TestEvents>({
trackingEndpoint: 'https://test.metrics.api',
})
client.track('click', {button: 'submit'})
mock.timers.tick(10_000)
await flush()
assert.strictEqual(fetchMock.mock.callCount(), 1)
assert.strictEqual(loggerErrorMock.mock.callCount(), 1)
const call = loggerErrorMock.mock.calls[0]
const arg = call.arguments[0] as {err: Error}
assert.ok(arg.err instanceof Error)
assert.strictEqual(call.arguments[1], 'Failed to send metrics')
})
it('handles fetch text() error gracefully', async () => {
fetchMock.mock.mockImplementation(async () => {
return {
ok: false,
status: 500,
text: async () => {
throw new Error('Failed to read response')
},
}
})
client = new MetricsClient<TestEvents>({
trackingEndpoint: 'https://test.metrics.api',
})
client.track('click', {button: 'submit'})
mock.timers.tick(10_000)
await flush()
assert.strictEqual(fetchMock.mock.callCount(), 1)
assert.strictEqual(loggerErrorMock.mock.callCount(), 1)
const call = loggerErrorMock.mock.calls[0]
const arg = call.arguments[0] as {err: Error}
assert.ok(arg.err instanceof Error)
assert.match(arg.err.message, /Unknown error/)
assert.strictEqual(call.arguments[1], 'Failed to send metrics')
})
it('flushes when stop() is called', async () => {
client = new MetricsClient<TestEvents>({
trackingEndpoint: 'https://test.metrics.api',
})
client.track('click', {button: 'submit'})
assert.strictEqual(fetchRequests.length, 0)
client.stop()
await flush()
assert.strictEqual(fetchRequests.length, 1)
assert.strictEqual(fetchRequests[0].body.events.length, 1)
assert.strictEqual(fetchRequests[0].body.events[0].event, 'click')
})
it('does not send if trackingEndpoint is not configured', async () => {
client = new MetricsClient<TestEvents>({})
client.track('click', {button: 'submit'})
mock.timers.tick(10_000)
await flush()
assert.strictEqual(fetchMock.mock.callCount(), 0)
})
it('start() is idempotent', async () => {
client = new MetricsClient<TestEvents>({
trackingEndpoint: 'https://test.metrics.api',
})
client.track('click', {button: 'submit'})
client.start()
client.start()
mock.timers.tick(10_000)
await flush()
assert.strictEqual(fetchRequests.length, 1)
})
it('does not flush if queue is empty', async () => {
client = new MetricsClient<TestEvents>({
trackingEndpoint: 'https://test.metrics.api',
})
client.start()
mock.timers.tick(10_000)
await flush()
assert.strictEqual(fetchMock.mock.callCount(), 0)
})
})
function flush() {
return new Promise(r => setImmediate(r))
}
+141
View File
@@ -0,0 +1,141 @@
import crypto from 'node:crypto'
import {httpLogger} from './logger.js'
/**
* New metrics events should be added here
*/
type Events = {
redirect: {
link: string
whitelisted: 'unknown' | 'yes'
blocked: boolean
warned: boolean
utm_source?: string
utm_medium?: string
utm_campaign?: string
utm_content?: string
utm_term?: string
}
invalid_redirect: {
link: string
}
}
type Event<M extends Record<string, any>> = {
time: number
event: keyof M
payload: M[keyof M]
metadata: Record<string, any>
}
export type Config = {
trackingEndpoint?: string
}
/**
* This MetricsClient is duplicated from both `social-app` and `atproto`
* codebases.
*/
export class MetricsClient<M extends Record<string, any> = Events> {
maxBatchSize = 100
private disabled: boolean = false
private started: boolean = false
private queue: Event<M>[] = []
private flushInterval: NodeJS.Timeout | null = null
constructor(private config: Config) {
this.disabled = !config.trackingEndpoint
}
start() {
if (this.disabled) return
if (this.started) return
this.started = true
this.flushInterval = setInterval(() => {
this.flush()
}, 10_000)
}
stop() {
if (this.flushInterval) {
clearInterval(this.flushInterval)
this.flushInterval = null
}
this.flush()
}
track<E extends keyof M>(event: E, payload: M[E]) {
if (this.disabled) return
this.start()
/**
* deviceId is required for sharding events in Middleman. To avoid a hot
* shard, we generate a random anonymous IDs for this client.
*
* @see https://github.com/bluesky-social/tango/blob/d5819cde419d13e0d2cf837f4b30d48529d64060/middleman/handlers_tracking.go#L195
*/
const anonId = `anon-${crypto.randomUUID()}`
/**
* Event structure is like this to ensure compat with Middleman, which
* receives events like this from other codebases, including `social-app`.
*/
const e = {
source: 'blink',
time: Date.now(),
event,
payload,
metadata: {
base: {
deviceId: anonId,
sessionId: anonId,
},
session: {
did: undefined,
},
},
}
this.queue.push(e)
if (this.queue.length > this.maxBatchSize) {
this.flush()
}
}
flush() {
if (this.disabled) return
if (!this.queue.length) return
const events = this.queue.splice(0, this.queue.length)
this.sendBatch(events)
}
private async sendBatch(events: Event<M>[]) {
if (this.disabled || !this.config.trackingEndpoint) return
try {
const res = await fetch(this.config.trackingEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({events}),
keepalive: true,
})
if (!res.ok) {
const errorText = await res.text().catch(() => 'Unknown error')
httpLogger.error(
{err: new Error(`${res.status} Failed to fetch - ${errorText}`)},
'Failed to send metrics',
)
} else {
// Drain response body to allow connection reuse.
await res.text().catch(() => {})
}
} catch (err) {
httpLogger.error({err}, 'Failed to send metrics')
}
}
}
+19
View File
@@ -37,6 +37,7 @@ export default function (ctx: AppContext, app: Express) {
url.pathname === '/redirect') || // is a redirect loop
INTERNAL_IP_REGEX.test(url.hostname) // isn't directing to an internal location
) {
ctx.metrics.track('invalid_redirect', {link})
res.setHeader('Cache-Control', 'no-store')
res.setHeader('Location', `https://${ctx.cfg.service.appHostname}`)
return res.status(302).end()
@@ -48,6 +49,9 @@ export default function (ctx: AppContext, app: Express) {
res.type('html')
let html: string | undefined
let whitelisted: 'unknown' | 'yes' = 'unknown'
let blocked: boolean = false
let warned: boolean = false
if (ctx.cfg.service.safelinkEnabled) {
const rule = await ctx.safelinkClient.tryFindRule(link)
@@ -55,6 +59,7 @@ export default function (ctx: AppContext, app: Express) {
switch (rule.action) {
case 'whitelist':
redirectLogger.info({rule}, 'Whitelist rule matched')
whitelisted = 'yes'
break
case 'block':
html = linkWarningLayout(
@@ -66,6 +71,7 @@ export default function (ctx: AppContext, app: Express) {
)
res.setHeader('Cache-Control', 'no-store')
redirectLogger.info({rule}, 'Block rule matched')
blocked = true
break
case 'warn':
html = linkWarningLayout(
@@ -77,6 +83,7 @@ export default function (ctx: AppContext, app: Express) {
)
res.setHeader('Cache-Control', 'no-store')
redirectLogger.info({rule}, 'Warn rule matched')
warned = true
break
default:
redirectLogger.warn({rule}, 'Unknown rule matched')
@@ -89,6 +96,18 @@ export default function (ctx: AppContext, app: Express) {
html = linkRedirectContents(url.href)
}
ctx.metrics.track('redirect', {
link,
whitelisted,
blocked,
warned,
utm_source: req.query.utm_source?.toString(),
utm_medium: req.query.utm_medium?.toString(),
utm_campaign: req.query.utm_campaign?.toString(),
utm_content: req.query.utm_content?.toString(),
utm_term: req.query.utm_term?.toString(),
})
return res.end(html)
}),
)
+29 -9
View File
@@ -2,11 +2,9 @@ import assert from 'node:assert'
import {type AddressInfo} from 'node:net'
import {after, before, describe, it} from 'node:test'
import {ToolsOzoneSafelinkDefs} from '@atproto/api'
import {Database, envToCfg, LinkService, readEnv} from '../src/index.js'
describe('link service', async () => {
describe.skip('link service', async () => {
let linkService: LinkService
let baseUrl: string
before(async () => {
@@ -18,9 +16,9 @@ describe('link service', async () => {
dbPostgresSchema: 'link_test',
dbPostgresUrl: process.env.DB_POSTGRES_URL,
safelinkEnabled: true,
ozoneUrl: 'http://localhost:2583',
ozoneAgentHandle: 'mod-authority.test',
ozoneAgentPass: 'hunter2',
safelinkPdsUrl: 'http://localhost:2583',
safelinkAgentIdentifier: 'mod-authority.test',
safelinkAgentPass: 'hunter2',
})
const migrateDb = Database.postgres({
url: cfg.db.url,
@@ -33,6 +31,7 @@ describe('link service', async () => {
const {port} = linkService.server?.address() as AddressInfo
baseUrl = `http://localhost:${port}`
/*
// Ensure blocklist, whitelist, and safelink rules are set up
const now = new Date().toISOString()
linkService.ctx.cfg.eventCache.smartUpdate({
@@ -110,6 +109,7 @@ describe('link service', async () => {
comment:
'Could be quite the mistake to get into this addicting game, but we will warn instead of block',
})
*/
})
after(async () => {
await linkService?.destroy()
@@ -213,6 +213,7 @@ describe('link service', async () => {
)
})
/*
it('Rule adjustment, safe redirect, 200 response for Instagram Account of teamsesh Bones', async () => {
// Retrieve the latest event after all updates
const result = linkService.ctx.cfg.eventCache.smartGet(
@@ -232,6 +233,7 @@ describe('link service', async () => {
new RegExp(urlToRedirect.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')),
)
})
*/
async function getRedirect(link: string): Promise<[number, string]> {
const url = new URL(link)
@@ -291,9 +293,10 @@ describe('link service no safelink', async () => {
dbPostgresSchema: 'link_test',
dbPostgresUrl: process.env.DB_POSTGRES_URL,
safelinkEnabled: false,
ozoneUrl: 'http://localhost:2583',
ozoneAgentHandle: 'mod-authority.test',
ozoneAgentPass: 'hunter2',
safelinkPdsUrl: 'http://localhost:2583',
safelinkAgentIdentifier: 'mod-authority.test',
safelinkAgentPass: 'hunter2',
metricsApiHost: 'http://localhost:2584',
})
const migrateDb = Database.postgres({
url: cfg.db.url,
@@ -357,4 +360,21 @@ describe('link service no safelink', async () => {
// No blocked-site div, always safe
assert.doesNotMatch(html, /"blocked-site"/)
})
it('normal redirect with query params', async () => {
const urlToRedirect = 'https://bsky.app/settings'
const url = new URL(`${baseUrl}/redirect`)
url.searchParams.set('u', urlToRedirect)
url.searchParams.set('utm_source', 'test')
const res = await fetch(url, {redirect: 'manual'})
assert.strictEqual(res.status, 200)
const html = await res.text()
assert.match(html, /meta http-equiv="refresh"/)
assert.match(
html,
new RegExp(urlToRedirect.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')),
)
// No blocked-site div, always safe
assert.doesNotMatch(html, /"blocked-site"/)
})
})
+5 -1
View File
@@ -14,6 +14,10 @@
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
"exclude": ["node_modules", "dist"],
"ts-node": {
"logError": true,
"pretty": true /* <= technically not required */
}
}
+2 -2
View File
@@ -5,8 +5,8 @@
"start": "NODE_ENV=development ./dev-infra/with-test-redis-and-db.sh ts-node ./mock-server.ts"
},
"dependencies": {
"@atproto/dev-env": "^0.3.213",
"@atproto/dev-env": "^0.3.215",
"ts-node": "^10.9.2",
"typescript": "^5.9.3"
"typescript": "^6.0.2"
}
}
+2 -1
View File
@@ -28,6 +28,7 @@
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": false,
"esModuleInterop": true,
"skipLibCheck": true,
"paths": {
"#/*": ["./src/*"],
"lib/*": ["./src/lib/*"],
@@ -43,4 +44,4 @@
"metro.config.js",
"jest.config.js"
]
}
}
+193 -77
View File
@@ -64,14 +64,14 @@
"@atproto/xrpc" "^0.7.6"
"@atproto/xrpc-server" "^0.10.0"
"@atproto/api@^0.19.2":
version "0.19.3"
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.19.3.tgz#61de8d2e31abe9eb2b4c8f4ad124ed79d4a77e89"
integrity sha512-G8YpBpRouHdTAIagi/QQIUZOhGd1jfBQWkJy9QfxAzjjEpPvaVOSk4e1S85QzGLm/xbzVONzGkmdtiOSfP6wVg==
"@atproto/api@^0.19.4":
version "0.19.4"
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.19.4.tgz#f3ff850baf4d85538c082fb91aa0982737eb68be"
integrity sha512-fYNM62vdXxer0h8a9Jzl4/ag9uFIe0nTO+LkC6KTlx1yUDigrAoQMMbllIiCWj62GhUMxAkHabk/BZjjVAfKng==
dependencies:
"@atproto/common-web" "^0.4.18"
"@atproto/lexicon" "^0.6.2"
"@atproto/syntax" "^0.5.0"
"@atproto/syntax" "^0.5.1"
"@atproto/xrpc" "^0.7.7"
await-lock "^2.2.2"
multiformats "^9.9.0"
@@ -96,23 +96,23 @@
multiformats "^9.9.0"
uint8arrays "3.0.0"
"@atproto/bsky@^0.0.219":
version "0.0.219"
resolved "https://registry.yarnpkg.com/@atproto/bsky/-/bsky-0.0.219.tgz#6a9f82eb4ab999e121d04ad2b3f08ff60cc75fba"
integrity sha512-Vm7JpIyCqd7sHzHsXqppGSaKkXKUhvdZ/UOldc247Bgmx+L/U+E6IeR028hzMr1YyDvU+bkO1hlqT8uUovOCdA==
"@atproto/bsky@^0.0.221":
version "0.0.221"
resolved "https://registry.yarnpkg.com/@atproto/bsky/-/bsky-0.0.221.tgz#b574456225db66c866848526947473d60bb4d0e7"
integrity sha512-feNR6xkJ9HCJbQdJU3ytsnAfaSBXJdaXIMi0tNPrsLfoqCWWbTfXIcMczXeY4SOhKGFR5oMYxE9zrRC/TTAssw==
dependencies:
"@atproto-labs/fetch-node" "^0.2.0"
"@atproto-labs/xrpc-utils" "^0.0.24"
"@atproto/api" "^0.19.2"
"@atproto/common" "^0.5.14"
"@atproto/api" "^0.19.4"
"@atproto/common" "^0.5.15"
"@atproto/crypto" "^0.4.5"
"@atproto/did" "^0.3.0"
"@atproto/identity" "^0.4.12"
"@atproto/lexicon" "^0.6.2"
"@atproto/repo" "^0.8.12"
"@atproto/repo" "^0.8.13"
"@atproto/sync" "^0.1.40"
"@atproto/syntax" "^0.5.0"
"@atproto/xrpc-server" "^0.10.15"
"@atproto/syntax" "^0.5.1"
"@atproto/xrpc-server" "^0.10.17"
"@bufbuild/protobuf" "^1.5.0"
"@connectrpc/connect" "^1.1.4"
"@connectrpc/connect-express" "^1.1.4"
@@ -146,13 +146,13 @@
undici "^6.19.8"
zod "3.23.8"
"@atproto/bsync@^0.0.24":
version "0.0.24"
resolved "https://registry.yarnpkg.com/@atproto/bsync/-/bsync-0.0.24.tgz#6b0d4b02c0c0241687456ab817471d36ee81ae61"
integrity sha512-JN+oncaPBNRjzjTPGR7Q1fkKF3cqOQ6oLRrAh9kVU04ZS3FhWUG8cQvnr8wb1PUhFb/XYpWkwDw5+GIhdb7Lfw==
"@atproto/bsync@^0.0.25":
version "0.0.25"
resolved "https://registry.yarnpkg.com/@atproto/bsync/-/bsync-0.0.25.tgz#0d6056f844c0b2579d9dfc04b6727974dc03fcd6"
integrity sha512-5tjP5QbUcNtMBw7FJeyRfA0OHQRKrg97Jva6Q26cKqLMICGYNbx0fpD7nZhGTP2/s1gd6UZkG9Zdh4GRZpbxWg==
dependencies:
"@atproto/common" "^0.5.14"
"@atproto/syntax" "^0.5.0"
"@atproto/syntax" "^0.5.1"
"@bufbuild/protobuf" "^1.5.0"
"@connectrpc/connect" "^1.1.4"
"@connectrpc/connect-node" "^1.1.4"
@@ -172,6 +172,16 @@
"@atproto/syntax" "^0.5.0"
zod "^3.23.8"
"@atproto/common-web@^0.4.19":
version "0.4.19"
resolved "https://registry.yarnpkg.com/@atproto/common-web/-/common-web-0.4.19.tgz#bbd7f84f545ebe73ca3bc00314ccf4ee66e7069e"
integrity sha512-3BTi58p5WpT+9/zb6UZrdsXcfPo5P45UJm0E4iwHLILr+jc37CuBj9JReDSZ4U0i9RTrI3ZkfySyZ9bd+LnMsw==
dependencies:
"@atproto/lex-data" "^0.0.14"
"@atproto/lex-json" "^0.0.14"
"@atproto/syntax" "^0.5.1"
zod "^3.23.8"
"@atproto/common@0.1.0":
version "0.1.0"
resolved "https://registry.yarnpkg.com/@atproto/common/-/common-0.1.0.tgz#4216a8fef5b985ab62ac21252a0f8ca0f4a0f210"
@@ -203,6 +213,17 @@
multiformats "^9.9.0"
pino "^8.21.0"
"@atproto/common@^0.5.15":
version "0.5.15"
resolved "https://registry.yarnpkg.com/@atproto/common/-/common-0.5.15.tgz#3c43c25d3493d868cc4281d6ac2f923b00644463"
integrity sha512-+cdfdMPAIbH9zQGLfH1gNY2KEZsMxj0EelVQL5uJUFL+UkkAXiiqWj7J5mbax8sf02cC/afJnfkWzERNAheKoA==
dependencies:
"@atproto/common-web" "^0.4.19"
"@atproto/lex-cbor" "^0.0.15"
"@atproto/lex-data" "^0.0.14"
multiformats "^9.9.0"
pino "^8.21.0"
"@atproto/crypto@0.1.0":
version "0.1.0"
resolved "https://registry.yarnpkg.com/@atproto/crypto/-/crypto-0.1.0.tgz#bc73a479f9dbe06fa025301c182d7f7ab01bc568"
@@ -223,23 +244,23 @@
"@noble/hashes" "^1.6.1"
uint8arrays "3.0.0"
"@atproto/dev-env@^0.3.213":
version "0.3.213"
resolved "https://registry.yarnpkg.com/@atproto/dev-env/-/dev-env-0.3.213.tgz#30ca66f827d44ccabb02b359119f68549fa3edf0"
integrity sha512-Bjhv+zzcQxhwV4I7si+yDls8sSetksILeiBemfRe3cvE4GOgs7KfsYI5+pqNmBZ5rrFPt3KUeN9vIo31LCgZOw==
"@atproto/dev-env@^0.3.215":
version "0.3.215"
resolved "https://registry.yarnpkg.com/@atproto/dev-env/-/dev-env-0.3.215.tgz#9da8c4a73abb4501ac72c73da9536e2ec86cb46f"
integrity sha512-zwZwGWYLgP2Zdie6/gMtxuDbSs7/UV/gPJYfOlXln1ZDoMkfFbgCTq44PWRnJpUKTzrq7gt19E0GsL7DMkppjA==
dependencies:
"@atproto/api" "^0.19.2"
"@atproto/bsky" "^0.0.219"
"@atproto/bsync" "^0.0.24"
"@atproto/common-web" "^0.4.18"
"@atproto/api" "^0.19.4"
"@atproto/bsky" "^0.0.221"
"@atproto/bsync" "^0.0.25"
"@atproto/common-web" "^0.4.19"
"@atproto/crypto" "^0.4.5"
"@atproto/identity" "^0.4.12"
"@atproto/lexicon" "^0.6.2"
"@atproto/ozone" "^0.1.166"
"@atproto/pds" "^0.4.214"
"@atproto/ozone" "^0.1.167"
"@atproto/pds" "^0.4.216"
"@atproto/sync" "^0.1.40"
"@atproto/syntax" "^0.5.0"
"@atproto/xrpc-server" "^0.10.15"
"@atproto/syntax" "^0.5.1"
"@atproto/xrpc-server" "^0.10.17"
"@did-plc/lib" "^0.0.1"
"@did-plc/server" "^0.0.1"
dotenv "^16.0.3"
@@ -288,6 +309,14 @@
"@atproto/lex-data" "^0.0.13"
tslib "^2.8.1"
"@atproto/lex-cbor@^0.0.15":
version "0.0.15"
resolved "https://registry.yarnpkg.com/@atproto/lex-cbor/-/lex-cbor-0.0.15.tgz#ae4558d8ce22119710ad22feb458d22774b3ca3b"
integrity sha512-3osDicK9bAMXJlKjLKqwYrhLQ60bOguWBNjE+fuNjMuizNzC0aqaClE3d+qMsFuFq9bjEHFw+4Vr9Qmd/m6VYg==
dependencies:
"@atproto/lex-data" "^0.0.14"
tslib "^2.8.1"
"@atproto/lex-client@^0.0.15":
version "0.0.15"
resolved "https://registry.yarnpkg.com/@atproto/lex-client/-/lex-client-0.0.15.tgz#c647d14e91ca3f52feebf4b34f80abb7e93b3bee"
@@ -298,6 +327,16 @@
"@atproto/lex-schema" "^0.0.14"
tslib "^2.8.1"
"@atproto/lex-client@^0.0.17":
version "0.0.17"
resolved "https://registry.yarnpkg.com/@atproto/lex-client/-/lex-client-0.0.17.tgz#566689a288f8b2af31f4a0fa081496dfaaef7278"
integrity sha512-lZ9clUjWgpno1XhSawQP+1/JeIYA9qBh759b/NSU0OiypQqgq7IxDvmzaBsiHK1sqjo0tyEkmG4X5Ym7YXjv0Q==
dependencies:
"@atproto/lex-data" "^0.0.14"
"@atproto/lex-json" "^0.0.14"
"@atproto/lex-schema" "^0.0.16"
tslib "^2.8.1"
"@atproto/lex-data@^0.0.13":
version "0.0.13"
resolved "https://registry.yarnpkg.com/@atproto/lex-data/-/lex-data-0.0.13.tgz#db1bcfa12d5056210f6eb7f3b8bac909909d6b9c"
@@ -308,12 +347,22 @@
uint8arrays "3.0.0"
unicode-segmenter "^0.14.0"
"@atproto/lex-document@^0.0.15":
version "0.0.15"
resolved "https://registry.yarnpkg.com/@atproto/lex-document/-/lex-document-0.0.15.tgz#b2f19756291a0d259cd99f5ebe4872e9133069b6"
integrity sha512-QT2MbICG4cTFrrA19SIHpZJ33WRLdzjhDsEhSknQ4dE5CjqPf4BP9LaC4pOeW8NE5Kn92hgIm3JWNjoak8blXw==
"@atproto/lex-data@^0.0.14":
version "0.0.14"
resolved "https://registry.yarnpkg.com/@atproto/lex-data/-/lex-data-0.0.14.tgz#2f2f3c64699925a0d4785e5afd0e7731ba1d46c0"
integrity sha512-53DUa9664SS76nGAMYopWsO10OH0AAdf7P/HSKB6Wzx3iqe6lk/K61QZnKxOG1LreYl5CfvIJU6eNf4txI6GlQ==
dependencies:
"@atproto/lex-schema" "^0.0.14"
multiformats "^9.9.0"
tslib "^2.8.1"
uint8arrays "3.0.0"
unicode-segmenter "^0.14.0"
"@atproto/lex-document@^0.0.17":
version "0.0.17"
resolved "https://registry.yarnpkg.com/@atproto/lex-document/-/lex-document-0.0.17.tgz#8460096235910bf5ec8305f03a8da6bab8d8a12b"
integrity sha512-rQiDCSYQwze4+kaArUtmp4bjZ9rV3vYUMhjdDwmZCKodpppNEYrP5AQzyKlxBtKO+MRdLYwHDDwwvakU8atRww==
dependencies:
"@atproto/lex-schema" "^0.0.16"
core-js "^3"
tslib "^2.8.1"
@@ -325,19 +374,27 @@
"@atproto/lex-data" "^0.0.13"
tslib "^2.8.1"
"@atproto/lex-resolver@^0.0.17":
version "0.0.17"
resolved "https://registry.yarnpkg.com/@atproto/lex-resolver/-/lex-resolver-0.0.17.tgz#2c474f6babeb54665656bf28b8d27a98de69deae"
integrity sha512-6nI5bYZUYh50ZI8r4erLRP9EbNcW226VShpVN3vHyOSgTje4VP1RTcvBhROBAPj4rL3vc+Oa8OiL6IQXkYrQBg==
"@atproto/lex-json@^0.0.14":
version "0.0.14"
resolved "https://registry.yarnpkg.com/@atproto/lex-json/-/lex-json-0.0.14.tgz#717e533ab583aa5f580acb2a77d9aa3e7eddaa17"
integrity sha512-6lPkDKqe7teEu4WrN5q7400cvZKgYS3uwUMvzG3F9XkgVYhOwSDCtouV/nSLBbpvo3l9OP0kiigtclcNcyekww==
dependencies:
"@atproto/lex-data" "^0.0.14"
tslib "^2.8.1"
"@atproto/lex-resolver@^0.0.19":
version "0.0.19"
resolved "https://registry.yarnpkg.com/@atproto/lex-resolver/-/lex-resolver-0.0.19.tgz#806fcb71e72d0db51e2eb29c594eddb8c4087414"
integrity sha512-oATn4RpZNLh5rp9doN5/UOYS/Cd25GOD90ohB5jnnmeoF8jTupqIYTVhntbnx1EFn+5tTlgkyXEBV+XESBUcdQ==
dependencies:
"@atproto-labs/did-resolver" "^0.2.6"
"@atproto/crypto" "^0.4.5"
"@atproto/lex-client" "^0.0.15"
"@atproto/lex-data" "^0.0.13"
"@atproto/lex-document" "^0.0.15"
"@atproto/lex-schema" "^0.0.14"
"@atproto/repo" "^0.8.12"
"@atproto/syntax" "^0.5.0"
"@atproto/lex-client" "^0.0.17"
"@atproto/lex-data" "^0.0.14"
"@atproto/lex-document" "^0.0.17"
"@atproto/lex-schema" "^0.0.16"
"@atproto/repo" "^0.8.13"
"@atproto/syntax" "^0.5.1"
tslib "^2.8.1"
"@atproto/lex-schema@^0.0.14":
@@ -349,6 +406,17 @@
"@atproto/syntax" "^0.5.0"
tslib "^2.8.1"
"@atproto/lex-schema@^0.0.16":
version "0.0.16"
resolved "https://registry.yarnpkg.com/@atproto/lex-schema/-/lex-schema-0.0.16.tgz#8362932e239b7eaa7c5d6982d06c3147e3afd138"
integrity sha512-O+IorivZHJPeV3kU3NDD2yI8ATfckOphgvDfeiyKHRTxRUKS+lHMCpGUiSTC3fJrfMvYITrruUVViUHVEScrbA==
dependencies:
"@atproto/lex-data" "^0.0.14"
"@atproto/syntax" "^0.5.1"
"@standard-schema/spec" "^1.1.0"
iso-datestring-validator "^2.2.2"
tslib "^2.8.1"
"@atproto/lexicon@^0.6.0", "@atproto/lexicon@^0.6.2":
version "0.6.2"
resolved "https://registry.yarnpkg.com/@atproto/lexicon/-/lexicon-0.6.2.tgz#f6152a2119df953236ca127c4b30e332265e81e7"
@@ -382,28 +450,28 @@
optionalDependencies:
"@atproto/oauth-provider-api" "0.3.7"
"@atproto/oauth-provider@^0.15.12":
version "0.15.12"
resolved "https://registry.yarnpkg.com/@atproto/oauth-provider/-/oauth-provider-0.15.12.tgz#9dbbfdd6808399d9d7ff8993ac888938fbf4c515"
integrity sha512-Ri4aVx2I4lOKxViB92jwPhAs/NctWEwV0tgYSHcaRpvqr2SVlC2LxTVjUq14ohdbVfv4VFRzj0vZypEX+mclHg==
"@atproto/oauth-provider@^0.15.14":
version "0.15.14"
resolved "https://registry.yarnpkg.com/@atproto/oauth-provider/-/oauth-provider-0.15.14.tgz#d969018b4ad5c0dd5863150cb8c1b65458738589"
integrity sha512-arA3O+Ye1YBhoIUnZtn8wfatnVnwiZrGyNkxhH0nqGbh/RRfwA5W0tgnSDq0VMclLkrPY/OnZ4v3oo9N81yWGg==
dependencies:
"@atproto-labs/fetch" "^0.2.3"
"@atproto-labs/fetch-node" "^0.2.0"
"@atproto-labs/pipe" "^0.1.1"
"@atproto-labs/simple-store" "^0.3.0"
"@atproto-labs/simple-store-memory" "^0.1.4"
"@atproto/common" "^0.5.14"
"@atproto/common" "^0.5.15"
"@atproto/did" "^0.3.0"
"@atproto/jwk" "^0.6.0"
"@atproto/jwk-jose" "^0.1.11"
"@atproto/lex-document" "^0.0.15"
"@atproto/lex-resolver" "^0.0.17"
"@atproto/lex-document" "^0.0.17"
"@atproto/lex-resolver" "^0.0.19"
"@atproto/oauth-provider-api" "0.3.7"
"@atproto/oauth-provider-frontend" "0.2.9"
"@atproto/oauth-provider-ui" "0.4.3"
"@atproto/oauth-scopes" "^0.3.2"
"@atproto/oauth-types" "^0.6.3"
"@atproto/syntax" "^0.5.0"
"@atproto/syntax" "^0.5.1"
"@hapi/accept" "^6.0.3"
"@hapi/address" "^5.1.1"
"@hapi/bourne" "^3.0.0"
@@ -442,20 +510,20 @@
"@atproto/jwk" "^0.6.0"
zod "^3.23.8"
"@atproto/ozone@^0.1.166":
version "0.1.166"
resolved "https://registry.yarnpkg.com/@atproto/ozone/-/ozone-0.1.166.tgz#9e65d6f67ef1fe285d0880e5f5a1b282dc63bd70"
integrity sha512-XZ77P/V/tt3SqTQYRsi5nM3P2h+QaNT7Nz3GTf+TMLVolACufRHPoDgp/PoTuS2FaLj1ndHnGyIPvZi/o8he6g==
"@atproto/ozone@^0.1.167":
version "0.1.167"
resolved "https://registry.yarnpkg.com/@atproto/ozone/-/ozone-0.1.167.tgz#c3974bbe06f0926f165f90d5ba51ba9ce2032fff"
integrity sha512-AFquyND8zsskjkDc3WrQObUnZlEky05pFo0YYLy5JoqQN0WXIePLgGY0SC0EIokfF8iYXJE1ZM1u/dgA7DHqGQ==
dependencies:
"@atproto/api" "^0.19.2"
"@atproto/api" "^0.19.4"
"@atproto/common" "^0.5.14"
"@atproto/crypto" "^0.4.5"
"@atproto/identity" "^0.4.12"
"@atproto/lexicon" "^0.6.2"
"@atproto/syntax" "^0.5.0"
"@atproto/syntax" "^0.5.1"
"@atproto/ws-client" "^0.0.4"
"@atproto/xrpc" "^0.7.7"
"@atproto/xrpc-server" "^0.10.15"
"@atproto/xrpc-server" "^0.10.16"
"@did-plc/lib" "^0.0.1"
compression "^1.7.4"
cors "^2.8.5"
@@ -473,30 +541,30 @@
undici "^6.14.1"
ws "^8.12.0"
"@atproto/pds@^0.4.214":
version "0.4.214"
resolved "https://registry.yarnpkg.com/@atproto/pds/-/pds-0.4.214.tgz#c68d55ec0b00a4e35f4801c826d44b65cd62f984"
integrity sha512-bTWeWg3H0TlELfE2eI2ySQuC6ojsCBSSmPtCXBh3Td9TFNpIoZQ/tYLUJMtMXaiVUi6HxzXQL1/iuYfC4Y+ZYQ==
"@atproto/pds@^0.4.216":
version "0.4.216"
resolved "https://registry.yarnpkg.com/@atproto/pds/-/pds-0.4.216.tgz#4d9c73a529bd00893753aba1e7bb33f99b9aaaf4"
integrity sha512-yPNatCb2kvudRp5DMbPemN1+uMsLOJDydw2PCBMxuzdimOf10PsekOhhZxtfGGRvk2NbvKoyfUvkROoFmTr+ew==
dependencies:
"@atproto-labs/fetch-node" "^0.2.0"
"@atproto-labs/simple-store" "^0.3.0"
"@atproto-labs/simple-store-memory" "^0.1.4"
"@atproto-labs/simple-store-redis" "^0.0.1"
"@atproto-labs/xrpc-utils" "^0.0.24"
"@atproto/api" "^0.19.2"
"@atproto/api" "^0.19.4"
"@atproto/aws" "^0.2.31"
"@atproto/common" "^0.5.14"
"@atproto/common" "^0.5.15"
"@atproto/crypto" "^0.4.5"
"@atproto/identity" "^0.4.12"
"@atproto/lex-cbor" "^0.0.14"
"@atproto/lex-data" "^0.0.13"
"@atproto/lex-cbor" "^0.0.15"
"@atproto/lex-data" "^0.0.14"
"@atproto/lexicon" "^0.6.2"
"@atproto/oauth-provider" "^0.15.12"
"@atproto/oauth-provider" "^0.15.14"
"@atproto/oauth-scopes" "^0.3.2"
"@atproto/repo" "^0.8.12"
"@atproto/syntax" "^0.5.0"
"@atproto/repo" "^0.8.13"
"@atproto/syntax" "^0.5.1"
"@atproto/xrpc" "^0.7.7"
"@atproto/xrpc-server" "^0.10.15"
"@atproto/xrpc-server" "^0.10.17"
"@did-plc/lib" "^0.0.4"
"@hapi/address" "^5.1.1"
better-sqlite3 "^10.0.0"
@@ -540,6 +608,21 @@
varint "^6.0.0"
zod "^3.23.8"
"@atproto/repo@^0.8.13":
version "0.8.13"
resolved "https://registry.yarnpkg.com/@atproto/repo/-/repo-0.8.13.tgz#70160b8b3f78b6addcba7cf3e3ae06306e6b6641"
integrity sha512-VS8XHaBMGdq60xwRI5zQmXzsMF1hU7NKPjmkdr65tJdrv2z0VW77mG01Ui19Xh9O0mUc/LG6GEhwVrabB9Txow==
dependencies:
"@atproto/common" "^0.5.14"
"@atproto/common-web" "^0.4.18"
"@atproto/crypto" "^0.4.5"
"@atproto/lexicon" "^0.6.2"
"@ipld/dag-cbor" "^7.0.0"
multiformats "^9.9.0"
uint8arrays "3.0.0"
varint "^6.0.0"
zod "^3.23.8"
"@atproto/sync@^0.1.40":
version "0.1.40"
resolved "https://registry.yarnpkg.com/@atproto/sync/-/sync-0.1.40.tgz#b8b467ac4fbf2e682d36cd5697508f993e9e645a"
@@ -562,6 +645,13 @@
dependencies:
tslib "^2.8.1"
"@atproto/syntax@^0.5.1":
version "0.5.1"
resolved "https://registry.yarnpkg.com/@atproto/syntax/-/syntax-0.5.1.tgz#78257b903a0723720dca32110379208791ac3c24"
integrity sha512-J8DJjgKgACIyCTbpfvoTnf7+ofTx1kxTGO7KAftkC+jczaMdQhKdgIBAg2DaYy+80cvYGTHy5q/HI9qMAwGbWw==
dependencies:
tslib "^2.8.1"
"@atproto/ws-client@^0.0.4":
version "0.0.4"
resolved "https://registry.yarnpkg.com/@atproto/ws-client/-/ws-client-0.0.4.tgz#9e436c0e72abea5da0d5a7e8ec862cec0fdb10cd"
@@ -591,6 +681,27 @@
rate-limiter-flexible "^2.4.1"
ws "^8.12.0"
"@atproto/xrpc-server@^0.10.16", "@atproto/xrpc-server@^0.10.17":
version "0.10.17"
resolved "https://registry.yarnpkg.com/@atproto/xrpc-server/-/xrpc-server-0.10.17.tgz#1016d4a6d97966a3f80e8785e5d5cba7b68c1c60"
integrity sha512-FjexO6P/LRTx6/FdiWTzycFF4TgACW9npsFOitnocydTCOLYouP0OvUwdkxkreFC7qerT4+ARKpqrxRzyj0MNA==
dependencies:
"@atproto/common" "^0.5.15"
"@atproto/crypto" "^0.4.5"
"@atproto/lex-cbor" "^0.0.15"
"@atproto/lex-client" "^0.0.17"
"@atproto/lex-data" "^0.0.14"
"@atproto/lex-json" "^0.0.14"
"@atproto/lex-schema" "^0.0.16"
"@atproto/lexicon" "^0.6.2"
"@atproto/ws-client" "^0.0.4"
"@atproto/xrpc" "^0.7.7"
express "^4.17.2"
http-errors "^2.0.0"
mime-types "^2.1.35"
rate-limiter-flexible "^2.4.1"
ws "^8.12.0"
"@atproto/xrpc@^0.7.6", "@atproto/xrpc@^0.7.7":
version "0.7.7"
resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.7.7.tgz#c0e3106c854cb9bc7d3129de2f31b8256eb0ed11"
@@ -2146,6 +2257,11 @@
dependencies:
tslib "^2.6.2"
"@standard-schema/spec@^1.1.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@standard-schema/spec/-/spec-1.1.0.tgz#a79b55dbaf8604812f52d140b2c9ab41bc150bb8"
integrity sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==
"@tokenizer/token@^0.3.0":
version "0.3.0"
resolved "https://registry.yarnpkg.com/@tokenizer/token/-/token-0.3.0.tgz#fe98a93fe789247e998c75e74e9c7c63217aa276"
@@ -4146,10 +4262,10 @@ typed-emitter@^2.1.0:
optionalDependencies:
rxjs "^7.5.2"
typescript@^5.9.3:
version "5.9.3"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.9.3.tgz#5b4f59e15310ab17a216f5d6cf53ee476ede670f"
integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==
typescript@^6.0.2:
version "6.0.2"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-6.0.2.tgz#0b1bfb15f68c64b97032f3d78abbf98bdbba501f"
integrity sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==
uglify-js@^3.1.4:
version "3.19.3"
+14 -1
View File
@@ -47,7 +47,6 @@ export default defineConfig(
js.configs.recommended,
tseslint.configs.recommendedTypeChecked,
reactHooks.configs.flat.recommended,
// @ts-expect-error https://github.com/un-ts/eslint-plugin-import-x/issues/439
importX.flatConfigs.recommended,
importX.flatConfigs.typescript,
importX.flatConfigs['react-native'],
@@ -62,6 +61,7 @@ export default defineConfig(
'react-native': reactNative,
'react-native-a11y': reactNativeA11y,
'simple-import-sort': simpleImportSort,
// @ts-expect-error - not sure why
lingui,
'react-compiler': reactCompiler,
'bsky-internal': bskyInternal,
@@ -127,6 +127,7 @@ export default defineConfig(
*/
...react.configs.recommended.rules,
...react.configs['jsx-runtime'].rules,
'react/hook-use-state': 'warn',
'react/no-unescaped-entities': 'off',
'react/prop-types': 'off',
'react-native/no-inline-styles': 'off',
@@ -189,6 +190,18 @@ export default defineConfig(
*/
ignore: ['^#\/locale\/locales\/.+\/messages'],
}],
'import-x/no-extraneous-dependencies': ['error', {
'whitelist': [
// test files only
'@jest/globals',
// we only use a really simple util from this, and we know it will be present
'expo-modules-core',
// this is a dep for @atproto/api, but we absolutely need them in sync, so just
// rely on the transient version
'@atproto/common-web',
]
}],
'import-x/no-nodejs-modules': 'error',
/**
* TypeScript-specific rules
+1
View File
@@ -29,6 +29,7 @@ function getTagName(node) {
return reversedIdentifiers.reverse().join('.')
}
/** @type {import('eslint').Rule.RuleModule} */
module.exports = {
meta: {
type: 'problem',
+1
View File
@@ -3,6 +3,7 @@ const BANNED_IMPORTS = [
'@fortawesome/free-solid-svg-icons',
]
/** @type {import('eslint').Rule.RuleModule} */
module.exports = {
meta: {
type: 'suggestion',
+1
View File
@@ -10,6 +10,7 @@ const BANNED_IMPORT_PREFIXES = [
'view/',
]
/** @type {import('eslint').Rule.RuleModule} */
module.exports = {
meta: {
type: 'suggestion',
+1
View File
@@ -9,6 +9,7 @@ jest.mock('@react-native-async-storage/async-storage', () =>
require('@react-native-async-storage/async-storage/jest/async-storage-mock'),
)
jest.mock('react-native/Libraries/EventEmitter/NativeEventEmitter', () => {
// eslint-disable-next-line import-x/no-nodejs-modules
const {EventEmitter} = require('events')
return {
__esModule: true,
-23
View File
@@ -1,23 +0,0 @@
import {GestureHandlerRootView} from 'react-native-gesture-handler'
import {SafeAreaProvider} from 'react-native-safe-area-context'
import {render} from '@testing-library/react-native'
import {ThemeProvider} from '../src/lib/ThemeContext'
import {type RootStoreModel, RootStoreProvider} from '../src/state'
const customRender = (ui: any, rootStore: RootStoreModel) =>
render(
<GestureHandlerRootView style={{flex: 1}}>
<RootStoreProvider value={rootStore}>
<ThemeProvider theme="light">
<SafeAreaProvider>{ui}</SafeAreaProvider>
</ThemeProvider>
</RootStoreProvider>
</GestureHandlerRootView>,
)
// re-export everything
export * from '@testing-library/react-native'
// override render method
export {customRender as render}
@@ -33,17 +33,27 @@ class BottomSheetView(
private var eventDispatcher: EventDispatcher? = null
// Native content height observation (eliminates JS bridge round-trip)
private var contentLayoutListener: View.OnLayoutChangeListener? = null
private var contentLayoutListener: OnLayoutChangeListener? = null
private var observedChildren: List<View> = emptyList()
private var lastObservedContentHeight: Float = 0f
private var pendingLayoutUpdate: Boolean = false
private val screenHeight: Float =
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.VANILLA_ICE_CREAM) {
// API 35+: edge-to-edge is mandatory, heightPixels is the full display
context.resources.displayMetrics.heightPixels.toFloat()
} else {
} else if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R) {
// API 30-34: heightPixels may exclude nav bar, use currentWindowMetrics
val wm = context.getSystemService(Context.WINDOW_SERVICE) as android.view.WindowManager
wm.currentWindowMetrics.bounds.height().toFloat()
} else {
// API < 30: currentWindowMetrics not available, use getRealSize
// which includes system bars (heightPixels may exclude them)
val wm = context.getSystemService(Context.WINDOW_SERVICE) as android.view.WindowManager
val size = android.graphics.Point()
@Suppress("DEPRECATION")
wm.defaultDisplay.getRealSize(size)
size.y.toFloat()
}
private fun getNavigationBarHeight(): Int {
@@ -355,7 +365,7 @@ class BottomSheetView(
val innerViewGroup = this.innerView as? ViewGroup ?: return
val listener = View.OnLayoutChangeListener { _, _, top, _, bottom, _, _, oldTop, oldBottom ->
val listener = OnLayoutChangeListener { _, _, top, _, bottom, _, _, oldTop, oldBottom ->
val newHeight = bottom - top
val oldHeight = oldBottom - oldTop
if (newHeight != oldHeight) {
+16 -12
View File
@@ -1,6 +1,6 @@
{
"name": "bsky.app",
"version": "1.119.0",
"version": "1.120.0",
"private": true,
"engines": {
"node": ">=20"
@@ -81,13 +81,15 @@
"icons:optimize": "svgo -f ./assets/icons"
},
"dependencies": {
"@atproto/api": "^0.19.3",
"@atproto/api": "^0.19.6",
"@bitdrift/react-native": "^0.6.8",
"@braintree/sanitize-url": "^6.0.2",
"@bsky.app/alf": "^0.1.7",
"@bsky.app/expo-image-crop-tool": "^0.5.0",
"@bsky.app/expo-translate-text": "^0.2.9",
"@bsky.app/react-native-mmkv": "2.12.5",
"@bsky.app/sift": "^0.3.1",
"@bsky.app/tapper": "^0.5.0",
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
"@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1",
@@ -109,7 +111,6 @@
"@ipld/dag-cbor": "^9.2.0",
"@lingui/core": "^5.9.2",
"@lingui/react": "^5.9.2",
"@mattermost/react-native-paste-input": "mattermost/react-native-paste-input",
"@miblanchard/react-native-slider": "^2.6.0",
"@mozzius/expo-dynamic-app-icon": "^1.8.0",
"@react-native-async-storage/async-storage": "2.2.0",
@@ -117,9 +118,9 @@
"@react-navigation/native": "^7.1.33",
"@react-navigation/native-stack": "^7.14.4",
"@sentry/react-native": "~6.20.0",
"@tanstack/query-async-storage-persister": "^5.25.0",
"@tanstack/react-query": "5.25.0",
"@tanstack/react-query-persist-client": "^5.25.0",
"@tanstack/query-async-storage-persister": "^5.96.2",
"@tanstack/react-query": "^5.96.2",
"@tanstack/react-query-persist-client": "^5.96.2",
"@tiptap/core": "^2.9.1",
"@tiptap/extension-document": "^2.9.1",
"@tiptap/extension-hard-break": "^2.9.1",
@@ -167,6 +168,7 @@
"expo-location": "~19.0.8",
"expo-media-library": "~18.2.1",
"expo-notifications": "~0.32.16",
"expo-paste-input": "^0.1.15",
"expo-privacy-sensitive": "^0.1.0",
"expo-screen-orientation": "~9.0.8",
"expo-sharing": "~14.0.8",
@@ -179,6 +181,7 @@
"expo-web-browser": "~15.0.10",
"fast-deep-equal": "^3.1.3",
"fast-text-encoding": "^1.0.6",
"fuse.js": "^7.1.0",
"hls.js": "^1.6.2",
"idb-keyval": "^6.2.2",
"js-sha256": "^0.9.0",
@@ -199,6 +202,7 @@
"react": "19.1.0",
"react-compiler-runtime": "^19.1.0-rc.1",
"react-dom": "19.1.0",
"react-hotkeys-hook": "5.2.4",
"react-image-crop": "^11.0.7",
"react-is": "19",
"react-keyed-flatten-children": "^5.0.0",
@@ -246,7 +250,6 @@
"@lingui/cli": "^5.9.2",
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.15",
"@react-native/babel-preset": "0.81.5",
"@react-native/eslint-config": "^0.81.5",
"@react-native/typescript-config": "^0.81.5",
"@sentry/webpack-plugin": "^3.2.2",
"@testing-library/react-native": "^13.2.0",
@@ -264,8 +267,8 @@
"eslint": "^9.39.2",
"eslint-import-resolver-typescript": "^4.4.4",
"eslint-plugin-bsky-internal": "link:./eslint",
"eslint-plugin-import-x": "^4.16.1",
"eslint-plugin-lingui": "^0.11.0",
"eslint-plugin-import-x": "^4.16.2",
"eslint-plugin-lingui": "^0.12.0",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-compiler": "^19.1.0-rc.2",
"eslint-plugin-react-hooks": "^7.0.1",
@@ -286,8 +289,8 @@
"react-refresh": "^0.14.0",
"svgo": "^3.3.2",
"ts-plugin-sort-import-suggestions": "^1.0.4",
"typescript": "^5.9.3",
"typescript-eslint": "^8.56.0",
"typescript": "^6.0.2",
"typescript-eslint": "^8.58.0",
"webpack-bundle-analyzer": "^4.10.1"
},
"resolutions": {
@@ -324,7 +327,8 @@
],
"modulePathIgnorePatterns": [
"__tests__/.*/__mocks__",
"__e2e__/.*"
"__e2e__/.*",
"bskylink/.*"
],
"coveragePathIgnorePatterns": [
"<rootDir>/node_modules/",
@@ -1,13 +0,0 @@
diff --git a/node_modules/@mattermost/react-native-paste-input/android/src/main/java/com/mattermost/pasteinputtext/PasteTextInputManager.kt b/node_modules/@mattermost/react-native-paste-input/android/src/main/java/com/mattermost/pasteinputtext/PasteTextInputManager.kt
index 4ed2307..ede1181 100644
--- a/node_modules/@mattermost/react-native-paste-input/android/src/main/java/com/mattermost/pasteinputtext/PasteTextInputManager.kt
+++ b/node_modules/@mattermost/react-native-paste-input/android/src/main/java/com/mattermost/pasteinputtext/PasteTextInputManager.kt
@@ -54,7 +54,7 @@ class PasteTextInputManager(context: ReactApplicationContext) : ReactTextInputMa
}
override fun getExportedCustomBubblingEventTypeConstants(): MutableMap<String, Any> {
- val map = super.getExportedCustomBubblingEventTypeConstants()!!
+ val map = super.getExportedCustomBubblingEventTypeConstants().toMutableMap()
map["onPaste"] = MapBuilder.of(
"phasedRegistrationNames",
MapBuilder.of("bubbled", "onPaste")
@@ -1,264 +0,0 @@
diff --git a/node_modules/@mattermost/react-native-paste-input/ios/PasteInputView.m b/node_modules/@mattermost/react-native-paste-input/ios/PasteInputView.m
index e916023..5049c33 100644
--- a/node_modules/@mattermost/react-native-paste-input/ios/PasteInputView.m
+++ b/node_modules/@mattermost/react-native-paste-input/ios/PasteInputView.m
@@ -4,6 +4,7 @@
//
// Created by Elias Nahum on 04-11-20.
// Copyright © 2020 Facebook. All rights reserved.
+// Updated to remove parents default text view
//
#import "PasteInputView.h"
@@ -12,49 +13,78 @@
@implementation PasteInputView
{
- PasteInputTextView *_backedTextInputView;
+ // We'll store the custom text view in this ivar
+ PasteInputTextView *_customBackedTextView;
}
- (instancetype)initWithBridge:(RCTBridge *)bridge
{
+ // Must call the supers designated initializer
if (self = [super initWithBridge:bridge]) {
- _backedTextInputView = [[PasteInputTextView alloc] initWithFrame:self.bounds];
- _backedTextInputView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
- _backedTextInputView.textInputDelegate = self;
+ // 1. The parent (RCTMultilineTextInputView) has already created
+ // its own _backedTextInputView = [RCTUITextView new] in super init.
+ // We can remove that subview:
- [self addSubview:_backedTextInputView];
- }
+ id<RCTBackedTextInputViewProtocol> parentInputView = super.backedTextInputView;
+ if ([parentInputView isKindOfClass:[UIView class]]) {
+ UIView *parentSubview = (UIView *)parentInputView;
+ if (parentSubview.superview == self) {
+ [parentSubview removeFromSuperview];
+ }
+ }
+ // 2. Now create our custom PasteInputTextView
+ _customBackedTextView = [[PasteInputTextView alloc] initWithFrame:self.bounds];
+ _customBackedTextView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
+ _customBackedTextView.textInputDelegate = self;
+
+ // Optional: disable inline predictions for iOS 17+
+ if (@available(iOS 17.0, *)) {
+ _customBackedTextView.inlinePredictionType = UITextInlinePredictionTypeNo;
+ }
+
+ // 3. Add your custom text view as the only subview
+ [self addSubview:_customBackedTextView];
+ }
return self;
}
+/**
+ * Override the parent's accessor so that anywhere in RN that calls
+ * `self.backedTextInputView` will get the custom PasteInputTextView.
+ */
- (id<RCTBackedTextInputViewProtocol>)backedTextInputView
{
- return _backedTextInputView;
+ return _customBackedTextView;
}
-- (void)setDisableCopyPaste:(BOOL)disableCopyPaste {
- _backedTextInputView.disableCopyPaste = disableCopyPaste;
+#pragma mark - Setters for React Props
+
+- (void)setDisableCopyPaste:(BOOL)disableCopyPaste
+{
+ _customBackedTextView.disableCopyPaste = disableCopyPaste;
}
-- (void)setOnPaste:(RCTDirectEventBlock)onPaste {
- _backedTextInputView.onPaste = onPaste;
+- (void)setOnPaste:(RCTDirectEventBlock)onPaste
+{
+ _customBackedTextView.onPaste = onPaste;
}
-- (void)setSmartPunctuation:(NSString *)smartPunctuation {
- if ([smartPunctuation isEqualToString:@"enable"]) {
- [_backedTextInputView setSmartDashesType:UITextSmartDashesTypeYes];
- [_backedTextInputView setSmartQuotesType:UITextSmartQuotesTypeYes];
- [_backedTextInputView setSmartInsertDeleteType:UITextSmartInsertDeleteTypeYes];
- } else if ([smartPunctuation isEqualToString:@"disable"]) {
- [_backedTextInputView setSmartDashesType:UITextSmartDashesTypeNo];
- [_backedTextInputView setSmartQuotesType:UITextSmartQuotesTypeNo];
- [_backedTextInputView setSmartInsertDeleteType:UITextSmartInsertDeleteTypeNo];
- } else {
- [_backedTextInputView setSmartDashesType:UITextSmartDashesTypeDefault];
- [_backedTextInputView setSmartQuotesType:UITextSmartQuotesTypeDefault];
- [_backedTextInputView setSmartInsertDeleteType:UITextSmartInsertDeleteTypeDefault];
- }
+- (void)setSmartPunctuation:(NSString *)smartPunctuation
+{
+ if ([smartPunctuation isEqualToString:@"enable"]) {
+ [_customBackedTextView setSmartDashesType:UITextSmartDashesTypeYes];
+ [_customBackedTextView setSmartQuotesType:UITextSmartQuotesTypeYes];
+ [_customBackedTextView setSmartInsertDeleteType:UITextSmartInsertDeleteTypeYes];
+ } else if ([smartPunctuation isEqualToString:@"disable"]) {
+ [_customBackedTextView setSmartDashesType:UITextSmartDashesTypeNo];
+ [_customBackedTextView setSmartQuotesType:UITextSmartQuotesTypeNo];
+ [_customBackedTextView setSmartInsertDeleteType:UITextSmartInsertDeleteTypeNo];
+ } else {
+ [_customBackedTextView setSmartDashesType:UITextSmartDashesTypeDefault];
+ [_customBackedTextView setSmartQuotesType:UITextSmartQuotesTypeDefault];
+ [_customBackedTextView setSmartInsertDeleteType:UITextSmartInsertDeleteTypeDefault];
+ }
}
#pragma mark - UIScrollViewDelegate
@@ -62,7 +92,6 @@ - (void)setSmartPunctuation:(NSString *)smartPunctuation {
- (void)scrollViewDidScroll:(UIScrollView *)scrollView
{
RCTDirectEventBlock onScroll = self.onScroll;
-
if (onScroll) {
CGPoint contentOffset = scrollView.contentOffset;
CGSize contentSize = scrollView.contentSize;
@@ -71,22 +100,22 @@ - (void)scrollViewDidScroll:(UIScrollView *)scrollView
onScroll(@{
@"contentOffset": @{
- @"x": @(contentOffset.x),
- @"y": @(contentOffset.y)
+ @"x": @(contentOffset.x),
+ @"y": @(contentOffset.y)
},
@"contentInset": @{
- @"top": @(contentInset.top),
- @"left": @(contentInset.left),
- @"bottom": @(contentInset.bottom),
- @"right": @(contentInset.right)
+ @"top": @(contentInset.top),
+ @"left": @(contentInset.left),
+ @"bottom": @(contentInset.bottom),
+ @"right": @(contentInset.right)
},
@"contentSize": @{
- @"width": @(contentSize.width),
- @"height": @(contentSize.height)
+ @"width": @(contentSize.width),
+ @"height": @(contentSize.height)
},
@"layoutMeasurement": @{
- @"width": @(size.width),
- @"height": @(size.height)
+ @"width": @(size.width),
+ @"height": @(size.height)
},
@"zoomScale": @(scrollView.zoomScale ?: 1),
});
diff --git a/node_modules/@mattermost/react-native-paste-input/ios/PasteTextInput.mm b/node_modules/@mattermost/react-native-paste-input/ios/PasteTextInput.mm
index dd50053..2ed7017 100644
--- a/node_modules/@mattermost/react-native-paste-input/ios/PasteTextInput.mm
+++ b/node_modules/@mattermost/react-native-paste-input/ios/PasteTextInput.mm
@@ -122,8 +122,8 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared &
const auto &newTextInputProps = static_cast<const PasteTextInputProps &>(*props);
// Traits:
- if (newTextInputProps.traits.multiline != oldTextInputProps.traits.multiline) {
- [self _setMultiline:newTextInputProps.traits.multiline];
+ if (newTextInputProps.multiline != oldTextInputProps.multiline) {
+ [self _setMultiline:newTextInputProps.multiline];
}
if (newTextInputProps.traits.autocapitalizationType != oldTextInputProps.traits.autocapitalizationType) {
@@ -421,7 +421,7 @@ - (void)textInputDidChangeSelection
return;
}
const auto &props = static_cast<const PasteTextInputProps &>(*_props);
- if (props.traits.multiline && ![_lastStringStateWasUpdatedWith isEqual:_backedTextInputView.attributedText]) {
+ if (props.multiline && ![_lastStringStateWasUpdatedWith isEqual:_backedTextInputView.attributedText]) {
[self textInputDidChange];
_ignoreNextTextInputCall = YES;
}
@@ -708,11 +708,11 @@ - (BOOL)_textOf:(NSAttributedString *)newText equals:(NSAttributedString *)oldTe
- (SubmitBehavior)getSubmitBehavior
{
const auto &props = static_cast<const PasteTextInputProps &>(*_props);
- const SubmitBehavior submitBehaviorDefaultable = props.traits.submitBehavior;
+ const SubmitBehavior submitBehaviorDefaultable = props.submitBehavior;
// We should always have a non-default `submitBehavior`, but in case we don't, set it based on multiline.
if (submitBehaviorDefaultable == SubmitBehavior::Default) {
- return props.traits.multiline ? SubmitBehavior::Newline : SubmitBehavior::BlurAndSubmit;
+ return props.multiline ? SubmitBehavior::Newline : SubmitBehavior::BlurAndSubmit;
}
return submitBehaviorDefaultable;
diff --git a/node_modules/@mattermost/react-native-paste-input/ios/PasteTextInputSpecs/Props.cpp b/node_modules/@mattermost/react-native-paste-input/ios/PasteTextInputSpecs/Props.cpp
index 29e094f..7ef519a 100644
--- a/node_modules/@mattermost/react-native-paste-input/ios/PasteTextInputSpecs/Props.cpp
+++ b/node_modules/@mattermost/react-native-paste-input/ios/PasteTextInputSpecs/Props.cpp
@@ -22,8 +22,7 @@ PasteTextInputProps::PasteTextInputProps(
const PropsParserContext &context,
const PasteTextInputProps &sourceProps,
const RawProps& rawProps)
- : ViewProps(context, sourceProps, rawProps),
- BaseTextProps(context, sourceProps, rawProps),
+ : BaseTextInputProps(context, sourceProps, rawProps),
traits(convertRawProp(context, rawProps, sourceProps.traits, {})),
smartPunctuation(convertRawProp(context, rawProps, "smartPunctuation", sourceProps.smartPunctuation, {})),
disableCopyPaste(convertRawProp(context, rawProps, "disableCopyPaste", sourceProps.disableCopyPaste, {false})),
@@ -133,7 +132,7 @@ TextAttributes PasteTextInputProps::getEffectiveTextAttributes(Float fontSizeMul
ParagraphAttributes PasteTextInputProps::getEffectiveParagraphAttributes() const {
auto result = paragraphAttributes;
- if (!traits.multiline) {
+ if (!multiline) {
result.maximumNumberOfLines = 1;
}
diff --git a/node_modules/@mattermost/react-native-paste-input/ios/PasteTextInputSpecs/Props.h b/node_modules/@mattermost/react-native-paste-input/ios/PasteTextInputSpecs/Props.h
index 723d00c..31cfe66 100644
--- a/node_modules/@mattermost/react-native-paste-input/ios/PasteTextInputSpecs/Props.h
+++ b/node_modules/@mattermost/react-native-paste-input/ios/PasteTextInputSpecs/Props.h
@@ -15,6 +15,7 @@
#include <react/renderer/components/iostextinput/conversions.h>
#include <react/renderer/components/iostextinput/primitives.h>
#include <react/renderer/components/text/BaseTextProps.h>
+#include <react/renderer/components/textinput/BaseTextInputProps.h>
#include <react/renderer/components/view/ViewProps.h>
#include <react/renderer/core/Props.h>
#include <react/renderer/core/PropsParserContext.h>
@@ -25,7 +26,7 @@
namespace facebook::react {
-class PasteTextInputProps final : public ViewProps, public BaseTextProps {
+class PasteTextInputProps final : public BaseTextInputProps {
public:
PasteTextInputProps() = default;
PasteTextInputProps(const PropsParserContext& context, const PasteTextInputProps& sourceProps, const RawProps& rawProps);
diff --git a/node_modules/@mattermost/react-native-paste-input/ios/PasteTextInputSpecs/ShadowNodes.cpp b/node_modules/@mattermost/react-native-paste-input/ios/PasteTextInputSpecs/ShadowNodes.cpp
index 31e07e3..7f0ebfb 100644
--- a/node_modules/@mattermost/react-native-paste-input/ios/PasteTextInputSpecs/ShadowNodes.cpp
+++ b/node_modules/@mattermost/react-native-paste-input/ios/PasteTextInputSpecs/ShadowNodes.cpp
@@ -91,20 +91,11 @@ void PasteTextInputShadowNode::updateStateIfNeeded(
const auto& state = getStateData();
react_native_assert(textLayoutManager_);
- react_native_assert(
- (!state.layoutManager || state.layoutManager == textLayoutManager_) &&
- "`StateData` refers to a different `TextLayoutManager`");
-
- if (state.reactTreeAttributedString == reactTreeAttributedString &&
- state.layoutManager == textLayoutManager_) {
- return;
- }
auto newState = TextInputState{};
newState.attributedStringBox = AttributedStringBox{reactTreeAttributedString};
newState.paragraphAttributes = getConcreteProps().paragraphAttributes;
newState.reactTreeAttributedString = reactTreeAttributedString;
- newState.layoutManager = textLayoutManager_;
newState.mostRecentEventCount = getConcreteProps().mostRecentEventCount;
setStateData(std::move(newState));
}
@@ -0,0 +1,16 @@
diff --git a/node_modules/react-native/third-party-podspecs/fmt.podspec b/node_modules/react-native/third-party-podspecs/fmt.podspec
index 2f38990..9b02e48 100644
--- a/node_modules/react-native/third-party-podspecs/fmt.podspec
+++ b/node_modules/react-native/third-party-podspecs/fmt.podspec
@@ -26,4 +26,11 @@ Pod::Spec.new do |spec|
spec.public_header_files = "include/fmt/*.h"
spec.header_mappings_dir = "include"
spec.source_files = ["include/fmt/*.h", "src/format.cc"]
+
+ # TODO: Remove after upgrading React Native past 0.83.x
+ # Fix fmt 11.0.2 consteval build error with Xcode 26.4 (facebook/react-native#55601)
+ # Fixed in RN 0.84+ which bumps fmt to a compatible version.
+ spec.prepare_command = <<~SCRIPT
+ perl -i -pe 's/^# define FMT_USE_CONSTEVAL 1$/# define FMT_USE_CONSTEVAL 0/' include/fmt/base.h
+ SCRIPT
end
-27
View File
@@ -1,27 +0,0 @@
// Based on https://github.com/expo/expo/pull/33957
// Could be removed once the app has been updated to Expo 53
const {withAndroidStyles} = require('@expo/config-plugins')
module.exports = function withAndroidDayNightThemePlugin(appConfig) {
const cleanupList = new Set([
'colorPrimary',
'android:editTextBackground',
'android:textColor',
'android:editTextStyle',
])
return withAndroidStyles(appConfig, config => {
config.modResults.resources.style = config.modResults.resources.style
?.map(style => {
if (style.$.name === 'AppTheme' && style.item != null) {
style.item = style.item.filter(item => !cleanupList.has(item.$.name))
}
return style
})
.filter(style => {
return style.$.name !== 'ResetEditText'
})
return config
})
}
+16 -18
View File
@@ -11,13 +11,11 @@ import {
import * as ScreenOrientation from 'expo-screen-orientation'
import * as SplashScreen from 'expo-splash-screen'
import * as SystemUI from 'expo-system-ui'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {useLingui} from '@lingui/react/macro'
import * as Sentry from '@sentry/react-native'
import {Provider as HideBottomBarBorderProvider} from '#/lib/hooks/useHideBottomBarBorder'
import {QueryProvider} from '#/lib/react-query'
import {s} from '#/lib/styles'
import {ThemeProvider} from '#/lib/ThemeContext'
import {Provider as TranslateOnDeviceProvider} from '#/lib/translation'
import I18nProvider from '#/locale/i18nProvider'
@@ -59,7 +57,7 @@ import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
import {Provider as HiddenRepliesProvider} from '#/state/threadgate-hidden-replies'
import {TestCtrls} from '#/view/com/testing/TestCtrls'
import {Shell} from '#/view/shell'
import {ThemeProvider as Alf} from '#/alf'
import {atoms as a, ThemeProvider as Alf} from '#/alf'
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
import {Provider as ContextMenuProvider} from '#/components/ContextMenu'
import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
@@ -89,9 +87,9 @@ import {Splash} from '#/Splash'
import {BottomSheetProvider} from '../modules/bottom-sheet'
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
SplashScreen.preventAutoHideAsync()
void SplashScreen.preventAutoHideAsync()
if (IS_IOS) {
SystemUI.setBackgroundColorAsync('black')
void SystemUI.setBackgroundColorAsync('black')
}
if (IS_ANDROID) {
// iOS is handled by the config plugin -sfn
@@ -105,17 +103,17 @@ if (IS_ANDROID) {
/**
* Begin geolocation ASAP
*/
Geo.resolve()
prefetchAgeAssuranceConfig()
prefetchLiveEvents()
prefetchAppConfig()
void Geo.resolve()
void prefetchAgeAssuranceConfig()
void prefetchLiveEvents()
void prefetchAppConfig()
function InnerApp() {
const [isReady, setIsReady] = useState(false)
const {currentAccount} = useSession()
const {resumeSession} = useSessionApi()
const theme = useColorModeTheme()
const {_} = useLingui()
const {t: l} = useLingui()
const hasCheckedReferrer = useStarterPackEntry()
// init
@@ -134,16 +132,16 @@ function InnerApp() {
}
}
const account = readLastActiveAccount()
onLaunch(account)
void onLaunch(account)
}, [resumeSession])
useEffect(() => {
return listenSessionDropped(() => {
Toast.show(_(msg`Sorry! Your session expired. Please sign in again.`), {
Toast.show(l`Sorry! Your session expired. Please sign in again.`, {
type: 'info',
})
})
}, [_])
}, [l])
return (
<Alf theme={theme}>
@@ -176,7 +174,7 @@ function InnerApp() {
<EmailVerificationProvider>
<HideBottomBarBorderProvider>
<GestureHandlerRootView
style={s.h100pct}>
style={a.h_full}>
<GlobalGestureEventsProvider>
<IntentDialogProvider>
<TranslateOnDeviceProvider>
@@ -217,11 +215,11 @@ function InnerApp() {
}
function App() {
const [isReady, setReady] = useState(false)
const [isReady, setIsReady] = useState(false)
useEffect(() => {
Promise.all([initPersistedState(), Geo.resolve(), setupDeviceId]).then(() =>
setReady(true),
void Promise.all([initPersistedState(), Geo.resolve(), setupDeviceId]).then(
() => setIsReady(true),
)
}, [])
+18 -16
View File
@@ -5,10 +5,10 @@ import './style.css'
import {Fragment, useEffect, useState} from 'react'
import {KeyboardProvider as KeyboardControllerProvider} from 'react-native-keyboard-controller'
import {SafeAreaProvider} from 'react-native-safe-area-context'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {useLingui} from '@lingui/react/macro'
import * as Sentry from '@sentry/react-native'
import {Provider as HotkeysProvider} from '#/lib/hotkeys'
import {QueryProvider} from '#/lib/react-query'
import {ThemeProvider} from '#/lib/ThemeContext'
import {Provider as TranslateOnDeviceProvider} from '#/lib/translation'
@@ -82,17 +82,17 @@ import {Provider as HideBottomBarBorderProvider} from './lib/hooks/useHideBottom
/**
* Begin geolocation ASAP
*/
Geo.resolve()
prefetchAgeAssuranceConfig()
prefetchLiveEvents()
prefetchAppConfig()
void Geo.resolve()
void prefetchAgeAssuranceConfig()
void prefetchLiveEvents()
void prefetchAppConfig()
function InnerApp() {
const [isReady, setIsReady] = useState(false)
const {currentAccount} = useSession()
const {resumeSession} = useSessionApi()
const theme = useColorModeTheme()
const {_} = useLingui()
const {t: l} = useLingui()
const hasCheckedReferrer = useStarterPackEntry()
// init
@@ -105,22 +105,22 @@ function InnerApp() {
await features.init
}
} catch (e) {
logger.error(`session: resumeSession failed`, {message: e})
logger.error('session: resumeSession failed', {message: e})
} finally {
setIsReady(true)
}
}
const account = readLastActiveAccount()
onLaunch(account)
void onLaunch(account)
}, [resumeSession])
useEffect(() => {
return listenSessionDropped(() => {
Toast.show(_(msg`Sorry! Your session expired. Please sign in again.`), {
Toast.show(l`Sorry! Your session expired. Please sign in again.`, {
type: 'info',
})
})
}, [_])
}, [l])
return (
<Alf theme={theme}>
@@ -156,8 +156,10 @@ function InnerApp() {
<HideBottomBarBorderProvider>
<IntentDialogProvider>
<TranslateOnDeviceProvider>
<Shell />
<ToastOutlet />
<HotkeysProvider>
<Shell />
<ToastOutlet />
</HotkeysProvider>
</TranslateOnDeviceProvider>
</IntentDialogProvider>
</HideBottomBarBorderProvider>
@@ -192,11 +194,11 @@ function InnerApp() {
}
function App() {
const [isReady, setReady] = useState(false)
const [isReady, setIsReady] = useState(false)
useEffect(() => {
Promise.all([initPersistedState(), Geo.resolve(), setupDeviceId]).then(() =>
setReady(true),
void Promise.all([initPersistedState(), Geo.resolve(), setupDeviceId]).then(
() => setIsReady(true),
)
}, [])
+12 -12
View File
@@ -105,19 +105,18 @@ export function getConfigFromCache():
)
}
let configPrefetchPromise: Promise<void> | undefined
export async function prefetchConfig() {
export function prefetchConfig() {
if (configPrefetchPromise) {
logger.debug(`prefetchAgeAssuranceConfig: already in progress`)
return
}
configPrefetchPromise = new Promise(async resolve => {
configPrefetchPromise = (async () => {
await cacheHydrationPromise
const cached = getConfigFromCache()
if (cached) {
logger.debug(`prefetchAgeAssuranceConfig: using cache`)
resolve()
} else {
try {
logger.debug(`prefetchAgeAssuranceConfig: resolving...`)
@@ -126,15 +125,14 @@ export async function prefetchConfig() {
configQueryKey,
res,
)
} catch (e: any) {
} catch (err) {
const e = err as Error
logger.warn(`prefetchAgeAssuranceConfig: failed`, {
safeMessage: e.message,
})
} finally {
resolve()
}
}
})
})()
}
export async function refetchConfig() {
logger.debug(`refetchConfig: fetching...`)
@@ -228,7 +226,8 @@ export async function prefetchServerState({agent}: {agent: AtpAgent}) {
logger.debug(`prefetchServerState: resolving...`)
const res = await networkRetry(3, () => getServerState({agent}))
qc.setQueryData<AppBskyAgeassuranceGetState.OutputSchema>(qk, res)
} catch (e: any) {
} catch (err) {
const e = err as Error
logger.warn(`prefetchServerState: failed`, {
safeMessage: e.message,
})
@@ -248,7 +247,7 @@ export async function refetchServerState({agent}: {agent: AtpAgent}) {
export function usePatchServerState() {
const {currentAccount} = useSession()
return useCallback(
async (next: AppBskyAgeassuranceDefs.State) => {
(next: AppBskyAgeassuranceDefs.State) => {
if (!currentAccount) return
const did = currentAccount.did
const prev = getServerStateFromCache({did})
@@ -313,7 +312,7 @@ export function useServerStateQuery() {
// only refetch when needed
if (isAssured || !isAArequired) return
refetch()
void refetch()
})
}, [did, refetch, isAssured])
@@ -409,7 +408,8 @@ export async function prefetchOtherRequiredData({agent}: {agent: AtpAgent}) {
logger.debug(`prefetchOtherRequiredData: resolving...`)
const res = await networkRetry(3, () => getOtherRequiredData({agent}))
qc.setQueryData<OtherRequiredData>(qk, res)
} catch (e: any) {
} catch (err) {
const e = err as Error
logger.warn(`prefetchOtherRequiredData: failed`, {
safeMessage: e.message,
})
@@ -418,7 +418,7 @@ export async function prefetchOtherRequiredData({agent}: {agent: AtpAgent}) {
export function usePatchOtherRequiredData() {
const {currentAccount} = useSession()
return useCallback(
async (next: OtherRequiredData) => {
(next: OtherRequiredData) => {
if (!currentAccount) return
const did = currentAccount.did
const prev = getOtherRequiredDataFromCache({did})
+1 -1
View File
@@ -85,7 +85,7 @@ function InnerProvider({children}: {children: React.ReactNode}) {
const handleAccessUpdate = useCallback(
(s: AgeAssuranceState) => {
getAndRegisterPushToken({
void getAndRegisterPushToken({
isAgeRestricted: s.access !== AgeAssuranceAccess.Full,
})
},
+1
View File
@@ -99,6 +99,7 @@ export function useOnAgeAssuranceAccessUpdate(
useEffect(() => {
if (prevAccess !== state.access) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setPrevAccess(state.access)
cb(state)
logger.debug(`useOnAgeAssuranceAccessUpdate`, {state})
+10 -2
View File
@@ -77,10 +77,18 @@ export function applyFonts(style: TextStyle, fontFamily: 'system' | 'theme') {
}
/**
* Disable contextual alternates in Inter
* Disable contextual alternates and emoji overrides in Inter
* {@link https://developer.mozilla.org/en-US/docs/Web/CSS/font-variant}
*/
style.fontVariant = (style.fontVariant || []).concat('no-contextual')
if (IS_WEB) {
// @ts-expect-error - web supports 'unicode' as a valid value for fontVariant
style.fontVariant = (style.fontVariant || []).concat(
'no-contextual',
'unicode',
)
} else {
style.fontVariant = (style.fontVariant || []).concat('no-contextual')
}
} else {
// fallback families only supported on web
if (IS_WEB) {
+8 -5
View File
@@ -1,12 +1,14 @@
import {Children} from 'react'
import {type TextProps as RNTextProps} from 'react-native'
import {type StyleProp, type TextStyle} from 'react-native'
import {
type StyleProp,
type TextProps as RNTextProps,
type TextStyle,
} from 'react-native'
import {UITextView} from 'react-native-uitextview'
import createEmojiRegex from 'emoji-regex'
import {type Alf, applyFonts, atoms, flatten} from '#/alf'
import {IS_NATIVE} from '#/env'
import {IS_IOS} from '#/env'
import {IS_IOS, IS_NATIVE} from '#/env'
/**
* Ensures that `lineHeight` defaults to a relative value of `1`, or applies
@@ -107,7 +109,8 @@ export function renderChildrenWithEmoji(
})
}
const SINGLE_EMOJI_RE = /^[\p{Emoji_Presentation}\p{Extended_Pictographic}]+$/u
const SINGLE_EMOJI_RE =
/^[\p{Emoji_Presentation}\p{Extended_Pictographic}\uFE0F\u200D]+$/u
export function isOnlyEmoji(text: string) {
return text.length <= 15 && SINGLE_EMOJI_RE.test(text)
}
+37 -1
View File
@@ -1,3 +1,39 @@
import {StyleSheet} from 'react-native'
import {type DimensionValue, StyleSheet} from 'react-native'
export const flatten = StyleSheet.flatten
/**
* Coerce a style value to a number. Padding values are typed as
* `DimensionValue` (numbers, percentages, "auto", etc.) but our ALF atoms
* are always plain numbers. Non-numeric values are treated as 0.
*/
function num(v: unknown): number {
return typeof v === 'number' ? v : 0
}
interface PaddingStyle {
padding?: DimensionValue
paddingHorizontal?: DimensionValue
paddingVertical?: DimensionValue
paddingTop?: DimensionValue
paddingBottom?: DimensionValue
paddingLeft?: DimensionValue
paddingRight?: DimensionValue
}
/**
* Extract resolved padding values from a style object. Returns numbers for
* each side, resolving shorthand properties (padding → paddingVertical →
* paddingTop/paddingBottom, etc.). Values are expected to be numbers — any
* non-numeric `DimensionValue` (e.g. percentages) is treated as 0.
*/
export function extractPadding(style: PaddingStyle | PaddingStyle[]) {
const s = flatten(style as any) ?? {}
const base = num(s.padding)
return {
paddingTop: num(s.paddingTop) || num(s.paddingVertical) || base,
paddingBottom: num(s.paddingBottom) || num(s.paddingVertical) || base,
paddingLeft: num(s.paddingLeft) || num(s.paddingHorizontal) || base,
paddingRight: num(s.paddingRight) || num(s.paddingHorizontal) || base,
}
}
+3
View File
@@ -9,6 +9,9 @@ export enum Features {
ImportContactsOnboardingDisable = 'import_contacts:onboarding:disable',
ImportContactsSettingsDisable = 'import_contacts:settings:disable',
LiveNowBetaDisable = 'live_now_beta:disable',
ImageUploadsHighResolution = 'image_uploads:high_resolution',
GroupChatsEnable = 'group_chats:enable',
DmsNewMessageComposerEnable = 'dms:new_message_composer:enable',
AATest = 'aa-test',
}
+1
View File
@@ -110,6 +110,7 @@ const Context = createContext<AnalyticsBaseContextType>({
},
},
})
Context.displayName = 'AnalyticsContext'
/**
* Ensures that deviceId is set and migrated from legacy storage. Handled on
+21 -12
View File
@@ -230,6 +230,9 @@ export type Events = {
'composer:gif:open': {}
'composer:gif:select': {}
'composer:image:edit': {
platform: Platform['OS']
}
'composerPrompt:press': {}
'composerPrompt:camera:press': {}
'composerPrompt:gallery:press': {}
@@ -477,10 +480,12 @@ export type Events = {
'suggestedUser:follow': {
logContext:
| 'Explore'
| 'InterstitialDiscover'
| 'InterstitialProfile'
| 'Profile'
| 'DiscoverInterstitial'
| 'ProfileInterstitial'
| 'ProfileHeader'
| 'Onboarding'
| 'SeeMoreSuggestedUsers'
| 'ProgressGuide'
location: 'Card' | 'Profile' | 'FollowAll'
recId?: number | string
position: number
@@ -490,9 +495,11 @@ export type Events = {
'suggestedUser:press': {
logContext:
| 'Explore'
| 'InterstitialDiscover'
| 'InterstitialProfile'
| 'DiscoverInterstitial'
| 'ProfileInterstitial'
| 'ProfileHeader'
| 'Onboarding'
| 'SeeMoreSuggestedUsers'
recId?: number | string
position: number
suggestedDid: string
@@ -501,10 +508,11 @@ export type Events = {
'suggestedUser:seen': {
logContext:
| 'Explore'
| 'InterstitialDiscover'
| 'InterstitialProfile'
| 'Profile'
| 'DiscoverInterstitial'
| 'ProfileInterstitial'
| 'ProfileHeader'
| 'Onboarding'
| 'SeeMoreSuggestedUsers'
| 'ProgressGuide'
recId?: number | string
position: number
@@ -514,13 +522,14 @@ export type Events = {
'suggestedUser:seeMore': {
logContext:
| 'Explore'
| 'InterstitialDiscover'
| 'InterstitialProfile'
| 'Profile'
| 'DiscoverInterstitial'
| 'ProfileInterstitial'
| 'ProfileHeader'
| 'Onboarding'
recId?: number | string
}
'suggestedUser:dismiss': {
logContext: 'InterstitialDiscover' | 'InterstitialProfile'
logContext: 'DiscoverInterstitial' | 'ProfileInterstitial' | 'ProfileHeader'
recId?: number | string
position: number
suggestedDid: string
@@ -0,0 +1,78 @@
import {useCallback} from 'react'
import {View} from 'react-native'
import {Sift, type UseSiftReturn} from '@bsky.app/sift'
import {atoms as a, useTheme} from '#/alf'
import {type AutocompleteItem} from '#/components/Autocomplete/types'
import {useOnKeyboard} from '#/components/hooks/useOnKeyboard'
import {Portal} from '#/components/Portal'
import {IS_WEB} from '#/env'
import {AutocompleteItemEmoji} from './AutocompleteItemEmoji'
import {AutocompleteItemProfile} from './AutocompleteItemProfile'
import {AutocompleteItemSearch} from './AutocompleteItemSearch'
function renderItem(
item: Parameters<Parameters<typeof Sift<AutocompleteItem>>[0]['render']>[0],
) {
switch (item.item.type) {
case 'profile':
return <AutocompleteItemProfile {...item} />
case 'emoji':
return <AutocompleteItemEmoji {...item} />
case 'search':
return <AutocompleteItemSearch {...item} />
default:
return <View />
}
}
export function Autocomplete({
inverted,
sift,
data,
render = renderItem,
onSelect,
onDismiss,
}: {
inverted?: boolean
sift: UseSiftReturn
data: AutocompleteItem[]
render?: Parameters<typeof Sift<AutocompleteItem>>[0]['render']
onSelect: (item: AutocompleteItem) => void
onDismiss: () => void
}) {
const t = useTheme()
const updatePosition = useCallback(() => {
sift.updatePosition()
}, [sift])
useOnKeyboard('keyboardDidShow', updatePosition)
useOnKeyboard('keyboardDidHide', updatePosition)
return (
<Portal>
<Sift
inverted={inverted}
sift={sift}
data={data}
onSelect={onSelect}
onDismiss={onDismiss}
style={[
a.overflow_hidden,
a.rounded_md,
a.border,
t.atoms.border_contrast_low,
t.atoms.bg,
a.w_full,
IS_WEB
? {
maxWidth: 300,
}
: {},
]}
render={render}
/>
</Portal>
)
}
@@ -0,0 +1,30 @@
import {SiftItem} from '@bsky.app/sift'
import {atoms as a, useTheme} from '#/alf'
import {Text} from '#/components/Typography'
import {type AutocompleteItemProps} from './types'
export function AutocompleteItemEmoji({
active,
props,
item,
}: AutocompleteItemProps) {
const t = useTheme()
if (item.type !== 'emoji') return null
return (
<SiftItem
{...props}
style={s => [
{paddingVertical: 6, paddingHorizontal: 10},
a.flex_row,
a.align_center,
a.gap_sm,
active || s.hovered || s.pressed ? [t.atoms.bg_contrast_25] : [],
]}>
<Text style={[a.text_xl, a.leading_tight]}>{item.value}</Text>
<Text style={[a.text_md, a.leading_tight]}>:{item.emoji.id}:</Text>
</SiftItem>
)
}
@@ -0,0 +1,47 @@
import {SiftItem} from '@bsky.app/sift'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {atoms as a, useTheme} from '#/alf'
import * as ProfileCard from '#/components/ProfileCard'
import {type AutocompleteItemProps} from './types'
export function AutocompleteItemProfile({
active,
isFirst,
isLast,
props,
item,
}: AutocompleteItemProps) {
const t = useTheme()
const moderationOpts = useModerationOpts()
if (item.type !== 'profile' || !moderationOpts) return null
return (
<SiftItem
{...props}
style={s => [
a.py_sm,
a.px_md,
active || s.hovered || s.pressed ? [t.atoms.bg_contrast_25] : [],
isFirst && {
paddingTop: a.py_sm.paddingTop * 1.2,
},
isLast && {
paddingBottom: a.py_sm.paddingTop * 1.2,
},
]}>
<ProfileCard.Header>
<ProfileCard.Avatar
disabledPreview
profile={item.profile}
moderationOpts={moderationOpts}
/>
<ProfileCard.NameAndHandle
profile={item.profile}
moderationOpts={moderationOpts}
/>
</ProfileCard.Header>
</SiftItem>
)
}
@@ -0,0 +1,49 @@
import {View} from 'react-native'
import {SiftItem} from '@bsky.app/sift'
import {atoms as a, useTheme} from '#/alf'
import {MagnifyingGlass_Stroke2_Corner0_Rounded as MagnifyingGlassIcon} from '#/components/icons/MagnifyingGlass'
import {Text} from '#/components/Typography'
import {type AutocompleteItemProps} from './types'
export function AutocompleteItemSearch({
active,
isFirst,
isLast,
props,
item,
}: AutocompleteItemProps) {
const t = useTheme()
if (item.type !== 'search') return null
return (
<SiftItem
{...props}
style={s => [
a.py_sm,
a.px_md,
a.flex_row,
a.align_center,
a.gap_sm,
active || s.hovered || s.pressed ? [t.atoms.bg_contrast_25] : [],
isFirst && {
paddingTop: a.py_sm.paddingTop * 1.2,
},
isLast && {
paddingBottom: a.py_sm.paddingTop * 1.2,
},
]}>
<View
style={[
a.align_center,
{
width: 40,
},
]}>
<MagnifyingGlassIcon fill={t.atoms.text_contrast_low.color} size="xl" />
</View>
<Text style={[a.text_md, a.leading_snug]}>{item.value}</Text>
</SiftItem>
)
}
+6
View File
@@ -0,0 +1,6 @@
export * from './Autocomplete'
export * from './AutocompleteItemEmoji'
export * from './AutocompleteItemProfile'
export * from './types'
export * from './useAutocomplete'
export * from './util'
+48
View File
@@ -0,0 +1,48 @@
import {type Sift} from '@bsky.app/sift'
import {type Emoji} from '@emoji-mart/data'
import type * as bsky from '#/types/bsky'
export type AutocompleteProfile = {
key: string
type: 'profile'
value: string
profile: bsky.profile.AnyProfileView
}
export type AutocompleteTag = {
key: string
type: 'tag'
value: string
tag: string
}
export type AutocompleteEmoji = {
key: string
type: 'emoji'
value: string
emoji: Emoji
}
export type AutocompleteSearch = {
key: string
type: 'search'
value: string
}
export type AutocompleteItem =
| AutocompleteProfile
| AutocompleteTag
| AutocompleteEmoji
| AutocompleteSearch
export type AutocompleteItemType = AutocompleteItem['type']
export type AutocompleteItemProps = Parameters<
Parameters<typeof Sift<AutocompleteItem>>[0]['render']
>[0]
export type AutocompleteApi = {
query: string
items: AutocompleteItem[]
}
@@ -0,0 +1,135 @@
import {useCallback} from 'react'
import {moderateProfile, type ModerationOpts} from '@atproto/api'
import {keepPreviousData, useQuery} from '@tanstack/react-query'
import {isJustAMute, moduiContainsHideableOffense} from '#/lib/moderation'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {STALE} from '#/state/queries'
import {DEFAULT_LOGGED_OUT_PREFERENCES} from '#/state/queries/preferences'
import {useAgent} from '#/state/session'
import {
type AutocompleteApi,
type AutocompleteItem,
type AutocompleteItemType,
type AutocompleteProfile,
} from '#/components/Autocomplete/types'
import {useEmojiSearch} from './useEmojiSearch'
const DEFAULT_MOD_OPTS = {
userDid: undefined,
prefs: DEFAULT_LOGGED_OUT_PREFERENCES.moderationPrefs,
}
export function useAutocomplete({
type,
query: q,
limit,
showSearchFallback = false,
}: {
type: AutocompleteItemType
query: string
limit?: number
showSearchFallback?: boolean
}): AutocompleteApi {
const agent = useAgent()
const moderationOpts = useModerationOpts()
const emojiSearch = useEmojiSearch()
const query = useQuery({
staleTime: STALE.MINUTES.ONE,
queryKey: [
'autocomplete',
{
type,
query: q,
},
],
async queryFn() {
if (type === 'profile') {
// TODO return recents
if (!q) return []
// Going from "foo" to "foo." should not clear matches.
q = q.toLowerCase().trim().replace(/\.$/, '')
const res = await agent.searchActorsTypeahead({
q,
limit: limit || 8,
})
return (res?.data.actors || []).map(profile => ({
key: profile.did,
type: 'profile' as const,
value: '@' + profile.handle,
profile,
}))
} else if (type === 'emoji') {
return emojiSearch(q, limit || 8)
}
return []
},
select: useCallback(
(items: AutocompleteItem[]) => {
const seen = new Set<string>()
let results: AutocompleteItem[] = []
for (const item of items) {
if (seen.has(item.key)) continue
seen.add(item.key)
if (item.type === 'profile') {
const moderated = moderateProfileItem({
query: q,
item,
moderationOpts: moderationOpts || DEFAULT_MOD_OPTS,
})
if (moderated) results.push(moderated)
} else {
results.push(item)
}
}
if (showSearchFallback && q) {
results.unshift({
key: `search-${q}`,
type: 'search' as const,
value: q,
})
}
return results
},
[q, showSearchFallback, moderationOpts],
),
placeholderData: keepPreviousData,
})
return {
query: q,
items: query.data || [],
}
}
function moderateProfileItem({
query,
item,
moderationOpts,
}: {
query: string
item: AutocompleteProfile
moderationOpts: ModerationOpts
}) {
const modui = moderateProfile(item.profile, moderationOpts).ui('profileList')
const isExactMatch = query && item.profile.handle.toLowerCase() === query
if (
(isExactMatch && !moduiContainsHideableOffense(modui)) ||
!modui.filter ||
isJustAMute(modui)
) {
return item
}
return null
}
@@ -0,0 +1,40 @@
import {useCallback} from 'react'
import {type Emoji} from '@emoji-mart/data'
import Fuse from 'fuse.js'
import {useGetEmojis} from '#/lib/useGetEmojis'
import {type AutocompleteEmoji} from '#/components/Autocomplete/types'
/*
* Lazily loaded Fuse instance for emoji search. Built once on first search,
* then reused for all subsequent searches.
*/
let emojiFuseInstance: Fuse<Emoji> | null = null
export function useEmojiSearch(): (
query: string,
limit?: number,
) => Promise<AutocompleteEmoji[]> {
const getEmojis = useGetEmojis()
return useCallback(
async (query: string, limit: number = 8) => {
if (!emojiFuseInstance) {
const data = await getEmojis()
emojiFuseInstance = new Fuse(Object.values(data.emojis), {
keys: ['search'],
threshold: 0.3,
})
}
const results = emojiFuseInstance.search(query, {limit})
return results.map(result => ({
key: result.item.id,
type: 'emoji' as const,
value: result.item.skins[0].native,
emoji: result.item,
}))
},
[getEmojis],
)
}
+12
View File
@@ -0,0 +1,12 @@
export function parseAutocompleteItemType(type: string) {
switch (type) {
case 'mention':
return 'profile'
case 'tag':
return 'tag'
case 'emoji':
return 'emoji'
default:
throw new Error(`Unknown autocomplete item type: ${type}`)
}
}
+432
View File
@@ -0,0 +1,432 @@
import {useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react'
import {
type TextInput,
type TextInputSubmitEditingEvent,
View,
} from 'react-native'
import Animated, {
useAnimatedStyle,
useSharedValue,
} from 'react-native-reanimated'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {useSift, type UseSiftReturn} from '@bsky.app/sift'
import {
facets,
type TapperActiveFacet,
type TapperFacet,
useTapper,
} from '@bsky.app/tapper'
import {mergeRefs} from '#/lib/merge-refs'
import {
atoms as a,
type TextStyleProp,
useAlf,
type ViewStyleProp,
web,
} from '#/alf'
import {normalizeTextStyles} from '#/alf/typography'
import {
Autocomplete as AutocompleteBase,
AutocompleteItemEmoji,
AutocompleteItemProfile,
parseAutocompleteItemType,
useAutocomplete,
} from '#/components/Autocomplete'
import {
AutosizedTextarea,
type AutosizedTextareaProps,
} from '#/components/forms/AutosizedTextarea'
import {Span, Text} from '#/components/Typography'
import {IS_IOS, IS_WEB, IS_WEB_TOUCH_DEVICE} from '#/env'
export type SubmitRequest =
| {
platform: 'web'
shiftKey: boolean
metaKey: boolean
nativeEvent: KeyboardEvent
}
| {
platform: 'native'
nativeEvent: TextInputSubmitEditingEvent
}
/**
* Imperative API exposed via `internalApiRef` prop for parent components that
* need to control the composer programmatically, e.g. to clear the input or
* insert text at the current cursor position.
*/
export type ComposerInternalApi = {
input?: ReturnType<typeof useTapper>['input']
clear: () => void
insert(text: string): void
setAutocompleteAnchor: (node: View | null) => void
}
export function useComposerInternalApiRef() {
return useRef<ComposerInternalApi>(null)
}
/*
* ─── Composer ─────────────────────────────────────────────────────────────────
*/
export type ComposerProps = Omit<
AutosizedTextareaProps,
| 'value'
| 'onChange'
| 'onChangeText'
| 'onSelectionChange'
| 'selection'
| 'style'
| 'onSubmitEditing'
> & {
label: string
ref?: React.RefObject<TextInput>
internalApiRef?: React.Ref<ComposerInternalApi>
outerStyle?: ViewStyleProp['style']
contentTextStyle?: TextStyleProp['style']
contentPaddingStyle?: {
paddingTop?: number
paddingBottom?: number
paddingLeft?: number
paddingRight?: number
}
onChange?: (text: string) => void
onActiveFacet?: (activeFacet: TapperActiveFacet | null) => void
onFacetCommitted?: (facet: TapperFacet) => void
onRequestSubmit?: (request: SubmitRequest) => void
autocompletePlacement?: Exclude<
Parameters<typeof useSift>[0],
undefined
>['placement']
disableEmojiFacets?: boolean
}
export function Composer({
label,
ref,
internalApiRef,
outerStyle,
contentTextStyle,
contentPaddingStyle,
onChange: onChangeOuter,
onActiveFacet: onActiveFacetOuter,
onFacetCommitted: onFacetCommittedOuter,
onRequestSubmit,
autocompletePlacement,
defaultValue,
disableEmojiFacets = !IS_WEB,
...rest
}: ComposerProps) {
const {theme: t, fonts} = useAlf()
const insets = useSafeAreaInsets()
/*
* Meat and potatoes
*/
const tapper = useTapper({
initialText: defaultValue ?? '',
facets: disableEmojiFacets
? {
mention: facets.mention,
tag: facets.tag,
url: facets.url,
}
: facets,
})
const sift = useSift({
offset: a.p_sm.padding,
placement: autocompletePlacement,
dynamicWidth: IS_WEB,
insets,
})
/*
* Active facet state for controlling the visibility of the Autocomplete.
*/
const [activeFacet, setActiveFacet] = useState<TapperActiveFacet | null>(null)
/*
* Reanimated shared value for syncing scroll on all platforms.
*/
const inputScrollSharedValue = useSharedValue(0)
/*
* Expose imperative internal API
*/
useImperativeHandle(
internalApiRef,
() => ({
input: tapper.input,
clear: () => {
tapper.inputProps.onChangeText('')
inputScrollSharedValue.value = 0
},
insert: tapper.insert,
setAutocompleteAnchor: sift.refs.setAnchor,
}),
[tapper.input, tapper.insert, inputScrollSharedValue, sift.refs.setAnchor],
)
/*
* Skip the initial mount to avoid an unnecessary re-render — the parent
* already knows the initial value since it passed `initialText`.
*/
const isFirstRender = useRef(true)
useEffect(() => {
if (isFirstRender.current) {
isFirstRender.current = false
return
}
onChangeOuter?.(tapper.state.text)
}, [tapper.state.text, onChangeOuter])
/*
* Tapper callbacks
*/
const callbackRefs = useRef({
onActiveFacetOuter,
onFacetCommittedOuter,
})
callbackRefs.current = {
onActiveFacetOuter,
onFacetCommittedOuter,
}
useEffect(() => {
const offActiveFacet = tapper.on('activeFacet', facet => {
setActiveFacet(facet)
callbackRefs.current.onActiveFacetOuter?.(facet)
})
const offFacetCommitted = tapper.on('facetCommitted', facet => {
callbackRefs.current.onFacetCommittedOuter?.(facet)
})
const offAfterInsert = tapper.on('afterInsert', () => {
tapper.input.focus()
})
return () => {
offActiveFacet()
offFacetCommitted()
offAfterInsert()
}
}, [tapper.on, tapper.input])
/*
* Styles
*/
const previewScrollStyle = useAnimatedStyle(() => ({
transform: [{translateY: -inputScrollSharedValue.value}],
}))
const textStyle = useMemo(() => {
const ts = normalizeTextStyles(
[a.leading_snug, t.atoms.text, contentTextStyle],
{
fontScale: fonts.scaleMultiplier,
fontFamily: fonts.family,
flags: {},
},
)
/**
* On iOS, having a lineHeight on the Text component causes the text to be
* vertically misaligned with the TextInput.
*
* This only seems to be an issue on iOS, and not on Android or web. It's
* possible that this is a bug in React Native's Text component on iOS,
* but in the meantime, we'll just remove the lineHeight on iOS to ensure
* the text is properly aligned.
*/
if (IS_IOS) {
delete ts.lineHeight
}
return ts
}, [contentTextStyle, fonts])
/*
* Web keyboard handling
*/
const isComposing = useRef(false)
const onKeyPressWeb = (e: React.KeyboardEvent | any) => {
if (IS_WEB_TOUCH_DEVICE) return
if (isComposing.current) return
/*
* On Safari, the final keydown to dismiss an IME is also "Enter" with
* keyCode 229. Chrome/Firefox don't have this problem.
*
* @see https://github.com/bluesky-social/social-app/issues/4178
*/
if (e.key === 'Enter' && e.keyCode === 229) return
if (e.key === 'Enter') {
onRequestSubmit?.({
platform: 'web',
shiftKey: e.shiftKey,
metaKey: e.metaKey,
nativeEvent: e.nativeEvent,
})
}
}
/*
* Sift popover positioning
*/
const updateAutocompletePosition = () => {
sift.updatePosition()
}
const textContent = (
<Text style={[textStyle, web({whiteSpace: 'pre-wrap'})]}>
{tapper.state.nodes.map((node, i) => {
switch (node.type) {
case 'text':
return <Span key={i}>{node.value}</Span>
case 'trigger':
case 'facet':
return (
<Span
key={i}
ref={IS_WEB ? sift.refs.setAnchor : undefined}
style={
node.type === 'facet' && {
color: t.palette.primary_500,
}
}>
{node.raw}
</Span>
)
}
})}
</Text>
)
return (
<>
<View style={[a.relative, outerStyle]}>
{IS_WEB && (
<View
pointerEvents="none"
style={[a.absolute, a.inset_0, a.z_10, {overflow: 'hidden'}]}>
<Animated.View
style={[
contentPaddingStyle,
{position: 'absolute', left: 0, right: 0},
previewScrollStyle,
]}>
{textContent}
</Animated.View>
</View>
)}
<AutosizedTextarea
placeholderTextColor={t.palette.contrast_500}
accessibilityLabel={label}
accessibilityHint={label}
onSubmitEditing={e => {
onRequestSubmit?.({platform: 'native', nativeEvent: e})
}}
style={[
textStyle,
contentPaddingStyle,
a.z_20,
{
color: 'transparent',
background: 'transparent',
},
web({
caretColor: textStyle.color ?? 'black',
overscrollBehavior: 'none',
}),
]}
{...rest}
{...tapper.inputProps}
{...sift.targetProps}
ref={mergeRefs([ref, tapper.inputProps.ref, sift.targetProps.ref])}
onBlur={e => {
rest.onBlur?.(e)
setActiveFacet(null)
}}
onKeyPress={IS_WEB ? onKeyPressWeb : undefined}
onScroll={e => {
if (IS_WEB) {
inputScrollSharedValue.value = (e.target as any).scrollTop
} else {
inputScrollSharedValue.value = e.nativeEvent.contentOffset.y
}
}}
// @ts-ignore web only
onCompositionStart={() => {
isComposing.current = true
}}
// @ts-ignore web only
onCompositionEnd={() => {
isComposing.current = false
}}
onUpdateHeight={updateAutocompletePosition}>
{IS_WEB ? null : textContent}
</AutosizedTextarea>
</View>
{activeFacet && activeFacet.type !== 'url' && (
<AutocompleteInner
sift={sift}
activeFacet={activeFacet}
onDismiss={() => setActiveFacet(null)}
/>
)}
</>
)
}
/*
* ─── Autocomplete (private) ───────────────────────────────────────────────────
*/
function AutocompleteInner({
sift,
activeFacet,
onDismiss,
}: {
sift: UseSiftReturn
activeFacet: TapperActiveFacet
onDismiss: () => void
}) {
const {items} = useAutocomplete({
type: parseAutocompleteItemType(activeFacet.type),
query: activeFacet.value,
})
useEffect(() => {
if (
activeFacet?.type === 'emoji' &&
!!activeFacet.value.length &&
activeFacet.raw.endsWith(':')
) {
if (items?.[0]) {
activeFacet.replace(items[0].value, {noTrailingSpace: true})
onDismiss()
}
}
}, [items, activeFacet])
return items && items.length ? (
<AutocompleteBase
inverted={!IS_WEB}
sift={sift}
data={items}
render={props => {
if (props.item.type === 'profile') {
return <AutocompleteItemProfile {...props} />
}
if (props.item.type === 'emoji') {
return <AutocompleteItemEmoji {...props} />
}
return <View />
}}
onSelect={item => {
activeFacet.replace(item.value)
onDismiss()
}}
onDismiss={onDismiss}
/>
) : null
}
+63 -166
View File
@@ -7,21 +7,17 @@ import Animated, {
LayoutAnimationConfig,
LinearTransition,
} from 'react-native-reanimated'
import {type AppBskyFeedDefs, AtUri} from '@atproto/api'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {type AppBskyFeedDefs} from '@atproto/api'
import {Trans, useLingui} from '@lingui/react/macro'
import {useNavigation} from '@react-navigation/native'
import {type NavigationProp} from '#/lib/routes/types'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useGetPopularFeedsQuery} from '#/state/queries/feed'
import {type FeedDescriptor} from '#/state/queries/post-feed'
import {useProfilesQuery} from '#/state/queries/profile'
import {useSuggestedFollowsByActorWithDismiss} from '#/state/queries/suggested-follows'
import {useGetSuggestedUsersForDiscoverQuery} from '#/state/queries/trending/useGetSuggestedUsersForDiscoverQuery'
import {useSession} from '#/state/session'
import * as userActionHistory from '#/state/userActionHistory'
import {type SeenPost} from '#/state/userActionHistory'
import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture'
import {
atoms as a,
@@ -39,12 +35,12 @@ import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Has
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
import {InlineLinkText} from '#/components/Link'
import * as ProfileCard from '#/components/ProfileCard'
import {ProgressGuideList} from '#/components/ProgressGuide/List'
import {Text} from '#/components/Typography'
import {type Metrics, useAnalytics} from '#/analytics'
import {IS_IOS} from '#/env'
import type * as bsky from '#/types/bsky'
import {FollowDialogWithoutGuide} from './ProgressGuide/FollowDialog'
import {ProgressGuideList} from './ProgressGuide/List'
const DISMISS_ANIMATION_DURATION = 200
@@ -111,95 +107,6 @@ export function SuggestedFeedsCardPlaceholder() {
)
}
function getRank(seenPost: SeenPost): string {
let tier: string
if (seenPost.feedContext === 'popfriends') {
tier = 'a'
} else if (seenPost.feedContext?.startsWith('cluster')) {
tier = 'b'
} else if (seenPost.feedContext === 'popcluster') {
tier = 'c'
} else if (seenPost.feedContext?.startsWith('ntpc')) {
tier = 'd'
} else if (seenPost.feedContext?.startsWith('t-')) {
tier = 'e'
} else if (seenPost.feedContext === 'nettop') {
tier = 'f'
} else {
tier = 'g'
}
let score = Math.round(
Math.log(
1 + seenPost.likeCount + seenPost.repostCount + seenPost.replyCount,
),
)
if (seenPost.isFollowedBy || Math.random() > 0.9) {
score *= 2
}
const rank = 100 - score
return `${tier}-${rank}`
}
function sortSeenPosts(postA: SeenPost, postB: SeenPost): 0 | 1 | -1 {
const rankA = getRank(postA)
const rankB = getRank(postB)
// Yes, we're comparing strings here.
// The "larger" string means a worse rank.
if (rankA > rankB) {
return 1
} else if (rankA < rankB) {
return -1
} else {
return 0
}
}
function useExperimentalSuggestedUsersQuery() {
const {currentAccount} = useSession()
const userActionSnapshot = userActionHistory.useActionHistorySnapshot()
const dids = useMemo(() => {
const {likes, follows, followSuggestions, seen} = userActionSnapshot
const likeDids = likes
.map(l => new AtUri(l))
.map(uri => uri.host)
.filter(did => !follows.includes(did))
let suggestedDids: string[] = []
if (followSuggestions.length > 0) {
suggestedDids = [
// It's ok if these will pick the same item (weighed by its frequency)
/* eslint-disable react-hooks/purity */
followSuggestions[Math.floor(Math.random() * followSuggestions.length)],
followSuggestions[Math.floor(Math.random() * followSuggestions.length)],
followSuggestions[Math.floor(Math.random() * followSuggestions.length)],
followSuggestions[Math.floor(Math.random() * followSuggestions.length)],
/* eslint-enable react-hooks/purity */
]
}
const seenDids = seen
.sort(sortSeenPosts)
.map(l => new AtUri(l.uri))
.map(uri => uri.host)
return [...new Set([...suggestedDids, ...likeDids, ...seenDids])].filter(
did => did !== currentAccount?.did,
)
}, [userActionSnapshot, currentAccount])
const {data, isLoading, error} = useProfilesQuery({
handles: dids.slice(0, 16),
})
const profiles = data
? data.profiles.filter(profile => {
return !profile.viewer?.following
})
: []
return {
isLoading,
error,
profiles: profiles.slice(0, 6),
}
}
export function SuggestedFollows({feed}: {feed: FeedDescriptor}) {
const {currentAccount} = useSession()
const [feedType, feedUriOrDid] = feed.split('|')
@@ -215,13 +122,14 @@ export function SuggestedFollows({feed}: {feed: FeedDescriptor}) {
}
export function SuggestedFollowsProfile({did}: {did: string}) {
const {profiles, onDismiss, isLoading, error} =
const {profiles, recId, onDismiss, isLoading, error} =
useSuggestedFollowsByActorWithDismiss({did})
return (
<ProfileGrid
isSuggestionsLoading={isLoading}
profiles={profiles}
recId={recId}
error={error}
viewContext="profile"
onDismiss={onDismiss}
@@ -230,11 +138,9 @@ export function SuggestedFollowsProfile({did}: {did: string}) {
}
export function SuggestedFollowsHome() {
const {
isLoading: isSuggestionsLoading,
profiles: experimentalProfiles,
error: experimentalError,
} = useExperimentalSuggestedUsersQuery()
const {isLoading, data, error} = useGetSuggestedUsersForDiscoverQuery()
const profiles = data?.actors
const [dismissedDids, setDismissedDids] = useState<Set<string>>(new Set())
@@ -248,12 +154,12 @@ export function SuggestedFollowsHome() {
recId?: string
}> = []
for (const profile of experimentalProfiles) {
result.push({actor: profile, recId: undefined})
for (const profile of profiles ?? []) {
result.push({actor: profile, recId: data?.recId})
}
return result
}, [experimentalProfiles])
}, [data?.recId, profiles])
const filteredProfiles = useMemo(() => {
return allProfiles.filter(p => !dismissedDids.has(p.actor.did))
@@ -261,10 +167,10 @@ export function SuggestedFollowsHome() {
return (
<ProfileGrid
isSuggestionsLoading={isSuggestionsLoading}
isSuggestionsLoading={isLoading}
profiles={filteredProfiles}
totalProfileCount={allProfiles.length}
error={experimentalError}
error={error}
viewContext="feed"
onDismiss={onDismiss}
/>
@@ -275,6 +181,7 @@ export function ProfileGrid({
isSuggestionsLoading,
error,
profiles,
recId,
totalProfileCount,
viewContext = 'feed',
onDismiss,
@@ -283,6 +190,7 @@ export function ProfileGrid({
}: {
isSuggestionsLoading: boolean
profiles: {actor: bsky.profile.AnyProfileView; recId?: string}[]
recId?: string
totalProfileCount?: number
error: Error | null
viewContext: 'profile' | 'profileHeader' | 'feed'
@@ -292,7 +200,7 @@ export function ProfileGrid({
}) {
const t = useTheme()
const ax = useAnalytics()
const {_} = useLingui()
const {t: l} = useLingui()
const moderationOpts = useModerationOpts()
const {gtMobile} = useBreakpoints()
const followDialogControl = useDialogControl()
@@ -309,10 +217,10 @@ export function ProfileGrid({
const containerRef = useRef<View>(null)
const hasTrackedRef = useRef(false)
const logContext: Metrics['suggestedUser:seen']['logContext'] = isFeedContext
? 'InterstitialDiscover'
? 'DiscoverInterstitial'
: isProfileHeaderContext
? 'Profile'
: 'InterstitialProfile'
? 'ProfileHeader'
: 'ProfileInterstitial'
// Callback to fire seen events
const fireSeen = useCallback(() => {
@@ -333,7 +241,7 @@ export function ProfileGrid({
})
}
})
}, [ax, isLoading, error, profiles, maxLength, logContext])
}, [isLoading, error, profiles, maxLength, ax, logContext])
// For profile header, fire when isVisible becomes true
useEffect(() => {
@@ -421,9 +329,7 @@ export function ProfileGrid({
profile={profile.actor}
onPress={() => {
ax.metric('suggestedUser:press', {
logContext: isFeedContext
? 'InterstitialDiscover'
: 'InterstitialProfile',
logContext,
recId: profile.recId,
position: index,
suggestedDid: profile.actor.did,
@@ -439,14 +345,12 @@ export function ProfileGrid({
<ProfileCard.Outer>
{onDismiss && (
<Button
label={_(msg`Dismiss this suggestion`)}
label={l`Dismiss this suggestion`}
onPress={e => {
e.preventDefault()
onDismiss(profile.actor.did)
ax.metric('suggestedUser:dismiss', {
logContext: isFeedContext
? 'InterstitialDiscover'
: 'InterstitialProfile',
logContext,
position: index,
suggestedDid: profile.actor.did,
recId: profile.recId,
@@ -512,10 +416,8 @@ export function ProfileGrid({
style={[a.rounded_sm]}
onFollow={() => {
ax.metric('suggestedUser:follow', {
logContext: isFeedContext
? 'InterstitialDiscover'
: 'InterstitialProfile',
location: 'Card',
logContext,
location: 'Profile',
recId: profile.recId,
position: index,
suggestedDid: profile.actor.did,
@@ -565,35 +467,32 @@ export function ProfileGrid({
<Text style={[a.text_sm, a.font_semi_bold, t.atoms.text]}>
<Trans>Suggested for you</Trans>
</Text>
{!isProfileHeaderContext && (
<Button
label={_(msg`See more suggested profiles`)}
onPress={() => {
followDialogControl.open()
ax.metric('suggestedUser:seeMore', {
logContext: isFeedContext ? 'Explore' : 'Profile',
})
}}>
{({hovered}) => (
<Text
style={[
a.text_sm,
{color: t.palette.primary_500},
hovered &&
web({
textDecorationLine: 'underline',
textDecorationColor: t.palette.primary_500,
}),
]}>
<Trans>See more</Trans>
</Text>
)}
</Button>
)}
<Button
label={l`See more suggested profiles`}
onPress={() => {
followDialogControl.open()
ax.metric('suggestedUser:seeMore', {
logContext,
recId,
})
}}>
{({hovered}) => (
<Text
style={[
a.text_sm,
{color: t.palette.primary_500},
hovered &&
web({
textDecorationLine: 'underline',
textDecorationColor: t.palette.primary_500,
}),
]}>
<Trans>See more</Trans>
</Text>
)}
</Button>
</View>
<FollowDialogWithoutGuide control={followDialogControl} />
<LayoutAnimationConfig skipExiting skipEntering>
{gtMobile ? (
<View style={[a.p_lg, a.pt_md]}>
@@ -611,16 +510,14 @@ export function ProfileGrid({
decelerationRate="fast">
{content}
{!isProfileHeaderContext && (
<SeeMoreSuggestedProfilesCard
onPress={() => {
followDialogControl.open()
ax.metric('suggestedUser:seeMore', {
logContext: 'Explore',
})
}}
/>
)}
<SeeMoreSuggestedProfilesCard
onPress={() => {
followDialogControl.open()
ax.metric('suggestedUser:seeMore', {
logContext,
})
}}
/>
</ScrollView>
</BlockDrawerGesture>
)}
@@ -630,11 +527,11 @@ export function ProfileGrid({
}
function SeeMoreSuggestedProfilesCard({onPress}: {onPress: () => void}) {
const {_} = useLingui()
const {t: l} = useLingui()
return (
<Button
label={_(msg`Browse more accounts`)}
label={l`Browse more accounts`}
onPress={onPress}
style={[
a.flex_col,
@@ -658,7 +555,7 @@ const numFeedsToDisplay = 3
export function SuggestedFeeds() {
const t = useTheme()
const ax = useAnalytics()
const {_} = useLingui()
const {t: l} = useLingui()
const {data, isLoading, error} = useGetPopularFeedsQuery({
limit: numFeedsToDisplay,
})
@@ -745,7 +642,7 @@ export function SuggestedFeeds() {
a.gap_md,
]}>
<InlineLinkText
label={_(msg`Browse more suggestions`)}
label={l`Browse more suggestions`}
to="/search"
style={[t.atoms.text_contrast_medium]}>
<Trans>Browse more suggestions</Trans>
@@ -764,7 +661,7 @@ export function SuggestedFeeds() {
{content}
<Button
label={_(msg`Browse more feeds on the Explore page`)}
label={l`Browse more feeds on the Explore page`}
onPress={() => {
navigation.navigate('SearchTab')
}}
+3 -2
View File
@@ -27,6 +27,7 @@ export function NewskieDialog({
profile: AppBskyActorDefs.ProfileViewDetailed
disabled?: boolean
}) {
const t = useTheme()
const {_} = useLingui()
const control = useDialogControl()
@@ -52,7 +53,7 @@ export function NewskieDialog({
{({hovered, pressed}) => (
<Newskie
size="lg"
fill="#FFC404"
fill={t.palette.yellow}
style={{
opacity: hovered || pressed ? 0.5 : 1,
}}
@@ -132,7 +133,7 @@ function DialogInner({
<Newskie
width={64}
height={64}
fill="#FFC404"
fill={t.palette.yellow}
style={[a.absolute, a.inset_0]}
/>
</View>
@@ -13,6 +13,7 @@ import {Mute_Stroke2_Corner0_Rounded as MuteIcon} from '#/components/icons/Mute'
import {Pause_Filled_Corner0_Rounded as PauseIcon} from '#/components/icons/Pause'
import {Play_Filled_Corner0_Rounded as PlayIcon} from '#/components/icons/Play'
import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as UnmuteIcon} from '#/components/icons/Speaker'
import {KeepAwake} from '#/components/KeepAwake'
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
import {useVideoMuteState} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext'
import {GifPresentationControls} from '../GifPresentationControls'
@@ -112,6 +113,7 @@ export function VideoEmbedInnerNative({
/>
)}
<MediaInsetBorder />
<KeepAwake enabled={isPlaying} />
</View>
)
}
@@ -136,6 +136,8 @@ export const BookmarkButton = memo(function BookmarkButton({
<PostControlButton
testID="postBookmarkBtn"
big={big}
active={isBookmarked}
activeColor={t.palette.primary_500}
label={
isBookmarked
? _(msg`Remove from saved posts`)
@@ -143,10 +145,7 @@ export const BookmarkButton = memo(function BookmarkButton({
}
onPress={onHandlePress}
hitSlop={hitSlop}>
<PostControlButtonIcon
fill={isBookmarked ? t.palette.primary_500 : undefined}
icon={isBookmarked ? BookmarkFilled : Bookmark}
/>
<PostControlButtonIcon icon={isBookmarked ? BookmarkFilled : Bookmark} />
</PostControlButton>
)
})
@@ -130,8 +130,11 @@ export function PostControlButtonText({style, ...props}: TextProps) {
<Text
style={[
color,
a.user_select_none,
big ? a.text_md : a.text_sm,
active && a.font_semi_bold,
// prevent layout shift on android
{includeFontPadding: false, textAlignVertical: 'center'},
style,
]}
{...props}
+11 -4
View File
@@ -24,7 +24,7 @@ import {
ProgressGuideAction,
useProgressGuideControls,
} from '#/state/shell/progress-guide'
import {atoms as a, useBreakpoints} from '#/alf'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {Reply as Bubble} from '#/components/icons/Reply'
import {useFormatPostStatCount} from '#/components/PostControls/util'
import * as Skele from '#/components/Skeleton'
@@ -74,6 +74,7 @@ let PostControls = ({
forceGoogleTranslate?: boolean
}): React.ReactNode => {
const ax = useAnalytics()
const t = useTheme()
const {t: l} = useLingui()
const {openComposer} = useOpenComposer()
const {feedDescriptor} = useFeedFeedbackContext()
@@ -270,6 +271,8 @@ let PostControls = ({
<PostControlButton
testID="likeBtn"
big={big}
active={Boolean(post.viewer?.like)}
activeColor={t.palette.pink}
onPress={() => requireAuth(() => onPressToggleLike())}
label={
post.viewer?.like
@@ -296,10 +299,14 @@ let PostControls = ({
hasBeenToggled={hasLikeIconBeenToggled}
/>
<CountWheel
likeCount={post.likeCount ?? 0}
big={big}
isLiked={Boolean(post.viewer?.like)}
count={post.likeCount ?? 0}
isToggled={Boolean(post.viewer?.like)}
hasBeenToggled={hasLikeIconBeenToggled}
renderCount={({count}) => (
<PostControlButtonText>
{formatPostStatCount(count)}
</PostControlButtonText>
)}
/>
</PostControlButton>
</View>
+15 -6
View File
@@ -1,7 +1,7 @@
import {View} from 'react-native'
import {useWindowDimensions, View} from 'react-native'
import {useProfileShadow} from '#/state/cache/profile-shadow'
import {atoms as a, type ViewStyleProp} from '#/alf'
import {atoms as a, useAlf, type ViewStyleProp} from '#/alf'
import {BotBadge, BotBadgeButton, isBotAccount} from '#/components/BotBadge'
import {useSimpleVerificationState} from '#/components/verification'
import {VerificationCheck} from '#/components/verification/VerificationCheck'
@@ -38,12 +38,21 @@ export function ProfileBadges({
}) {
const shadowed = useProfileShadow(profile)
const verification = useSimpleVerificationState({profile})
const {fontScale: nativeScaleMultiplier} = useWindowDimensions()
const {
fonts: {scaleMultiplier: alfScaleMultiplier},
} = useAlf()
// if nothing to show, don't render the container at all
if (!verification.showBadge && !isBotAccount(shadowed)) return null
const isOnTheSmallSide = size === 'xs' || size === 'sm'
const verificationIconWidth =
verificationIconSizes[size] * nativeScaleMultiplier * alfScaleMultiplier
const botIconWidth =
botIconSizes[size] * nativeScaleMultiplier * alfScaleMultiplier
return (
<View
style={[
@@ -56,19 +65,19 @@ export function ProfileBadges({
<>
<VerificationCheckButton
profile={shadowed}
width={verificationIconSizes[size]}
width={verificationIconWidth}
/>
<BotBadgeButton profile={shadowed} width={botIconSizes[size]} />
<BotBadgeButton profile={shadowed} width={botIconWidth} />
</>
) : (
<>
{verification.showBadge && (
<VerificationCheck
verifier={verification.role === 'verifier'}
width={verificationIconSizes[size]}
width={verificationIconWidth}
/>
)}
<BotBadge profile={shadowed} width={botIconSizes[size]} />
<BotBadge profile={shadowed} width={botIconWidth} />
</>
)}
</View>
+64 -37
View File
@@ -1,16 +1,14 @@
import {memo, useCallback, useEffect, useMemo, useRef, useState} from 'react'
import {TextInput, View, type ViewToken} from 'react-native'
import {type ModerationOpts} from '@atproto/api'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {Trans, useLingui} from '@lingui/react/macro'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {popularInterests, useInterestsDisplayNames} from '#/lib/interests'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useActorSearch} from '#/state/queries/actor-search'
import {usePreferencesQuery} from '#/state/queries/preferences'
import {useGetSuggestedUsersQuery} from '#/state/queries/trending/useGetSuggestedUsersQuery'
import {useGetSuggestedUsersForSeeMoreQuery} from '#/state/queries/trending/useGetSuggestedUsersForSeeMoreQuery'
import {useSession} from '#/state/session'
import {type Follow10ProgressGuide} from '#/state/shell/progress-guide'
import {type ListMethods} from '#/view/com/util/List'
@@ -64,14 +62,14 @@ export function FollowDialog({
showArrow?: boolean
}) {
const ax = useAnalytics()
const {_} = useLingui()
const {t: l} = useLingui()
const control = Dialog.useDialogControl()
const {gtPhone} = useBreakpoints()
return (
<>
<Button
label={_(msg`Find people to follow`)}
label={l`Find people to follow`}
onPress={() => {
control.open()
ax.metric('progressGuide:followDialog:open', {})
@@ -112,7 +110,7 @@ let lastSelectedInterest = ''
let lastSearchText = ''
function DialogInner({guide}: {guide?: Follow10ProgressGuide}) {
const {_} = useLingui()
const {t: l} = useLingui()
const ax = useAnalytics()
const interestsDisplayNames = useInterestsDisplayNames()
const {data: preferences} = usePreferencesQuery()
@@ -143,7 +141,7 @@ function DialogInner({guide}: {guide?: Follow10ProgressGuide}) {
data: suggestions,
isFetching: isFetchingSuggestions,
error: suggestionsError,
} = useGetSuggestedUsersQuery({
} = useGetSuggestedUsersForSeeMoreQuery({
category: selectedInterest,
limit: 50,
})
@@ -182,7 +180,7 @@ function DialogInner({guide}: {guide?: Follow10ProgressGuide}) {
_items.push({
type: 'empty',
key: 'empty',
message: _(msg`We're having network issues, try again`),
message: l`We're having network issues, try again`,
})
} else {
const seen = new Set<string>()
@@ -208,12 +206,12 @@ function DialogInner({guide}: {guide?: Follow10ProgressGuide}) {
!_items.length &&
!isSearchResultsError
) {
_items.push({type: 'empty', key: 'empty', message: _(msg`No results`)})
_items.push({type: 'empty', key: 'empty', message: l`No results`})
}
return _items
}, [
_,
l,
suggestions,
suggestionsError,
isFetchingSuggestions,
@@ -226,6 +224,9 @@ function DialogInner({guide}: {guide?: Follow10ProgressGuide}) {
isSearchResultsError,
])
const isGuide = Boolean(guide)
const recIdForLogging = hasSearchText ? undefined : suggestions?.recId
const renderItems = useCallback(
({item, index}: {item: Item; index: number}) => {
switch (item.type) {
@@ -235,6 +236,9 @@ function DialogInner({guide}: {guide?: Follow10ProgressGuide}) {
profile={item.profile}
moderationOpts={moderationOpts!}
noBorder={index === 0}
position={index}
recId={recIdForLogging}
isGuide={isGuide}
/>
)
}
@@ -248,7 +252,7 @@ function DialogInner({guide}: {guide?: Follow10ProgressGuide}) {
return null
}
},
[moderationOpts],
[moderationOpts, recIdForLogging, isGuide],
)
// Track seen profiles
@@ -269,8 +273,8 @@ function DialogInner({guide}: {guide?: Follow10ProgressGuide}) {
i => i.type === 'profile' && i.profile.did === item.profile.did,
)
ax.metric('suggestedUser:seen', {
logContext: 'ProgressGuide',
recId: hasSearchText ? undefined : suggestions?.recId,
logContext: isGuide ? 'ProgressGuide' : 'SeeMoreSuggestedUsers',
recId: recIdForLogging,
position: position !== -1 ? position : 0,
suggestedDid: item.profile.did,
category: selectedInterestRef.current,
@@ -404,7 +408,7 @@ let Header = ({
Header = memo(Header)
function HeaderTop({guide}: {guide?: Follow10ProgressGuide}) {
const {_} = useLingui()
const {t: l} = useLingui()
const t = useTheme()
const control = Dialog.useDialogContext()
return (
@@ -438,7 +442,7 @@ function HeaderTop({guide}: {guide?: Follow10ProgressGuide}) {
)}
{IS_WEB ? (
<Button
label={_(msg`Close`)}
label={l`Close`}
size="small"
shape="round"
variant={IS_WEB ? 'ghost' : 'solid'}
@@ -474,22 +478,18 @@ let Tab = ({
onLayout: (index: number, x: number, width: number) => void
}): React.ReactNode => {
const t = useTheme()
const {_} = useLingui()
const {t: l} = useLingui()
const label = active
? _(
msg({
message: `Search for "${interestsDisplayName}" (active)`,
comment:
'Accessibility label for a tab that searches for accounts in a category (e.g. Art, Video Games, Sports, etc.) that are suggested for the user to follow. The tab is currently selected.',
}),
)
: _(
msg({
message: `Search for "${interestsDisplayName}"`,
comment:
'Accessibility label for a tab that searches for accounts in a category (e.g. Art, Video Games, Sports, etc.) that are suggested for the user to follow. The tab is not currently active and can be selected.',
}),
)
? l({
message: `Search for "${interestsDisplayName}" (active)`,
comment:
'Accessibility label for a tab that searches for accounts in a category (e.g. Art, Video Games, Sports, etc.) that are suggested for the user to follow. The tab is currently selected.',
})
: l({
message: `Search for "${interestsDisplayName}"`,
comment:
'Accessibility label for a tab that searches for accounts in a category (e.g. Art, Video Games, Sports, etc.) that are suggested for the user to follow. The tab is not currently active and can be selected.',
})
return (
<View
key={interest}
@@ -532,16 +532,25 @@ let FollowProfileCard = ({
profile,
moderationOpts,
noBorder,
position,
recId,
isGuide,
}: {
profile: bsky.profile.AnyProfileView
moderationOpts: ModerationOpts
noBorder?: boolean
position: number
recId?: string
isGuide: boolean
}): React.ReactNode => {
return (
<FollowProfileCardInner
profile={profile}
moderationOpts={moderationOpts}
noBorder={noBorder}
position={position}
recId={recId}
isGuide={isGuide}
/>
)
}
@@ -552,14 +561,21 @@ function FollowProfileCardInner({
moderationOpts,
onFollow,
noBorder,
position,
recId,
isGuide,
}: {
profile: bsky.profile.AnyProfileView
moderationOpts: ModerationOpts
onFollow?: () => void
noBorder?: boolean
position: number
recId?: string
isGuide: boolean
}) {
const control = Dialog.useDialogContext()
const t = useTheme()
const ax = useAnalytics()
return (
<ProfileCard.Link
profile={profile}
@@ -588,7 +604,19 @@ function FollowProfileCardInner({
moderationOpts={moderationOpts}
logContext="PostOnboardingFindFollows"
shape="round"
onPress={onFollow}
onPress={() => {
ax.metric('suggestedUser:follow', {
logContext: isGuide
? 'ProgressGuide'
: 'SeeMoreSuggestedUsers',
location: 'Card',
recId,
position,
suggestedDid: profile.did,
category: null,
})
onFollow?.()
}}
colorInverted
/>
</ProfileCard.Header>
@@ -632,7 +660,7 @@ function SearchInput({
defaultValue: string
}) {
const t = useTheme()
const {_} = useLingui()
const {t: l} = useLingui()
const {
state: hovered,
onIn: onMouseEnter,
@@ -652,10 +680,9 @@ function SearchInput({
size="md"
fill={interacted ? t.palette.primary_500 : t.palette.contrast_300}
/>
<TextInput
ref={inputRef}
placeholder={_(msg`Search by name or interest`)}
placeholder={l`Search by name or interest`}
defaultValue={defaultValue}
onChangeText={onChangeText}
onFocus={onFocus}
@@ -674,8 +701,8 @@ function SearchInput({
autoCorrect={false}
autoComplete="off"
autoCapitalize="none"
accessibilityLabel={_(msg`Search profiles`)}
accessibilityHint={_(msg`Searches for profiles`)}
accessibilityLabel={l`Search profiles`}
accessibilityHint={l`Searches for profiles`}
/>
</View>
)
+2 -3
View File
@@ -3,8 +3,7 @@ import {View} from 'react-native'
import {Select as RadixSelect} from 'radix-ui'
import {useA11y} from '#/state/a11y'
import {flatten, useTheme, web} from '#/alf'
import {atoms as a} from '#/alf'
import {atoms as a, flatten, useTheme, web} from '#/alf'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import {Check_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check'
import {
@@ -109,7 +108,7 @@ export function Trigger({children, label}: TriggerProps) {
borderRadius: 10,
maxWidth: 400,
outline: 0,
borderWidth: 2,
borderWidth: 1,
borderStyle: 'solid',
borderColor: focused
? t.palette.primary_500
@@ -46,6 +46,7 @@ export const PostsList = forwardRef<SectionRef, ProfilesListProps>(
return (
<View>
<PostFeed
enabled
feed={feed}
pollInterval={60e3}
scrollElRef={scrollElRef}
+5 -1
View File
@@ -1,6 +1,6 @@
import {View} from 'react-native'
import {atoms as a, useTheme, type ViewStyleProp} from '#/alf'
import {atoms as a, useTheme, type ViewStyleProp, web as webOnly} from '#/alf'
import {IS_NATIVE, IS_WEB, IS_WEB_TOUCH_DEVICE} from '#/env'
export function SubtleHover({
@@ -33,6 +33,10 @@ export function SubtleHover({
a.transition_opacity,
t.atoms.bg_contrast_50,
style,
// Force Safari to composite the overlay on its own GPU layer.
// This fixes a layout shift that happens due to different subpixel
// rounding when the overlay is composited on hover.
webOnly({willChange: 'opacity'}),
{opacity: hover ? opacity : 0},
]}
/>
+37 -11
View File
@@ -1,7 +1,7 @@
import {UITextView} from 'react-native-uitextview'
import {logger} from '#/logger'
import {atoms, useAlf, useTheme, web} from '#/alf'
import {atoms as a, type TextStyleProp, useAlf, useTheme, web} from '#/alf'
import {
childHasEmoji,
normalizeTextStyles,
@@ -22,15 +22,24 @@ export function Text({
selectable,
title,
dataSet,
numberOfLines,
...rest
}: TextProps) {
const {fonts, flags} = useAlf()
const t = useTheme()
const s = normalizeTextStyles([atoms.text_sm, t.atoms.text, style], {
fontScale: fonts.scaleMultiplier,
fontFamily: fonts.family,
flags,
})
const s = normalizeTextStyles(
[
a.text_sm,
t.atoms.text,
web(numberOfLines === 1 && numberOfLinesClippingFix),
style,
],
{
fontScale: fonts.scaleMultiplier,
fontFamily: fonts.family,
flags,
},
)
if (__DEV__) {
if (!emoji && childHasEmoji(children)) {
@@ -44,6 +53,7 @@ export function Text({
const shared = {
uiTextView: true,
selectable,
numberOfLines,
style: s,
dataSet: Object.assign({tooltip: title}, dataSet || {}),
...rest,
@@ -82,10 +92,26 @@ export function P({style, ...rest}: TextProps) {
role: 'paragraph',
}) || {}
return (
<Text
{...attr}
{...rest}
style={[atoms.text_md, atoms.leading_relaxed, style]}
/>
<Text {...attr} {...rest} style={[a.text_md, a.leading_relaxed, style]} />
)
}
/**
* HACKFIX: React Native Web applies `overflow: hidden` to
* text when using the `numberOfLines` prop, which causes it to clip
* ascenders/descenders. It only needs to be doing this for the X axis,
* so override the style with `overflowX: 'hidden'`.
* Note this only works for `numberOfLines={1}` -sfn
*
* @see https://github.com/necolas/react-native-web/pull/2836
*/
const numberOfLinesClippingFix = {
overflowY: 'visible',
overflowX: 'clip',
// mimic browser default behavior of `min-width: 0` on `overflow: hidden`
// elements to allow text to shrink smaller than its intrinsic width when
// necessary
minWidth: 0,
// this is neater and supports vertical writing modes, but it's only baseline newly available
// overflowInline: 'clip',
} satisfies React.CSSProperties as TextStyleProp
@@ -1,6 +1,6 @@
import {useState} from 'react'
import {View} from 'react-native'
import {XRPCError} from '@atproto/xrpc'
import {XRPCError} from '@atproto/api'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
+1 -1
View File
@@ -1,5 +1,5 @@
import {useEffect} from 'react'
import EventEmitter from 'eventemitter3'
import {EventEmitter} from 'eventemitter3'
const events = new EventEmitter<{
emailVerified: void
+169
View File
@@ -0,0 +1,169 @@
import {useCallback, useEffect} from 'react'
import {type ScrollView, View} from 'react-native'
import Animated, {useAnimatedRef, useSharedValue} from 'react-native-reanimated'
import {moderateProfile} from '@atproto/api'
import {useLingui} from '@lingui/react/macro'
import {HITSLOP_10} from '#/lib/constants'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {DraggableScrollView} from '#/view/com/pager/DraggableScrollView'
import {atoms as a, useTheme} from '#/alf'
import {Button} from '#/components/Button'
import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
import * as ProfileCard from '#/components/ProfileCard'
import {Text} from '#/components/Typography'
import type * as bsky from '#/types/bsky'
type Props = {
testID?: string
profiles: bsky.profile.AnyProfileView[]
onRemove?: (did: string) => void
}
export function ChatProfileTabs({testID, profiles, onRemove}: Props) {
const t = useTheme()
const scrollElRef = useAnimatedRef<ScrollView>()
const contentSize = useSharedValue(0)
const scrollX = useSharedValue(0)
useEffect(() => {
requestAnimationFrame(() => {
// Scroll to the end of the list when `profiles` changes.
scrollElRef.current?.scrollToEnd({animated: true})
})
}, [profiles, scrollElRef])
return (
<View testID={testID} accessibilityRole="list" style={[t.atoms.bg]}>
<DraggableScrollView
ref={scrollElRef}
testID={`${testID}-selector`}
horizontal={true}
showsHorizontalScrollIndicator={false}
onScroll={e => {
scrollX.set(Math.round(e.nativeEvent.contentOffset.x))
}}>
<Animated.View
style={[
a.flex_row,
a.flex_grow,
a.gap_sm,
a.align_center,
a.justify_start,
]}
onLayout={e => {
contentSize.set(e.nativeEvent.layout.width)
}}>
{profiles.map((profile, index) => (
<Tab
key={profile.did}
testID={testID}
index={index}
profile={profile}
total={profiles.length}
onRemove={onRemove}
/>
))}
</Animated.View>
</DraggableScrollView>
</View>
)
}
function Tab({
testID,
index,
profile,
total,
onRemove,
}: {
testID?: string
index: number
profile: bsky.profile.AnyProfileView
total: number
onRemove?: (did: string) => void
}) {
const t = useTheme()
const {t: l} = useLingui()
const moderationOpts = useModerationOpts()
const moderation = moderateProfile(profile, moderationOpts!)
const displayName = sanitizeDisplayName(
profile.displayName || sanitizeHandle(profile.handle),
moderation.ui('displayName'),
)
const onPressItem = useCallback(
(did: string) => {
onRemove?.(did)
},
[onRemove],
)
return (
<View
testID={`${testID}-selector-${profile.did}`}
style={[
a.flex_row,
a.align_center,
a.border,
a.justify_center,
a.rounded_lg,
a.pl_xs,
a.pr_sm,
a.py_xs,
t.atoms.border_contrast_low,
t.atoms.bg,
index === 0 ? a.ml_lg : index === total - 1 ? a.mr_lg : null,
]}>
{moderationOpts ? (
<>
<ProfileCard.Avatar
profile={profile}
moderationOpts={moderationOpts}
size={24}
disabledPreview
/>
<View style={[a.flex_row, a.align_center, a.max_w_full, a.ml_xs]}>
<Text
emoji
style={[
a.text_sm,
a.font_normal,
a.leading_snug,
a.self_start,
a.flex_shrink,
t.atoms.text,
]}
numberOfLines={1}>
{displayName}
</Text>
</View>
</>
) : (
<>
<ProfileCard.AvatarPlaceholder size={24} />
<ProfileCard.NamePlaceholder />
</>
)}
<Button
hitSlop={HITSLOP_10}
label={l`Remove ${displayName} from group chat`}
style={[a.ml_xs]}
onPress={() => onPressItem(profile.did)}>
{({hovered, pressed, focused}) => (
<XIcon
size="sm"
style={[
hovered || pressed || focused
? t.atoms.text
: t.atoms.text_contrast_high,
]}
/>
)}
</Button>
</View>
)
}
File diff suppressed because it is too large Load Diff
+27 -12
View File
@@ -1,7 +1,5 @@
import {useCallback} from 'react'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {Trans, useLingui} from '@lingui/react/macro'
import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification'
import {logger} from '#/logger'
@@ -10,6 +8,7 @@ import {FAB} from '#/view/com/util/fab/FAB'
import {useTheme} from '#/alf'
import * as Dialog from '#/components/Dialog'
import {SearchablePeopleList} from '#/components/dialogs/SearchablePeopleList'
import {InitiateChatFlow} from '#/components/dms/InitiateChatFlow'
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
import * as Toast from '#/components/Toast'
import {useAnalytics} from '#/analytics'
@@ -22,10 +21,12 @@ export function NewChat({
onNewChat: (chatId: string) => void
}) {
const t = useTheme()
const {_} = useLingui()
const {t: l} = useLingui()
const ax = useAnalytics()
const requireEmailVerification = useRequireEmailVerification()
const isGroupChatEnabled = ax.features.enabled(ax.features.GroupChatsEnable)
const {mutate: createChat} = useGetConvoForMembers({
onSuccess: data => {
onNewChat(data.convo.id)
@@ -37,7 +38,7 @@ export function NewChat({
},
onError: error => {
logger.error('Failed to create chat', {safeMessage: error})
Toast.show(_(msg`An issue occurred starting the chat`), {
Toast.show(l`An issue occurred starting the chat`, {
type: 'error',
})
},
@@ -50,6 +51,13 @@ export function NewChat({
[control, createChat],
)
const onCreateGroupChat = useCallback(
(_dids: string[], _groupName: string) => {
control.close()
},
[control],
)
const onPress = useCallback(() => {
control.open()
}, [control])
@@ -68,20 +76,27 @@ export function NewChat({
onPress={wrappedOnPress}
icon={<Plus size="lg" fill={t.palette.white} />}
accessibilityRole="button"
accessibilityLabel={_(msg`New chat`)}
accessibilityLabel={l`New chat`}
accessibilityHint=""
/>
<Dialog.Outer
control={control}
testID="newChatDialog"
nativeOptions={{fullHeight: true}}>
<Dialog.Handle />
<SearchablePeopleList
title={_(msg`Start a new chat`)}
onSelectChat={onCreateChat}
sortByMessageDeclaration
/>
{isGroupChatEnabled ? (
<InitiateChatFlow
title={l`New chat`}
onSelectChat={onCreateChat}
onSelectGroupChat={onCreateGroupChat}
/>
) : (
<SearchablePeopleList
title={l`Start a new chat`}
onSelectChat={onCreateChat}
sortByMessageDeclaration
/>
)}
</Dialog.Outer>
</>
)
+166
View File
@@ -0,0 +1,166 @@
import {useMemo, useRef, useState} from 'react'
import {
TextInput,
type TextInputContentSizeChangeEvent,
type TextInputProps,
} from 'react-native'
import {mergeRefs} from '#/lib/merge-refs'
import {atoms as a, extractPadding, useAlf, web} from '#/alf'
import {normalizeTextStyles} from '#/alf/typography'
import {IS_ANDROID, IS_IOS, IS_WEB} from '#/env'
export type AutosizedTextareaProps = Omit<TextInputProps, 'multiline'> & {
ref?: React.Ref<TextInput>
label: string
minRows?: number
maxRows?: number
onUpdateHeight?: (height: number) => void
}
export function AutosizedTextarea({
ref,
label,
minRows = 1,
maxRows,
onUpdateHeight,
onChangeText: onChangeTextOuter,
onContentSizeChange: onContentSizeChangeOuter,
style: outerStyle,
...rest
}: AutosizedTextareaProps) {
const {theme: t, fonts} = useAlf()
const internalRef = useRef<TextInput>(null)
const {style, minInputHeight, maxInputHeight, verticalContentPadding} =
useMemo(() => {
const normalizedStyles = normalizeTextStyles(
[a.text_md, a.leading_snug, t.atoms.text, outerStyle],
{
fontScale: fonts.scaleMultiplier,
fontFamily: fonts.family,
flags: {},
},
)
const lineHeight = normalizedStyles.lineHeight || 20
const {paddingTop, paddingBottom} = extractPadding(normalizedStyles ?? {})
const verticalContentPadding = paddingTop + paddingBottom
const minInputHeight = lineHeight * minRows + verticalContentPadding
const maxInputHeight = maxRows
? lineHeight * maxRows + verticalContentPadding
: Infinity
/*
* iOS: minHeight/maxHeight works fine natively.
* Web + Android: we set an explicit initial height and resize dynamically
* (web via DOM measurement, Android via onContentSizeChange state).
*
* iOS also seems to need 1px headroom to actually expand to the correct
* maxHeight
*/
const heightConstraints = IS_IOS
? {minHeight: minInputHeight, maxHeight: maxInputHeight + 1}
: {height: minInputHeight}
return {
style: {
...normalizedStyles,
...heightConstraints,
},
minInputHeight,
maxInputHeight,
verticalContentPadding,
}
}, [t, fonts, outerStyle, minRows, maxRows])
/*
* Web handling
*/
const prevWebHeight = useRef(0)
const handleResizeWeb = () => {
const el = internalRef.current as unknown as HTMLTextAreaElement
if (!el) return
// collapse to get natural scroll height
el.style.height = '0px'
const scrollHeight = Math.ceil(el.scrollHeight)
const nextHeight = Math.min(
Math.max(scrollHeight, minInputHeight),
maxInputHeight,
)
// immediately update height to prevent flicker
el.style.height = `${nextHeight}px`
el.style.overflowY = scrollHeight > maxInputHeight ? 'auto' : 'hidden'
if (nextHeight !== prevWebHeight.current) {
prevWebHeight.current = nextHeight
onUpdateHeight?.(nextHeight)
}
}
const onChangeText = (text: string) => {
if (IS_WEB) handleResizeWeb()
onChangeTextOuter?.(text)
}
/*
* Native handling
*
* We track the height as state on native, and on Android, we use this to
* directly drive the `height`.
*/
const [nativeHeight, setNativeHeight] = useState(minInputHeight)
const onContentSizeChange = (e: TextInputContentSizeChangeEvent) => {
const contentSize = Math.ceil(e.nativeEvent.contentSize.height)
// ios reports the content size without padding
const height = IS_IOS ? contentSize + verticalContentPadding : contentSize
const nextHeight = Math.min(
Math.max(height, minInputHeight),
maxInputHeight,
)
if (nextHeight !== nativeHeight) {
setNativeHeight(nextHeight)
onUpdateHeight?.(nextHeight)
}
onContentSizeChangeOuter?.(e)
}
return (
<TextInput
multiline
placeholderTextColor={t.palette.contrast_500}
accessibilityLabel={label}
accessibilityHint={label}
placeholder={label}
keyboardAppearance={t.scheme}
submitBehavior="newline"
scrollEnabled={nativeHeight >= maxInputHeight}
style={[
a.relative,
a.border_0,
{
textAlignVertical: 'top',
includeFontPadding: false,
},
web({
resize: 'none',
outline: 'none',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}),
style,
IS_ANDROID ? {height: nativeHeight} : {},
]}
{...rest}
ref={mergeRefs([
(node: TextInput | null) => {
internalRef.current = node
// bop resize on first render
if (IS_WEB && node) handleResizeWeb()
},
ref,
])}
onChangeText={onChangeText}
onContentSizeChange={onContentSizeChange}
/>
)
}
@@ -63,7 +63,7 @@ export function DateFieldButton({
paddingLeft: 14,
paddingRight: 14,
borderColor: 'transparent',
borderWidth: 2,
borderWidth: 1,
},
native({
paddingTop: 10,
+79 -62
View File
@@ -1,8 +1,10 @@
import {forwardRef} from 'react'
import {useEffect, useRef} from 'react'
import {type TextInput, View} from 'react-native'
import {useLingui} from '@lingui/react/macro'
import {HITSLOP_10} from '#/lib/constants'
import {mergeRefs} from '#/lib/merge-refs'
import {listenFocusSearch} from '#/state/events'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonIcon} from '#/components/Button'
import * as TextField from '#/components/forms/TextField'
@@ -10,73 +12,88 @@ import {MagnifyingGlass_Stroke2_Corner0_Rounded as MagnifyingGlassIcon} from '#/
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
import {IS_NATIVE} from '#/env'
type SearchInputProps = Omit<TextField.InputProps, 'label'> & {
type Props = Omit<TextField.InputProps, 'label'> & {
label?: TextField.InputProps['label']
/**
* Called when the user presses the (X) button
*/
onClearText?: () => void
hotkey?: boolean
ref?: React.Ref<TextInput>
}
export const SearchInput = forwardRef<TextInput, SearchInputProps>(
function SearchInput({value, label, onClearText, ...rest}, ref) {
const t = useTheme()
const {t: l} = useLingui()
const showClear = value && value.length > 0
export function SearchInput({
value,
label,
onClearText,
hotkey,
ref,
...rest
}: Props) {
const t = useTheme()
const {t: l} = useLingui()
const showClear = value && value.length > 0
const internalRef = useRef<TextInput>(null)
return (
<View style={[a.w_full, a.relative]}>
<TextField.Root>
<TextField.Icon icon={MagnifyingGlassIcon} />
<TextField.Input
inputRef={ref}
label={label || l`Search`}
value={value}
placeholder={l`Search`}
returnKeyType="search"
keyboardAppearance={t.scheme}
selectTextOnFocus={IS_NATIVE}
autoFocus={false}
accessibilityRole="search"
autoCorrect={false}
autoComplete="off"
autoCapitalize="none"
style={[
showClear
? {
paddingRight: 24,
}
: {},
]}
{...rest}
/>
</TextField.Root>
useEffect(() => {
if (!hotkey) return
return listenFocusSearch(() => {
internalRef.current?.focus()
})
}, [hotkey])
{showClear && (
<View
style={[
a.absolute,
a.z_20,
a.my_auto,
a.inset_0,
a.justify_center,
a.pr_sm,
{left: 'auto'},
]}>
<Button
testID="searchTextInputClearBtn"
onPress={onClearText}
label={l`Clear search query`}
hitSlop={HITSLOP_10}
size="tiny"
shape="round"
variant="ghost"
color="secondary">
<ButtonIcon icon={X} size="xs" />
</Button>
</View>
)}
</View>
)
},
)
return (
<View style={[a.w_full, a.relative]}>
<TextField.Root>
<TextField.Icon icon={MagnifyingGlassIcon} />
<TextField.Input
inputRef={mergeRefs([internalRef, ref])}
label={label || l`Search`}
value={value}
placeholder={l`Search`}
returnKeyType="search"
keyboardAppearance={t.scheme}
selectTextOnFocus={IS_NATIVE}
autoFocus={false}
accessibilityRole="search"
autoCorrect={false}
autoComplete="off"
autoCapitalize="none"
style={[
showClear
? {
paddingRight: 24,
}
: {},
]}
{...rest}
/>
</TextField.Root>
{showClear && (
<View
style={[
a.absolute,
a.z_20,
a.my_auto,
a.inset_0,
a.justify_center,
a.pr_sm,
{left: 'auto'},
]}>
<Button
testID="searchTextInputClearBtn"
onPress={onClearText}
label={l`Clear search query`}
hitSlop={HITSLOP_10}
size="tiny"
shape="round"
variant="ghost"
color="secondary">
<ButtonIcon icon={X} size="xs" />
</Button>
</View>
)}
</View>
)
}
+2 -2
View File
@@ -115,7 +115,7 @@ export function useSharedInputStyles() {
]
const focus: ViewStyle[] = [
{
backgroundColor: t.palette.contrast_50,
backgroundColor: t.palette.primary_25,
borderColor: t.palette.primary_500,
},
]
@@ -279,7 +279,7 @@ export function createInput(Component: typeof TextInput) {
a.inset_0,
{borderRadius: 10},
t.atoms.bg_contrast_50,
{borderColor: 'transparent', borderWidth: 2},
{borderColor: 'transparent', borderWidth: 1},
ctx.hovered ? chromeHover : {},
ctx.focused ? chromeFocus : {},
ctx.isInvalid || isInvalid ? chromeError : {},
+7 -8
View File
@@ -1,15 +1,14 @@
import {useEffect, useState} from 'react'
export function useDelayedLoading(delay: number, initialState: boolean = true) {
const [isLoading, setIsLoading] = useState(initialState)
export function useDelayedLoading(delay: number, isActuallyLoading: boolean) {
const [isDelayActive, setIsDelayActive] = useState(isActuallyLoading)
useEffect(() => {
let timeout: NodeJS.Timeout
// on initial load, show a loading spinner for a hot sec to prevent flash
if (isLoading) timeout = setTimeout(() => setIsLoading(false), delay)
if (!isDelayActive) return
return () => timeout && clearTimeout(timeout)
}, [isLoading, delay])
const timeout = setTimeout(() => setIsDelayActive(false), delay)
return () => clearTimeout(timeout)
}, [isDelayActive, delay])
return isLoading
return isDelayActive || isActuallyLoading
}
@@ -1,7 +1,7 @@
import {useCallback, useMemo, useState} from 'react'
import {View} from 'react-native'
import {type ComAtprotoLabelDefs, ToolsOzoneReportDefs} from '@atproto/api'
import {XRPCError} from '@atproto/xrpc'
import {XRPCError} from '@atproto/api'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
@@ -184,7 +184,7 @@ function Inner(props: ReportDialogProps) {
)
})
}, [
props,
props.subject,
allLabelers,
state.selectedOption,
isBskyOnlyReason,
@@ -241,7 +241,17 @@ function Inner(props: ReportDialogProps) {
} finally {
setPending(false)
}
}, [_, submitReport, state, dispatch, props, setPending, setSuccess])
}, [
_,
submitReport,
state,
dispatch,
props.subject,
props.control,
props.onAfterSubmit,
setPending,
setSuccess,
])
useCallOnce(() => {
ax.metric('reportDialog:open', {
+7 -2
View File
@@ -10,6 +10,10 @@ const iOSMajorVersion =
Platform.OS === 'ios' && typeof Platform.Version === 'string'
? parseInt(Platform.Version.split('.')[0], 10)
: 0
const androidPlatformVersion =
Platform.OS === 'android' && typeof Platform.Version === 'number'
? Platform.Version
: 0
/**
* The semver version of the app, specified in our `package.json`.file. On
@@ -50,5 +54,6 @@ export const IS_HIGH_DPI: boolean = true
// ideally we'd use isLiquidGlassAvailable() from expo-glass-effect but checking iOS version is good enough for now
export const IS_LIQUID_GLASS: boolean = iOSMajorVersion >= 26
// So we can avoid attempting on-device translation when we know it's unsupported.
export const HAS_ON_DEVICE_TRANSLATION: boolean =
(IS_IOS && iOSMajorVersion >= 18) || IS_ANDROID
export const IS_TRANSLATION_SUPPORTED: boolean =
(IS_IOS && iOSMajorVersion >= 18) ||
(IS_ANDROID && androidPlatformVersion > 22)
+1 -1
View File
@@ -48,4 +48,4 @@ export const IS_HIGH_DPI: boolean = window.matchMedia(
'(min-resolution: 2dppx)',
).matches
export const IS_LIQUID_GLASS: boolean = false
export const HAS_ON_DEVICE_TRANSLATION: boolean = false
export const IS_TRANSLATION_SUPPORTED: boolean = false
+1 -1
View File
@@ -1,5 +1,5 @@
import {useEffect, useState} from 'react'
import EventEmitter from 'eventemitter3'
import {EventEmitter} from 'eventemitter3'
import {networkRetry} from '#/lib/async/retry'
import {
+2
View File
@@ -0,0 +1,2 @@
// TS6.0 enables noUncheckedSideEffectImports
declare module '*.css'
+19 -2
View File
@@ -51,10 +51,15 @@ interface PostOpts {
langs?: string[]
}
type FeatureFlags = {
highResolutionImages?: boolean
}
export async function post(
agent: BskyAgent,
queryClient: QueryClient,
opts: PostOpts,
featureFlags?: FeatureFlags,
) {
const thread = opts.thread
opts.onStateChange?.(t`Processing...`)
@@ -91,6 +96,7 @@ export async function post(
queryClient,
draft,
opts.onStateChange,
featureFlags,
)
let labels: $Typed<ComAtprotoLabelDefs.SelfLabels> | undefined
if (draft.labels.length) {
@@ -230,6 +236,7 @@ async function resolveEmbed(
queryClient: QueryClient,
draft: PostDraft,
onStateChange: ((state: string) => void) | undefined,
featureFlags?: FeatureFlags,
): Promise<
| $Typed<AppBskyEmbedImages.Main>
| $Typed<AppBskyEmbedVideo.Main>
@@ -240,7 +247,13 @@ async function resolveEmbed(
> {
if (draft.embed.quote) {
const [resolvedMedia, resolvedQuote] = await Promise.all([
resolveMedia(agent, queryClient, draft.embed, onStateChange),
resolveMedia(
agent,
queryClient,
draft.embed,
onStateChange,
featureFlags,
),
resolveRecord(agent, queryClient, draft.embed.quote.uri),
])
if (resolvedMedia) {
@@ -263,6 +276,7 @@ async function resolveEmbed(
queryClient,
draft.embed,
onStateChange,
featureFlags,
)
if (resolvedMedia) {
return resolvedMedia
@@ -288,6 +302,7 @@ async function resolveMedia(
queryClient: QueryClient,
embedDraft: EmbedDraft,
onStateChange: ((state: string) => void) | undefined,
featureFlags?: FeatureFlags,
): Promise<
| $Typed<AppBskyEmbedExternal.Main>
| $Typed<AppBskyEmbedImages.Main>
@@ -303,7 +318,9 @@ async function resolveMedia(
const images: AppBskyEmbedImages.Image[] = await Promise.all(
imagesDraft.map(async (image, i) => {
logger.debug(`Compressing image #${i}`)
const {path, width, height, mime} = await compressImage(image)
const {path, width, height, mime} = await compressImage(image, {
highResolution: featureFlags?.highResolutionImages,
})
logger.debug(`Uploading image #${i}`)
const res = await uploadBlob(agent, path, mime)
return {
+20 -46
View File
@@ -8,10 +8,7 @@ import Animated, {
} from 'react-native-reanimated'
import {decideShouldRoll} from '#/lib/custom-animations/util'
import {s} from '#/lib/styles'
import {Text} from '#/view/com/util/text/Text'
import {atoms as a, useTheme} from '#/alf'
import {useFormatPostStatCount} from '#/components/PostControls/util'
import {atoms as a} from '#/alf'
const animationConfig = {
duration: 400,
@@ -87,89 +84,66 @@ function ExitingDown() {
}
export function CountWheel({
likeCount,
big,
isLiked,
count,
isToggled,
hasBeenToggled,
renderCount,
}: {
likeCount: number
big?: boolean
isLiked: boolean
count: number
isToggled: boolean
hasBeenToggled: boolean
renderCount: (props: {count: number}) => React.ReactNode
}) {
const t = useTheme()
const shouldAnimate = !useReducedMotion() && hasBeenToggled
const shouldRoll = decideShouldRoll(isLiked, likeCount)
const shouldRoll = decideShouldRoll(isToggled, count)
// Incrementing the key will cause the `Animated.View` to re-render, with the newly selected entering/exiting
// animation
// The initial entering/exiting animations will get skipped, since these will happen on screen mounts and would
// be unnecessary
const [key, setKey] = useState(0)
const [prevCount, setPrevCount] = useState(likeCount)
const prevIsLiked = useRef(isLiked)
const formatPostStatCount = useFormatPostStatCount()
const formattedCount = formatPostStatCount(likeCount)
const formattedPrevCount = formatPostStatCount(prevCount)
const [prevCount, setPrevCount] = useState(count)
const prevIsToggled = useRef(isToggled)
useEffect(() => {
if (isLiked === prevIsLiked.current) {
if (isToggled === prevIsToggled.current) {
return
}
const newPrevCount = isLiked ? likeCount - 1 : likeCount + 1
const newPrevCount = isToggled ? count - 1 : count + 1
setKey(prev => prev + 1)
setPrevCount(newPrevCount)
prevIsLiked.current = isLiked
}, [isLiked, likeCount])
prevIsToggled.current = isToggled
}, [isToggled, count])
const enteringAnimation =
shouldAnimate && shouldRoll
? isLiked
? isToggled
? EnteringUp
: EnteringDown
: undefined
const exitingAnimation =
shouldAnimate && shouldRoll
? isLiked
? isToggled
? ExitingUp
: ExitingDown
: undefined
return (
<LayoutAnimationConfig skipEntering skipExiting>
{likeCount > 0 ? (
{count > 0 ? (
<View style={[a.justify_center]}>
<Animated.View entering={enteringAnimation} key={key}>
<Text
testID="likeCount"
style={[
big ? a.text_md : a.text_sm,
a.user_select_none,
isLiked
? [a.font_semi_bold, s.likeColor]
: {color: t.palette.contrast_500},
]}>
{formattedCount}
</Text>
{renderCount({count})}
</Animated.View>
{shouldAnimate && (likeCount > 1 || !isLiked) ? (
{shouldAnimate && (count > 1 || !isToggled) ? (
<Animated.View
entering={exitingAnimation}
// Add 2 to the key so there are never duplicates
key={key + 2}
style={[a.absolute, {width: 50, opacity: 0}]}
aria-disabled={true}>
<Text
style={[
big ? a.text_md : a.text_sm,
a.user_select_none,
isLiked
? [a.font_semi_bold, s.likeColor]
: {color: t.palette.contrast_500},
]}>
{formattedPrevCount}
</Text>
{renderCount({count: prevCount})}
</Animated.View>
) : null}
</View>
+19 -46
View File
@@ -3,10 +3,6 @@ import {View} from 'react-native'
import {useReducedMotion} from 'react-native-reanimated'
import {decideShouldRoll} from '#/lib/custom-animations/util'
import {s} from '#/lib/styles'
import {Text} from '#/view/com/util/text/Text'
import {atoms as a, useTheme} from '#/alf'
import {useFormatPostStatCount} from '#/components/PostControls/util'
const animationConfig = {
duration: 400,
@@ -35,50 +31,46 @@ const exitingDownKeyframe = [
]
export function CountWheel({
likeCount,
big,
isLiked,
count,
isToggled,
hasBeenToggled,
renderCount,
}: {
likeCount: number
big?: boolean
isLiked: boolean
count: number
isToggled: boolean
hasBeenToggled: boolean
renderCount: (props: {count: number}) => React.ReactNode
}) {
const t = useTheme()
const shouldAnimate = !useReducedMotion() && hasBeenToggled
const shouldRoll = decideShouldRoll(isLiked, likeCount)
const shouldRoll = decideShouldRoll(isToggled, count)
const countView = useRef<HTMLDivElement>(null)
const prevCountView = useRef<HTMLDivElement>(null)
const [prevCount, setPrevCount] = useState(likeCount)
const prevIsLiked = useRef(isLiked)
const formatPostStatCount = useFormatPostStatCount()
const formattedCount = formatPostStatCount(likeCount)
const formattedPrevCount = formatPostStatCount(prevCount)
const [prevCount, setPrevCount] = useState(count)
const prevIsToggled = useRef(isToggled)
useEffect(() => {
if (isLiked === prevIsLiked.current) {
if (isToggled === prevIsToggled.current) {
return
}
const newPrevCount = isLiked ? likeCount - 1 : likeCount + 1
const newPrevCount = isToggled ? count - 1 : count + 1
if (shouldAnimate && shouldRoll) {
countView.current?.animate?.(
isLiked ? enteringUpKeyframe : enteringDownKeyframe,
isToggled ? enteringUpKeyframe : enteringDownKeyframe,
animationConfig,
)
prevCountView.current?.animate?.(
isLiked ? exitingUpKeyframe : exitingDownKeyframe,
isToggled ? exitingUpKeyframe : exitingDownKeyframe,
animationConfig,
)
setPrevCount(newPrevCount)
}
prevIsLiked.current = isLiked
}, [isLiked, likeCount, shouldAnimate, shouldRoll])
prevIsToggled.current = isToggled
}, [isToggled, count, shouldAnimate, shouldRoll])
if (likeCount < 1) {
if (count < 1) {
return null
}
@@ -87,34 +79,15 @@ export function CountWheel({
<View
// @ts-expect-error is div
ref={countView}>
<Text
testID="likeCount"
style={[
big ? a.text_md : a.text_sm,
a.user_select_none,
isLiked
? [a.font_semi_bold, s.likeColor]
: {color: t.palette.contrast_500},
]}>
{formattedCount}
</Text>
{renderCount({count})}
</View>
{shouldAnimate && (likeCount > 1 || !isLiked) ? (
{shouldAnimate && (count > 1 || !isToggled) ? (
<View
style={{position: 'absolute', opacity: 0}}
aria-disabled={true}
// @ts-expect-error is div
ref={prevCountView}>
<Text
style={[
big ? a.text_md : a.text_sm,
a.user_select_none,
isLiked
? [a.font_semi_bold, s.likeColor]
: {color: t.palette.contrast_500},
]}>
{formattedPrevCount}
</Text>
{renderCount({count: prevCount})}
</View>
) : null}
</View>
+2 -3
View File
@@ -5,7 +5,6 @@ import Animated, {
useReducedMotion,
} from 'react-native-reanimated'
import {s} from '#/lib/styles'
import {useTheme} from '#/alf'
import {
Heart2_Filled_Stroke2_Corner0_Rounded as HeartIconFilled,
@@ -86,7 +85,7 @@ export function AnimatedLikeIcon({
{isLiked ? (
<Animated.View
entering={shouldAnimate ? keyframe.duration(300) : undefined}>
<HeartIconFilled style={s.likeColor} width={size} />
<HeartIconFilled style={{color: t.palette.pink}} width={size} />
</Animated.View>
) : (
<HeartIconOutline
@@ -100,7 +99,7 @@ export function AnimatedLikeIcon({
entering={circle1Keyframe.duration(300)}
style={{
position: 'absolute',
backgroundColor: s.likeColor.color,
backgroundColor: t.palette.pink,
top: 0,
left: 0,
width: size,
+2 -3
View File
@@ -2,7 +2,6 @@ import {useEffect, useRef} from 'react'
import {View} from 'react-native'
import {useReducedMotion} from 'react-native-reanimated'
import {s} from '#/lib/styles'
import {useTheme} from '#/alf'
import {
Heart2_Filled_Stroke2_Corner0_Rounded as HeartIconFilled,
@@ -74,7 +73,7 @@ export function AnimatedLikeIcon({
{isLiked ? (
// @ts-expect-error is div
<View ref={likeIconRef}>
<HeartIconFilled style={s.likeColor} width={size} />
<HeartIconFilled style={{color: t.palette.pink}} width={size} />
</View>
) : (
<HeartIconOutline
@@ -87,7 +86,7 @@ export function AnimatedLikeIcon({
ref={circle1Ref}
style={{
position: 'absolute',
backgroundColor: s.likeColor.color,
backgroundColor: t.palette.pink,
top: 0,
left: 0,
width: size,
+1 -1
View File
@@ -1,5 +1,5 @@
import {useMemo} from 'react'
import {useNavigation} from '@react-navigation/core'
import {useNavigation} from '@react-navigation/native'
import {useDedupe} from '#/lib/hooks/useDedupe'
import {type NavigationProp} from '#/lib/routes/types'
+1 -1
View File
@@ -1,5 +1,5 @@
import {useEffect, useMemo, useState} from 'react'
import {type EventArg, useNavigation} from '@react-navigation/core'
import {type EventArg, useNavigation} from '@react-navigation/native'
if ('scrollRestoration' in history) {
// Tell the brower not to mess with the scroll.
+10
View File
@@ -0,0 +1,10 @@
export function Provider({children}: {children: React.ReactNode}) {
return children
}
export function useHotkeysContext() {
return {
enableScope: () => {},
disableScope: () => {},
}
}
+75
View File
@@ -0,0 +1,75 @@
import {useLingui} from '@lingui/react/macro'
import {
HotkeysProvider,
useHotkeys,
useHotkeysContext,
} from 'react-hotkeys-hook'
import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
import {emitFocusSearch} from '#/state/events'
import {useSession} from '#/state/session'
enum Hotkeys {
OPEN_COMPOSER = 'n',
FOCUS_SEARCH = 'slash',
}
export function Provider({children}: React.PropsWithChildren<unknown>) {
return (
<HotkeysProvider initiallyActiveScopes={['global']}>
<KeyboardShortcuts>{children}</KeyboardShortcuts>
</HotkeysProvider>
)
}
export {useHotkeysContext}
function KeyboardShortcuts({children}: React.PropsWithChildren<unknown>) {
useKeyboardShortcuts()
return children
}
function useKeyboardShortcuts() {
const {openComposer} = useOpenComposer()
const {hasSession} = useSession()
const {t: l} = useLingui()
const shouldIgnore = (requiresSession: boolean = false) => {
if (requiresSession && !hasSession) {
return true
}
return false
}
const handleKey = (
callback: () => void,
options?: {requiresSession?: boolean},
) => {
if (shouldIgnore(options?.requiresSession)) {
return
}
callback()
}
useHotkeys(
Hotkeys.OPEN_COMPOSER,
() =>
handleKey(
() => {
openComposer({logContext: 'Other'})
},
{
requiresSession: true,
},
),
{scopes: ['global'], description: l`Compose new post`},
[openComposer],
)
useHotkeys(Hotkeys.FOCUS_SEARCH, () => handleKey(emitFocusSearch), {
scopes: ['global'],
preventDefault: true,
description: l`Focus the search field`,
})
}
+6 -2
View File
@@ -15,7 +15,6 @@ import {
import {manipulateAsync, SaveFormat} from 'expo-image-manipulator'
import * as MediaLibrary from 'expo-media-library'
import * as Sharing from 'expo-sharing'
import {Buffer} from 'buffer'
import {POST_IMG_MAX} from '#/lib/constants'
import {logger} from '#/logger'
@@ -322,7 +321,12 @@ export async function saveBytesToDisk(
bytes: Uint8Array,
type: string,
) {
const encoded = Buffer.from(bytes).toString('base64')
// ideally we'd use `bytes.toBase64()`, but that's only baseline newly available
let binary = ''
for (const byte of bytes) {
binary += String.fromCharCode(byte)
}
const encoded = btoa(binary)
return await saveToDevice(filename, encoded, type)
}
+1 -1
View File
@@ -13,7 +13,7 @@
* returns a ref callback function that can be used to merge multiple refs into a single ref.
*/
export function mergeRefs<T = any>(
refs: Array<React.MutableRefObject<T> | React.Ref<T>>,
refs: Array<React.MutableRefObject<T> | React.Ref<T> | undefined>,
): React.RefCallback<T> {
return value => {
refs.forEach(ref => {
+2 -3
View File
@@ -10,7 +10,7 @@ import {
import {createPersistedQueryStorage} from '#/lib/persisted-query-storage'
import {listenNetworkConfirmed, listenNetworkLost} from '#/state/events'
import {PERSISTED_QUERY_ROOT} from '#/state/queries'
import {isQueryPersisted} from '#/state/queries/util'
import * as env from '#/env'
import {IS_NATIVE, IS_WEB} from '#/env'
@@ -137,8 +137,7 @@ const dehydrateOptions: PersistQueryClientProviderProps['persistOptions']['dehyd
{
shouldDehydrateMutation: (_: any) => false,
shouldDehydrateQuery: query => {
const root = String(query.queryKey[0])
return root === PERSISTED_QUERY_ROOT
return isQueryPersisted(query.queryKey)
},
}
+1 -1
View File
@@ -1,4 +1,4 @@
import {XRPCError} from '@atproto/xrpc'
import {XRPCError} from '@atproto/api'
import {t} from '@lingui/core/macro'
export function cleanError(str: any): string {
+1 -124
View File
@@ -1,9 +1,4 @@
import {
Dimensions,
type StyleProp,
StyleSheet,
type TextStyle,
} from 'react-native'
import {type StyleProp, StyleSheet, type TextStyle} from 'react-native'
import {IS_WEB} from '#/env'
import {type Theme, type TypographyVariant} from './ThemeContext'
@@ -61,14 +56,6 @@ export const colors = {
green5: '#082b03',
unreadNotifBg: '#ebf6ff',
brandBlue: '#0066FF',
like: '#ec4899',
}
export const gradients = {
blueLight: {start: '#5A71FA', end: colors.blue3}, // buttons
blue: {start: '#5E55FB', end: colors.blue3}, // fab
blueDark: {start: '#5F45E0', end: colors.blue3}, // avis, banner
}
/**
@@ -78,57 +65,6 @@ export const s = StyleSheet.create({
// helpers
footerSpacer: {height: 100},
contentContainer: {paddingBottom: 200},
contentContainerExtra: {paddingBottom: 300},
border0: {borderWidth: 0},
border1: {borderWidth: 1},
borderTop1: {borderTopWidth: 1},
borderRight1: {borderRightWidth: 1},
borderBottom1: {borderBottomWidth: 1},
borderLeft1: {borderLeftWidth: 1},
hidden: {display: 'none'},
dimmed: {opacity: 0.5},
// font weights
fw600: {fontWeight: '600'},
bold: {fontWeight: '600'},
fw500: {fontWeight: '600'},
semiBold: {fontWeight: '600'},
fw400: {fontWeight: '400'},
normal: {fontWeight: '400'},
fw300: {fontWeight: '400'},
light: {fontWeight: '400'},
// text decoration
underline: {textDecorationLine: 'underline'},
// font variants
tabularNum: {fontVariant: ['tabular-nums']},
// font sizes
f9: {fontSize: 9},
f10: {fontSize: 10},
f11: {fontSize: 11},
f12: {fontSize: 12},
f13: {fontSize: 13},
f14: {fontSize: 14},
f15: {fontSize: 15},
f16: {fontSize: 16},
f17: {fontSize: 17},
f18: {fontSize: 18},
// line heights
['lh13-1']: {lineHeight: 13},
['lh13-1.3']: {lineHeight: 16.9}, // 1.3 of 13px
['lh14-1']: {lineHeight: 14},
['lh14-1.3']: {lineHeight: 18.2}, // 1.3 of 14px
['lh15-1']: {lineHeight: 15},
['lh15-1.3']: {lineHeight: 19.5}, // 1.3 of 15px
['lh16-1']: {lineHeight: 16},
['lh16-1.3']: {lineHeight: 20.8}, // 1.3 of 16px
['lh17-1']: {lineHeight: 17},
['lh17-1.3']: {lineHeight: 22.1}, // 1.3 of 17px
['lh18-1']: {lineHeight: 18},
['lh18-1.3']: {lineHeight: 23.4}, // 1.3 of 18px
// margins
mr2: {marginRight: 2},
@@ -171,74 +107,15 @@ export const s = StyleSheet.create({
pb20: {paddingBottom: 20},
px5: {paddingHorizontal: 5},
// flex
flexRow: {flexDirection: 'row'},
flexCol: {flexDirection: 'column'},
flex1: {flex: 1},
flexGrow1: {flexGrow: 1},
alignCenter: {alignItems: 'center'},
alignBaseline: {alignItems: 'baseline'},
justifyCenter: {justifyContent: 'center'},
// position
absolute: {position: 'absolute'},
// dimensions
w100pct: {width: '100%'},
h100pct: {height: '100%'},
hContentRegion: IS_WEB ? {minHeight: '100%'} : {height: '100%'},
window: {
width: Dimensions.get('window').width,
height: Dimensions.get('window').height,
},
// text align
textLeft: {textAlign: 'left'},
textCenter: {textAlign: 'center'},
textRight: {textAlign: 'right'},
// colors
white: {color: colors.white},
black: {color: colors.black},
gray1: {color: colors.gray1},
gray2: {color: colors.gray2},
gray3: {color: colors.gray3},
gray4: {color: colors.gray4},
gray5: {color: colors.gray5},
blue1: {color: colors.blue1},
blue2: {color: colors.blue2},
blue3: {color: colors.blue3},
blue4: {color: colors.blue4},
blue5: {color: colors.blue5},
red1: {color: colors.red1},
red2: {color: colors.red2},
red3: {color: colors.red3},
red4: {color: colors.red4},
red5: {color: colors.red5},
pink1: {color: colors.pink1},
pink2: {color: colors.pink2},
pink3: {color: colors.pink3},
pink4: {color: colors.pink4},
pink5: {color: colors.pink5},
purple1: {color: colors.purple1},
purple2: {color: colors.purple2},
purple3: {color: colors.purple3},
purple4: {color: colors.purple4},
purple5: {color: colors.purple5},
green1: {color: colors.green1},
green2: {color: colors.green2},
green3: {color: colors.green3},
green4: {color: colors.green4},
green5: {color: colors.green5},
brandBlue: {color: colors.brandBlue},
likeColor: {color: colors.like},
})
export function lh(
+30 -6
View File
@@ -7,9 +7,11 @@ import {useLingui} from '@lingui/react/macro'
import {useFocusEffect} from '@react-navigation/native'
import {useGoogleTranslate} from '#/lib/hooks/useGoogleTranslate'
import {codeToLanguageName} from '#/locale/helpers'
import {logger} from '#/logger'
import {useLanguagePrefs} from '#/state/preferences'
import {useAnalytics} from '#/analytics'
import {HAS_ON_DEVICE_TRANSLATION, IS_ANDROID, IS_IOS} from '#/env'
import {IS_ANDROID, IS_IOS, IS_TRANSLATION_SUPPORTED} from '#/env'
import {Context} from './context'
import {
type ContextType,
@@ -22,6 +24,11 @@ import {guessLanguage} from './utils'
export * from './types'
export * from './utils'
const E_SAME_AS_SOURCE_LANGUAGE =
'Translation result is the same as the source text.'
const E_EMPTY_RESULT = 'Translation result is empty.'
const E_INVALID_SOURCE_LANGUAGE = 'Invalid source language'
/**
* Attempts on-device translation via @bsky.app/expo-translate-text.
* Uses a lazy import to avoid crashing if the native module isn't linked into
@@ -77,11 +84,11 @@ async function attemptTranslation(
typeof result.translatedTexts === 'string' ? result.translatedTexts : ''
if (translatedText === input) {
throw new Error('Translation result is the same as the source text.')
throw new Error(E_SAME_AS_SOURCE_LANGUAGE)
}
if (translatedText === '') {
throw new Error('Translation result is empty.')
throw new Error(E_EMPTY_RESULT)
}
return {
@@ -156,6 +163,7 @@ export function Provider({children}: React.PropsWithChildren<unknown>) {
>({})
const [refCounts, setRefCounts] = useState<Record<string, number>>({})
const ax = useAnalytics()
const langPrefs = useLanguagePrefs()
const {t: l} = useLingui()
const googleTranslate = useGoogleTranslate()
@@ -232,7 +240,7 @@ export function Provider({children}: React.PropsWithChildren<unknown>) {
googleTranslate: shouldForceGoogleTranslate,
})
if (shouldForceGoogleTranslate || !HAS_ON_DEVICE_TRANSLATION) {
if (shouldForceGoogleTranslate || !IS_TRANSLATION_SUPPORTED) {
await googleTranslate(
text,
expectedTargetLanguage,
@@ -277,7 +285,8 @@ export function Provider({children}: React.PropsWithChildren<unknown>) {
postLanguages: possibleSourceLanguages,
},
}))
} catch (e) {
} catch (err) {
const e = err as Error
logger.error('Failed to translate text on device', {safeMessage: e})
// On-device translation failed (language pack missing or user
// dismissed the download prompt).
@@ -292,6 +301,21 @@ export function Provider({children}: React.PropsWithChildren<unknown>) {
textLength: text.length,
})
let errorMessage = l`Device failed to translate :(`
if (e.message === E_SAME_AS_SOURCE_LANGUAGE) {
errorMessage = l`Translation to the same language is unavailable on your device.`
}
if (e.message === E_EMPTY_RESULT) {
errorMessage = l`No translation received from your device.`
}
if (
expectedSourceLanguage &&
e.message.includes(E_INVALID_SOURCE_LANGUAGE)
) {
errorMessage = l`${codeToLanguageName(
expectedSourceLanguage,
langPrefs.appLanguage,
)} is not supported by your device.`
}
if (!IS_ANDROID) {
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
}
@@ -301,7 +325,7 @@ export function Provider({children}: React.PropsWithChildren<unknown>) {
}))
}
},
[ax, googleTranslate, l],
[ax, googleTranslate, l, langPrefs.appLanguage],
)
const ctx = useMemo(
+5
View File
@@ -0,0 +1,5 @@
import Emojis, {type EmojiMartData} from '@emoji-mart/data'
export async function getEmojis(): Promise<EmojiMartData> {
return Emojis as EmojiMartData
}
+5
View File
@@ -0,0 +1,5 @@
import {type EmojiMartData} from '@emoji-mart/data'
export async function getEmojis(): Promise<EmojiMartData> {
return (await import('@emoji-mart/data')).default as EmojiMartData
}
+12
View File
@@ -0,0 +1,12 @@
import {useCallback} from 'react'
import {getEmojis} from './getEmojis'
let emojis: Awaited<ReturnType<typeof getEmojis>> | null = null
export function useGetEmojis() {
return useCallback(async () => {
emojis ??= await getEmojis()
return emojis
}, [])
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff

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