Compare commits

..

49 Commits

Author SHA1 Message Date
Eric Bailey 2d842fc624 Handle persisted case, add to docs 2026-04-01 16:21:06 -05:00
Eric Bailey 2d9bbe57dc Add some more clarity to the RQ docs 2026-03-24 15:28:56 -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
DS Boyce 3e7d7ce5f4 Fix issue with site loading on older versions of Safari (#10077) 2026-03-19 15:50:25 -07:00
DS Boyce 876e20166a Ensure mute state stays in sync after mutation (#10067) 2026-03-19 06:18:48 -07:00
pfrazee 2a87f6f6b5 Nightly source-language update 2026-03-19 03:12:12 +00:00
Eric Bailey bfd2d20192 Add back starter packs button in ProfileMenu (#10069) 2026-03-18 18:29:22 -05:00
Eric Bailey dc8570c524 Bump keyboard controller and add missing provider on web (#10068) 2026-03-18 15:43:22 -05:00
Samuel Newman 530afe87c7 web: LRU screen cache to bound memory in long sessions (#10063)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 08:50:48 -07:00
Eric Bailey 317ce2b3da Prep 1.119.0 (#10062) 2026-03-17 16:16:01 -05:00
Eric Bailey 45df50ec19 Rename translation properties for clarity (#10061) 2026-03-17 13:59:28 -05:00
surfdude29 b9a3256e51 Don't strip query parameters on in-app navigation (#9948) 2026-03-17 13:43:37 -05:00
DS Boyce 1386a559b7 Log languages a post is tagged with after translating (#10014)
Co-authored-by: Eric Bailey <git@esb.lol>
2026-03-17 12:31:23 -05:00
RetroSunstar be0d00de17 Set displayName and description to undefined if no values are provided during a profile edit (#10016) 2026-03-17 12:04:27 -05:00
RetroSunstar dbba97f28d Use proper constants.ts exports for Edit Profile Dialog limits (#10054) 2026-03-17 12:04:04 -05:00
Samuel Newman 5e8ef6aa9b Adjust VTT cue line to avoid occlusion by video controls (#10017)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 11:05:58 -05:00
surfdude29 177bdcd2b7 [bskylink] URL-encode apostrophes before HTML escaping (#9559) 2026-03-16 11:18:23 -07:00
pfrazee 2027589c55 Nightly source-language update 2026-03-14 03:04:35 +00:00
DS Boyce b8d60eb0e2 Address lint warnings in List.web.tsx (#10046) 2026-03-13 09:41:38 -07:00
DS Boyce 97fdd7c59b Create new useSuggestedFollowsByActorWithDismiss hook (#10048) 2026-03-13 09:18:23 -07:00
pfrazee 6afe48db6a Nightly source-language update 2026-03-13 03:06:17 +00:00
DS Boyce 0b6ff8000d Remove getSuggestedFollowsByActor fallbacks and use recIdStr (#9988) 2026-03-12 16:57:38 -07:00
DS Boyce 35cb2bcf94 Create codemod for updating Toast calls to v2 (#10045) 2026-03-12 11:35:58 -07:00
DS Boyce 9c9970f680 Don't show chat notification if viewer is age gated (#10042) 2026-03-12 11:21:57 -07:00
DS Boyce 7b5d5a4f76 Provide age-restricted viewers the option to delete their account (#10041) 2026-03-12 11:13:23 -07:00
Samuel Newman 4737bfb7ed Update expo deps, unpin things (#10044) 2026-03-12 05:33:11 -07:00
pfrazee 374ce2c39e Nightly source-language update 2026-03-12 03:08:04 +00:00
DS Boyce 1db01a09a8 Create codemod for addressing ESLint warnings (#10032) 2026-03-11 15:51:31 -07:00
Samuel Newman 80429ec902 Add “download image” option to web, simplify downloads (#10025) 2026-03-11 23:44:19 +02:00
heikadog 0c7b2d5353 Update issue templates to take uploads, and fix typo (#10036) 2026-03-11 14:50:35 -05:00
DS Boyce 850e6e6f52 Add new metrics for post menu interactions (#10022)
Co-authored-by: Eric Bailey <git@esb.lol>
2026-03-11 13:30:33 -05:00
Eric Bailey 0937f522af Serve og:video tags on post URLs (#10039) 2026-03-11 11:46:27 -05:00
365 changed files with 68457 additions and 64717 deletions
+3 -2
View File
@@ -1,5 +1,5 @@
name: "Bug Report"
description: "Create a report for an issue you have experience in the app."
description: "Create a report for an issue you have experienced in the app."
labels: ["bug"]
body:
- type: markdown
@@ -19,13 +19,14 @@ body:
4. See error
validations:
required: true
- type: textarea
- type: upload
attributes:
label: Attachments
description: |
If possible, please provide any images or videos that may help us understand the issue you are experiencing.
validations:
required: false
accept: ".png,.jpg,.jpeg,.gif,.webp,.mp4,.mov,.webm"
- type: dropdown
attributes:
label: What platform(s) does this occur on?
+2 -1
View File
@@ -26,13 +26,14 @@ body:
4. See error
validations:
required: true
- type: textarea
- type: upload
attributes:
label: Attachments
description: |
If possible, please provide any images or videos that may help us understand the issue you are experiencing.
validations:
required: false
accept: ".png,.jpg,.jpeg,.gif,.webp,.mp4,.mov,.webm"
- type: dropdown
attributes:
label: What platform(s) does this occur on?
+2 -1
View File
@@ -15,7 +15,7 @@ body:
implement it in a timely manner.
validations:
required: true
- type: textarea
- type: upload
attributes:
label: Attachments
description: |
@@ -24,6 +24,7 @@ body:
in or is missing from.
validations:
required: false
accept: ".png,.jpg,.jpeg,.gif,.webp,.mp4,.mov,.webm"
- type: textarea
attributes:
label: Describe Alternatives
+135
View File
@@ -0,0 +1,135 @@
/**
* Codemod to replace namespaced React calls with named imports
*
* Before:
* import React from 'react'
* React.useEffect(() => {}, [])
*
* After:
* import { useEffect } from 'react'
* useEffect(() => {}, [])
*
* Usage: jscodeshift -t .jscodeshift/react-import.js <file-path>
* Example: jscodeshift -t .jscodeshift/react-import.js src/App.native.tsx
*/
/* eslint-disable */
export const parser = 'tsx'
export default function transformer(file, api) {
const j = api.jscodeshift
const root = j(file.source)
// Find the React import
let reactImportPath = null
const reactMembers = new Set()
root.find(j.ImportDeclaration).forEach(path => {
const node = path.value
if (node.source.value === 'react') {
node.specifiers.forEach(spec => {
// Check if this is a default import of React
if (
spec.type === 'ImportDefaultSpecifier' &&
spec.local.name === 'React'
) {
reactImportPath = path
}
})
}
})
if (!reactImportPath) {
// No React import found, nothing to do
return file.source
}
// Find all React.* member expressions
root
.find(j.MemberExpression)
.filter(path => {
const node = path.value
return (
node.object.type === 'Identifier' &&
node.object.name === 'React' &&
node.property.type === 'Identifier'
)
})
.forEach(path => {
const propertyName = path.value.property.name
reactMembers.add(propertyName)
})
// Find all React.* JSX member expressions (e.g., <React.Fragment>)
root
.find(j.JSXMemberExpression)
.filter(path => {
const node = path.value
return node.object.name === 'React' && node.property.name
})
.forEach(path => {
const propertyName = path.value.property.name
reactMembers.add(propertyName)
})
// If no React members are used, remove the import
if (reactMembers.size === 0) {
reactImportPath.prune()
return root.toSource()
}
// Sort the members for consistent output
const sortedMembers = Array.from(reactMembers).sort()
// Create new import specifiers
const newSpecifiers = sortedMembers.map(name =>
j.importSpecifier(j.identifier(name), j.identifier(name)),
)
// Get the existing import specifiers
const sortedImports = Array.from(reactImportPath.value.specifiers).sort()
const existingSpecifiers = sortedImports.filter(
specifier => specifier.type !== 'ImportDefaultSpecifier',
)
const allSpecifiers = [
...new Map(
[...existingSpecifiers, ...newSpecifiers].map(item => [
item.imported.name,
item,
]),
).values(),
]
// Update the import declaration
reactImportPath.value.specifiers = allSpecifiers
// Replace all React.* member expressions with just the identifier
root
.find(j.MemberExpression)
.filter(path => {
const node = path.value
return (
node.object.type === 'Identifier' &&
node.object.name === 'React' &&
node.property.type === 'Identifier'
)
})
.replaceWith(path => {
return j.identifier(path.value.property.name)
})
// Replace all React.* JSX member expressions with just the identifier
root
.find(j.JSXMemberExpression)
.filter(path => {
const node = path.value
return node.object.name === 'React' && node.property.name
})
.replaceWith(path => {
return j.jsxIdentifier(path.value.property.name)
})
return root.toSource()
}
+106
View File
@@ -0,0 +1,106 @@
/**
* Codemod to replace namespaced React calls with named imports
*
* Before:
* import * as Toast from '#/view/com/util/Toast'
* Toast.show(message, 'xmark')
*
* After:
* import * as Toast from '#/components/Toast'
* Toast.show(message, {type: 'error'})
*
* Usage: jscodeshift -t .jscodeshift/toast-v2.js <file-path>
* Example: jscodeshift -t .jscodeshift/toast-v2.js src/App.native.tsx
*/
/* eslint-disable */
export const parser = 'tsx'
const OLD_IMPORT = '#/view/com/util/Toast'
const NEW_IMPORT = '#/components/Toast'
const convertLegacyToastType = type => {
switch (type) {
// these ones are fine
case 'default':
case 'success':
case 'error':
case 'warning':
case 'info':
return type
// legacy ones need conversion
case 'xmark':
return 'error'
case 'exclamation-circle':
return 'warning'
case 'check':
return 'success'
case 'clipboard-check':
return 'success'
case 'circle-exclamation':
case 'exclamation-circle':
return 'warning'
default:
return 'default'
}
}
export default function transformer(file, api) {
const j = api.jscodeshift
const root = j(file.source)
// Find Toast import declarations using the old path
const toastImports = root
.find(j.ImportDeclaration)
.filter(path => path.value.source.value === OLD_IMPORT)
if (toastImports.length === 0) {
return file.source
}
// Update import path
toastImports.forEach(path => {
path.value.source.value = NEW_IMPORT
})
// Collect all local names the Toast namespace is bound to
const toastLocalNames = new Set()
toastImports.forEach(path => {
path.value.specifiers.forEach(spec => {
if (spec.type === 'ImportNamespaceSpecifier') {
toastLocalNames.add(spec.local.name)
}
})
})
// Transform Toast.show(message, type) calls
root.find(j.CallExpression).forEach(path => {
const {callee, arguments: args} = path.value
// Match <ToastName>.show(...)
if (
callee.type !== 'MemberExpression' ||
callee.object.type !== 'Identifier' ||
!toastLocalNames.has(callee.object.name) ||
callee.property.name !== 'show'
) {
return
}
// Only transform 2-arg calls where the second arg is a string literal
if (args.length !== 2) return
const typeArg = args[1]
if (typeArg.type !== 'StringLiteral' && typeArg.type !== 'Literal') return
const legacyType = typeArg.value
const newType = convertLegacyToastType(legacyType)
// Replace the second argument with an options object: {type: 'newType'}
args[1] = j.objectExpression([
j.property('init', j.identifier('type'), j.stringLiteral(newType)),
])
})
return root.toSource()
}
+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
+4 -2
View File
@@ -91,7 +91,8 @@ appId: xyz.blueskyweb.app
- tapOn: "Add user to list"
- swipe:
direction: DOWN
- assertVisible: "View Bob's profile"
- assertVisible:
id: "profileCard-bob.test-link"
- tapOn: "Posts"
- assertVisible:
@@ -123,7 +124,8 @@ appId: xyz.blueskyweb.app
- tapOn: "Good Ppl"
- tapOn: "People"
- assertVisible: "View Bob's profile"
- assertVisible:
id: "profileCard-bob.test-link"
- tapOn:
point: "90%,43%"
- tapOn:
+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>) {
+11 -1
View File
@@ -1,10 +1,20 @@
import escapeHTML from 'escape-html'
export function linkRedirectContents(link: string): string {
// Encode characters that could break out of the single-quoted URL in meta refresh.
// HTML entity escaping (&#39;) is insufficient because the browser decodes entities
// before the meta refresh parser processes the URL, allowing apostrophes to
// prematurely terminate the URL string.
//
// Example: "They're" with HTML escaping becomes "They&#39;re" in HTML, but after
// the browser decodes the content attribute, the meta refresh parser sees "They're"
// and interprets the apostrophe as the closing quote, truncating the URL to "They".
const safeLink = link.replace(/'/g, '%27')
return `
<html>
<head>
<meta http-equiv="refresh" content="0; URL='${escapeHTML(link)}'" />
<meta http-equiv="refresh" content="0; URL='${escapeHTML(safeLink)}'" />
<meta
http-equiv="Cache-Control"
content="no-store, no-cache, must-revalidate, max-age=0" />
+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
@@ -1,6 +1,6 @@
import {type SVGAttributes} from 'react'
import React from 'react'
export function Butterfly(props: SVGAttributes<SVGSVGElement>) {
export function Butterfly(props: React.SVGAttributes<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
+2 -2
View File
@@ -1,4 +1,4 @@
import {type ImgHTMLAttributes} from 'react'
import React from 'react'
// @NOTE satori does not currently support webp, see vercel/satori#273
function detectMime(buf: Buffer): string {
@@ -10,7 +10,7 @@ function detectMime(buf: Buffer): string {
}
export function Img(
props: Omit<ImgHTMLAttributes<HTMLImageElement>, 'src'> & {src: Buffer},
props: Omit<React.ImgHTMLAttributes<HTMLImageElement>, 'src'> & {src: Buffer},
) {
const {src, ...others} = props
return (
+2 -1
View File
@@ -1,5 +1,6 @@
/* eslint-disable bsky-internal/avoid-unwrapped-text */
import {type AppBskyGraphDefs, AppBskyGraphStarterpack} from '@atproto/api'
import React from 'react'
import {AppBskyGraphDefs, AppBskyGraphStarterpack} from '@atproto/api'
import {Butterfly} from './Butterfly.js'
import {Img} from './Img.js'
+1
View File
@@ -1,5 +1,6 @@
import assert from 'node:assert'
import React from 'react'
import {type AppBskyGraphDefs, AtUri} from '@atproto/api'
import resvg from '@resvg/resvg-js'
import {type Express} from 'express'
+16
View File
@@ -590,6 +590,14 @@ func (srv *Server) WebPost(c echo.Context) error {
if postView.Embed.EmbedVideo_View.Thumbnail != nil {
data["imgThumbUrls"] = []string{*postView.Embed.EmbedVideo_View.Thumbnail}
}
if postView.Embed.EmbedVideo_View.Playlist != "" {
data["videoUrl"] = postView.Embed.EmbedVideo_View.Playlist
data["videoType"] = "application/vnd.apple.mpegurl"
if postView.Embed.EmbedVideo_View.AspectRatio != nil {
data["videoWidth"] = postView.Embed.EmbedVideo_View.AspectRatio.Width
data["videoHeight"] = postView.Embed.EmbedVideo_View.AspectRatio.Height
}
}
} else if hasMediaImages {
var thumbUrls []string
for i := range postView.Embed.EmbedRecordWithMedia_View.Media.EmbedImages_View.Images {
@@ -600,6 +608,14 @@ func (srv *Server) WebPost(c echo.Context) error {
if postView.Embed.EmbedRecordWithMedia_View.Media.EmbedVideo_View.Thumbnail != nil {
data["imgThumbUrls"] = []string{*postView.Embed.EmbedRecordWithMedia_View.Media.EmbedVideo_View.Thumbnail}
}
if postView.Embed.EmbedRecordWithMedia_View.Media.EmbedVideo_View.Playlist != "" {
data["videoUrl"] = postView.Embed.EmbedRecordWithMedia_View.Media.EmbedVideo_View.Playlist
data["videoType"] = "application/vnd.apple.mpegurl"
if postView.Embed.EmbedRecordWithMedia_View.Media.EmbedVideo_View.AspectRatio != nil {
data["videoWidth"] = postView.Embed.EmbedRecordWithMedia_View.Media.EmbedVideo_View.AspectRatio.Width
data["videoHeight"] = postView.Embed.EmbedRecordWithMedia_View.Media.EmbedVideo_View.AspectRatio.Height
}
}
}
}
+8
View File
@@ -34,6 +34,14 @@
<meta property="twitter:image" content="{{ imgThumbUrl }}">
{% endfor %}
<meta name="twitter:card" content="summary_large_image">
{%- if videoUrl %}
<meta property="og:video" content="{{ videoUrl }}">
<meta property="og:video:type" content="{{ videoType }}">
{%- if videoWidth %}
<meta property="og:video:width" content="{{ videoWidth }}">
<meta property="og:video:height" content="{{ videoHeight }}">
{% endif -%}
{% endif -%}
{% else %}
<meta property="og:image" content="{{ postView.Author.Avatar|avatar_thumbnail }}">
<meta property="twitter:image" content="{{ postView.Author.Avatar|avatar_thumbnail }}">
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "dev-env",
"version": "0.0.0",
"scripts": {
"e2e:mock-server": "NODE_ENV=development ./dev-infra/with-test-redis-and-db.sh ts-node ./mock-server.ts"
"start": "NODE_ENV=development ./dev-infra/with-test-redis-and-db.sh ts-node ./mock-server.ts"
},
"dependencies": {
"@atproto/dev-env": "^0.3.213",
+1 -2
View File
@@ -47,8 +47,7 @@ Every night, a GitHub action will run `yarn intl:extract` to update the english
### Release process
1. Pull main and create a branch.
1. Run `yarn intl:pull` to fetch all translation updates from Crowdin. Commit.
1. Run `yarn intl:extract:all` to ensure all `.po` files are synced with the current state of the code. Commit.
1. Run `yarn intl:release` to fetch all translation updates from Crowdin and extract all `.po` files so that they're synced with the latest code. Commit that.
1. Create a PR, ensure the translations all look correct, and merge.
1. If needed:
1. Merge all approved translation PRs (contributions from outside crowdin).
+1
View File
@@ -37,6 +37,7 @@ export default defineConfig(
'*.e2e.ts',
'*.e2e.tsx',
'eslint.config.mjs',
'.jscodeshift/**',
],
},
@@ -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) {
@@ -1,5 +1,4 @@
import {Component, createRef} from 'react'
import {type ComponentType, type ContextType, type RefObject} from 'react'
import * as React from 'react'
import {
Dimensions,
type LayoutChangeEvent,
@@ -22,9 +21,9 @@ import {
Context as PortalContext,
} from './BottomSheetPortal'
const NativeView: ComponentType<
const NativeView: React.ComponentType<
BottomSheetViewProps & {
ref: RefObject<any>
ref: React.RefObject<any>
style: StyleProp<ViewStyle>
}
> = requireNativeViewManager('BottomSheet')
@@ -40,14 +39,14 @@ const IS_IOS15 =
const IS_NON_E2E_ANDROID =
Platform.OS === 'android' && Number(Platform.Version) < 35
export class BottomSheetNativeComponent extends Component<
export class BottomSheetNativeComponent extends React.Component<
BottomSheetViewProps,
{
open: boolean
viewHeight?: number
}
> {
ref = createRef<any>()
ref = React.createRef<any>()
static contextType = PortalContext
@@ -80,7 +79,7 @@ export class BottomSheetNativeComponent extends Component<
}
render() {
const Portal = this.context as ContextType<typeof PortalContext>
const Portal = this.context as React.ContextType<typeof PortalContext>
if (!Portal) {
throw new Error(
'BottomSheet: You need to wrap your component tree with a <BottomSheetPortalProvider> to use the bottom sheet.',
@@ -140,7 +139,7 @@ function BottomSheetNativeComponentInner({
onStateChange: (
event: NativeSyntheticEvent<{state: BottomSheetState}>,
) => void
nativeViewRef: RefObject<View>
nativeViewRef: React.RefObject<View>
onLayout?: (event: LayoutChangeEvent) => void
}) {
const insets = useSafeAreaInsets()
+11 -8
View File
@@ -1,17 +1,20 @@
import {createContext, useContext, useMemo} from 'react'
import {type ElementType, type ReactNode} from 'react'
import React from 'react'
import {createPortalGroup_INTERNAL} from './lib/Portal'
type PortalContext = ElementType<{children: ReactNode}>
type PortalContext = React.ElementType<{children: React.ReactNode}>
export const Context = createContext({} as PortalContext)
export const Context = React.createContext({} as PortalContext)
Context.displayName = 'BottomSheetPortalContext'
export const useBottomSheetPortal_INTERNAL = () => useContext(Context)
export const useBottomSheetPortal_INTERNAL = () => React.useContext(Context)
export function BottomSheetPortalProvider({children}: {children: ReactNode}) {
const portal = useMemo(() => {
export function BottomSheetPortalProvider({
children,
}: {
children: React.ReactNode
}) {
const portal = React.useMemo(() => {
return createPortalGroup_INTERNAL()
}, [])
@@ -29,7 +32,7 @@ const defaultPortal = createPortalGroup_INTERNAL()
export const BottomSheetOutlet = defaultPortal.Outlet
export function BottomSheetProvider({children}: {children: ReactNode}) {
export function BottomSheetProvider({children}: {children: React.ReactNode}) {
return (
<Context.Provider value={defaultPortal.Portal}>
<defaultPortal.Provider>{children}</defaultPortal.Provider>
@@ -1,7 +1,6 @@
import {createContext, useContext, useEffect, useMemo, useState} from 'react'
import {type ReactNode} from 'react'
import React from 'react'
import {type BackgroundNotificationHandlerPreferences} from './ExpoBackgroundNotificationHandler.types'
import {BackgroundNotificationHandlerPreferences} from './ExpoBackgroundNotificationHandler.types'
import {BackgroundNotificationHandler} from './ExpoBackgroundNotificationHandlerModule'
interface BackgroundNotificationPreferencesContext {
@@ -12,29 +11,30 @@ interface BackgroundNotificationPreferencesContext {
) => void
}
const Context = createContext<BackgroundNotificationPreferencesContext>(
const Context = React.createContext<BackgroundNotificationPreferencesContext>(
{} as BackgroundNotificationPreferencesContext,
)
export const useBackgroundNotificationPreferences = () => useContext(Context)
export const useBackgroundNotificationPreferences = () =>
React.useContext(Context)
export function BackgroundNotificationPreferencesProvider({
children,
}: {
children: ReactNode
children: React.ReactNode
}) {
const [preferences, setPreferences] =
useState<BackgroundNotificationHandlerPreferences>({
React.useState<BackgroundNotificationHandlerPreferences>({
playSoundChat: true,
})
useEffect(() => {
React.useEffect(() => {
;(async () => {
const prefs = await BackgroundNotificationHandler.getAllPrefsAsync()
setPreferences(prefs)
})()
}, [])
const value = useMemo(
const value = React.useMemo(
() => ({
preferences,
setPref: async <
@@ -1,17 +1,17 @@
import {createRef, PureComponent} from 'react'
import {type ComponentType, type RefObject} from 'react'
import React from 'react'
import {requireNativeModule} from 'expo'
import {requireNativeViewManager} from 'expo-modules-core'
import {type GifViewProps} from './GifView.types'
import {GifViewProps} from './GifView.types'
const NativeModule = requireNativeModule('ExpoBlueskyGifView')
const NativeView: ComponentType<GifViewProps & {ref: RefObject<any>}> =
requireNativeViewManager('ExpoBlueskyGifView')
const NativeView: React.ComponentType<
GifViewProps & {ref: React.RefObject<any>}
> = requireNativeViewManager('ExpoBlueskyGifView')
export class GifView extends PureComponent<GifViewProps> {
export class GifView extends React.PureComponent<GifViewProps> {
// TODO native types, should all be the same as those in this class
private nativeRef: RefObject<any> = createRef()
private nativeRef: React.RefObject<any> = React.createRef()
constructor(props: GifViewProps | Readonly<GifViewProps>) {
super(props)
@@ -1,11 +1,11 @@
import {createRef, PureComponent} from 'react'
import {type RefObject} from 'react'
import * as React from 'react'
import {StyleSheet} from 'react-native'
import {type GifViewProps} from './GifView.types'
import {GifViewProps} from './GifView.types'
export class GifView extends PureComponent<GifViewProps> {
private readonly videoPlayerRef: RefObject<HTMLMediaElement> = createRef()
export class GifView extends React.PureComponent<GifViewProps> {
private readonly videoPlayerRef: React.RefObject<HTMLMediaElement> =
React.createRef()
private isLoaded = false
constructor(props: GifViewProps | Readonly<GifViewProps>) {
+27 -22
View File
@@ -1,6 +1,6 @@
{
"name": "bsky.app",
"version": "1.119.0",
"version": "1.120.0",
"private": true,
"engines": {
"node": ">=20"
@@ -52,7 +52,7 @@
"lint-native": "swiftlint ./modules && ktlint ./modules",
"lint-native:fix": "swiftlint --fix ./modules && ktlint --format ./modules",
"typecheck": "tsc --project ./tsconfig.check.json",
"e2e:mock-server": "cd dev-env && yarn e2e:mock-server",
"e2e:mock-server": "cd dev-env && yarn start",
"e2e:build": "EXPO_PUBLIC_ENV=e2e NODE_ENV=test RN_SRC_EXT=e2e.ts,e2e.tsx expo run:ios",
"e2e:build-android": "EXPO_PUBLIC_ENV=e2e NODE_ENV=test RN_SRC_EXT=e2e.ts,e2e.tsx expo run:android",
"e2e:start": "EXPO_PUBLIC_ENV=e2e NODE_ENV=test RN_SRC_EXT=e2e.ts,e2e.tsx expo start -c",
@@ -70,6 +70,7 @@
"intl:pull": "crowdin download translations --verbose -b main",
"intl:push": "crowdin push translations --verbose -b main",
"intl:push-sources": "crowdin push sources --verbose -b main",
"intl:release": "yarn intl:pull && yarn intl:extract:all",
"nuke": "rm -rf ./node_modules && rm -rf ./ios && rm -rf ./android",
"update-extensions": "bash scripts/updateExtensions.sh",
"export": "npx expo export --dump-sourcemap && yarn upload-native-sourcemaps",
@@ -85,7 +86,7 @@
"@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.7",
"@bsky.app/expo-translate-text": "^0.2.9",
"@bsky.app/react-native-mmkv": "2.12.5",
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
"@emoji-mart/data": "^1.2.1",
@@ -143,7 +144,7 @@
"emoji-mart": "^5.6.0",
"emoji-regex": "^10.4.0",
"eventemitter3": "^5.0.1",
"expo": "^54.0.27",
"expo": "^54.0.33",
"expo-application": "~7.0.8",
"expo-blur": "~15.0.8",
"expo-build-properties": "~1.0.10",
@@ -152,28 +153,28 @@
"expo-contacts": "^15.0.10",
"expo-dev-client": "~6.0.20",
"expo-device": "~8.0.10",
"expo-file-system": "~19.0.20",
"expo-font": "~14.0.10",
"expo-file-system": "~19.0.21",
"expo-font": "~14.0.11",
"expo-haptics": "~15.0.8",
"expo-image": "~3.0.11",
"expo-image-manipulator": "~14.0.8",
"expo-image-picker": "~17.0.9",
"expo-image-picker": "~17.0.10",
"expo-intent-launcher": "~13.0.8",
"expo-keep-awake": "~15.0.8",
"expo-linear-gradient": "~15.0.8",
"expo-linking": "~8.0.10",
"expo-linking": "~8.0.11",
"expo-localization": "~17.0.8",
"expo-location": "~19.0.8",
"expo-media-library": "~18.2.1",
"expo-notifications": "~0.32.14",
"expo-notifications": "~0.32.16",
"expo-privacy-sensitive": "^0.1.0",
"expo-screen-orientation": "~9.0.8",
"expo-sharing": "~14.0.8",
"expo-sms": "^14.0.7",
"expo-splash-screen": "~31.0.12",
"expo-splash-screen": "~31.0.13",
"expo-system-ui": "~6.0.9",
"expo-updates": "~29.0.14",
"expo-video": "~3.0.15",
"expo-updates": "~29.0.16",
"expo-video": "~3.0.16",
"expo-video-thumbnails": "^10.0.8",
"expo-web-browser": "~15.0.10",
"fast-deep-equal": "^3.1.3",
@@ -208,7 +209,7 @@
"react-native-drawer-layout": "^4.2.2",
"react-native-edge-to-edge": "^1.6.0",
"react-native-gesture-handler": "~2.28.0",
"react-native-keyboard-controller": "^1.20.7",
"react-native-keyboard-controller": "^1.21.0",
"react-native-pager-view": "6.8.0",
"react-native-progress": "bluesky-social/react-native-progress",
"react-native-qrcode-styled": "^0.3.3",
@@ -238,8 +239,9 @@
"@babel/core": "^7.26.0",
"@babel/preset-env": "^7.26.0",
"@babel/runtime": "^7.26.0",
"@crowdin/cli": "^4.14.1",
"@eslint/js": "^9.39.2",
"@expo/config-plugins": "~54.0.1",
"@expo/config-plugins": "~54.0.4",
"@lingui/babel-plugin-lingui-macro": "^5.9.2",
"@lingui/cli": "^5.9.2",
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.15",
@@ -258,7 +260,7 @@
"babel-jest": "^29.7.0",
"babel-plugin-module-resolver": "^5.0.2",
"babel-plugin-react-compiler": "^19.1.0-rc.3",
"babel-preset-expo": "~54.0.0",
"babel-preset-expo": "~54.0.10",
"eslint": "^9.39.2",
"eslint-import-resolver-typescript": "^4.4.4",
"eslint-plugin-bsky-internal": "link:./eslint",
@@ -275,7 +277,7 @@
"husky": "^8.0.3",
"is-ci": "^3.0.1",
"jest": "^29.7.0",
"jest-expo": "~54.0.14",
"jest-expo": "~54.0.17",
"jest-junit": "^16.0.0",
"lint-staged": "^13.2.3",
"lockfile-lint": "^4.14.0",
@@ -291,13 +293,15 @@
"resolutions": {
"@react-native/babel-preset": "0.81.5",
"@react-native/normalize-colors": "0.81.5",
"**/@expo/image-utils": "0.8.7",
"**/@react-native-async-storage/async-storage": "2.2.0",
"**/expo-constants": "18.0.8",
"**/expo-device": "7.1.4",
"**/@expo/image-utils": "0.8.12",
"**/multiformats": "9.9.0",
"unicode-segmenter": "0.14.5",
"@types/estree": "1.0.6"
"@types/estree": "1.0.6",
"metro": "0.83.3",
"metro-core": "0.83.3",
"metro-config": "0.83.3",
"metro-runtime": "0.83.3",
"metro-source-map": "0.83.3"
},
"jest": {
"preset": "jest-expo/ios",
@@ -320,7 +324,8 @@
],
"modulePathIgnorePatterns": [
"__tests__/.*/__mocks__",
"__e2e__/.*"
"__e2e__/.*",
"bskylink/.*"
],
"coveragePathIgnorePatterns": [
"<rootDir>/node_modules/",
-44
View File
@@ -1,44 +0,0 @@
diff --git a/node_modules/expo-modules-core/android/src/main/java/expo/modules/kotlin/activityresult/AppContextActivityResultLauncher.kt b/node_modules/expo-modules-core/android/src/main/java/expo/modules/kotlin/activityresult/AppContextActivityResultLauncher.kt
index d300fc2..0890878 100644
--- a/node_modules/expo-modules-core/android/src/main/java/expo/modules/kotlin/activityresult/AppContextActivityResultLauncher.kt
+++ b/node_modules/expo-modules-core/android/src/main/java/expo/modules/kotlin/activityresult/AppContextActivityResultLauncher.kt
@@ -3,8 +3,8 @@ package expo.modules.kotlin.activityresult
import androidx.activity.result.ActivityResultCallback
import androidx.activity.result.contract.ActivityResultContract
import java.io.Serializable
+import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.resume
-import kotlin.coroutines.suspendCoroutine
/**
* A launcher for a previously-[AppContextActivityResultCaller.registerForActivityResult] prepared call
@@ -22,8 +22,12 @@ abstract class AppContextActivityResultLauncher<I : Serializable, O> {
*/
abstract fun launch(input: I, callback: ActivityResultCallback<O>)
- suspend fun launch(input: I): O = suspendCoroutine { continuation ->
- launch(input) { output -> continuation.resume(output) }
+ suspend fun launch(input: I): O = suspendCancellableCoroutine { continuation ->
+ launch(input) { output ->
+ if (continuation.isActive) {
+ continuation.resume(output)
+ }
+ }
}
abstract val contract: AppContextActivityResultContract<I, O>
diff --git a/node_modules/expo-modules-core/android/src/main/java/expo/modules/kotlin/devtools/ExpoNetworkInspectOkHttpInterceptors.kt b/node_modules/expo-modules-core/android/src/main/java/expo/modules/kotlin/devtools/ExpoNetworkInspectOkHttpInterceptors.kt
index 47c4d15..afe138d 100644
--- a/node_modules/expo-modules-core/android/src/main/java/expo/modules/kotlin/devtools/ExpoNetworkInspectOkHttpInterceptors.kt
+++ b/node_modules/expo-modules-core/android/src/main/java/expo/modules/kotlin/devtools/ExpoNetworkInspectOkHttpInterceptors.kt
@@ -125,6 +125,10 @@ internal fun peekResponseBody(
}
internal fun shouldParseBody(response: Response): Boolean {
+ if (response.request.url.encodedPath == "/bitdrift_public.protobuf.client.v1.ApiService/Mux") {
+ return false
+ }
+
// Check for Content-Type
val skipContentTypes = listOf(
"text/event-stream", // Server Sent Events
+15
View File
@@ -0,0 +1,15 @@
diff --git a/node_modules/expo-modules-core/android/src/main/java/expo/modules/kotlin/devtools/ExpoNetworkInspectOkHttpInterceptors.kt b/node_modules/expo-modules-core/android/src/main/java/expo/modules/kotlin/devtools/ExpoNetworkInspectOkHttpInterceptors.kt
index 47c4d15..afe138d 100644
--- a/node_modules/expo-modules-core/android/src/main/java/expo/modules/kotlin/devtools/ExpoNetworkInspectOkHttpInterceptors.kt
+++ b/node_modules/expo-modules-core/android/src/main/java/expo/modules/kotlin/devtools/ExpoNetworkInspectOkHttpInterceptors.kt
@@ -125,6 +125,10 @@ internal fun peekResponseBody(
}
internal fun shouldParseBody(response: Response): Boolean {
+ if (response.request.url.encodedPath == "/bitdrift_public.protobuf.client.v1.ApiService/Mux") {
+ return false
+ }
+
// Check for Content-Type
val skipContentTypes = listOf(
"text/event-stream", // Server Sent Events
-992
View File
@@ -1,992 +0,0 @@
diff --git a/node_modules/expo-notifications/android/.gradle/8.10/checksums/checksums.lock b/node_modules/expo-notifications/android/.gradle/8.10/checksums/checksums.lock
new file mode 100644
index 0000000..883ef6a
Binary files /dev/null and b/node_modules/expo-notifications/android/.gradle/8.10/checksums/checksums.lock differ
diff --git a/node_modules/expo-notifications/android/.gradle/8.10/dependencies-accessors/gc.properties b/node_modules/expo-notifications/android/.gradle/8.10/dependencies-accessors/gc.properties
new file mode 100644
index 0000000..e69de29
diff --git a/node_modules/expo-notifications/android/.gradle/8.10/fileChanges/last-build.bin b/node_modules/expo-notifications/android/.gradle/8.10/fileChanges/last-build.bin
new file mode 100644
index 0000000..f76dd23
Binary files /dev/null and b/node_modules/expo-notifications/android/.gradle/8.10/fileChanges/last-build.bin differ
diff --git a/node_modules/expo-notifications/android/.gradle/8.10/fileHashes/fileHashes.lock b/node_modules/expo-notifications/android/.gradle/8.10/fileHashes/fileHashes.lock
new file mode 100644
index 0000000..774caf7
Binary files /dev/null and b/node_modules/expo-notifications/android/.gradle/8.10/fileHashes/fileHashes.lock differ
diff --git a/node_modules/expo-notifications/android/.gradle/8.10/gc.properties b/node_modules/expo-notifications/android/.gradle/8.10/gc.properties
new file mode 100644
index 0000000..e69de29
diff --git a/node_modules/expo-notifications/android/.gradle/buildOutputCleanup/buildOutputCleanup.lock b/node_modules/expo-notifications/android/.gradle/buildOutputCleanup/buildOutputCleanup.lock
new file mode 100644
index 0000000..a3c1514
Binary files /dev/null and b/node_modules/expo-notifications/android/.gradle/buildOutputCleanup/buildOutputCleanup.lock differ
diff --git a/node_modules/expo-notifications/android/.gradle/buildOutputCleanup/cache.properties b/node_modules/expo-notifications/android/.gradle/buildOutputCleanup/cache.properties
new file mode 100644
index 0000000..0e5b4da
--- /dev/null
+++ b/node_modules/expo-notifications/android/.gradle/buildOutputCleanup/cache.properties
@@ -0,0 +1,2 @@
+#Thu Apr 24 20:44:36 PDT 2025
+gradle.version=8.10
diff --git a/node_modules/expo-notifications/android/.gradle/config.properties b/node_modules/expo-notifications/android/.gradle/config.properties
new file mode 100644
index 0000000..0bd71c6
--- /dev/null
+++ b/node_modules/expo-notifications/android/.gradle/config.properties
@@ -0,0 +1,2 @@
+#Thu Apr 24 20:44:32 PDT 2025
+java.home=/Applications/Android Studio.app/Contents/jbr/Contents/Home
diff --git a/node_modules/expo-notifications/android/.gradle/vcs-1/gc.properties b/node_modules/expo-notifications/android/.gradle/vcs-1/gc.properties
new file mode 100644
index 0000000..e69de29
diff --git a/node_modules/expo-notifications/android/.idea/.gitignore b/node_modules/expo-notifications/android/.idea/.gitignore
new file mode 100644
index 0000000..26d3352
--- /dev/null
+++ b/node_modules/expo-notifications/android/.idea/.gitignore
@@ -0,0 +1,3 @@
+# Default ignored files
+/shelf/
+/workspace.xml
diff --git a/node_modules/expo-notifications/android/.idea/AndroidProjectSystem.xml b/node_modules/expo-notifications/android/.idea/AndroidProjectSystem.xml
new file mode 100644
index 0000000..4a53bee
--- /dev/null
+++ b/node_modules/expo-notifications/android/.idea/AndroidProjectSystem.xml
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+ <component name="AndroidProjectSystem">
+ <option name="providerId" value="com.android.tools.idea.GradleProjectSystem" />
+ </component>
+</project>
\ No newline at end of file
diff --git a/node_modules/expo-notifications/android/.idea/caches/deviceStreaming.xml b/node_modules/expo-notifications/android/.idea/caches/deviceStreaming.xml
new file mode 100644
index 0000000..9e9ba09
--- /dev/null
+++ b/node_modules/expo-notifications/android/.idea/caches/deviceStreaming.xml
@@ -0,0 +1,607 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+ <component name="DeviceStreaming">
+ <option name="deviceSelectionList">
+ <list>
+ <PersistentDeviceSelectionData>
+ <option name="api" value="27" />
+ <option name="brand" value="DOCOMO" />
+ <option name="codename" value="F01L" />
+ <option name="id" value="F01L" />
+ <option name="labId" value="google" />
+ <option name="manufacturer" value="FUJITSU" />
+ <option name="name" value="F-01L" />
+ <option name="screenDensity" value="360" />
+ <option name="screenX" value="720" />
+ <option name="screenY" value="1280" />
+ </PersistentDeviceSelectionData>
+ <PersistentDeviceSelectionData>
+ <option name="api" value="34" />
+ <option name="brand" value="OnePlus" />
+ <option name="codename" value="OP5552L1" />
+ <option name="id" value="OP5552L1" />
+ <option name="labId" value="google" />
+ <option name="manufacturer" value="OnePlus" />
+ <option name="name" value="CPH2415" />
+ <option name="screenDensity" value="480" />
+ <option name="screenX" value="1080" />
+ <option name="screenY" value="2412" />
+ </PersistentDeviceSelectionData>
+ <PersistentDeviceSelectionData>
+ <option name="api" value="34" />
+ <option name="brand" value="OPPO" />
+ <option name="codename" value="OP573DL1" />
+ <option name="id" value="OP573DL1" />
+ <option name="labId" value="google" />
+ <option name="manufacturer" value="OPPO" />
+ <option name="name" value="CPH2557" />
+ <option name="screenDensity" value="480" />
+ <option name="screenX" value="1080" />
+ <option name="screenY" value="2400" />
+ </PersistentDeviceSelectionData>
+ <PersistentDeviceSelectionData>
+ <option name="api" value="28" />
+ <option name="brand" value="DOCOMO" />
+ <option name="codename" value="SH-01L" />
+ <option name="id" value="SH-01L" />
+ <option name="labId" value="google" />
+ <option name="manufacturer" value="SHARP" />
+ <option name="name" value="AQUOS sense2 SH-01L" />
+ <option name="screenDensity" value="480" />
+ <option name="screenX" value="1080" />
+ <option name="screenY" value="2160" />
+ </PersistentDeviceSelectionData>
+ <PersistentDeviceSelectionData>
+ <option name="api" value="34" />
+ <option name="brand" value="Lenovo" />
+ <option name="codename" value="TB370FU" />
+ <option name="formFactor" value="Tablet" />
+ <option name="id" value="TB370FU" />
+ <option name="labId" value="google" />
+ <option name="manufacturer" value="Lenovo" />
+ <option name="name" value="Tab P12" />
+ <option name="screenDensity" value="340" />
+ <option name="screenX" value="1840" />
+ <option name="screenY" value="2944" />
+ </PersistentDeviceSelectionData>
+ <PersistentDeviceSelectionData>
+ <option name="api" value="34" />
+ <option name="brand" value="samsung" />
+ <option name="codename" value="a15" />
+ <option name="id" value="a15" />
+ <option name="labId" value="google" />
+ <option name="manufacturer" value="Samsung" />
+ <option name="name" value="A15" />
+ <option name="screenDensity" value="450" />
+ <option name="screenX" value="1080" />
+ <option name="screenY" value="2340" />
+ </PersistentDeviceSelectionData>
+ <PersistentDeviceSelectionData>
+ <option name="api" value="34" />
+ <option name="brand" value="samsung" />
+ <option name="codename" value="a35x" />
+ <option name="id" value="a35x" />
+ <option name="labId" value="google" />
+ <option name="manufacturer" value="Samsung" />
+ <option name="name" value="A35" />
+ <option name="screenDensity" value="450" />
+ <option name="screenX" value="1080" />
+ <option name="screenY" value="2340" />
+ </PersistentDeviceSelectionData>
+ <PersistentDeviceSelectionData>
+ <option name="api" value="31" />
+ <option name="brand" value="samsung" />
+ <option name="codename" value="a51" />
+ <option name="id" value="a51" />
+ <option name="labId" value="google" />
+ <option name="manufacturer" value="Samsung" />
+ <option name="name" value="Galaxy A51" />
+ <option name="screenDensity" value="420" />
+ <option name="screenX" value="1080" />
+ <option name="screenY" value="2400" />
+ </PersistentDeviceSelectionData>
+ <PersistentDeviceSelectionData>
+ <option name="api" value="34" />
+ <option name="brand" value="google" />
+ <option name="codename" value="akita" />
+ <option name="id" value="akita" />
+ <option name="labId" value="google" />
+ <option name="manufacturer" value="Google" />
+ <option name="name" value="Pixel 8a" />
+ <option name="screenDensity" value="420" />
+ <option name="screenX" value="1080" />
+ <option name="screenY" value="2400" />
+ </PersistentDeviceSelectionData>
+ <PersistentDeviceSelectionData>
+ <option name="api" value="34" />
+ <option name="brand" value="motorola" />
+ <option name="codename" value="arcfox" />
+ <option name="id" value="arcfox" />
+ <option name="labId" value="google" />
+ <option name="manufacturer" value="Motorola" />
+ <option name="name" value="razr plus 2024" />
+ <option name="screenDensity" value="360" />
+ <option name="screenX" value="1080" />
+ <option name="screenY" value="1272" />
+ </PersistentDeviceSelectionData>
+ <PersistentDeviceSelectionData>
+ <option name="api" value="33" />
+ <option name="brand" value="motorola" />
+ <option name="codename" value="austin" />
+ <option name="id" value="austin" />
+ <option name="labId" value="google" />
+ <option name="manufacturer" value="Motorola" />
+ <option name="name" value="moto g 5G (2022)" />
+ <option name="screenDensity" value="280" />
+ <option name="screenX" value="720" />
+ <option name="screenY" value="1600" />
+ </PersistentDeviceSelectionData>
+ <PersistentDeviceSelectionData>
+ <option name="api" value="33" />
+ <option name="brand" value="samsung" />
+ <option name="codename" value="b0q" />
+ <option name="id" value="b0q" />
+ <option name="labId" value="google" />
+ <option name="manufacturer" value="Samsung" />
+ <option name="name" value="Galaxy S22 Ultra" />
+ <option name="screenDensity" value="600" />
+ <option name="screenX" value="1440" />
+ <option name="screenY" value="3088" />
+ </PersistentDeviceSelectionData>
+ <PersistentDeviceSelectionData>
+ <option name="api" value="32" />
+ <option name="brand" value="google" />
+ <option name="codename" value="bluejay" />
+ <option name="id" value="bluejay" />
+ <option name="labId" value="google" />
+ <option name="manufacturer" value="Google" />
+ <option name="name" value="Pixel 6a" />
+ <option name="screenDensity" value="420" />
+ <option name="screenX" value="1080" />
+ <option name="screenY" value="2400" />
+ </PersistentDeviceSelectionData>
+ <PersistentDeviceSelectionData>
+ <option name="api" value="34" />
+ <option name="brand" value="google" />
+ <option name="codename" value="caiman" />
+ <option name="id" value="caiman" />
+ <option name="labId" value="google" />
+ <option name="manufacturer" value="Google" />
+ <option name="name" value="Pixel 9 Pro" />
+ <option name="screenDensity" value="360" />
+ <option name="screenX" value="960" />
+ <option name="screenY" value="2142" />
+ </PersistentDeviceSelectionData>
+ <PersistentDeviceSelectionData>
+ <option name="api" value="34" />
+ <option name="brand" value="google" />
+ <option name="codename" value="comet" />
+ <option name="default" value="true" />
+ <option name="id" value="comet" />
+ <option name="labId" value="google" />
+ <option name="manufacturer" value="Google" />
+ <option name="name" value="Pixel 9 Pro Fold" />
+ <option name="screenDensity" value="390" />
+ <option name="screenX" value="2076" />
+ <option name="screenY" value="2152" />
+ </PersistentDeviceSelectionData>
+ <PersistentDeviceSelectionData>
+ <option name="api" value="29" />
+ <option name="brand" value="samsung" />
+ <option name="codename" value="crownqlteue" />
+ <option name="id" value="crownqlteue" />
+ <option name="labId" value="google" />
+ <option name="manufacturer" value="Samsung" />
+ <option name="name" value="Galaxy Note9" />
+ <option name="screenDensity" value="420" />
+ <option name="screenX" value="2220" />
+ <option name="screenY" value="1080" />
+ </PersistentDeviceSelectionData>
+ <PersistentDeviceSelectionData>
+ <option name="api" value="34" />
+ <option name="brand" value="samsung" />
+ <option name="codename" value="dm2q" />
+ <option name="id" value="dm2q" />
+ <option name="labId" value="google" />
+ <option name="manufacturer" value="Samsung" />
+ <option name="name" value="S23 Plus" />
+ <option name="screenDensity" value="450" />
+ <option name="screenX" value="1080" />
+ <option name="screenY" value="2340" />
+ </PersistentDeviceSelectionData>
+ <PersistentDeviceSelectionData>
+ <option name="api" value="34" />
+ <option name="brand" value="samsung" />
+ <option name="codename" value="dm3q" />
+ <option name="id" value="dm3q" />
+ <option name="labId" value="google" />
+ <option name="manufacturer" value="Samsung" />
+ <option name="name" value="Galaxy S23 Ultra" />
+ <option name="screenDensity" value="600" />
+ <option name="screenX" value="1440" />
+ <option name="screenY" value="3088" />
+ </PersistentDeviceSelectionData>
+ <PersistentDeviceSelectionData>
+ <option name="api" value="34" />
+ <option name="brand" value="samsung" />
+ <option name="codename" value="e1q" />
+ <option name="default" value="true" />
+ <option name="id" value="e1q" />
+ <option name="labId" value="google" />
+ <option name="manufacturer" value="Samsung" />
+ <option name="name" value="Galaxy S24" />
+ <option name="screenDensity" value="480" />
+ <option name="screenX" value="1080" />
+ <option name="screenY" value="2340" />
+ </PersistentDeviceSelectionData>
+ <PersistentDeviceSelectionData>
+ <option name="api" value="34" />
+ <option name="brand" value="samsung" />
+ <option name="codename" value="e3q" />
+ <option name="id" value="e3q" />
+ <option name="labId" value="google" />
+ <option name="manufacturer" value="Samsung" />
+ <option name="name" value="Galaxy S24 Ultra" />
+ <option name="screenDensity" value="450" />
+ <option name="screenX" value="1440" />
+ <option name="screenY" value="3120" />
+ </PersistentDeviceSelectionData>
+ <PersistentDeviceSelectionData>
+ <option name="api" value="33" />
+ <option name="brand" value="google" />
+ <option name="codename" value="eos" />
+ <option name="id" value="eos" />
+ <option name="labId" value="google" />
+ <option name="manufacturer" value="Google" />
+ <option name="name" value="Eos" />
+ <option name="screenDensity" value="320" />
+ <option name="screenX" value="384" />
+ <option name="screenY" value="384" />
+ </PersistentDeviceSelectionData>
+ <PersistentDeviceSelectionData>
+ <option name="api" value="33" />
+ <option name="brand" value="google" />
+ <option name="codename" value="felix" />
+ <option name="id" value="felix" />
+ <option name="labId" value="google" />
+ <option name="manufacturer" value="Google" />
+ <option name="name" value="Pixel Fold" />
+ <option name="screenDensity" value="420" />
+ <option name="screenX" value="2208" />
+ <option name="screenY" value="1840" />
+ </PersistentDeviceSelectionData>
+ <PersistentDeviceSelectionData>
+ <option name="api" value="34" />
+ <option name="brand" value="google" />
+ <option name="codename" value="felix" />
+ <option name="id" value="felix" />
+ <option name="labId" value="google" />
+ <option name="manufacturer" value="Google" />
+ <option name="name" value="Pixel Fold" />
+ <option name="screenDensity" value="420" />
+ <option name="screenX" value="2208" />
+ <option name="screenY" value="1840" />
+ </PersistentDeviceSelectionData>
+ <PersistentDeviceSelectionData>
+ <option name="api" value="33" />
+ <option name="brand" value="google" />
+ <option name="codename" value="felix_camera" />
+ <option name="id" value="felix_camera" />
+ <option name="labId" value="google" />
+ <option name="manufacturer" value="Google" />
+ <option name="name" value="Pixel Fold (Camera-enabled)" />
+ <option name="screenDensity" value="420" />
+ <option name="screenX" value="2208" />
+ <option name="screenY" value="1840" />
+ </PersistentDeviceSelectionData>
+ <PersistentDeviceSelectionData>
+ <option name="api" value="34" />
+ <option name="brand" value="motorola" />
+ <option name="codename" value="fogona" />
+ <option name="id" value="fogona" />
+ <option name="labId" value="google" />
+ <option name="manufacturer" value="Motorola" />
+ <option name="name" value="moto g play - 2024" />
+ <option name="screenDensity" value="280" />
+ <option name="screenX" value="720" />
+ <option name="screenY" value="1600" />
+ </PersistentDeviceSelectionData>
+ <PersistentDeviceSelectionData>
+ <option name="api" value="34" />
+ <option name="brand" value="samsung" />
+ <option name="codename" value="g0q" />
+ <option name="id" value="g0q" />
+ <option name="labId" value="google" />
+ <option name="manufacturer" value="Samsung" />
+ <option name="name" value="SM-S906U1" />
+ <option name="screenDensity" value="450" />
+ <option name="screenX" value="1080" />
+ <option name="screenY" value="2340" />
+ </PersistentDeviceSelectionData>
+ <PersistentDeviceSelectionData>
+ <option name="api" value="34" />
+ <option name="brand" value="samsung" />
+ <option name="codename" value="gta9pwifi" />
+ <option name="id" value="gta9pwifi" />
+ <option name="labId" value="google" />
+ <option name="manufacturer" value="Samsung" />
+ <option name="name" value="SM-X210" />
+ <option name="screenDensity" value="240" />
+ <option name="screenX" value="1200" />
+ <option name="screenY" value="1920" />
+ </PersistentDeviceSelectionData>
+ <PersistentDeviceSelectionData>
+ <option name="api" value="34" />
+ <option name="brand" value="samsung" />
+ <option name="codename" value="gts7xllite" />
+ <option name="id" value="gts7xllite" />
+ <option name="labId" value="google" />
+ <option name="manufacturer" value="Samsung" />
+ <option name="name" value="SM-T738U" />
+ <option name="screenDensity" value="340" />
+ <option name="screenX" value="1600" />
+ <option name="screenY" value="2560" />
+ </PersistentDeviceSelectionData>
+ <PersistentDeviceSelectionData>
+ <option name="api" value="33" />
+ <option name="brand" value="samsung" />
+ <option name="codename" value="gts8uwifi" />
+ <option name="formFactor" value="Tablet" />
+ <option name="id" value="gts8uwifi" />
+ <option name="labId" value="google" />
+ <option name="manufacturer" value="Samsung" />
+ <option name="name" value="Galaxy Tab S8 Ultra" />
+ <option name="screenDensity" value="320" />
+ <option name="screenX" value="1848" />
+ <option name="screenY" value="2960" />
+ </PersistentDeviceSelectionData>
+ <PersistentDeviceSelectionData>
+ <option name="api" value="34" />
+ <option name="brand" value="samsung" />
+ <option name="codename" value="gts8wifi" />
+ <option name="formFactor" value="Tablet" />
+ <option name="id" value="gts8wifi" />
+ <option name="labId" value="google" />
+ <option name="manufacturer" value="Samsung" />
+ <option name="name" value="Galaxy Tab S8" />
+ <option name="screenDensity" value="274" />
+ <option name="screenX" value="1600" />
+ <option name="screenY" value="2560" />
+ </PersistentDeviceSelectionData>
+ <PersistentDeviceSelectionData>
+ <option name="api" value="34" />
+ <option name="brand" value="samsung" />
+ <option name="codename" value="gts9fe" />
+ <option name="id" value="gts9fe" />
+ <option name="labId" value="google" />
+ <option name="manufacturer" value="Samsung" />
+ <option name="name" value="Galaxy Tab S9 FE 5G" />
+ <option name="screenDensity" value="280" />
+ <option name="screenX" value="1440" />
+ <option name="screenY" value="2304" />
+ </PersistentDeviceSelectionData>
+ <PersistentDeviceSelectionData>
+ <option name="api" value="34" />
+ <option name="brand" value="google" />
+ <option name="codename" value="husky" />
+ <option name="id" value="husky" />
+ <option name="labId" value="google" />
+ <option name="manufacturer" value="Google" />
+ <option name="name" value="Pixel 8 Pro" />
+ <option name="screenDensity" value="390" />
+ <option name="screenX" value="1008" />
+ <option name="screenY" value="2244" />
+ </PersistentDeviceSelectionData>
+ <PersistentDeviceSelectionData>
+ <option name="api" value="30" />
+ <option name="brand" value="motorola" />
+ <option name="codename" value="java" />
+ <option name="id" value="java" />
+ <option name="labId" value="google" />
+ <option name="manufacturer" value="Motorola" />
+ <option name="name" value="G20" />
+ <option name="screenDensity" value="280" />
+ <option name="screenX" value="720" />
+ <option name="screenY" value="1600" />
+ </PersistentDeviceSelectionData>
+ <PersistentDeviceSelectionData>
+ <option name="api" value="34" />
+ <option name="brand" value="google" />
+ <option name="codename" value="komodo" />
+ <option name="id" value="komodo" />
+ <option name="labId" value="google" />
+ <option name="manufacturer" value="Google" />
+ <option name="name" value="Pixel 9 Pro XL" />
+ <option name="screenDensity" value="360" />
+ <option name="screenX" value="1008" />
+ <option name="screenY" value="2244" />
+ </PersistentDeviceSelectionData>
+ <PersistentDeviceSelectionData>
+ <option name="api" value="33" />
+ <option name="brand" value="google" />
+ <option name="codename" value="lynx" />
+ <option name="id" value="lynx" />
+ <option name="labId" value="google" />
+ <option name="manufacturer" value="Google" />
+ <option name="name" value="Pixel 7a" />
+ <option name="screenDensity" value="420" />
+ <option name="screenX" value="1080" />
+ <option name="screenY" value="2400" />
+ </PersistentDeviceSelectionData>
+ <PersistentDeviceSelectionData>
+ <option name="api" value="33" />
+ <option name="brand" value="motorola" />
+ <option name="codename" value="maui" />
+ <option name="id" value="maui" />
+ <option name="labId" value="google" />
+ <option name="manufacturer" value="Motorola" />
+ <option name="name" value="moto g play - 2023" />
+ <option name="screenDensity" value="280" />
+ <option name="screenX" value="720" />
+ <option name="screenY" value="1600" />
+ </PersistentDeviceSelectionData>
+ <PersistentDeviceSelectionData>
+ <option name="api" value="34" />
+ <option name="brand" value="samsung" />
+ <option name="codename" value="o1q" />
+ <option name="id" value="o1q" />
+ <option name="labId" value="google" />
+ <option name="manufacturer" value="Samsung" />
+ <option name="name" value="Galaxy S21" />
+ <option name="screenDensity" value="421" />
+ <option name="screenX" value="1080" />
+ <option name="screenY" value="2400" />
+ </PersistentDeviceSelectionData>
+ <PersistentDeviceSelectionData>
+ <option name="api" value="31" />
+ <option name="brand" value="google" />
+ <option name="codename" value="oriole" />
+ <option name="id" value="oriole" />
+ <option name="labId" value="google" />
+ <option name="manufacturer" value="Google" />
+ <option name="name" value="Pixel 6" />
+ <option name="screenDensity" value="420" />
+ <option name="screenX" value="1080" />
+ <option name="screenY" value="2400" />
+ </PersistentDeviceSelectionData>
+ <PersistentDeviceSelectionData>
+ <option name="api" value="33" />
+ <option name="brand" value="google" />
+ <option name="codename" value="panther" />
+ <option name="id" value="panther" />
+ <option name="labId" value="google" />
+ <option name="manufacturer" value="Google" />
+ <option name="name" value="Pixel 7" />
+ <option name="screenDensity" value="420" />
+ <option name="screenX" value="1080" />
+ <option name="screenY" value="2400" />
+ </PersistentDeviceSelectionData>
+ <PersistentDeviceSelectionData>
+ <option name="api" value="34" />
+ <option name="brand" value="samsung" />
+ <option name="codename" value="q5q" />
+ <option name="id" value="q5q" />
+ <option name="labId" value="google" />
+ <option name="manufacturer" value="Samsung" />
+ <option name="name" value="Galaxy Z Fold5" />
+ <option name="screenDensity" value="420" />
+ <option name="screenX" value="1812" />
+ <option name="screenY" value="2176" />
+ </PersistentDeviceSelectionData>
+ <PersistentDeviceSelectionData>
+ <option name="api" value="34" />
+ <option name="brand" value="samsung" />
+ <option name="codename" value="q6q" />
+ <option name="id" value="q6q" />
+ <option name="labId" value="google" />
+ <option name="manufacturer" value="Samsung" />
+ <option name="name" value="Galaxy Z Fold6" />
+ <option name="screenDensity" value="420" />
+ <option name="screenX" value="1856" />
+ <option name="screenY" value="2160" />
+ </PersistentDeviceSelectionData>
+ <PersistentDeviceSelectionData>
+ <option name="api" value="30" />
+ <option name="brand" value="google" />
+ <option name="codename" value="r11" />
+ <option name="formFactor" value="Wear OS" />
+ <option name="id" value="r11" />
+ <option name="labId" value="google" />
+ <option name="manufacturer" value="Google" />
+ <option name="name" value="Pixel Watch" />
+ <option name="screenDensity" value="320" />
+ <option name="screenX" value="384" />
+ <option name="screenY" value="384" />
+ <option name="type" value="WEAR_OS" />
+ </PersistentDeviceSelectionData>
+ <PersistentDeviceSelectionData>
+ <option name="api" value="34" />
+ <option name="brand" value="samsung" />
+ <option name="codename" value="r11q" />
+ <option name="id" value="r11q" />
+ <option name="labId" value="google" />
+ <option name="manufacturer" value="Samsung" />
+ <option name="name" value="SM-S711U" />
+ <option name="screenDensity" value="450" />
+ <option name="screenX" value="1080" />
+ <option name="screenY" value="2340" />
+ </PersistentDeviceSelectionData>
+ <PersistentDeviceSelectionData>
+ <option name="api" value="30" />
+ <option name="brand" value="google" />
+ <option name="codename" value="redfin" />
+ <option name="id" value="redfin" />
+ <option name="labId" value="google" />
+ <option name="manufacturer" value="Google" />
+ <option name="name" value="Pixel 5" />
+ <option name="screenDensity" value="440" />
+ <option name="screenX" value="1080" />
+ <option name="screenY" value="2340" />
+ </PersistentDeviceSelectionData>
+ <PersistentDeviceSelectionData>
+ <option name="api" value="34" />
+ <option name="brand" value="google" />
+ <option name="codename" value="shiba" />
+ <option name="id" value="shiba" />
+ <option name="labId" value="google" />
+ <option name="manufacturer" value="Google" />
+ <option name="name" value="Pixel 8" />
+ <option name="screenDensity" value="420" />
+ <option name="screenX" value="1080" />
+ <option name="screenY" value="2400" />
+ </PersistentDeviceSelectionData>
+ <PersistentDeviceSelectionData>
+ <option name="api" value="34" />
+ <option name="brand" value="samsung" />
+ <option name="codename" value="t2q" />
+ <option name="id" value="t2q" />
+ <option name="labId" value="google" />
+ <option name="manufacturer" value="Samsung" />
+ <option name="name" value="Galaxy S21 Plus" />
+ <option name="screenDensity" value="394" />
+ <option name="screenX" value="1080" />
+ <option name="screenY" value="2400" />
+ </PersistentDeviceSelectionData>
+ <PersistentDeviceSelectionData>
+ <option name="api" value="33" />
+ <option name="brand" value="google" />
+ <option name="codename" value="tangorpro" />
+ <option name="formFactor" value="Tablet" />
+ <option name="id" value="tangorpro" />
+ <option name="labId" value="google" />
+ <option name="manufacturer" value="Google" />
+ <option name="name" value="Pixel Tablet" />
+ <option name="screenDensity" value="320" />
+ <option name="screenX" value="1600" />
+ <option name="screenY" value="2560" />
+ </PersistentDeviceSelectionData>
+ <PersistentDeviceSelectionData>
+ <option name="api" value="34" />
+ <option name="brand" value="google" />
+ <option name="codename" value="tokay" />
+ <option name="default" value="true" />
+ <option name="id" value="tokay" />
+ <option name="labId" value="google" />
+ <option name="manufacturer" value="Google" />
+ <option name="name" value="Pixel 9" />
+ <option name="screenDensity" value="420" />
+ <option name="screenX" value="1080" />
+ <option name="screenY" value="2424" />
+ </PersistentDeviceSelectionData>
+ <PersistentDeviceSelectionData>
+ <option name="api" value="35" />
+ <option name="brand" value="google" />
+ <option name="codename" value="tokay" />
+ <option name="default" value="true" />
+ <option name="id" value="tokay" />
+ <option name="labId" value="google" />
+ <option name="manufacturer" value="Google" />
+ <option name="name" value="Pixel 9" />
+ <option name="screenDensity" value="420" />
+ <option name="screenX" value="1080" />
+ <option name="screenY" value="2424" />
+ </PersistentDeviceSelectionData>
+ </list>
+ </option>
+ </component>
+</project>
\ No newline at end of file
diff --git a/node_modules/expo-notifications/android/.idea/gradle.xml b/node_modules/expo-notifications/android/.idea/gradle.xml
new file mode 100644
index 0000000..b838237
--- /dev/null
+++ b/node_modules/expo-notifications/android/.idea/gradle.xml
@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+ <component name="GradleSettings">
+ <option name="linkedExternalProjectsSettings">
+ <GradleProjectSettings>
+ <option name="testRunner" value="CHOOSE_PER_TEST" />
+ <option name="externalProjectPath" value="$PROJECT_DIR$" />
+ <option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
+ </GradleProjectSettings>
+ </option>
+ </component>
+</project>
\ No newline at end of file
diff --git a/node_modules/expo-notifications/android/.idea/migrations.xml b/node_modules/expo-notifications/android/.idea/migrations.xml
new file mode 100644
index 0000000..f8051a6
--- /dev/null
+++ b/node_modules/expo-notifications/android/.idea/migrations.xml
@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+ <component name="ProjectMigrations">
+ <option name="MigrateToGradleLocalJavaHome">
+ <set>
+ <option value="$PROJECT_DIR$" />
+ </set>
+ </option>
+ </component>
+</project>
\ No newline at end of file
diff --git a/node_modules/expo-notifications/android/.idea/misc.xml b/node_modules/expo-notifications/android/.idea/misc.xml
new file mode 100644
index 0000000..3040d03
--- /dev/null
+++ b/node_modules/expo-notifications/android/.idea/misc.xml
@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+ <component name="ExternalStorageConfigurationManager" enabled="true" />
+ <component name="ProjectRootManager">
+ <output url="file://$PROJECT_DIR$/build/classes" />
+ </component>
+ <component name="ProjectType">
+ <option name="id" value="Android" />
+ </component>
+</project>
\ No newline at end of file
diff --git a/node_modules/expo-notifications/android/.idea/runConfigurations.xml b/node_modules/expo-notifications/android/.idea/runConfigurations.xml
new file mode 100644
index 0000000..16660f1
--- /dev/null
+++ b/node_modules/expo-notifications/android/.idea/runConfigurations.xml
@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+ <component name="RunConfigurationProducerService">
+ <option name="ignoredProducers">
+ <set>
+ <option value="com.intellij.execution.junit.AbstractAllInDirectoryConfigurationProducer" />
+ <option value="com.intellij.execution.junit.AllInPackageConfigurationProducer" />
+ <option value="com.intellij.execution.junit.PatternConfigurationProducer" />
+ <option value="com.intellij.execution.junit.TestInClassConfigurationProducer" />
+ <option value="com.intellij.execution.junit.UniqueIdConfigurationProducer" />
+ <option value="com.intellij.execution.junit.testDiscovery.JUnitTestDiscoveryConfigurationProducer" />
+ <option value="org.jetbrains.kotlin.idea.junit.KotlinJUnitRunConfigurationProducer" />
+ <option value="org.jetbrains.kotlin.idea.junit.KotlinPatternConfigurationProducer" />
+ </set>
+ </option>
+ </component>
+</project>
\ No newline at end of file
diff --git a/node_modules/expo-notifications/android/.idea/workspace.xml b/node_modules/expo-notifications/android/.idea/workspace.xml
new file mode 100644
index 0000000..df26928
--- /dev/null
+++ b/node_modules/expo-notifications/android/.idea/workspace.xml
@@ -0,0 +1,47 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+ <component name="AutoImportSettings">
+ <option name="autoReloadType" value="NONE" />
+ </component>
+ <component name="ChangeListManager">
+ <list default="true" id="fed6a9c0-2e93-4b6e-953a-d1cd1e93b59f" name="Changes" comment="" />
+ <option name="SHOW_DIALOG" value="false" />
+ <option name="HIGHLIGHT_CONFLICTS" value="true" />
+ <option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
+ <option name="LAST_RESOLUTION" value="IGNORE" />
+ </component>
+ <component name="ClangdSettings">
+ <option name="formatViaClangd" value="false" />
+ </component>
+ <component name="ProjectColorInfo"><![CDATA[{
+ "associatedIndex": 4
+}]]></component>
+ <component name="ProjectId" id="2wCjuanPzVGKP91vdmftQVgUlaM" />
+ <component name="ProjectViewState">
+ <option name="hideEmptyMiddlePackages" value="true" />
+ <option name="showLibraryContents" value="true" />
+ </component>
+ <component name="PropertiesComponent"><![CDATA[{
+ "keyToString": {
+ "RunOnceActivity.ShowReadmeOnStart": "true",
+ "RunOnceActivity.cidr.known.project.marker": "true",
+ "RunOnceActivity.readMode.enableVisualFormatting": "true",
+ "android.gradle.sync.needed": "true",
+ "cf.first.check.clang-format": "false",
+ "cidr.known.project.marker": "true",
+ "kotlin-language-version-configured": "true",
+ "last_opened_file_path": "/Users/hailey/bsky/social-app/node_modules/expo-notifications/android"
+ }
+}]]></component>
+ <component name="SpellCheckerSettings" RuntimeDictionaries="0" Folders="0" CustomDictionaries="0" DefaultDictionary="application-level" UseSingleDictionary="true" transferred="true" />
+ <component name="TaskManager">
+ <task active="true" id="Default" summary="Default task">
+ <changelist id="fed6a9c0-2e93-4b6e-953a-d1cd1e93b59f" name="Changes" comment="" />
+ <created>1745552672693</created>
+ <option name="number" value="Default" />
+ <option name="presentableId" value="Default" />
+ <updated>1745552672693</updated>
+ </task>
+ <servers />
+ </component>
+</project>
\ No newline at end of file
diff --git a/node_modules/expo-notifications/android/build.gradle b/node_modules/expo-notifications/android/build.gradle
index bc479ee..1ebfa00 100644
--- a/node_modules/expo-notifications/android/build.gradle
+++ b/node_modules/expo-notifications/android/build.gradle
@@ -42,6 +42,7 @@ dependencies {
implementation 'com.google.firebase:firebase-messaging:24.0.1'
implementation 'me.leolin:ShortcutBadger:1.1.22@aar'
+ implementation project(':expo-background-notification-handler')
if (project.findProject(':expo-modules-test-core')) {
testImplementation project(':expo-modules-test-core')
diff --git a/node_modules/expo-notifications/android/local.properties b/node_modules/expo-notifications/android/local.properties
new file mode 100644
index 0000000..ab4c86d
--- /dev/null
+++ b/node_modules/expo-notifications/android/local.properties
@@ -0,0 +1,8 @@
+## This file must *NOT* be checked into Version Control Systems,
+# as it contains information specific to your local configuration.
+#
+# Location of the SDK. This is only used by Gradle.
+# For customization when using a Version Control System, please read the
+# header note.
+#Thu Apr 24 20:44:32 PDT 2025
+sdk.dir=/Users/hailey/Library/Android/sdk
diff --git a/node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/notifications/interfaces/INotificationContent.kt b/node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/notifications/interfaces/INotificationContent.kt
index 7b99e6c..45a450d 100644
--- a/node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/notifications/interfaces/INotificationContent.kt
+++ b/node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/notifications/interfaces/INotificationContent.kt
@@ -15,6 +15,7 @@ import org.json.JSONObject
* This interface exists to provide a common API for both classes.
* */
interface INotificationContent : Parcelable {
+ val channelId: String?
val title: String?
val text: String?
val subText: String?
diff --git a/node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/notifications/model/NotificationContent.java b/node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/notifications/model/NotificationContent.java
index 191b64e..fe8b3c5 100644
--- a/node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/notifications/model/NotificationContent.java
+++ b/node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/notifications/model/NotificationContent.java
@@ -35,6 +35,7 @@ import kotlin.coroutines.Continuation;
* Refactoring this class may require a migration strategy for the data stored in SharedPreferences.
*/
public class NotificationContent implements Parcelable, Serializable, INotificationContent {
+ private String mChannelId;
private String mTitle;
private String mText;
private String mSubtitle;
@@ -65,6 +66,11 @@ public class NotificationContent implements Parcelable, Serializable, INotificat
}
};
+ @Nullable
+ public String getChannelId() {
+ return mChannelId;
+ }
+
@Nullable
public String getTitle() {
return mTitle;
@@ -158,6 +164,7 @@ public class NotificationContent implements Parcelable, Serializable, INotificat
}
protected NotificationContent(Parcel in) {
+ mChannelId = in.readString();
mTitle = in.readString();
mText = in.readString();
mSubtitle = in.readString();
@@ -183,6 +190,7 @@ public class NotificationContent implements Parcelable, Serializable, INotificat
@Override
public void writeToParcel(Parcel dest, int flags) {
+ dest.writeString(mChannelId);
dest.writeString(mTitle);
dest.writeString(mText);
dest.writeString(mSubtitle);
@@ -203,6 +211,7 @@ public class NotificationContent implements Parcelable, Serializable, INotificat
private static final long serialVersionUID = 397666843266836802L;
private void writeObject(java.io.ObjectOutputStream out) throws IOException {
+ out.writeObject(mChannelId);
out.writeObject(mTitle);
out.writeObject(mText);
out.writeObject(mSubtitle);
@@ -285,6 +294,11 @@ public class NotificationContent implements Parcelable, Serializable, INotificat
useDefaultVibrationPattern();
}
+ public Builder setChannelId(String channelId) {
+ content.mChannelId = channelId;
+ return this;
+ }
+
public Builder setTitle(String title) {
content.mTitle = title;
return this;
diff --git a/node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/notifications/model/NotificationData.kt b/node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/notifications/model/NotificationData.kt
index 3af254c..3c77e9d 100644
--- a/node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/notifications/model/NotificationData.kt
+++ b/node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/notifications/model/NotificationData.kt
@@ -11,6 +11,9 @@ import org.json.JSONObject
* */
@JvmInline
value class NotificationData(private val data: Map<String, String>) {
+ val channelId: String?
+ get() = data["channelId"]
+
val title: String?
get() = data["title"]
diff --git a/node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/notifications/model/RemoteNotificationContent.kt b/node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/notifications/model/RemoteNotificationContent.kt
index d2cc6cf..6a48ff2 100644
--- a/node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/notifications/model/RemoteNotificationContent.kt
+++ b/node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/notifications/model/RemoteNotificationContent.kt
@@ -31,6 +31,8 @@ class RemoteNotificationContent(private val remoteMessage: RemoteMessage) : INot
return remoteMessage.notification?.imageUrl != null
}
+ override val channelId = remoteMessage.notification?.channelId ?: notificationData.channelId
+
override val title = remoteMessage.notification?.title ?: notificationData.title
override val text = remoteMessage.notification?.body ?: notificationData.message
diff --git a/node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/notifications/presentation/builders/ExpoNotificationBuilder.kt b/node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/notifications/presentation/builders/ExpoNotificationBuilder.kt
index 98f003f..2f745e8 100644
--- a/node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/notifications/presentation/builders/ExpoNotificationBuilder.kt
+++ b/node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/notifications/presentation/builders/ExpoNotificationBuilder.kt
@@ -101,6 +101,9 @@ open class ExpoNotificationBuilder(
builder.setOngoing(content.isSticky)
// see "Notification anatomy" https://developer.android.com/develop/ui/views/notifications#Templates
+ content.channelId?.let {
+ builder.setChannelId(it)
+ }
builder.setContentTitle(content.title)
builder.setContentText(content.text)
builder.setSubText(content.subText)
diff --git a/node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/service/delegates/FirebaseMessagingDelegate.kt b/node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/service/delegates/FirebaseMessagingDelegate.kt
index 90ca4ff..9d4cb09 100644
--- a/node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/service/delegates/FirebaseMessagingDelegate.kt
+++ b/node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/service/delegates/FirebaseMessagingDelegate.kt
@@ -3,6 +3,9 @@ package expo.modules.notifications.service.delegates
import android.content.Context
import android.os.Bundle
import com.google.firebase.messaging.RemoteMessage
+import expo.modules.backgroundnotificationhandler.BackgroundNotificationHandler
+import expo.modules.backgroundnotificationhandler.BackgroundNotificationHandlerInterface
+import expo.modules.backgroundnotificationhandler.ExpoBackgroundNotificationHandlerModule
import expo.modules.interfaces.taskManager.TaskServiceProviderHelper
import expo.modules.notifications.notifications.RemoteMessageSerializer
import expo.modules.notifications.notifications.background.BackgroundRemoteNotificationTaskConsumer
@@ -18,7 +21,7 @@ import expo.modules.notifications.tokens.interfaces.FirebaseTokenListener
import java.lang.ref.WeakReference
import java.util.*
-open class FirebaseMessagingDelegate(protected val context: Context) : FirebaseMessagingDelegate {
+open class FirebaseMessagingDelegate(protected val context: Context) : FirebaseMessagingDelegate, BackgroundNotificationHandlerInterface{
companion object {
// Unfortunately we cannot save state between instances of a service other way
// than by static properties. Fortunately, using weak references we can
@@ -105,8 +108,19 @@ open class FirebaseMessagingDelegate(protected val context: Context) : FirebaseM
DebugLogging.logRemoteMessage("FirebaseMessagingDelegate.onMessageReceived: message", remoteMessage)
val notification = createNotification(remoteMessage)
DebugLogging.logNotification("FirebaseMessagingDelegate.onMessageReceived: notification", notification)
- NotificationsService.receive(context, notification)
- runTaskManagerTasks(context.applicationContext, RemoteMessageSerializer.toBundle(remoteMessage))
+ if (!ExpoBackgroundNotificationHandlerModule.isForegrounded) {
+ BackgroundNotificationHandler(context, this).handleMessage(remoteMessage)
+ } else {
+ NotificationsService.receive(context, notification)
+ runTaskManagerTasks(
+ context.applicationContext,
+ RemoteMessageSerializer.toBundle(remoteMessage)
+ )
+ }
+ }
+
+ override fun showMessage(remoteMessage: RemoteMessage) {
+ NotificationsService.receive(context, createNotification(remoteMessage))
}
protected fun createNotification(remoteMessage: RemoteMessage): Notification {
+170
View File
@@ -0,0 +1,170 @@
diff --git a/node_modules/expo-notifications/android/build.gradle b/node_modules/expo-notifications/android/build.gradle
index bc479ee..1ebfa00 100644
--- a/node_modules/expo-notifications/android/build.gradle
+++ b/node_modules/expo-notifications/android/build.gradle
@@ -42,6 +42,7 @@ dependencies {
implementation 'com.google.firebase:firebase-messaging:24.0.1'
implementation 'me.leolin:ShortcutBadger:1.1.22@aar'
+ implementation project(':expo-background-notification-handler')
if (project.findProject(':expo-modules-test-core')) {
testImplementation project(':expo-modules-test-core')
diff --git a/node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/notifications/interfaces/INotificationContent.kt b/node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/notifications/interfaces/INotificationContent.kt
index 7b99e6c..45a450d 100644
--- a/node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/notifications/interfaces/INotificationContent.kt
+++ b/node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/notifications/interfaces/INotificationContent.kt
@@ -15,6 +15,7 @@ import org.json.JSONObject
* This interface exists to provide a common API for both classes.
* */
interface INotificationContent : Parcelable {
+ val channelId: String?
val title: String?
val text: String?
val subText: String?
diff --git a/node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/notifications/model/NotificationContent.java b/node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/notifications/model/NotificationContent.java
index 191b64e..fe8b3c5 100644
--- a/node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/notifications/model/NotificationContent.java
+++ b/node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/notifications/model/NotificationContent.java
@@ -35,6 +35,7 @@ import kotlin.coroutines.Continuation;
* Refactoring this class may require a migration strategy for the data stored in SharedPreferences.
*/
public class NotificationContent implements Parcelable, Serializable, INotificationContent {
+ private String mChannelId;
private String mTitle;
private String mText;
private String mSubtitle;
@@ -65,6 +66,11 @@ public class NotificationContent implements Parcelable, Serializable, INotificat
}
};
+ @Nullable
+ public String getChannelId() {
+ return mChannelId;
+ }
+
@Nullable
public String getTitle() {
return mTitle;
@@ -158,6 +164,7 @@ public class NotificationContent implements Parcelable, Serializable, INotificat
}
protected NotificationContent(Parcel in) {
+ mChannelId = in.readString();
mTitle = in.readString();
mText = in.readString();
mSubtitle = in.readString();
@@ -183,6 +190,7 @@ public class NotificationContent implements Parcelable, Serializable, INotificat
@Override
public void writeToParcel(Parcel dest, int flags) {
+ dest.writeString(mChannelId);
dest.writeString(mTitle);
dest.writeString(mText);
dest.writeString(mSubtitle);
@@ -203,6 +211,7 @@ public class NotificationContent implements Parcelable, Serializable, INotificat
private static final long serialVersionUID = 397666843266836802L;
private void writeObject(java.io.ObjectOutputStream out) throws IOException {
+ out.writeObject(mChannelId);
out.writeObject(mTitle);
out.writeObject(mText);
out.writeObject(mSubtitle);
@@ -285,6 +294,11 @@ public class NotificationContent implements Parcelable, Serializable, INotificat
useDefaultVibrationPattern();
}
+ public Builder setChannelId(String channelId) {
+ content.mChannelId = channelId;
+ return this;
+ }
+
public Builder setTitle(String title) {
content.mTitle = title;
return this;
diff --git a/node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/notifications/model/NotificationData.kt b/node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/notifications/model/NotificationData.kt
index 3af254c..3c77e9d 100644
--- a/node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/notifications/model/NotificationData.kt
+++ b/node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/notifications/model/NotificationData.kt
@@ -11,6 +11,9 @@ import org.json.JSONObject
* */
@JvmInline
value class NotificationData(private val data: Map<String, String>) {
+ val channelId: String?
+ get() = data["channelId"]
+
val title: String?
get() = data["title"]
diff --git a/node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/notifications/model/RemoteNotificationContent.kt b/node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/notifications/model/RemoteNotificationContent.kt
index d2cc6cf..6a48ff2 100644
--- a/node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/notifications/model/RemoteNotificationContent.kt
+++ b/node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/notifications/model/RemoteNotificationContent.kt
@@ -31,6 +31,8 @@ class RemoteNotificationContent(private val remoteMessage: RemoteMessage) : INot
return remoteMessage.notification?.imageUrl != null
}
+ override val channelId = remoteMessage.notification?.channelId ?: notificationData.channelId
+
override val title = remoteMessage.notification?.title ?: notificationData.title
override val text = remoteMessage.notification?.body ?: notificationData.message
diff --git a/node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/notifications/presentation/builders/ExpoNotificationBuilder.kt b/node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/notifications/presentation/builders/ExpoNotificationBuilder.kt
index 98f003f..2f745e8 100644
--- a/node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/notifications/presentation/builders/ExpoNotificationBuilder.kt
+++ b/node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/notifications/presentation/builders/ExpoNotificationBuilder.kt
@@ -101,6 +101,9 @@ open class ExpoNotificationBuilder(
builder.setOngoing(content.isSticky)
// see "Notification anatomy" https://developer.android.com/develop/ui/views/notifications#Templates
+ content.channelId?.let {
+ builder.setChannelId(it)
+ }
builder.setContentTitle(content.title)
builder.setContentText(content.text)
builder.setSubText(content.subText)
diff --git a/node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/service/delegates/FirebaseMessagingDelegate.kt b/node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/service/delegates/FirebaseMessagingDelegate.kt
index 90ca4ff..9d4cb09 100644
--- a/node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/service/delegates/FirebaseMessagingDelegate.kt
+++ b/node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/service/delegates/FirebaseMessagingDelegate.kt
@@ -3,6 +3,9 @@ package expo.modules.notifications.service.delegates
import android.content.Context
import android.os.Bundle
import com.google.firebase.messaging.RemoteMessage
+import expo.modules.backgroundnotificationhandler.BackgroundNotificationHandler
+import expo.modules.backgroundnotificationhandler.BackgroundNotificationHandlerInterface
+import expo.modules.backgroundnotificationhandler.ExpoBackgroundNotificationHandlerModule
import expo.modules.interfaces.taskManager.TaskServiceProviderHelper
import expo.modules.notifications.notifications.RemoteMessageSerializer
import expo.modules.notifications.notifications.background.BackgroundRemoteNotificationTaskConsumer
@@ -18,7 +21,7 @@ import expo.modules.notifications.tokens.interfaces.FirebaseTokenListener
import java.lang.ref.WeakReference
import java.util.*
-open class FirebaseMessagingDelegate(protected val context: Context) : FirebaseMessagingDelegate {
+open class FirebaseMessagingDelegate(protected val context: Context) : FirebaseMessagingDelegate, BackgroundNotificationHandlerInterface{
companion object {
// Unfortunately we cannot save state between instances of a service other way
// than by static properties. Fortunately, using weak references we can
@@ -105,8 +108,19 @@ open class FirebaseMessagingDelegate(protected val context: Context) : FirebaseM
DebugLogging.logRemoteMessage("FirebaseMessagingDelegate.onMessageReceived: message", remoteMessage)
val notification = createNotification(remoteMessage)
DebugLogging.logNotification("FirebaseMessagingDelegate.onMessageReceived: notification", notification)
- NotificationsService.receive(context, notification)
- runTaskManagerTasks(context.applicationContext, RemoteMessageSerializer.toBundle(remoteMessage))
+ if (!ExpoBackgroundNotificationHandlerModule.isForegrounded) {
+ BackgroundNotificationHandler(context, this).handleMessage(remoteMessage)
+ } else {
+ NotificationsService.receive(context, notification)
+ runTaskManagerTasks(
+ context.applicationContext,
+ RemoteMessageSerializer.toBundle(remoteMessage)
+ )
+ }
+ }
+
+ override fun showMessage(remoteMessage: RemoteMessage) {
+ NotificationsService.receive(context, createNotification(remoteMessage))
}
protected fun createNotification(remoteMessage: RemoteMessage): Notification {
+9 -11
View File
@@ -1,8 +1,7 @@
import '#/logger/sentry/setup'
import '#/view/icons'
import {useEffect, useState} from 'react'
import * as React from 'react'
import {Fragment, useEffect, useState} from 'react'
import {GestureHandlerRootView} from 'react-native-gesture-handler'
import {KeyboardProvider as KeyboardControllerProvider} from 'react-native-keyboard-controller'
import {
@@ -59,7 +58,6 @@ import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
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 * as Toast from '#/view/com/util/Toast'
import {Shell} from '#/view/shell'
import {ThemeProvider as Alf} from '#/alf'
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
@@ -69,6 +67,7 @@ import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialo
import {Provider as PolicyUpdateOverlayProvider} from '#/components/PolicyUpdateOverlay'
import {Provider as PortalProvider} from '#/components/Portal'
import {Provider as VideoVolumeProvider} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext'
import * as Toast from '#/components/Toast'
import {ToastOutlet} from '#/components/Toast'
import {
prefetchAgeAssuranceConfig,
@@ -112,7 +111,7 @@ prefetchLiveEvents()
prefetchAppConfig()
function InnerApp() {
const [isReady, setIsReady] = React.useState(false)
const [isReady, setIsReady] = useState(false)
const {currentAccount} = useSession()
const {resumeSession} = useSessionApi()
const theme = useColorModeTheme()
@@ -140,10 +139,9 @@ function InnerApp() {
useEffect(() => {
return listenSessionDropped(() => {
Toast.show(
_(msg`Sorry! Your session expired. Please sign in again.`),
'info',
)
Toast.show(_(msg`Sorry! Your session expired. Please sign in again.`), {
type: 'info',
})
})
}, [_])
@@ -153,7 +151,7 @@ function InnerApp() {
<ContextMenuProvider>
<Splash isReady={isReady && hasCheckedReferrer}>
<VideoVolumeProvider>
<React.Fragment
<Fragment
// Resets the entire tree below when it changes:
key={currentAccount?.did}>
<AnalyticsFeaturesContext>
@@ -209,7 +207,7 @@ function InnerApp() {
</PolicyUpdateOverlayProvider>
</QueryProvider>
</AnalyticsFeaturesContext>
</React.Fragment>
</Fragment>
</VideoVolumeProvider>
</Splash>
</ContextMenuProvider>
@@ -221,7 +219,7 @@ function InnerApp() {
function App() {
const [isReady, setReady] = useState(false)
React.useEffect(() => {
useEffect(() => {
Promise.all([initPersistedState(), Geo.resolve(), setupDeviceId]).then(() =>
setReady(true),
)
+30 -28
View File
@@ -3,6 +3,7 @@ import '#/view/icons'
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'
@@ -47,7 +48,6 @@ import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide'
import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
import {Provider as HiddenRepliesProvider} from '#/state/threadgate-hidden-replies'
import * as Toast from '#/view/com/util/Toast'
import {Shell} from '#/view/shell/index'
import {ThemeProvider as Alf} from '#/alf'
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
@@ -58,6 +58,7 @@ import {Provider as PolicyUpdateOverlayProvider} from '#/components/PolicyUpdate
import {Provider as PortalProvider} from '#/components/Portal'
import {Provider as ActiveVideoProvider} from '#/components/Post/Embed/VideoEmbed/ActiveVideoWebContext'
import {Provider as VideoVolumeProvider} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext'
import * as Toast from '#/components/Toast'
import {ToastOutlet} from '#/components/Toast'
import {
prefetchAgeAssuranceConfig,
@@ -115,10 +116,9 @@ function InnerApp() {
useEffect(() => {
return listenSessionDropped(() => {
Toast.show(
_(msg`Sorry! Your session expired. Please sign in again.`),
'info',
)
Toast.show(_(msg`Sorry! Your session expired. Please sign in again.`), {
type: 'info',
})
})
}, [_])
@@ -212,29 +212,31 @@ function App() {
<Geo.Provider>
<AppConfigProvider>
<A11yProvider>
<OnboardingProvider>
<AnalyticsContext>
<SessionProvider>
<PrefsStateProvider>
<I18nProvider>
<ShellStateProvider>
<ModalStateProvider>
<DialogStateProvider>
<LightboxStateProvider>
<PortalProvider>
<StarterPackProvider>
<InnerApp />
</StarterPackProvider>
</PortalProvider>
</LightboxStateProvider>
</DialogStateProvider>
</ModalStateProvider>
</ShellStateProvider>
</I18nProvider>
</PrefsStateProvider>
</SessionProvider>
</AnalyticsContext>
</OnboardingProvider>
<KeyboardControllerProvider>
<OnboardingProvider>
<AnalyticsContext>
<SessionProvider>
<PrefsStateProvider>
<I18nProvider>
<ShellStateProvider>
<ModalStateProvider>
<DialogStateProvider>
<LightboxStateProvider>
<PortalProvider>
<StarterPackProvider>
<InnerApp />
</StarterPackProvider>
</PortalProvider>
</LightboxStateProvider>
</DialogStateProvider>
</ModalStateProvider>
</ShellStateProvider>
</I18nProvider>
</PrefsStateProvider>
</SessionProvider>
</AnalyticsContext>
</OnboardingProvider>
</KeyboardControllerProvider>
</A11yProvider>
</AppConfigProvider>
</Geo.Provider>
+1 -2
View File
@@ -1,5 +1,4 @@
import {forwardRef, useCallback, useEffect, useState} from 'react'
import {type PropsWithChildren} from 'react'
import {
AccessibilityInfo,
Image as RNImage,
@@ -52,7 +51,7 @@ type Props = {
isReady: boolean
}
export function Splash(props: PropsWithChildren<Props>) {
export function Splash(props: React.PropsWithChildren<Props>) {
'use no memo'
const insets = useSafeAreaInsets()
const intro = useSharedValue(0)
+33 -3
View File
@@ -12,6 +12,8 @@ import {
import {dateDiff, useGetTimeAgo} from '#/lib/hooks/useTimeAgo'
import {useIsBirthdateUpdateAllowed} from '#/state/birthdate'
import {useSessionApi} from '#/state/session'
import {DeactivateAccountDialog} from '#/screens/Settings/components/DeactivateAccountDialog'
import {DeleteAccountDialog} from '#/screens/Settings/components/DeleteAccountDialog'
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
import {Admonition} from '#/components/Admonition'
import {AgeAssuranceAppealDialog} from '#/components/ageAssurance/AgeAssuranceAppealDialog'
@@ -49,6 +51,8 @@ export function NoAccessScreen() {
const {gtPhone} = useBreakpoints()
const insets = useSafeAreaInsets()
const birthdateControl = useDialogControl()
const deactivateAccountControl = useDialogControl()
const deleteAccountControl = useDialogControl()
const {data} = useAgeAssuranceDataContext()
const region = useAgeAssuranceRegionConfig()
const isBirthdateUpdateAllowed = useIsBirthdateUpdateAllowed()
@@ -71,6 +75,7 @@ export function NoAccessScreen() {
hasDeclaredAge,
canUpdateBirthday,
})
// TODO This can be cleaned up with useEffectEvent once we're on 19.2
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
@@ -234,18 +239,38 @@ export function NoAccessScreen() {
</View>
)}
<View style={[a.pt_lg, a.gap_xl]}>
<View style={[a.pt_lg, a.gap_xl, {maxWidth: 280}]}>
<Logo width={120} textFill={t.atoms.text.color} />
<Text style={[a.text_sm, a.italic, t.atoms.text_contrast_medium]}>
<Text
style={[
a.text_sm,
a.italic,
a.leading_snug,
t.atoms.text_contrast_medium,
]}>
<Trans>
To log out,{' '}
<SimpleInlineLinkText
label={_(msg`Click here to log out`)}
{...createStaticClick(() => {
onPressLogout()
})}>
})}
style={[a.italic]}>
click here
</SimpleInlineLinkText>
. Or if youd prefer, you can{' '}
<SimpleInlineLinkText
label={_(msg`Click here to delete your account`)}
{...createStaticClick(() => {
ax.metric(
'ageAssurance:noAccessScreen:openDeleteAccountDialog',
{},
)
deleteAccountControl.open()
})}
style={[a.italic]}>
delete your account
</SimpleInlineLinkText>
.
</Trans>
</Text>
@@ -255,6 +280,11 @@ export function NoAccessScreen() {
</View>
<BirthDateSettingsDialog control={birthdateControl} />
<DeactivateAccountDialog control={deactivateAccountControl} />
<DeleteAccountDialog
control={deleteAccountControl}
deactivateDialogControl={deactivateAccountControl}
/>
{/*
* While this blocking overlay is up, other dialogs in the shell
+1 -1
View File
@@ -57,7 +57,7 @@ export const otherRequiredData: OtherRequiredData = {
birthdate: new Date(2000, 1, 1).toISOString(),
}
const serverStateEnabled = false
const serverStateEnabled = false || IS_E2E
export const serverState: AppBskyAgeassuranceGetState.OutputSchema | undefined =
serverStateEnabled
? {
+4 -2
View File
@@ -2,8 +2,10 @@ import {createContext, useCallback, useContext, useEffect, useMemo} from 'react'
import {useGetAndRegisterPushToken} from '#/lib/notifications/notifications'
import {Provider as RedirectOverlayProvider} from '#/ageAssurance/components/RedirectOverlay'
import {AgeAssuranceDataProvider} from '#/ageAssurance/data'
import {useAgeAssuranceDataContext} from '#/ageAssurance/data'
import {
AgeAssuranceDataProvider,
useAgeAssuranceDataContext,
} from '#/ageAssurance/data'
import {logger} from '#/ageAssurance/logger'
import {
useAgeAssuranceState,
+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) {
+1 -2
View File
@@ -1,5 +1,4 @@
import {createContext, useCallback, useContext, useMemo, useState} from 'react'
import {type PropsWithChildren} from 'react'
import {type Theme, type ThemeName} from '@bsky.app/alf'
import {
@@ -65,7 +64,7 @@ Context.displayName = 'AlfContext'
export function ThemeProvider({
children,
theme: themeName,
}: PropsWithChildren<{theme: ThemeName}>) {
}: React.PropsWithChildren<{theme: ThemeName}>) {
const [fontScale, setFontScale] = useState<Alf['fonts']['scale']>(() =>
getFontScale(),
)
+1
View File
@@ -9,6 +9,7 @@ export enum Features {
ImportContactsOnboardingDisable = 'import_contacts:onboarding:disable',
ImportContactsSettingsDisable = 'import_contacts:settings:disable',
LiveNowBetaDisable = 'live_now_beta:disable',
ImageUploadsHighResolution = 'image_uploads:high_resolution',
AATest = 'aa-test',
}
+111 -7
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': {}
@@ -470,6 +473,10 @@ export type Events = {
profileDid: string
position?: number
}
'profile:mute': {}
'profile:unmute': {}
'profile:block': {}
'profile:unblock': {}
'suggestedUser:follow': {
logContext:
| 'Explore'
@@ -514,6 +521,7 @@ export type Events = {
| 'InterstitialProfile'
| 'Profile'
| 'Onboarding'
recId?: number | string
}
'suggestedUser:dismiss': {
logContext: 'InterstitialDiscover' | 'InterstitialProfile'
@@ -703,20 +711,115 @@ export type Events = {
'reportDialog:failure': {}
translate: {
sourceLanguages: string[]
targetLanguage: string
os: Platform['OS']
/**
* The languages the content might be in, such as the user-supplied
* language codes on posts. Currently only available on posts.
*/
possibleSourceLanguages: string[] | undefined
/**
* This is the user's configured primary language, which is always defined.
*/
expectedTargetLanguage: string
/**
* The length of the text being translated. We assume shorter texts are
* more likely to have inaccurate translations.
*/
textLength: number
googleTranslate: boolean
}
'translate:result': {
method: 'on-device' | 'google-translate' | 'fallback-alert'
success: boolean
os: Platform['OS']
sourceLanguage: string | null
targetLanguage: string
/**
* The languages the content might be in, such as the user-supplied
* language codes on posts. Currently only available on posts.
*/
possibleSourceLanguages: string[] | undefined
/**
* The language we expected the content to be in. This could be based on
* user selection or on our confidence in the detected language. This is
* nullable because we may not always have an expected source language.
*/
expectedSourceLanguage: string | null
/**
* This is the user's configured primary language, which is always defined.
*/
expectedTargetLanguage: string
/**
* The language the translation result was actually in. This is nullable
* because the translation could have failed, in which case we won't have a
* result source language.
*/
resultSourceLanguage: string | null
/**
* The language the translation result was translated into. This should be
* the same as `expectedTargetLanguage`, but we include it for completeness
* and in case there are any edge cases where they differ. This is nullable
* because if the translation failed, we won't have a result target
* language.
*/
resultTargetLanguage: string | null
/**
* The length of the text being translated. We assume shorter texts are
* more likely to have inaccurate translations.
*/
textLength: number
}
'translate:override': {
os: Platform['OS']
sourceLanguage: string
targetLanguage: string
/**
* The languages the content might be in, such as the user-supplied
* language codes on posts. Currently only available on posts.
*/
possibleSourceLanguages: string[] | undefined
/**
* The language the user has indicated the content is actually in, which
* may be different from the expected source language if the user is
* overriding the auto-detected language. This is the language the user
* wants to translate from after overriding.
*/
expectedSourceLanguage: string
/**
* This is the user's configured primary language, which is always defined.
*/
expectedTargetLanguage: string
/**
* The language the translation result was actually in, which the user now
* wishes to override.
*/
resultSourceLanguage: string
}
'postMenu:openMuteWordsDialog': {
uri: string
authorDid: string
logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
feedDescriptor?: string
}
'postMenu:muteAccount': {
uri: string
authorDid: string
logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
feedDescriptor?: string
}
'postMenu:unmuteAccount': {
uri: string
authorDid: string
logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
feedDescriptor?: string
}
'postMenu:blockAccount': {
uri: string
authorDid: string
logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
feedDescriptor?: string
}
'postMenu:reportPost': {
uri: string
authorDid: string
logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
feedDescriptor?: string
}
'verification:create': {}
@@ -812,6 +915,7 @@ export type Events = {
canUpdateBirthday: boolean
}
'ageAssurance:noAccessScreen:openBirthdateDialog': {}
'ageAssurance:noAccessScreen:openDeleteAccountDialog': {}
/*
* Specifically for the `BlockedGeoOverlay`
+6 -7
View File
@@ -6,7 +6,6 @@ import {
useMemo,
useState,
} from 'react'
import {type ComponentType, type ReactElement, type ReactNode} from 'react'
import {
type AccessibilityProps,
type GestureResponderEvent,
@@ -83,8 +82,8 @@ export type ButtonState = {
export type ButtonContext = VariantProps & ButtonState
type NonTextElements =
| ReactElement<any>
| Iterable<ReactElement<any> | null | undefined | boolean>
| React.ReactElement<any>
| Iterable<React.ReactElement<any> | null | undefined | boolean>
export type ButtonProps = Pick<
PressableProps,
@@ -110,7 +109,7 @@ export type ButtonProps = Pick<
style?: StyleProp<ViewStyle>
hoverStyle?: StyleProp<ViewStyle>
children: NonTextElements | ((context: ButtonContext) => NonTextElements)
PressableComponent?: ComponentType<PressableProps>
PressableComponent?: React.ComponentType<PressableProps>
}
export type ButtonTextProps = TextProps &
@@ -777,7 +776,7 @@ export function ButtonIcon({
icon: Comp,
size,
}: {
icon: ComponentType<SVGIconProps>
icon: React.ComponentType<SVGIconProps>
/**
* @deprecated no longer needed
*/
@@ -895,8 +894,8 @@ export type StackedButtonProps = Omit<
keyof VariantProps | 'children'
> &
Pick<VariantProps, 'color'> & {
children: ReactNode
icon: ComponentType<SVGIconProps>
children: React.ReactNode
icon: React.ComponentType<SVGIconProps>
}
export function StackedButton({children, ...props}: StackedButtonProps) {
+4 -5
View File
@@ -9,7 +9,6 @@ import {
useRef,
useState,
} from 'react'
import {type ReactNode} from 'react'
import {
BackHandler,
Keyboard,
@@ -103,7 +102,7 @@ const SPRING_OUT: WithSpringConfig = {
/**
* Needs placing near the top of the provider stack, but BELOW the theme provider.
*/
export function Provider({children}: {children: ReactNode}) {
export function Provider({children}: {children: React.ReactNode}) {
return (
<PortalProvider>
{children}
@@ -112,7 +111,7 @@ export function Provider({children}: {children: ReactNode}) {
)
}
export function Root({children}: {children: ReactNode}) {
export function Root({children}: {children: React.ReactNode}) {
const playHaptic = useHaptics()
const [mode, setMode] = useState<'full' | 'auxiliary-only'>('full')
const [measurement, setMeasurement] = useState<Measurement | null>(null)
@@ -573,7 +572,7 @@ export function Outer({
style,
align = 'left',
}: {
children: ReactNode
children: React.ReactNode
style?: StyleProp<ViewStyle>
align?: 'left' | 'right'
}) {
@@ -896,7 +895,7 @@ export function ItemRadio({selected}: {selected: boolean}) {
)
}
export function LabelText({children}: {children: ReactNode}) {
export function LabelText({children}: {children: React.ReactNode}) {
const t = useTheme()
return (
<Text
+2 -3
View File
@@ -6,7 +6,6 @@ import {
useMemo,
useState,
} from 'react'
import {type PropsWithChildren, type ReactNode} from 'react'
import {
FlatList,
type FlatListProps,
@@ -53,7 +52,7 @@ export function Outer({
control,
onClose,
webOptions,
}: PropsWithChildren<DialogOuterProps>) {
}: React.PropsWithChildren<DialogOuterProps>) {
const {_} = useLingui()
const {gtMobile} = useBreakpoints()
const [isOpen, setIsOpen] = useState(false)
@@ -266,7 +265,7 @@ export function FlatListFooter({
children,
onLayout,
}: {
children: ReactNode
children: React.ReactNode
onLayout?: (event: LayoutChangeEvent) => void
}) {
const t = useTheme()
+4 -2
View File
@@ -18,7 +18,6 @@ import {
useRemoveFeedMutation,
} from '#/state/queries/preferences'
import {useSession} from '#/state/session'
import * as Toast from '#/view/com/util/Toast'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, select, useTheme} from '#/alf'
import {
@@ -33,6 +32,7 @@ import {Link as InternalLink, type LinkProps} from '#/components/Link'
import {Loader} from '#/components/Loader'
import * as Prompt from '#/components/Prompt'
import {RichText, type RichTextProps} from '#/components/RichText'
import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography'
import {useActiveLiveEventFeedUris} from '#/features/liveEvents/context'
import type * as bsky from '#/types/bsky'
@@ -313,7 +313,9 @@ function SaveButtonInner({
Toast.show(l({message: 'Feeds updated!', context: 'toast'}))
} catch (err: any) {
logger.error(err, {message: `FeedCard: failed to update feeds`, pin})
Toast.show(l`Failed to update feeds`, 'xmark')
Toast.show(l`Failed to update feeds`, {
type: 'error',
})
}
},
[l, pin, saveFeeds, removeFeed, uri, savedFeedConfig, type],
+27 -135
View File
@@ -18,10 +18,7 @@ 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 {
useSuggestedFollowsByActorQuery,
useSuggestedFollowsQuery,
} from '#/state/queries/suggested-follows'
import {useSuggestedFollowsByActorWithDismiss} from '#/state/queries/suggested-follows'
import {useSession} from '#/state/session'
import * as userActionHistory from '#/state/userActionHistory'
import {type SeenPost} from '#/state/userActionHistory'
@@ -170,10 +167,12 @@ function useExperimentalSuggestedUsersQuery() {
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
@@ -216,86 +215,14 @@ export function SuggestedFollows({feed}: {feed: FeedDescriptor}) {
}
export function SuggestedFollowsProfile({did}: {did: string}) {
const {gtMobile} = useBreakpoints()
const moderationOpts = useModerationOpts()
const maxLength = gtMobile ? 4 : 6
const {
isLoading: isSuggestionsLoading,
data,
error,
} = useSuggestedFollowsByActorQuery({
did,
})
const {
data: moreSuggestions,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useSuggestedFollowsQuery({limit: 25})
const [dismissedDids, setDismissedDids] = useState<Set<string>>(new Set())
const onDismiss = useCallback((dismissedDid: string) => {
setDismissedDids(prev => new Set(prev).add(dismissedDid))
}, [])
// Combine profiles from the actor-specific query with fallback suggestions
const allProfiles = useMemo(() => {
const actorProfiles = data?.suggestions ?? []
const fallbackProfiles =
moreSuggestions?.pages.flatMap(page =>
page.actors.map(actor => ({actor, recId: page.recId})),
) ?? []
// Dedupe by did, preferring actor-specific profiles
const seen = new Set<string>()
const combined: {actor: bsky.profile.AnyProfileView; recId?: number}[] = []
for (const profile of actorProfiles) {
if (!seen.has(profile.did)) {
seen.add(profile.did)
combined.push({actor: profile, recId: data?.recId})
}
}
for (const profile of fallbackProfiles) {
if (!seen.has(profile.actor.did) && profile.actor.did !== did) {
seen.add(profile.actor.did)
combined.push(profile)
}
}
return combined
}, [data?.suggestions, moreSuggestions?.pages, did, data?.recId])
const filteredProfiles = useMemo(() => {
return allProfiles.filter(p => !dismissedDids.has(p.actor.did))
}, [allProfiles, dismissedDids])
// Fetch more when running low
useEffect(() => {
if (
moderationOpts &&
filteredProfiles.length < maxLength &&
hasNextPage &&
!isFetchingNextPage
) {
void fetchNextPage()
}
}, [
filteredProfiles.length,
maxLength,
hasNextPage,
isFetchingNextPage,
fetchNextPage,
moderationOpts,
])
const {profiles, recId, onDismiss, isLoading, error} =
useSuggestedFollowsByActorWithDismiss({did})
return (
<ProfileGrid
isSuggestionsLoading={isSuggestionsLoading}
profiles={filteredProfiles}
totalProfileCount={allProfiles.length}
isSuggestionsLoading={isLoading}
profiles={profiles}
recId={recId}
error={error}
viewContext="profile"
onDismiss={onDismiss}
@@ -304,21 +231,11 @@ export function SuggestedFollowsProfile({did}: {did: string}) {
}
export function SuggestedFollowsHome() {
const {gtMobile} = useBreakpoints()
const moderationOpts = useModerationOpts()
const maxLength = gtMobile ? 4 : 6
const {
isLoading: isSuggestionsLoading,
profiles: experimentalProfiles,
error: experimentalError,
} = useExperimentalSuggestedUsersQuery()
const {
data: moreSuggestions,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
error: suggestionsError,
} = useSuggestedFollowsQuery({limit: 25})
const [dismissedDids, setDismissedDids] = useState<Set<string>>(new Set())
@@ -326,66 +243,29 @@ export function SuggestedFollowsHome() {
setDismissedDids(prev => new Set(prev).add(did))
}, [])
// Combine profiles from experimental query with paginated suggestions
const allProfiles = useMemo(() => {
const fallbackProfiles =
moreSuggestions?.pages.flatMap(page =>
page.actors.map(actor => ({actor, recId: page.recId})),
) ?? []
// Dedupe by did, preferring experimental profiles
const seen = new Set<string>()
const combined: Array<{
const result: Array<{
actor: bsky.profile.AnyProfileView
recId?: number
recId?: string
}> = []
for (const profile of experimentalProfiles) {
if (!seen.has(profile.did)) {
seen.add(profile.did)
combined.push({actor: profile, recId: undefined})
}
result.push({actor: profile, recId: undefined})
}
for (const profile of fallbackProfiles) {
if (!seen.has(profile.actor.did)) {
seen.add(profile.actor.did)
combined.push(profile)
}
}
return combined
}, [experimentalProfiles, moreSuggestions?.pages])
return result
}, [experimentalProfiles])
const filteredProfiles = useMemo(() => {
return allProfiles.filter(p => !dismissedDids.has(p.actor.did))
}, [allProfiles, dismissedDids])
// Fetch more when running low
useEffect(() => {
if (
moderationOpts &&
filteredProfiles.length < maxLength &&
hasNextPage &&
!isFetchingNextPage
) {
void fetchNextPage()
}
}, [
filteredProfiles.length,
maxLength,
hasNextPage,
isFetchingNextPage,
fetchNextPage,
moderationOpts,
])
return (
<ProfileGrid
isSuggestionsLoading={isSuggestionsLoading}
profiles={filteredProfiles}
totalProfileCount={allProfiles.length}
error={experimentalError || suggestionsError}
error={experimentalError}
viewContext="feed"
onDismiss={onDismiss}
/>
@@ -396,18 +276,22 @@ export function ProfileGrid({
isSuggestionsLoading,
error,
profiles,
recId,
totalProfileCount,
viewContext = 'feed',
onDismiss,
isVisible = true,
onRequestHide,
}: {
isSuggestionsLoading: boolean
profiles: {actor: bsky.profile.AnyProfileView; recId?: number}[]
profiles: {actor: bsky.profile.AnyProfileView; recId?: string}[]
recId?: string
totalProfileCount?: number
error: Error | null
viewContext: 'profile' | 'profileHeader' | 'feed'
onDismiss?: (did: string) => void
isVisible?: boolean
onRequestHide?: () => void
}) {
const t = useTheme()
const ax = useAnalytics()
@@ -651,6 +535,13 @@ export function ProfileGrid({
// Use totalProfileCount (before dismissals) for minLength check on initial render.
const profileCountForMinCheck = totalProfileCount ?? profiles.length
useEffect(() => {
if (error || (!isLoading && profileCountForMinCheck < minLength)) {
onRequestHide?.()
}
}, [error, isLoading, onRequestHide, profileCountForMinCheck, minLength])
if (error || (!isLoading && profileCountForMinCheck < minLength)) {
ax.logger.debug(`Not enough profiles to show suggested follows`)
return null
@@ -684,6 +575,7 @@ export function ProfileGrid({
followDialogControl.open()
ax.metric('suggestedUser:seeMore', {
logContext: isFeedContext ? 'Explore' : 'Profile',
recId,
})
}}>
{({hovered}) => (
-1
View File
@@ -1,5 +1,4 @@
import {View} from 'react-native'
import type React from 'react'
import {atoms as a, type ViewStyleProp} from '#/alf'
@@ -3,7 +3,6 @@ import {type AppBskyLabelerDefs} from '@atproto/api'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Plural, Trans} from '@lingui/react/macro'
import type React from 'react'
import {getLabelingServiceTitle} from '#/lib/moderation'
import {sanitizeHandle} from '#/lib/strings/handles'
@@ -1,6 +1,5 @@
import {type StyleProp, type ViewStyle} from 'react-native'
import {LinearGradient} from 'expo-linear-gradient'
import type React from 'react'
import {gradients} from '#/alf/tokens'
+1 -2
View File
@@ -1,5 +1,4 @@
import {useCallback, useMemo} from 'react'
import {type PropsWithChildren} from 'react'
import {type GestureResponderEvent, Linking} from 'react-native'
import {sanitizeUrl} from '@braintree/sanitize-url'
import {
@@ -319,7 +318,7 @@ export function Link({
)
}
export type InlineLinkProps = PropsWithChildren<
export type InlineLinkProps = React.PropsWithChildren<
BaseLinkProps &
TextStyleProp &
Pick<TextProps, 'selectable' | 'numberOfLines' | 'emoji'> &
-1
View File
@@ -1,5 +1,4 @@
import {StyleSheet} from 'react-native'
import type React from 'react'
import {atoms as a, platform, useTheme, type ViewStyleProp} from '#/alf'
import {Fill} from '#/components/Fill'
-1
View File
@@ -4,7 +4,6 @@ import {
type GestureResponderEvent,
type PressableProps,
} from 'react-native'
import type React from 'react'
import {type TextStyleProp, type ViewStyleProp} from '#/alf'
import type * as Dialog from '#/components/Dialog'
+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>
+2 -2
View File
@@ -1,5 +1,4 @@
import {useMemo} from 'react'
import {type ReactNode} from 'react'
import {View} from 'react-native'
import {BSKY_LABELER_DID, type ModerationCause} from '@atproto/api'
import {Trans} from '@lingui/react/macro'
@@ -31,7 +30,8 @@ export function Row({
children,
style,
size = 'sm',
}: {children: ReactNode | ReactNode[]} & CommonProps & ViewStyleProp) {
}: {children: React.ReactNode | React.ReactNode[]} & CommonProps &
ViewStyleProp) {
const styles = useMemo(() => {
switch (size) {
case 'lg':
@@ -1,4 +1,4 @@
import React from 'react'
import {useCallback, useRef, useState} from 'react'
import {
ActivityIndicator,
type GestureResponderEvent,
@@ -31,16 +31,16 @@ export function ExternalGif({
const consentDialogControl = useDialogControl()
// Tracking if the placer has been activated
const [isPlayerActive, setIsPlayerActive] = React.useState(false)
const [isPlayerActive, setIsPlayerActive] = useState(false)
// Tracking whether the gif has been loaded yet
const [isPrefetched, setIsPrefetched] = React.useState(false)
const [isPrefetched, setIsPrefetched] = useState(false)
// Tracking whether the image is animating
const [isAnimating, setIsAnimating] = React.useState(true)
const [isAnimating, setIsAnimating] = useState(true)
// Used for controlling animation
const imageRef = React.useRef<Image>(null)
const imageRef = useRef<Image>(null)
const load = React.useCallback(() => {
const load = useCallback(() => {
setIsPlayerActive(true)
Image.prefetch(params.playerUri).then(() => {
// Replace the image once it's fetched
@@ -48,7 +48,7 @@ export function ExternalGif({
})
}, [params.playerUri])
const onPlayPress = React.useCallback(
const onPlayPress = useCallback(
(event: GestureResponderEvent) => {
// Don't propagate on web
event.preventDefault()
@@ -1,4 +1,4 @@
import React from 'react'
import {useCallback, useEffect, useMemo, useState} from 'react'
import {
ActivityIndicator,
type GestureResponderEvent,
@@ -84,7 +84,7 @@ function Player({
}) {
// ensures we only load what's requested
// when it's a youtube video, we need to allow both bsky.app and youtube.com
const onShouldStartLoadWithRequest = React.useCallback(
const onShouldStartLoadWithRequest = useCallback(
(event: ShouldStartLoadRequest) =>
event.url === params.playerUri ||
(params.source.startsWith('youtube') &&
@@ -129,10 +129,10 @@ export function ExternalPlayer({
const externalEmbedsPrefs = useExternalEmbedsPrefs()
const consentDialogControl = useDialogControl()
const [isPlayerActive, setPlayerActive] = React.useState(false)
const [isLoading, setIsLoading] = React.useState(true)
const [isPlayerActive, setPlayerActive] = useState(false)
const [isLoading, setIsLoading] = useState(true)
const aspect = React.useMemo(() => {
const aspect = useMemo(() => {
return getPlayerAspect({
type: params.type,
width: windowDims.width,
@@ -166,7 +166,7 @@ export function ExternalPlayer({
}, false) // False here disables autostarting the callback
// watch for leaving the viewport due to scrolling
React.useEffect(() => {
useEffect(() => {
// We don't want to do anything if the player isn't active
if (!isPlayerActive) return
@@ -185,11 +185,11 @@ export function ExternalPlayer({
}
}, [navigation, isPlayerActive, frameCallback])
const onLoad = React.useCallback(() => {
const onLoad = useCallback(() => {
setIsLoading(false)
}, [])
const onPlayPress = React.useCallback(
const onPlayPress = useCallback(
(event: GestureResponderEvent) => {
// Prevent this from propagating upward on web
event.preventDefault()
@@ -204,7 +204,7 @@ export function ExternalPlayer({
[externalEmbedsPrefs, consentDialogControl, params.source],
)
const onAcceptConsent = React.useCallback(() => {
const onAcceptConsent = useCallback(() => {
setPlayerActive(true)
}, [])
@@ -1,4 +1,4 @@
import React, {useCallback} from 'react'
import {useCallback, useMemo} from 'react'
import {type StyleProp, View, type ViewStyle} from 'react-native'
import {Image} from 'expo-image'
import {type AppBskyEmbedExternal} from '@atproto/api'
@@ -38,7 +38,7 @@ export const ExternalEmbed = ({
const externalEmbedPrefs = useExternalEmbedsPrefs()
const niceUrl = toNiceDomain(link.uri)
const imageUri = link.thumb
const embedPlayerParams = React.useMemo(() => {
const embedPlayerParams = useMemo(() => {
const params = parseEmbedPlayerFromUrl(link.uri)
if (params && externalEmbedPrefs?.[params.source] !== 'hide') {
@@ -1,5 +1,7 @@
import React, {
import {
createContext,
useCallback,
useContext,
useEffect,
useId,
useMemo,
@@ -10,7 +12,7 @@ import {useWindowDimensions} from 'react-native'
import {IS_NATIVE, IS_WEB} from '#/env'
const Context = React.createContext<{
const Context = createContext<{
activeViewId: string | null
setActiveView: (viewId: string) => void
sendViewPosition: (viewId: string, y: number) => void
@@ -94,7 +96,7 @@ export function Provider({children}: {children: React.ReactNode}) {
}
export function useActiveVideoWeb() {
const context = React.useContext(Context)
const context = useContext(Context)
if (!context) {
throw new Error(
'useActiveVideoWeb must be used within a ActiveVideoWebProvider',
@@ -1,4 +1,4 @@
import {useEffect, useId, useRef, useState} from 'react'
import {useCallback, useEffect, useId, useRef, useState} from 'react'
import {View} from 'react-native'
import {type AppBskyEmbedVideo} from '@atproto/api'
import {msg} from '@lingui/core/macro'
@@ -37,7 +37,7 @@ export function VideoEmbedInnerWeb({
throw error
}
const {hlsRef, loop} = useHLS({
const {hlsRef, loop, updateCuePositions} = useHLS({
playlist: embed.playlist,
setHasSubtitleTrack,
setError,
@@ -90,6 +90,7 @@ export function VideoEmbedInnerWeb({
hasSubtitleTrack={hasSubtitleTrack}
isGif={embed.presentation === 'gif'}
altText={embed.alt}
updateCuePositions={updateCuePositions}
/>
</div>
</View>
@@ -145,6 +146,47 @@ function useHLS({
}, [Hls, setHlsLoading])
const hlsRef = useRef<HlsTypes.default | undefined>(undefined)
const controlsVisibleRef = useRef(false)
/**
* Repositions VTT subtitle cues using percentage-based line values
* (snapToLines=false) so that multi-line/wrapped cues grow upward
* instead of extending offscreen. Moves cues higher when controls
* are visible to avoid occlusion by the scrub bar.
*
* Called from two sites:
* - SUBTITLE_FRAG_PROCESSED: applies positioning to newly loaded cues
* - VideoControls effect: updates positioning when controls show/hide
*/
const updateCuePositions = useCallback(
(controlsVisible?: boolean) => {
if (controlsVisible != null) {
// save controlsVisible state so that when it's called from SUBTITLE_FRAG_PROCESSED,
// the most recent value is used (as we won't know the control state there)
controlsVisibleRef.current = controlsVisible
}
// magic numbers: cue position, % from top of video
const line = controlsVisibleRef.current ? 70 : 85
const video = videoRef.current
if (!video) return
for (let i = 0; i < video.textTracks.length; i++) {
const track = video.textTracks[i]
if (track.cues) {
for (let j = 0; j < track.cues.length; j++) {
const cue = track.cues[j] as VTTCue
cue.snapToLines = false
cue.line = line
}
}
// toggle track mode to force the browser to re-render active cues
if (track.mode === 'showing') {
track.mode = 'hidden'
track.mode = 'showing'
}
}
},
[videoRef],
)
const [lowQualityFragments, setLowQualityFragments] = useState<
HlsTypes.Fragment[]
>([])
@@ -220,6 +262,10 @@ function useHLS({
}
})
hls.on(Hls.Events.SUBTITLE_FRAG_PROCESSED, () => {
updateCuePositions()
})
hls.on(Hls.Events.FRAG_BUFFERED, (_event, {frag}) => {
if (frag.level === 0) {
setLowQualityFragments(prev => [...prev, frag])
@@ -307,5 +353,6 @@ function useHLS({
return {
hlsRef,
loop: !hasLowQualityFragmentAtStart,
updateCuePositions,
}
}
@@ -48,6 +48,7 @@ export function Controls({
hasSubtitleTrack,
isGif,
altText,
updateCuePositions,
}: {
videoRef: React.RefObject<HTMLVideoElement | null>
hlsRef: React.RefObject<Hls | undefined | null>
@@ -61,6 +62,7 @@ export function Controls({
hasSubtitleTrack: boolean
isGif: boolean
altText?: string
updateCuePositions: (controlsVisible?: boolean) => void
}) {
const {
play,
@@ -294,6 +296,13 @@ export function Controls({
((focused || autoplayDisabled) && !playing) ||
(interactingViaKeypress ? hasFocus : hovered)
// adjust subtitle cue positioning to avoid occlusion by controls
// uses percentage-based positioning (snapToLines=false) so wrapped
// multi-line cues grow upward instead of extending offscreen
useEffect(() => {
updateCuePositions(showControls)
}, [showControls, updateCuePositions])
if (isGif) {
return (
<GifPresentationControls
@@ -1,7 +1,6 @@
import React from 'react'
import {createContext, useContext, useMemo, useState} from 'react'
const Context = React.createContext<{
// native
const Context = createContext<{
muted: boolean
setMuted: React.Dispatch<React.SetStateAction<boolean>>
// web
@@ -11,10 +10,10 @@ const Context = React.createContext<{
Context.displayName = 'VideoVolumeContext'
export function Provider({children}: {children: React.ReactNode}) {
const [muted, setMuted] = React.useState(true)
const [volume, setVolume] = React.useState(1)
const [muted, setMuted] = useState(true)
const [volume, setVolume] = useState(1)
const value = React.useMemo(
const value = useMemo(
() => ({
muted,
setMuted,
@@ -28,7 +27,7 @@ export function Provider({children}: {children: React.ReactNode}) {
}
export function useVideoVolumeState() {
const context = React.useContext(Context)
const context = useContext(Context)
if (!context) {
throw new Error(
'useVideoVolumeState must be used within a VideoVolumeProvider',
@@ -38,7 +37,7 @@ export function useVideoVolumeState() {
}
export function useVideoMuteState() {
const context = React.useContext(Context)
const context = useContext(Context)
if (!context) {
throw new Error(
'useVideoMuteState must be used within a VideoVolumeProvider',
+65 -56
View File
@@ -1,14 +1,17 @@
import {useCallback, useMemo} from 'react'
import {Platform, type StyleProp, type TextStyle, View} from 'react-native'
import {type AppBskyFeedDefs} from '@atproto/api'
import {type AppBskyFeedDefs, AppBskyFeedPost} from '@atproto/api'
import {Trans, useLingui} from '@lingui/react/macro'
import {HITSLOP_30} from '#/lib/constants'
import {useGoogleTranslate} from '#/lib/hooks/useGoogleTranslate'
import {useTranslate} from '#/lib/translation'
import {type TranslationFunction} from '#/lib/translation'
import {
type TranslationFunction,
type TranslationFunctionParams,
} from '#/lib/translation'
import {
codeToLanguageName,
getPostLanguageTags,
isPostInLanguage,
languageName,
} from '#/locale/helpers'
@@ -25,18 +28,17 @@ import * as Select from '#/components/Select'
import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
import {IS_WEB} from '#/env'
import * as bsky from '#/types/bsky'
const X_ICON_OFFSET = 16
export function TranslatedPost({
hideTranslateLink = false,
post,
postText,
postTextStyle = a.text_md,
}: {
hideTranslateLink?: boolean
post: AppBskyFeedDefs.PostView
postText: string
postTextStyle?: StyleProp<TextStyle>
}) {
const langPrefs = useLanguagePrefs()
@@ -44,6 +46,21 @@ export function TranslatedPost({
key: post.uri,
})
const record = useMemo<AppBskyFeedPost.Record | undefined>(() => {
return bsky.dangerousIsType<AppBskyFeedPost.Record>(
post.record,
AppBskyFeedPost.isRecord,
)
? post.record
: undefined
}, [post])
const initialTranslationParams = useMemo<TranslationFunctionParams>(() => {
return {
text: record?.text || '',
expectedTargetLanguage: langPrefs.primaryLanguage,
possibleSourceLanguages: getPostLanguageTags(post),
}
}, [post, record, langPrefs])
const needsTranslation = useMemo(() => {
if (hideTranslateLink) return false
return !isPostInLanguage(post, [langPrefs.primaryLanguage])
@@ -55,11 +72,11 @@ export function TranslatedPost({
case 'success':
return (
<TranslationResult
clearTranslation={clearTranslation}
translate={translate}
postText={postText}
clearTranslation={clearTranslation}
initialTranslationParams={initialTranslationParams}
postTextStyle={postTextStyle}
sourceLanguage={
resultSourceLanguage={
translationState.sourceLanguage ?? null // Fallback primarily for iOS
}
translatedText={translationState.translatedText}
@@ -68,19 +85,18 @@ export function TranslatedPost({
case 'error':
return (
<TranslationError
translate={translate}
clearTranslation={clearTranslation}
message={translationState.message}
postText={postText}
primaryLanguage={langPrefs.primaryLanguage}
initialTranslationParams={initialTranslationParams}
/>
)
default:
return (
needsTranslation && (
<TranslationLink
postText={postText}
primaryLanguage={langPrefs.primaryLanguage}
translate={translate}
initialTranslationParams={initialTranslationParams}
/>
)
)
@@ -103,30 +119,18 @@ function TranslationLoading() {
}
function TranslationLink({
postText,
primaryLanguage,
translate,
initialTranslationParams,
}: {
postText: string
primaryLanguage: string
translate: TranslationFunction
initialTranslationParams: TranslationFunctionParams
}) {
const t = useTheme()
const {t: l} = useLingui()
const ax = useAnalytics()
const handleTranslate = useCallback(() => {
void translate({
text: postText,
targetLangCode: primaryLanguage,
})
ax.metric('translate', {
sourceLanguages: [], // todo: get from post maybe?
targetLanguage: primaryLanguage,
textLength: postText.length,
})
}, [ax, postText, primaryLanguage, translate])
void translate(initialTranslationParams)
}, [initialTranslationParams, translate])
return (
<View
@@ -158,22 +162,24 @@ function TranslationLink({
}
function TranslationError({
translate,
clearTranslation,
message,
postText,
primaryLanguage,
initialTranslationParams,
}: {
translate: TranslationFunction
clearTranslation: () => void
message: string
postText: string
primaryLanguage: string
initialTranslationParams: TranslationFunctionParams
}) {
const t = useTheme()
const {t: l} = useLingui()
const translate = useGoogleTranslate()
const handleFallback = () => {
void translate(postText, primaryLanguage)
void translate({
...initialTranslationParams,
forceGoogleTranslate: true,
})
}
return (
@@ -244,24 +250,24 @@ function TranslationError({
function TranslationResult({
clearTranslation,
translate,
postText,
postTextStyle,
sourceLanguage,
resultSourceLanguage,
translatedText,
initialTranslationParams,
}: {
clearTranslation: () => void
translate: TranslationFunction
postText: string
postTextStyle?: StyleProp<TextStyle>
sourceLanguage: string | null
resultSourceLanguage: string | null
translatedText: string
initialTranslationParams: TranslationFunctionParams
}) {
const t = useTheme()
const langPrefs = useLanguagePrefs()
const {i18n, t: l} = useLingui()
const langName = sourceLanguage
? codeToLanguageName(sourceLanguage, i18n.locale)
const langName = resultSourceLanguage
? codeToLanguageName(resultSourceLanguage, i18n.locale)
: undefined
const flattenedStyle = flatten(postTextStyle) ?? {}
@@ -320,7 +326,7 @@ function TranslationResult({
<Trans>Translated</Trans>
</Text>
)}
{sourceLanguage != null && (
{resultSourceLanguage != null && (
<>
<Text
style={[
@@ -333,9 +339,9 @@ function TranslationResult({
&middot;{' '}
</Text>
<TranslationLanguageSelect
sourceLanguage={sourceLanguage}
resultSourceLanguage={resultSourceLanguage}
translate={translate}
postText={postText}
initialTranslationParams={initialTranslationParams}
/>
</>
)}
@@ -359,12 +365,12 @@ function TranslationResult({
function TranslationLanguageSelect({
translate,
postText,
sourceLanguage,
resultSourceLanguage,
initialTranslationParams,
}: {
translate: TranslationFunction
postText: string
sourceLanguage: string
resultSourceLanguage: string
initialTranslationParams: TranslationFunctionParams
}) {
const t = useTheme()
const ax = useAnalytics()
@@ -380,8 +386,8 @@ function TranslationLanguageSelect({
)
.sort((a, b) => {
// Prioritize sourceLanguage at the top
if (a.code2 === sourceLanguage) return -1
if (b.code2 === sourceLanguage) return 1
if (a.code2 === resultSourceLanguage) return -1
if (b.code2 === resultSourceLanguage) return 1
// Localized sort
return languageName(a, langPrefs.appLanguage).localeCompare(
languageName(b, langPrefs.appLanguage),
@@ -392,25 +398,28 @@ function TranslationLanguageSelect({
label: languageName(l, langPrefs.appLanguage), // The viewer may not be familiar with the source language, so localize the name
value: l.code2,
})),
[langPrefs, sourceLanguage],
[langPrefs, resultSourceLanguage],
)
const handleChangeTranslationLanguage = (sourceLangCode: string) => {
ax.metric('translate:override', {
os: Platform.OS,
sourceLanguage: sourceLangCode,
targetLanguage: langPrefs.primaryLanguage,
possibleSourceLanguages: initialTranslationParams.possibleSourceLanguages,
expectedSourceLanguage: sourceLangCode,
expectedTargetLanguage: initialTranslationParams.expectedTargetLanguage,
resultSourceLanguage,
})
void translate({
text: postText,
targetLangCode: langPrefs.primaryLanguage,
sourceLangCode,
text: initialTranslationParams.text,
expectedTargetLanguage: initialTranslationParams.expectedTargetLanguage,
expectedSourceLanguage: sourceLangCode,
possibleSourceLanguages: initialTranslationParams.possibleSourceLanguages,
})
}
return (
<Select.Root
value={sourceLanguage}
value={resultSourceLanguage}
onValueChange={handleChangeTranslationLanguage}>
<Select.Trigger label={l`Change the source language`}>
{({props}) => {
@@ -4,7 +4,6 @@ import {type AppBskyFeedDefs} from '@atproto/api'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import type React from 'react'
import {useCleanError} from '#/lib/hooks/useCleanError'
import {type Shadow} from '#/state/cache/post-shadow'
@@ -8,7 +8,7 @@ import {
import * as Clipboard from 'expo-clipboard'
import {
type AppBskyFeedDefs,
AppBskyFeedPost,
type AppBskyFeedPost,
type AppBskyFeedThreadgate,
AtUri,
type RichText as RichTextAPI,
@@ -28,6 +28,7 @@ import {
import {richTextToString} from '#/lib/strings/rich-text-helpers'
import {toShareUrl} from '#/lib/strings/url-helpers'
import {useTranslate} from '#/lib/translation'
import {getPostLanguageTags} from '#/locale/helpers'
import {logger} from '#/logger'
import {type Shadow} from '#/state/cache/post-shadow'
import {useProfileShadow} from '#/state/cache/profile-shadow'
@@ -56,7 +57,6 @@ import {
} from '#/state/queries/threadgate'
import {useRequireAuth, useSession} from '#/state/session'
import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies'
import * as Toast from '#/view/com/util/Toast'
import {useDialogControl} from '#/components/Dialog'
import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
import {
@@ -93,9 +93,9 @@ import {
useReportDialogControl,
} from '#/components/moderation/ReportDialog'
import * as Prompt from '#/components/Prompt'
import * as Toast from '#/components/Toast'
import {useAnalytics} from '#/analytics'
import {IS_INTERNAL} from '#/env'
import * as bsky from '#/types/bsky'
let PostMenuItems = ({
post,
@@ -216,7 +216,9 @@ let PostMenuItems = ({
},
e => {
logger.error('Failed to delete post', {message: e})
Toast.show(l`Failed to delete post, please try again`, 'xmark')
Toast.show(l`Failed to delete post, please try again`, {
type: 'error',
})
},
)
}
@@ -246,36 +248,38 @@ let PostMenuItems = ({
const e = err as Error
if (e?.name !== 'AbortError') {
logger.error('Failed to toggle thread mute', {message: e})
Toast.show(l`Failed to toggle thread mute, please try again`, 'xmark')
Toast.show(l`Failed to toggle thread mute, please try again`, {
type: 'error',
})
}
}
}
const onToggleWordsAndTagsMute = () => {
ax.metric('postMenu:openMuteWordsDialog', {
uri: postUri,
authorDid: postAuthor.did,
logContext,
feedDescriptor: feedFeedback.feedDescriptor,
})
mutedWordsDialogControl.open()
}
const onCopyPostText = () => {
const str = richTextToString(richText, true)
void Clipboard.setStringAsync(str)
Toast.show(l`Copied to clipboard`, 'clipboard-check')
Toast.show(l`Copied to clipboard`, {
type: 'success',
})
}
const onPressTranslate = () => {
void translate({
text: record.text,
targetLangCode: langPrefs.primaryLanguage,
expectedTargetLanguage: langPrefs.primaryLanguage,
possibleSourceLanguages: getPostLanguageTags(post),
})
if (
bsky.dangerousIsType<AppBskyFeedPost.Record>(
post.record,
AppBskyFeedPost.isRecord,
)
) {
ax.metric('translate', {
sourceLanguages: post.record.langs ?? [],
targetLanguage: langPrefs.primaryLanguage,
textLength: post.record.text.length,
})
}
}
const onHidePost = () => {
@@ -424,8 +428,17 @@ let PostMenuItems = ({
const e = err as Error
if (e?.name !== 'AbortError') {
logger.error('Failed to block account', {message: e})
Toast.show(l`There was an issue! ${e.toString()}`, 'xmark')
Toast.show(l`There was an issue! ${e.toString()}`, {
type: 'error',
})
}
} finally {
ax.metric('postMenu:blockAccount', {
uri: postUri,
authorDid: postAuthor.did,
logContext,
feedDescriptor: feedFeedback.feedDescriptor,
})
}
}
@@ -438,8 +451,17 @@ let PostMenuItems = ({
const e = err as Error
if (e?.name !== 'AbortError') {
logger.error('Failed to unmute account', {message: e})
Toast.show(l`There was an issue! ${e.toString()}`, 'xmark')
Toast.show(l`There was an issue! ${e.toString()}`, {
type: 'error',
})
}
} finally {
ax.metric('postMenu:unmuteAccount', {
uri: postUri,
authorDid: postAuthor.did,
logContext,
feedDescriptor: feedFeedback.feedDescriptor,
})
}
} else {
try {
@@ -449,8 +471,17 @@ let PostMenuItems = ({
const e = err as Error
if (e?.name !== 'AbortError') {
logger.error('Failed to mute account', {message: e})
Toast.show(l`There was an issue! ${e.toString()}`, 'xmark')
Toast.show(l`There was an issue! ${e.toString()}`, {
type: 'error',
})
}
} finally {
ax.metric('postMenu:muteAccount', {
uri: postUri,
authorDid: postAuthor.did,
logContext,
feedDescriptor: feedFeedback.feedDescriptor,
})
}
}
}
@@ -601,7 +632,7 @@ let PostMenuItems = ({
<Menu.Item
testID="postDropdownMuteWordsBtn"
label={l`Mute words & tags`}
onPress={() => mutedWordsDialogControl.open()}>
onPress={onToggleWordsAndTagsMute}>
<Menu.ItemText>{l`Mute words & tags`}</Menu.ItemText>
<Menu.ItemIcon icon={Filter} position="right" />
</Menu.Item>
@@ -785,6 +816,14 @@ let PostMenuItems = ({
...post,
$type: 'app.bsky.feed.defs#postView',
}}
onAfterSubmit={() => {
ax.metric('postMenu:reportPost', {
uri: postUri,
authorDid: postAuthor.did,
logContext,
feedDescriptor: feedFeedback.feedDescriptor,
})
}}
/>
<PostInteractionSettingsDialog
control={postInteractionSettingsDialogControl}
@@ -12,7 +12,6 @@ import {shareText, shareUrl} from '#/lib/sharing'
import {toShareUrl} from '#/lib/strings/url-helpers'
import {useProfileShadow} from '#/state/cache/profile-shadow'
import {useSession} from '#/state/session'
import * as Toast from '#/view/com/util/Toast'
import {atoms as a} from '#/alf'
import {Admonition} from '#/components/Admonition'
import {useDialogControl} from '#/components/Dialog'
@@ -22,6 +21,7 @@ import {ChainLink_Stroke2_Corner0_Rounded as ChainLinkIcon} from '#/components/i
import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard'
import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlaneIcon} from '#/components/icons/PaperPlane'
import * as Menu from '#/components/Menu'
import * as Toast from '#/components/Toast'
import {useAgeAssurance} from '#/ageAssurance'
import {useAnalytics} from '#/analytics'
import {IS_IOS} from '#/env'
@@ -71,7 +71,9 @@ let ShareMenuItems = ({
} else {
await ExpoClipboard.setStringAsync(url)
}
Toast.show(_(msg`Copied to clipboard`), 'clipboard-check')
Toast.show(_(msg`Copied to clipboard`), {
type: 'success',
})
onShareProp()
}
+10 -4
View File
@@ -24,11 +24,11 @@ import {
ProgressGuideAction,
useProgressGuideControls,
} from '#/state/shell/progress-guide'
import * as Toast from '#/view/com/util/Toast'
import {atoms as a, useBreakpoints} from '#/alf'
import {Reply as Bubble} from '#/components/icons/Reply'
import {useFormatPostStatCount} from '#/components/PostControls/util'
import * as Skele from '#/components/Skeleton'
import * as Toast from '#/components/Toast'
import {useAnalytics} from '#/analytics'
import {BookmarkButton} from './BookmarkButton'
import {
@@ -106,7 +106,9 @@ let PostControls = ({
const onPressToggleLike = async () => {
if (isBlocked) {
Toast.show(l`Cannot interact with a blocked user`, 'exclamation-circle')
Toast.show(l`Cannot interact with a blocked user`, {
type: 'warning',
})
return
}
@@ -135,7 +137,9 @@ let PostControls = ({
const onRepost = async () => {
if (isBlocked) {
Toast.show(l`Cannot interact with a blocked user`, 'exclamation-circle')
Toast.show(l`Cannot interact with a blocked user`, {
type: 'warning',
})
return
}
@@ -161,7 +165,9 @@ let PostControls = ({
const onQuote = () => {
if (isBlocked) {
Toast.show(l`Cannot interact with a blocked user`, 'exclamation-circle')
Toast.show(l`Cannot interact with a blocked user`, {
type: 'warning',
})
return
}
+8 -3
View File
@@ -22,7 +22,6 @@ import {sanitizeHandle} from '#/lib/strings/handles'
import {useProfileShadow} from '#/state/cache/profile-shadow'
import {useProfileFollowMutationQueue} from '#/state/queries/profile'
import {useSession} from '#/state/session'
import * as Toast from '#/view/com/util/Toast'
import {PreviewableUserAvatar, UserAvatar} from '#/view/com/util/UserAvatar'
import {
atoms as a,
@@ -43,6 +42,7 @@ import {Link as InternalLink, type LinkProps} from '#/components/Link'
import * as Pills from '#/components/Pills'
import {ProfileBadges} from '#/components/ProfileBadges'
import {RichText} from '#/components/RichText'
import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography'
import {type Metrics} from '#/analytics'
import {useActorStatus} from '#/features/liveNow'
@@ -145,6 +145,7 @@ export function Link({
return (
<InternalLink
testID={`profileCard-${profile.handle}-link`}
label={l`View ${
profile.displayName || sanitizeHandle(profile.handle)
}s profile`}
@@ -504,7 +505,9 @@ export function FollowButtonInner({
} catch (e) {
const err = e as Error
if (err?.name !== 'AbortError') {
Toast.show(l`An issue occurred, please try again.`, 'xmark')
Toast.show(l`An issue occurred, please try again.`, {
type: 'error',
})
}
}
}
@@ -524,7 +527,9 @@ export function FollowButtonInner({
} catch (e) {
const err = e as Error
if (err?.name !== 'AbortError') {
Toast.show(l`An issue occurred, please try again.`, 'xmark')
Toast.show(l`An issue occurred, please try again.`, {
type: 'error',
})
}
}
}
+15 -16
View File
@@ -1,5 +1,4 @@
import {useCallback} from 'react'
import * as React from 'react'
import {memo, useCallback, useEffect, useMemo, useReducer, useRef} from 'react'
import {View} from 'react-native'
import {
type AppBskyActorDefs,
@@ -62,7 +61,7 @@ const floatingMiddlewares = [
export function ProfileHoverCard(props: ProfileHoverCardProps) {
const prefetchProfileQuery = usePrefetchProfileQuery()
const prefetchedProfile = React.useRef(false)
const prefetchedProfile = useRef(false)
const onPointerMove = () => {
if (!prefetchedProfile.current) {
prefetchedProfile.current = true
@@ -117,7 +116,7 @@ export function ProfileHoverCardInner(props: ProfileHoverCardProps) {
middleware: floatingMiddlewares,
})
const [currentState, dispatch] = React.useReducer(
const [currentState, dispatch] = useReducer(
// Tip: console.log(state, action) when debugging.
(state: State, action: Action): State => {
// Pressing within a card should always hide it.
@@ -263,7 +262,7 @@ export function ProfileHoverCardInner(props: ProfileHoverCardProps) {
{stage: 'hidden'},
)
React.useEffect(() => {
useEffect(() => {
if (currentState.effect) {
const effect = currentState.effect
return effect()
@@ -271,16 +270,16 @@ export function ProfileHoverCardInner(props: ProfileHoverCardProps) {
}, [currentState])
const prefetchProfileQuery = usePrefetchProfileQuery()
const prefetchedProfile = React.useRef(false)
const prefetchIfNeeded = React.useCallback(async () => {
const prefetchedProfile = useRef(false)
const prefetchIfNeeded = useCallback(async () => {
if (!prefetchedProfile.current) {
prefetchedProfile.current = true
prefetchProfileQuery(props.did)
}
}, [prefetchProfileQuery, props.did])
const didFireHover = React.useRef(false)
const onPointerMoveTarget = React.useCallback(() => {
const didFireHover = useRef(false)
const onPointerMoveTarget = useCallback(() => {
prefetchIfNeeded()
// Conceptually we want something like onPointerEnter,
// but we want to ignore entering only due to scrolling.
@@ -291,20 +290,20 @@ export function ProfileHoverCardInner(props: ProfileHoverCardProps) {
}
}, [prefetchIfNeeded])
const onPointerLeaveTarget = React.useCallback(() => {
const onPointerLeaveTarget = useCallback(() => {
didFireHover.current = false
dispatch('unhovered-target')
}, [])
const onPointerEnterCard = React.useCallback(() => {
const onPointerEnterCard = useCallback(() => {
dispatch('hovered-card')
}, [])
const onPointerLeaveCard = React.useCallback(() => {
const onPointerLeaveCard = useCallback(() => {
dispatch('unhovered-card')
}, [])
const onPress = React.useCallback(() => {
const onPress = useCallback(() => {
dispatch('pressed')
}, [])
@@ -412,7 +411,7 @@ let Card = ({
</View>
)
}
Card = React.memo(Card)
Card = memo(Card)
function Inner({
profile,
@@ -426,7 +425,7 @@ function Inner({
const t = useTheme()
const {_, i18n} = useLingui()
const {currentAccount} = useSession()
const moderation = React.useMemo(
const moderation = useMemo(
() => moderateProfile(profile, moderationOpts),
[profile, moderationOpts],
)
@@ -454,7 +453,7 @@ function Inner({
did: profile.did,
handle: profile.handle,
})
const isMe = React.useMemo(
const isMe = useMemo(
() => currentAccount?.did === profile.did,
[currentAccount, profile],
)
-1
View File
@@ -6,7 +6,6 @@ import Animated, {
SlideInLeft,
SlideInRight,
} from 'react-native-reanimated'
import type React from 'react'
import {IS_WEB} from '#/env'
@@ -1,4 +1,4 @@
import React, {useCallback} from 'react'
import {forwardRef, useCallback, useImperativeHandle, useState} from 'react'
import {type ListRenderItemInfo, View} from 'react-native'
import {type AppBskyFeedDefs} from '@atproto/api'
@@ -19,9 +19,9 @@ interface ProfilesListProps {
scrollElRef: ListRef
}
export const FeedsList = React.forwardRef<SectionRef, ProfilesListProps>(
export const FeedsList = forwardRef<SectionRef, ProfilesListProps>(
function FeedsListImpl({feeds, headerHeight, scrollElRef}, ref) {
const [initialHeaderHeight] = React.useState(headerHeight)
const [initialHeaderHeight] = useState(headerHeight)
const bottomBarOffset = useBottomBarOffset(20)
const t = useTheme()
@@ -32,7 +32,7 @@ export const FeedsList = React.forwardRef<SectionRef, ProfilesListProps>(
})
}, [scrollElRef, headerHeight])
React.useImperativeHandle(ref, () => ({
useImperativeHandle(ref, () => ({
scrollToTop: onScrollToTop,
}))
@@ -1,4 +1,4 @@
import React, {useCallback} from 'react'
import {forwardRef, useCallback, useImperativeHandle} from 'react'
import {View} from 'react-native'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
@@ -17,7 +17,7 @@ interface ProfilesListProps {
scrollElRef: ListRef
}
export const PostsList = React.forwardRef<SectionRef, ProfilesListProps>(
export const PostsList = forwardRef<SectionRef, ProfilesListProps>(
function PostsListImpl({listUri, headerHeight, scrollElRef}, ref) {
const feed: FeedDescriptor = `list|${listUri}`
const {_} = useLingui()
@@ -29,7 +29,7 @@ export const PostsList = React.forwardRef<SectionRef, ProfilesListProps>(
})
}, [scrollElRef, headerHeight])
React.useImperativeHandle(ref, () => ({
useImperativeHandle(ref, () => ({
scrollToTop: onScrollToTop,
}))
@@ -1,4 +1,4 @@
import React, {useCallback} from 'react'
import {forwardRef, useCallback, useImperativeHandle, useState} from 'react'
import {type ListRenderItemInfo, View} from 'react-native'
import {
type AppBskyActorDefs,
@@ -37,7 +37,7 @@ interface ProfilesListProps {
scrollElRef: ListRef
}
export const ProfilesList = React.forwardRef<SectionRef, ProfilesListProps>(
export const ProfilesList = forwardRef<SectionRef, ProfilesListProps>(
function ProfilesListImpl(
{listUri, moderationOpts, headerHeight, scrollElRef},
ref,
@@ -48,7 +48,7 @@ export const ProfilesList = React.forwardRef<SectionRef, ProfilesListProps>(
const {currentAccount} = useSession()
const {data, refetch, isError} = useAllListMembersQuery(listUri)
const [isPTRing, setIsPTRing] = React.useState(false)
const [isPTRing, setIsPTRing] = useState(false)
// The server returns these sorted by descending creation date, so we want to invert
@@ -80,7 +80,7 @@ export const ProfilesList = React.forwardRef<SectionRef, ProfilesListProps>(
})
}, [scrollElRef, headerHeight])
React.useImperativeHandle(ref, () => ({
useImperativeHandle(ref, () => ({
scrollToTop: onScrollToTop,
}))
+2 -3
View File
@@ -1,5 +1,4 @@
import {isValidElement} from 'react'
import {type ReactNode} from 'react'
import {View} from 'react-native'
import {nanoid} from 'nanoid/non-secure'
import {toast as sonner, Toaster} from 'sonner-native'
@@ -26,7 +25,7 @@ export function ToastOutlet() {
return <Toaster pauseWhenPageIsHidden gap={a.gap_sm.gap} />
}
export function Outer({children}: {children: ReactNode}) {
export function Outer({children}: {children: React.ReactNode}) {
return (
<View style={[a.px_xl, a.w_full]}>
<BaseOuter>{children}</BaseOuter>
@@ -43,7 +42,7 @@ export const api = sonner
* Our base toast API, using the `Toast` export of this file.
*/
export function show(
content: ReactNode,
content: React.ReactNode,
{type = 'default', ...options}: BaseToastOptions = {},
) {
const id = nanoid()
+1 -2
View File
@@ -1,5 +1,4 @@
import {isValidElement} from 'react'
import {type ReactNode} from 'react'
import {nanoid} from 'nanoid/non-secure'
import {toast as sonner, Toaster} from 'sonner'
@@ -41,7 +40,7 @@ export const api = sonner
* Our base toast API, using the `Toast` export of this file.
*/
export function show(
content: ReactNode,
content: React.ReactNode,
{type = 'default', ...options}: BaseToastOptions = {},
) {
const id = nanoid()
+33 -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,22 @@ 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',
// this is neater and supports vertical writing modes, but it's only baseline newly available
// overflowInline: 'clip',
} satisfies React.CSSProperties as TextStyleProp
@@ -21,7 +21,6 @@ import {sanitizeHandle} from '#/lib/strings/handles'
import {updateProfileShadow} from '#/state/cache/profile-shadow'
import {RQKEY_getActivitySubscriptions} from '#/state/queries/activity-subscriptions'
import {useAgent} from '#/state/session'
import * as Toast from '#/view/com/util/Toast'
import {atoms as a, platform, useTheme, web} from '#/alf'
import {Admonition} from '#/components/Admonition'
import {
@@ -34,6 +33,7 @@ import * as Dialog from '#/components/Dialog'
import * as Toggle from '#/components/forms/Toggle'
import {Loader} from '#/components/Loader'
import * as ProfileCard from '#/components/ProfileCard'
import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
import {IS_WEB} from '#/env'
@@ -139,7 +139,9 @@ function DialogInner({
_(
msg`You will no longer receive notifications for ${sanitizeHandle(profile.handle, '@')}`,
),
'check',
{
type: 'success',
},
)
// filter out the subscription
@@ -169,10 +171,14 @@ function DialogInner({
_(
msg`You'll start receiving notifications for ${sanitizeHandle(profile.handle, '@')}!`,
),
'check',
{
type: 'success',
},
)
} else {
Toast.show(_(msg`Changes saved`), 'check')
Toast.show(_(msg`Changes saved`), {
type: 'success',
})
}
}
})
@@ -8,12 +8,12 @@ import {useMutation} from '@tanstack/react-query'
import {BLUESKY_MOD_SERVICE_HEADERS} from '#/lib/constants'
import {useAgent, useSession} from '#/state/session'
import * as Toast from '#/view/com/util/Toast'
import {atoms as a, useBreakpoints, web} from '#/alf'
import {AgeAssuranceBadge} from '#/components/ageAssurance/AgeAssuranceBadge'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {Loader} from '#/components/Loader'
import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography'
import {logger} from '#/ageAssurance'
import {useAnalytics} from '#/analytics'
@@ -70,7 +70,9 @@ function Inner({control}: {control: Dialog.DialogControlProps}) {
logger.error('AgeAssuranceAppealDialog failed', {safeMessage: err})
Toast.show(
_(msg`Age assurance inquiry failed to send, please try again.`),
'xmark',
{
type: 'error',
},
)
},
onSuccess: () => {
+1 -2
View File
@@ -1,5 +1,4 @@
import {useCallback, useState} from 'react'
import {type PropsWithChildren} from 'react'
import {View} from 'react-native'
import {type AppBskyActorDefs, sanitizeMutedWordValue} from '@atproto/api'
import {msg} from '@lingui/core/macro'
@@ -625,7 +624,7 @@ function MutedWordRow({
)
}
function TargetToggle({children}: PropsWithChildren<{}>) {
function TargetToggle({children}: React.PropsWithChildren<{}>) {
const t = useTheme()
const ctx = Toggle.useItemContext()
const {gtMobile} = useBreakpoints()
@@ -37,7 +37,6 @@ import {
usePostThreadContext,
} from '#/state/queries/usePostThread'
import {useAgent, useSession} from '#/state/session'
import * as Toast from '#/view/com/util/Toast'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useTheme, web} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
@@ -50,6 +49,7 @@ import {
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
import {CloseQuote_Stroke2_Corner1_Rounded as QuoteIcon} from '#/components/icons/Quote'
import {Loader} from '#/components/Loader'
import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
import {IS_IOS} from '#/env'
@@ -240,7 +240,9 @@ export function PostInteractionSettingsDialogControlledInner(
_(
msg`There was an issue. Please check your internet connection and try again.`,
),
'xmark',
{
type: 'error',
},
)
} finally {
setIsSaving(false)
+11 -2
View File
@@ -19,6 +19,7 @@ import {
useListMembershipRemoveMutation,
} from '#/state/queries/list-memberships'
import {useProfileQuery} from '#/state/queries/profile'
import {useSession} from '#/state/session'
import {atoms as a, native, platform, useTheme} from '#/alf'
import {AvatarStack} from '#/components/AvatarStack'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
@@ -260,6 +261,8 @@ function StarterPackItem({
const t = useTheme()
const ax = useAnalytics()
const {_} = useLingui()
const {currentAccount} = useSession()
const isSelf = subject?.did === currentAccount?.did
const starterPack = starterPackWithMembership.starterPack
const isInPack = !!starterPackWithMembership.listItem
@@ -373,11 +376,17 @@ function StarterPackItem({
label={isInPack ? _(msg`Remove`) : _(msg`Add`)}
color={isInPack ? 'secondary' : 'primary_subtle'}
size="tiny"
disabled={isPending}
disabled={isPending || isSelf}
onPress={handleToggleMembership}>
{isPending && <ButtonIcon icon={Loader} />}
<ButtonText>
{isInPack ? <Trans>Remove</Trans> : <Trans>Add</Trans>}
{isSelf ? (
<Trans>Owner</Trans>
) : isInPack ? (
<Trans>Remove</Trans>
) : (
<Trans>Add</Trans>
)}
</ButtonText>
</Button>
</View>
@@ -17,7 +17,6 @@ import {
} from '#/state/queries/list'
import {useAgent} from '#/state/session'
import {ErrorMessage} from '#/view/com/util/error/ErrorMessage'
import * as Toast from '#/view/com/util/Toast'
import {EditableUserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useTheme, web} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
@@ -25,6 +24,7 @@ import * as Dialog from '#/components/Dialog'
import * as TextField from '#/components/forms/TextField'
import {Loader} from '#/components/Loader'
import * as Prompt from '#/components/Prompt'
import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography'
import {IS_WEB} from '#/env'
@@ -14,7 +14,6 @@ import {
useListMembershipAddMutation,
useListMembershipRemoveMutation,
} from '#/state/queries/list-memberships'
import * as Toast from '#/view/com/util/Toast'
import {atoms as a} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
@@ -24,6 +23,7 @@ import {
} from '#/components/dialogs/SearchablePeopleList'
import {Loader} from '#/components/Loader'
import * as ProfileCard from '#/components/ProfileCard'
import * as Toast from '#/components/Toast'
import type * as bsky from '#/types/bsky'
export function ListAddRemoveUsersDialog({
@@ -113,7 +113,10 @@ function UserResult({
Toast.show(_(msg`Added to list`))
onChange?.('add', profile)
},
onError: e => Toast.show(cleanError(e), 'xmark'),
onError: e =>
Toast.show(cleanError(e), {
type: 'error',
}),
})
const {mutate: listMembershipRemove, isPending: isRemovingPending} =
useListMembershipRemoveMutation({
@@ -121,7 +124,10 @@ function UserResult({
Toast.show(_(msg`Removed from list`))
onChange?.('remove', profile)
},
onError: e => Toast.show(cleanError(e), 'xmark'),
onError: e =>
Toast.show(cleanError(e), {
type: 'error',
}),
})
const isMutating = isAddingPending || isRemovingPending
+6 -6
View File
@@ -6,11 +6,11 @@ import {useLingui} from '@lingui/react'
import {useConvoActive} from '#/state/messages/convo'
import {useSession} from '#/state/session'
import * as Toast from '#/view/com/util/Toast'
import {atoms as a, useTheme} from '#/alf'
import {MessageContextMenu} from '#/components/dms/MessageContextMenu'
import {DotGrid3x1_Stroke2_Corner0_Rounded as DotsHorizontalIcon} from '#/components/icons/DotGrid'
import {EmojiSmile_Stroke2_Corner0_Rounded as EmojiSmileIcon} from '#/components/icons/Emoji'
import * as Toast from '#/components/Toast'
import {EmojiReactionPicker} from './EmojiReactionPicker'
import {hasReachedReactionLimit} from './util'
@@ -60,11 +60,11 @@ export function ActionsWrapper({
.catch(() => Toast.show(_(msg`Failed to remove emoji reaction`)))
} else {
if (hasReachedReactionLimit(message, currentAccount?.did)) return
convo
.addReaction(message.id, emoji)
.catch(() =>
Toast.show(_(msg`Failed to add emoji reaction`), 'xmark'),
)
convo.addReaction(message.id, emoji).catch(() =>
Toast.show(_(msg`Failed to add emoji reaction`), {
type: 'error',
}),
)
}
},
[_, convo, message, currentAccount?.did],
+7 -4
View File
@@ -5,7 +5,6 @@ import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {StackActions, useNavigation} from '@react-navigation/native'
import type React from 'react'
import {type NavigationProp} from '#/lib/routes/types'
import {useProfileShadow} from '#/state/cache/profile-shadow'
@@ -14,12 +13,12 @@ import {
useProfileBlockMutationQueue,
useProfileQuery,
} from '#/state/queries/profile'
import * as Toast from '#/view/com/util/Toast'
import {atoms as a, platform, useBreakpoints, useTheme, web} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import * as Toggle from '#/components/forms/Toggle'
import {Loader} from '#/components/Loader'
import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography'
import {IS_NATIVE} from '#/env'
@@ -136,7 +135,9 @@ function DoneStep({
}
},
onError: () => {
Toast.show(_(msg`Could not leave chat`), 'xmark')
Toast.show(_(msg`Could not leave chat`), {
type: 'error',
})
},
})
@@ -162,7 +163,9 @@ function DoneStep({
leaveConvo()
}
if (toastMsg) {
Toast.show(toastMsg, 'check')
Toast.show(toastMsg, {
type: 'success',
})
}
})
}
+7 -6
View File
@@ -1,5 +1,4 @@
import {useCallback} from 'react'
import * as React from 'react'
import {memo, useCallback} from 'react'
import {Keyboard, View} from 'react-native'
import {type ChatBskyConvoDefs, type ModerationCause} from '@atproto/api'
import {msg} from '@lingui/core/macro'
@@ -19,7 +18,6 @@ import {
unstableCacheProfileView,
useProfileBlockMutationQueue,
} from '#/state/queries/profile'
import * as Toast from '#/view/com/util/Toast'
import {type ViewStyleProp} from '#/alf'
import {atoms as a} from '#/alf'
import {Button, ButtonIcon} from '#/components/Button'
@@ -41,6 +39,7 @@ import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/
import * as Menu from '#/components/Menu'
import {ReportDialog} from '#/components/moderation/ReportDialog'
import * as Prompt from '#/components/Prompt'
import * as Toast from '#/components/Toast'
import type * as bsky from '#/types/bsky'
let ConvoMenu = ({
@@ -160,7 +159,7 @@ let ConvoMenu = ({
</>
)
}
ConvoMenu = React.memo(ConvoMenu)
ConvoMenu = memo(ConvoMenu)
function MenuContent({
convo: initialConvo,
@@ -206,13 +205,15 @@ function MenuContent({
}
},
onError: () => {
Toast.show(_(msg`Could not mute chat`), 'xmark')
Toast.show(_(msg`Could not mute chat`), {
type: 'error',
})
},
})
const [queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile)
const toggleBlock = React.useCallback(() => {
const toggleBlock = useCallback(() => {
if (listBlocks.length) {
blockedByListControl.open()
return
+1 -2
View File
@@ -1,5 +1,4 @@
import {memo} from 'react'
import {type ReactNode} from 'react'
import {View} from 'react-native'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
@@ -29,7 +28,7 @@ const longDateFormatterWithYear = new Intl.DateTimeFormat(undefined, {
year: 'numeric',
})
let DateDivider = ({date: dateStr}: {date: string}): ReactNode => {
let DateDivider = ({date: dateStr}: {date: string}): React.ReactNode => {
const {_} = useLingui()
const t = useTheme()
+4 -2
View File
@@ -4,9 +4,9 @@ import {StackActions, useNavigation} from '@react-navigation/native'
import {type NavigationProp} from '#/lib/routes/types'
import {useLeaveConvo} from '#/state/queries/messages/leave-conversation'
import * as Toast from '#/view/com/util/Toast'
import {type DialogOuterProps} from '#/components/Dialog'
import * as Prompt from '#/components/Prompt'
import * as Toast from '#/components/Toast'
import {IS_NATIVE} from '#/env'
export function LeaveConvoPrompt({
@@ -32,7 +32,9 @@ export function LeaveConvoPrompt({
}
},
onError: () => {
Toast.show(_(msg`Could not leave chat`), 'xmark')
Toast.show(_(msg`Could not leave chat`), {
type: 'error',
})
},
})
+5 -2
View File
@@ -1,10 +1,13 @@
import {createContext, useContext} from 'react'
import {type ReactNode} from 'react'
const MessageContext = createContext(false)
MessageContext.displayName = 'MessageContext'
export function MessageContextProvider({children}: {children: ReactNode}) {
export function MessageContextProvider({
children,
}: {
children: React.ReactNode
}) {
return (
<MessageContext.Provider value={true}>{children}</MessageContext.Provider>
)
+14 -10
View File
@@ -1,5 +1,5 @@
import {memo, useCallback} from 'react'
import {LayoutAnimation} from 'react-native'
import {LayoutAnimation, Platform} from 'react-native'
import * as Clipboard from 'expo-clipboard'
import {type ChatBskyConvoDefs, RichText} from '@atproto/api'
import {msg} from '@lingui/core/macro'
@@ -12,7 +12,6 @@ import {useConvoActive} from '#/state/messages/convo'
import {useLanguagePrefs} from '#/state/preferences'
import {unstableCacheProfileView} from '#/state/queries/unstable-profile-cache'
import {useSession} from '#/state/session'
import * as Toast from '#/view/com/util/Toast'
import * as ContextMenu from '#/components/ContextMenu'
import {type TriggerProps} from '#/components/ContextMenu/types'
import {AfterReportDialog} from '#/components/dms/AfterReportDialog'
@@ -23,6 +22,7 @@ import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/War
import {ReportDialog} from '#/components/moderation/ReportDialog'
import * as Prompt from '#/components/Prompt'
import {usePromptControl} from '#/components/Prompt'
import * as Toast from '#/components/Toast'
import {useAnalytics} from '#/analytics'
import {IS_NATIVE} from '#/env'
import {EmojiReactionPicker} from './EmojiReactionPicker'
@@ -58,16 +58,20 @@ export let MessageContextMenu = ({
)
void Clipboard.setStringAsync(str)
Toast.show(_(msg`Copied to clipboard`), 'clipboard-check')
Toast.show(_(msg`Copied to clipboard`), {
type: 'success',
})
}, [_, message.text, message.facets])
const onPressTranslateMessage = useCallback(() => {
void translate(message.text, langPrefs.primaryLanguage)
ax.metric('translate', {
sourceLanguages: [],
targetLanguage: langPrefs.primaryLanguage,
os: Platform.OS,
possibleSourceLanguages: [], // N/A for chats
expectedTargetLanguage: langPrefs.primaryLanguage,
textLength: message.text.length,
googleTranslate: true,
})
}, [ax, langPrefs.primaryLanguage, message.text, translate])
@@ -95,11 +99,11 @@ export let MessageContextMenu = ({
.catch(() => Toast.show(_(msg`Failed to remove emoji reaction`)))
} else {
if (hasReachedReactionLimit(message, currentAccount?.did)) return
convo
.addReaction(message.id, emoji)
.catch(() =>
Toast.show(_(msg`Failed to add emoji reaction`), 'xmark'),
)
convo.addReaction(message.id, emoji).catch(() =>
Toast.show(_(msg`Failed to add emoji reaction`), {
type: 'error',
}),
)
}
},
[_, convo, message, currentAccount?.did],
+2 -3
View File
@@ -1,5 +1,4 @@
import {memo, useCallback, useMemo} from 'react'
import {type ReactNode} from 'react'
import {
type GestureResponderEvent,
type StyleProp,
@@ -41,7 +40,7 @@ let MessageItem = ({
item,
}: {
item: ConvoItem & {type: 'message' | 'pending-message'}
}): ReactNode => {
}): React.ReactNode => {
const t = useTheme()
const {currentAccount} = useSession()
const {_} = useLingui()
@@ -243,7 +242,7 @@ let MessageItemMetadata = ({
}: {
item: ConvoItem & {type: 'message' | 'pending-message'}
style: StyleProp<TextStyle>
}): ReactNode => {
}): React.ReactNode => {
const t = useTheme()
const {_} = useLingui()
const {message} = item

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