Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b410ba867f |
@@ -1,5 +1,5 @@
|
||||
name: "Bug Report"
|
||||
description: "Create a report for an issue you have experienced in the app."
|
||||
description: "Create a report for an issue you have experience in the app."
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: markdown
|
||||
@@ -19,14 +19,13 @@ body:
|
||||
4. See error
|
||||
validations:
|
||||
required: true
|
||||
- type: upload
|
||||
- type: textarea
|
||||
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?
|
||||
|
||||
@@ -26,14 +26,13 @@ body:
|
||||
4. See error
|
||||
validations:
|
||||
required: true
|
||||
- type: upload
|
||||
- type: textarea
|
||||
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?
|
||||
|
||||
@@ -15,7 +15,7 @@ body:
|
||||
implement it in a timely manner.
|
||||
validations:
|
||||
required: true
|
||||
- type: upload
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Attachments
|
||||
description: |
|
||||
@@ -24,7 +24,6 @@ body:
|
||||
in or is missing from.
|
||||
validations:
|
||||
required: false
|
||||
accept: ".png,.jpg,.jpeg,.gif,.webp,.mp4,.mov,.webm"
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Describe Alternatives
|
||||
|
||||
@@ -56,7 +56,7 @@ jobs:
|
||||
|
||||
- uses: maxim-lobanov/setup-xcode@v1
|
||||
with:
|
||||
xcode-version: "26.4"
|
||||
xcode-version: "26.0"
|
||||
|
||||
- name: ☕️ Setup Cocoapods
|
||||
uses: maxim-lobanov/setup-cocoapods@v1
|
||||
|
||||
@@ -197,7 +197,7 @@ jobs:
|
||||
|
||||
- uses: maxim-lobanov/setup-xcode@v1
|
||||
with:
|
||||
xcode-version: "26.4"
|
||||
xcode-version: "26.0"
|
||||
|
||||
- name: ☕️ Setup Cocoapods
|
||||
uses: maxim-lobanov/setup-cocoapods@v1
|
||||
|
||||
Vendored
-135
@@ -1,135 +0,0 @@
|
||||
/**
|
||||
* 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()
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
/**
|
||||
* 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()
|
||||
}
|
||||
@@ -431,30 +431,16 @@ yarn intl:compile # Compile translations for runtime
|
||||
// src/state/queries/profile.ts
|
||||
import {useQuery, useMutation, useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {createQueryKey} from '#/state/queries/util'
|
||||
// Query key pattern
|
||||
const RQKEY_ROOT = 'profile'
|
||||
export const RQKEY = (did: string) => [RQKEY_ROOT, did]
|
||||
|
||||
/*
|
||||
* 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
|
||||
*/
|
||||
// Query hook
|
||||
export function useProfileQuery({did}: {did: string}) {
|
||||
const agent = useAgent()
|
||||
|
||||
return useQuery({
|
||||
queryKey: createProfileQueryKey({did}),
|
||||
queryKey: RQKEY(did),
|
||||
queryFn: async () => {
|
||||
const res = await agent.getProfile({actor: did})
|
||||
return res.data
|
||||
@@ -464,12 +450,8 @@ export function useProfileQuery({did}: {did: string}) {
|
||||
})
|
||||
}
|
||||
|
||||
/*
|
||||
* 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() {
|
||||
// Mutation hook
|
||||
export function useUpdateProfile() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
@@ -477,9 +459,7 @@ export function useProfileMutation() {
|
||||
// Update logic
|
||||
},
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: createProfileQueryKey({did: variables.did}),
|
||||
})
|
||||
queryClient.invalidateQueries({queryKey: RQKEY(variables.did)})
|
||||
},
|
||||
onError: (error) => {
|
||||
if (isNetworkError(error)) {
|
||||
@@ -493,24 +473,6 @@ export function useProfileMutation() {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/*
|
||||
* 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`):
|
||||
@@ -529,7 +491,7 @@ export function useDraftsQuery() {
|
||||
const agent = useAgent()
|
||||
|
||||
return useInfiniteQuery({
|
||||
queryKey: createQueryKey('drafts'),
|
||||
queryKey: ['drafts'],
|
||||
queryFn: async ({pageParam}) => {
|
||||
const res = await agent.app.bsky.draft.getDrafts({cursor: pageParam})
|
||||
return res.data
|
||||
@@ -542,19 +504,6 @@ 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
|
||||
|
||||
@@ -91,8 +91,7 @@ appId: xyz.blueskyweb.app
|
||||
- tapOn: "Add user to list"
|
||||
- swipe:
|
||||
direction: DOWN
|
||||
- assertVisible:
|
||||
id: "profileCard-bob.test-link"
|
||||
- assertVisible: "View Bob's profile"
|
||||
|
||||
- tapOn: "Posts"
|
||||
- assertVisible:
|
||||
@@ -124,8 +123,7 @@ appId: xyz.blueskyweb.app
|
||||
- tapOn: "Good Ppl"
|
||||
|
||||
- tapOn: "People"
|
||||
- assertVisible:
|
||||
id: "profileCard-bob.test-link"
|
||||
- assertVisible: "View Bob's profile"
|
||||
- tapOn:
|
||||
point: "90%,43%"
|
||||
- tapOn:
|
||||
|
||||
+2
-2
@@ -54,7 +54,7 @@ module.exports = function (_config) {
|
||||
},
|
||||
icon: './assets/app-icons/ios_icon_default_next.png',
|
||||
userInterfaceStyle: 'automatic',
|
||||
primaryColor: '#006AFF',
|
||||
primaryColor: '#1083fe',
|
||||
newArchEnabled: false,
|
||||
ios: {
|
||||
supportsTablet: false,
|
||||
@@ -64,7 +64,6 @@ module.exports = function (_config) {
|
||||
},
|
||||
icon: IOS_ICON_FILE,
|
||||
infoPlist: {
|
||||
CADisableMinimumFrameDurationOnPhone: true,
|
||||
UIBackgroundModes: ['remote-notification'],
|
||||
NSCameraUsageDescription:
|
||||
'Used for profile pictures, posts, and other kinds of content.',
|
||||
@@ -297,6 +296,7 @@ module.exports = function (_config) {
|
||||
'./plugins/withAndroidManifestFCMIconPlugin.js',
|
||||
'./plugins/withAndroidManifestIntentQueriesPlugin.js',
|
||||
'./plugins/withAndroidStylesAccentColorPlugin.js',
|
||||
'./plugins/withAndroidDayNightThemePlugin.js',
|
||||
'./plugins/withAndroidNoJitpackPlugin.js',
|
||||
'./plugins/shareExtension/withShareExtensions.js',
|
||||
'./plugins/notificationsExtension/withNotificationsExtension.js',
|
||||
|
||||
@@ -4,9 +4,7 @@
|
||||
"type": "module",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
"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",
|
||||
"test": "./tests/infra/with-test-db.sh node --loader ts-node/esm --test ./tests/index.ts",
|
||||
"build": "tsc"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -15,7 +15,6 @@ export type ServiceConfig = {
|
||||
safelinkPdsUrl?: string
|
||||
safelinkAgentIdentifier?: string
|
||||
safelinkAgentPass?: string
|
||||
metricsApiHost?: string
|
||||
}
|
||||
|
||||
export type DbConfig = {
|
||||
@@ -46,7 +45,6 @@ export type Environment = {
|
||||
safelinkPdsUrl?: string
|
||||
safelinkAgentIdentifier?: string
|
||||
safelinkAgentPass?: string
|
||||
metricsApiHost?: string
|
||||
}
|
||||
|
||||
export const readEnv = (): Environment => {
|
||||
@@ -67,7 +65,6 @@ 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'),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,7 +79,6 @@ 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)')
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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
|
||||
@@ -13,7 +12,6 @@ export class AppContext {
|
||||
db: Database
|
||||
safelinkClient: SafelinkClient
|
||||
abortController = new AbortController()
|
||||
metrics: MetricsClient
|
||||
|
||||
constructor(private opts: AppContextOptions) {
|
||||
this.cfg = this.opts.cfg
|
||||
@@ -22,9 +20,6 @@ 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>) {
|
||||
|
||||
@@ -1,20 +1,10 @@
|
||||
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 (') 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'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(safeLink)}'" />
|
||||
<meta http-equiv="refresh" content="0; URL='${escapeHTML(link)}'" />
|
||||
<meta
|
||||
http-equiv="Cache-Control"
|
||||
content="no-store, no-cache, must-revalidate, max-age=0" />
|
||||
|
||||
@@ -36,7 +36,6 @@ 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})
|
||||
@@ -47,6 +46,5 @@ export class LinkService {
|
||||
this.ctx.abortController.abort()
|
||||
await this.terminator?.terminate()
|
||||
await this.ctx.db.close()
|
||||
this.ctx.metrics.stop()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,183 +0,0 @@
|
||||
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))
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
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')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,6 @@ 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()
|
||||
@@ -49,9 +48,6 @@ 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)
|
||||
@@ -59,7 +55,6 @@ 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(
|
||||
@@ -71,7 +66,6 @@ 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(
|
||||
@@ -83,7 +77,6 @@ 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')
|
||||
@@ -96,18 +89,6 @@ 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)
|
||||
}),
|
||||
)
|
||||
|
||||
+9
-29
@@ -2,9 +2,11 @@ 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.skip('link service', async () => {
|
||||
describe('link service', async () => {
|
||||
let linkService: LinkService
|
||||
let baseUrl: string
|
||||
before(async () => {
|
||||
@@ -16,9 +18,9 @@ describe.skip('link service', async () => {
|
||||
dbPostgresSchema: 'link_test',
|
||||
dbPostgresUrl: process.env.DB_POSTGRES_URL,
|
||||
safelinkEnabled: true,
|
||||
safelinkPdsUrl: 'http://localhost:2583',
|
||||
safelinkAgentIdentifier: 'mod-authority.test',
|
||||
safelinkAgentPass: 'hunter2',
|
||||
ozoneUrl: 'http://localhost:2583',
|
||||
ozoneAgentHandle: 'mod-authority.test',
|
||||
ozoneAgentPass: 'hunter2',
|
||||
})
|
||||
const migrateDb = Database.postgres({
|
||||
url: cfg.db.url,
|
||||
@@ -31,7 +33,6 @@ describe.skip('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({
|
||||
@@ -109,7 +110,6 @@ describe.skip('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,7 +213,6 @@ describe.skip('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(
|
||||
@@ -233,7 +232,6 @@ describe.skip('link service', async () => {
|
||||
new RegExp(urlToRedirect.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')),
|
||||
)
|
||||
})
|
||||
*/
|
||||
|
||||
async function getRedirect(link: string): Promise<[number, string]> {
|
||||
const url = new URL(link)
|
||||
@@ -293,10 +291,9 @@ describe('link service no safelink', async () => {
|
||||
dbPostgresSchema: 'link_test',
|
||||
dbPostgresUrl: process.env.DB_POSTGRES_URL,
|
||||
safelinkEnabled: false,
|
||||
safelinkPdsUrl: 'http://localhost:2583',
|
||||
safelinkAgentIdentifier: 'mod-authority.test',
|
||||
safelinkAgentPass: 'hunter2',
|
||||
metricsApiHost: 'http://localhost:2584',
|
||||
ozoneUrl: 'http://localhost:2583',
|
||||
ozoneAgentHandle: 'mod-authority.test',
|
||||
ozoneAgentPass: 'hunter2',
|
||||
})
|
||||
const migrateDb = Database.postgres({
|
||||
url: cfg.db.url,
|
||||
@@ -360,21 +357,4 @@ 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"/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -14,10 +14,6 @@
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"],
|
||||
"ts-node": {
|
||||
"logError": true,
|
||||
"pretty": true /* <= technically not required */
|
||||
}
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react'
|
||||
import {type SVGAttributes} from 'react'
|
||||
|
||||
export function Butterfly(props: React.SVGAttributes<SVGSVGElement>) {
|
||||
export function Butterfly(props: SVGAttributes<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react'
|
||||
import {type ImgHTMLAttributes} 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<React.ImgHTMLAttributes<HTMLImageElement>, 'src'> & {src: Buffer},
|
||||
props: Omit<ImgHTMLAttributes<HTMLImageElement>, 'src'> & {src: Buffer},
|
||||
) {
|
||||
const {src, ...others} = props
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
/* eslint-disable bsky-internal/avoid-unwrapped-text */
|
||||
import React from 'react'
|
||||
import {AppBskyGraphDefs, AppBskyGraphStarterpack} from '@atproto/api'
|
||||
import {type AppBskyGraphDefs, AppBskyGraphStarterpack} from '@atproto/api'
|
||||
|
||||
import {Butterfly} from './Butterfly.js'
|
||||
import {Img} from './Img.js'
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
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'
|
||||
|
||||
@@ -590,14 +590,6 @@ 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 {
|
||||
@@ -608,14 +600,6 @@ 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -34,14 +34,6 @@
|
||||
<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 }}">
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
"name": "dev-env",
|
||||
"version": "0.0.0",
|
||||
"scripts": {
|
||||
"start": "NODE_ENV=development ./dev-infra/with-test-redis-and-db.sh ts-node ./mock-server.ts"
|
||||
"e2e:mock-server": "NODE_ENV=development ./dev-infra/with-test-redis-and-db.sh ts-node ./mock-server.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atproto/dev-env": "^0.3.215",
|
||||
"@atproto/dev-env": "^0.3.213",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^6.0.2"
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,6 @@
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"forceConsistentCasingInFileNames": false,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"paths": {
|
||||
"#/*": ["./src/*"],
|
||||
"lib/*": ["./src/lib/*"],
|
||||
@@ -44,4 +43,4 @@
|
||||
"metro.config.js",
|
||||
"jest.config.js"
|
||||
]
|
||||
}
|
||||
}
|
||||
+77
-193
@@ -64,14 +64,14 @@
|
||||
"@atproto/xrpc" "^0.7.6"
|
||||
"@atproto/xrpc-server" "^0.10.0"
|
||||
|
||||
"@atproto/api@^0.19.4":
|
||||
version "0.19.4"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.19.4.tgz#f3ff850baf4d85538c082fb91aa0982737eb68be"
|
||||
integrity sha512-fYNM62vdXxer0h8a9Jzl4/ag9uFIe0nTO+LkC6KTlx1yUDigrAoQMMbllIiCWj62GhUMxAkHabk/BZjjVAfKng==
|
||||
"@atproto/api@^0.19.2":
|
||||
version "0.19.3"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.19.3.tgz#61de8d2e31abe9eb2b4c8f4ad124ed79d4a77e89"
|
||||
integrity sha512-G8YpBpRouHdTAIagi/QQIUZOhGd1jfBQWkJy9QfxAzjjEpPvaVOSk4e1S85QzGLm/xbzVONzGkmdtiOSfP6wVg==
|
||||
dependencies:
|
||||
"@atproto/common-web" "^0.4.18"
|
||||
"@atproto/lexicon" "^0.6.2"
|
||||
"@atproto/syntax" "^0.5.1"
|
||||
"@atproto/syntax" "^0.5.0"
|
||||
"@atproto/xrpc" "^0.7.7"
|
||||
await-lock "^2.2.2"
|
||||
multiformats "^9.9.0"
|
||||
@@ -96,23 +96,23 @@
|
||||
multiformats "^9.9.0"
|
||||
uint8arrays "3.0.0"
|
||||
|
||||
"@atproto/bsky@^0.0.221":
|
||||
version "0.0.221"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/bsky/-/bsky-0.0.221.tgz#b574456225db66c866848526947473d60bb4d0e7"
|
||||
integrity sha512-feNR6xkJ9HCJbQdJU3ytsnAfaSBXJdaXIMi0tNPrsLfoqCWWbTfXIcMczXeY4SOhKGFR5oMYxE9zrRC/TTAssw==
|
||||
"@atproto/bsky@^0.0.219":
|
||||
version "0.0.219"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/bsky/-/bsky-0.0.219.tgz#6a9f82eb4ab999e121d04ad2b3f08ff60cc75fba"
|
||||
integrity sha512-Vm7JpIyCqd7sHzHsXqppGSaKkXKUhvdZ/UOldc247Bgmx+L/U+E6IeR028hzMr1YyDvU+bkO1hlqT8uUovOCdA==
|
||||
dependencies:
|
||||
"@atproto-labs/fetch-node" "^0.2.0"
|
||||
"@atproto-labs/xrpc-utils" "^0.0.24"
|
||||
"@atproto/api" "^0.19.4"
|
||||
"@atproto/common" "^0.5.15"
|
||||
"@atproto/api" "^0.19.2"
|
||||
"@atproto/common" "^0.5.14"
|
||||
"@atproto/crypto" "^0.4.5"
|
||||
"@atproto/did" "^0.3.0"
|
||||
"@atproto/identity" "^0.4.12"
|
||||
"@atproto/lexicon" "^0.6.2"
|
||||
"@atproto/repo" "^0.8.13"
|
||||
"@atproto/repo" "^0.8.12"
|
||||
"@atproto/sync" "^0.1.40"
|
||||
"@atproto/syntax" "^0.5.1"
|
||||
"@atproto/xrpc-server" "^0.10.17"
|
||||
"@atproto/syntax" "^0.5.0"
|
||||
"@atproto/xrpc-server" "^0.10.15"
|
||||
"@bufbuild/protobuf" "^1.5.0"
|
||||
"@connectrpc/connect" "^1.1.4"
|
||||
"@connectrpc/connect-express" "^1.1.4"
|
||||
@@ -146,13 +146,13 @@
|
||||
undici "^6.19.8"
|
||||
zod "3.23.8"
|
||||
|
||||
"@atproto/bsync@^0.0.25":
|
||||
version "0.0.25"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/bsync/-/bsync-0.0.25.tgz#0d6056f844c0b2579d9dfc04b6727974dc03fcd6"
|
||||
integrity sha512-5tjP5QbUcNtMBw7FJeyRfA0OHQRKrg97Jva6Q26cKqLMICGYNbx0fpD7nZhGTP2/s1gd6UZkG9Zdh4GRZpbxWg==
|
||||
"@atproto/bsync@^0.0.24":
|
||||
version "0.0.24"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/bsync/-/bsync-0.0.24.tgz#6b0d4b02c0c0241687456ab817471d36ee81ae61"
|
||||
integrity sha512-JN+oncaPBNRjzjTPGR7Q1fkKF3cqOQ6oLRrAh9kVU04ZS3FhWUG8cQvnr8wb1PUhFb/XYpWkwDw5+GIhdb7Lfw==
|
||||
dependencies:
|
||||
"@atproto/common" "^0.5.14"
|
||||
"@atproto/syntax" "^0.5.1"
|
||||
"@atproto/syntax" "^0.5.0"
|
||||
"@bufbuild/protobuf" "^1.5.0"
|
||||
"@connectrpc/connect" "^1.1.4"
|
||||
"@connectrpc/connect-node" "^1.1.4"
|
||||
@@ -172,16 +172,6 @@
|
||||
"@atproto/syntax" "^0.5.0"
|
||||
zod "^3.23.8"
|
||||
|
||||
"@atproto/common-web@^0.4.19":
|
||||
version "0.4.19"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/common-web/-/common-web-0.4.19.tgz#bbd7f84f545ebe73ca3bc00314ccf4ee66e7069e"
|
||||
integrity sha512-3BTi58p5WpT+9/zb6UZrdsXcfPo5P45UJm0E4iwHLILr+jc37CuBj9JReDSZ4U0i9RTrI3ZkfySyZ9bd+LnMsw==
|
||||
dependencies:
|
||||
"@atproto/lex-data" "^0.0.14"
|
||||
"@atproto/lex-json" "^0.0.14"
|
||||
"@atproto/syntax" "^0.5.1"
|
||||
zod "^3.23.8"
|
||||
|
||||
"@atproto/common@0.1.0":
|
||||
version "0.1.0"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/common/-/common-0.1.0.tgz#4216a8fef5b985ab62ac21252a0f8ca0f4a0f210"
|
||||
@@ -213,17 +203,6 @@
|
||||
multiformats "^9.9.0"
|
||||
pino "^8.21.0"
|
||||
|
||||
"@atproto/common@^0.5.15":
|
||||
version "0.5.15"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/common/-/common-0.5.15.tgz#3c43c25d3493d868cc4281d6ac2f923b00644463"
|
||||
integrity sha512-+cdfdMPAIbH9zQGLfH1gNY2KEZsMxj0EelVQL5uJUFL+UkkAXiiqWj7J5mbax8sf02cC/afJnfkWzERNAheKoA==
|
||||
dependencies:
|
||||
"@atproto/common-web" "^0.4.19"
|
||||
"@atproto/lex-cbor" "^0.0.15"
|
||||
"@atproto/lex-data" "^0.0.14"
|
||||
multiformats "^9.9.0"
|
||||
pino "^8.21.0"
|
||||
|
||||
"@atproto/crypto@0.1.0":
|
||||
version "0.1.0"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/crypto/-/crypto-0.1.0.tgz#bc73a479f9dbe06fa025301c182d7f7ab01bc568"
|
||||
@@ -244,23 +223,23 @@
|
||||
"@noble/hashes" "^1.6.1"
|
||||
uint8arrays "3.0.0"
|
||||
|
||||
"@atproto/dev-env@^0.3.215":
|
||||
version "0.3.215"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/dev-env/-/dev-env-0.3.215.tgz#9da8c4a73abb4501ac72c73da9536e2ec86cb46f"
|
||||
integrity sha512-zwZwGWYLgP2Zdie6/gMtxuDbSs7/UV/gPJYfOlXln1ZDoMkfFbgCTq44PWRnJpUKTzrq7gt19E0GsL7DMkppjA==
|
||||
"@atproto/dev-env@^0.3.213":
|
||||
version "0.3.213"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/dev-env/-/dev-env-0.3.213.tgz#30ca66f827d44ccabb02b359119f68549fa3edf0"
|
||||
integrity sha512-Bjhv+zzcQxhwV4I7si+yDls8sSetksILeiBemfRe3cvE4GOgs7KfsYI5+pqNmBZ5rrFPt3KUeN9vIo31LCgZOw==
|
||||
dependencies:
|
||||
"@atproto/api" "^0.19.4"
|
||||
"@atproto/bsky" "^0.0.221"
|
||||
"@atproto/bsync" "^0.0.25"
|
||||
"@atproto/common-web" "^0.4.19"
|
||||
"@atproto/api" "^0.19.2"
|
||||
"@atproto/bsky" "^0.0.219"
|
||||
"@atproto/bsync" "^0.0.24"
|
||||
"@atproto/common-web" "^0.4.18"
|
||||
"@atproto/crypto" "^0.4.5"
|
||||
"@atproto/identity" "^0.4.12"
|
||||
"@atproto/lexicon" "^0.6.2"
|
||||
"@atproto/ozone" "^0.1.167"
|
||||
"@atproto/pds" "^0.4.216"
|
||||
"@atproto/ozone" "^0.1.166"
|
||||
"@atproto/pds" "^0.4.214"
|
||||
"@atproto/sync" "^0.1.40"
|
||||
"@atproto/syntax" "^0.5.1"
|
||||
"@atproto/xrpc-server" "^0.10.17"
|
||||
"@atproto/syntax" "^0.5.0"
|
||||
"@atproto/xrpc-server" "^0.10.15"
|
||||
"@did-plc/lib" "^0.0.1"
|
||||
"@did-plc/server" "^0.0.1"
|
||||
dotenv "^16.0.3"
|
||||
@@ -309,14 +288,6 @@
|
||||
"@atproto/lex-data" "^0.0.13"
|
||||
tslib "^2.8.1"
|
||||
|
||||
"@atproto/lex-cbor@^0.0.15":
|
||||
version "0.0.15"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/lex-cbor/-/lex-cbor-0.0.15.tgz#ae4558d8ce22119710ad22feb458d22774b3ca3b"
|
||||
integrity sha512-3osDicK9bAMXJlKjLKqwYrhLQ60bOguWBNjE+fuNjMuizNzC0aqaClE3d+qMsFuFq9bjEHFw+4Vr9Qmd/m6VYg==
|
||||
dependencies:
|
||||
"@atproto/lex-data" "^0.0.14"
|
||||
tslib "^2.8.1"
|
||||
|
||||
"@atproto/lex-client@^0.0.15":
|
||||
version "0.0.15"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/lex-client/-/lex-client-0.0.15.tgz#c647d14e91ca3f52feebf4b34f80abb7e93b3bee"
|
||||
@@ -327,16 +298,6 @@
|
||||
"@atproto/lex-schema" "^0.0.14"
|
||||
tslib "^2.8.1"
|
||||
|
||||
"@atproto/lex-client@^0.0.17":
|
||||
version "0.0.17"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/lex-client/-/lex-client-0.0.17.tgz#566689a288f8b2af31f4a0fa081496dfaaef7278"
|
||||
integrity sha512-lZ9clUjWgpno1XhSawQP+1/JeIYA9qBh759b/NSU0OiypQqgq7IxDvmzaBsiHK1sqjo0tyEkmG4X5Ym7YXjv0Q==
|
||||
dependencies:
|
||||
"@atproto/lex-data" "^0.0.14"
|
||||
"@atproto/lex-json" "^0.0.14"
|
||||
"@atproto/lex-schema" "^0.0.16"
|
||||
tslib "^2.8.1"
|
||||
|
||||
"@atproto/lex-data@^0.0.13":
|
||||
version "0.0.13"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/lex-data/-/lex-data-0.0.13.tgz#db1bcfa12d5056210f6eb7f3b8bac909909d6b9c"
|
||||
@@ -347,22 +308,12 @@
|
||||
uint8arrays "3.0.0"
|
||||
unicode-segmenter "^0.14.0"
|
||||
|
||||
"@atproto/lex-data@^0.0.14":
|
||||
version "0.0.14"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/lex-data/-/lex-data-0.0.14.tgz#2f2f3c64699925a0d4785e5afd0e7731ba1d46c0"
|
||||
integrity sha512-53DUa9664SS76nGAMYopWsO10OH0AAdf7P/HSKB6Wzx3iqe6lk/K61QZnKxOG1LreYl5CfvIJU6eNf4txI6GlQ==
|
||||
"@atproto/lex-document@^0.0.15":
|
||||
version "0.0.15"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/lex-document/-/lex-document-0.0.15.tgz#b2f19756291a0d259cd99f5ebe4872e9133069b6"
|
||||
integrity sha512-QT2MbICG4cTFrrA19SIHpZJ33WRLdzjhDsEhSknQ4dE5CjqPf4BP9LaC4pOeW8NE5Kn92hgIm3JWNjoak8blXw==
|
||||
dependencies:
|
||||
multiformats "^9.9.0"
|
||||
tslib "^2.8.1"
|
||||
uint8arrays "3.0.0"
|
||||
unicode-segmenter "^0.14.0"
|
||||
|
||||
"@atproto/lex-document@^0.0.17":
|
||||
version "0.0.17"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/lex-document/-/lex-document-0.0.17.tgz#8460096235910bf5ec8305f03a8da6bab8d8a12b"
|
||||
integrity sha512-rQiDCSYQwze4+kaArUtmp4bjZ9rV3vYUMhjdDwmZCKodpppNEYrP5AQzyKlxBtKO+MRdLYwHDDwwvakU8atRww==
|
||||
dependencies:
|
||||
"@atproto/lex-schema" "^0.0.16"
|
||||
"@atproto/lex-schema" "^0.0.14"
|
||||
core-js "^3"
|
||||
tslib "^2.8.1"
|
||||
|
||||
@@ -374,27 +325,19 @@
|
||||
"@atproto/lex-data" "^0.0.13"
|
||||
tslib "^2.8.1"
|
||||
|
||||
"@atproto/lex-json@^0.0.14":
|
||||
version "0.0.14"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/lex-json/-/lex-json-0.0.14.tgz#717e533ab583aa5f580acb2a77d9aa3e7eddaa17"
|
||||
integrity sha512-6lPkDKqe7teEu4WrN5q7400cvZKgYS3uwUMvzG3F9XkgVYhOwSDCtouV/nSLBbpvo3l9OP0kiigtclcNcyekww==
|
||||
dependencies:
|
||||
"@atproto/lex-data" "^0.0.14"
|
||||
tslib "^2.8.1"
|
||||
|
||||
"@atproto/lex-resolver@^0.0.19":
|
||||
version "0.0.19"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/lex-resolver/-/lex-resolver-0.0.19.tgz#806fcb71e72d0db51e2eb29c594eddb8c4087414"
|
||||
integrity sha512-oATn4RpZNLh5rp9doN5/UOYS/Cd25GOD90ohB5jnnmeoF8jTupqIYTVhntbnx1EFn+5tTlgkyXEBV+XESBUcdQ==
|
||||
"@atproto/lex-resolver@^0.0.17":
|
||||
version "0.0.17"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/lex-resolver/-/lex-resolver-0.0.17.tgz#2c474f6babeb54665656bf28b8d27a98de69deae"
|
||||
integrity sha512-6nI5bYZUYh50ZI8r4erLRP9EbNcW226VShpVN3vHyOSgTje4VP1RTcvBhROBAPj4rL3vc+Oa8OiL6IQXkYrQBg==
|
||||
dependencies:
|
||||
"@atproto-labs/did-resolver" "^0.2.6"
|
||||
"@atproto/crypto" "^0.4.5"
|
||||
"@atproto/lex-client" "^0.0.17"
|
||||
"@atproto/lex-data" "^0.0.14"
|
||||
"@atproto/lex-document" "^0.0.17"
|
||||
"@atproto/lex-schema" "^0.0.16"
|
||||
"@atproto/repo" "^0.8.13"
|
||||
"@atproto/syntax" "^0.5.1"
|
||||
"@atproto/lex-client" "^0.0.15"
|
||||
"@atproto/lex-data" "^0.0.13"
|
||||
"@atproto/lex-document" "^0.0.15"
|
||||
"@atproto/lex-schema" "^0.0.14"
|
||||
"@atproto/repo" "^0.8.12"
|
||||
"@atproto/syntax" "^0.5.0"
|
||||
tslib "^2.8.1"
|
||||
|
||||
"@atproto/lex-schema@^0.0.14":
|
||||
@@ -406,17 +349,6 @@
|
||||
"@atproto/syntax" "^0.5.0"
|
||||
tslib "^2.8.1"
|
||||
|
||||
"@atproto/lex-schema@^0.0.16":
|
||||
version "0.0.16"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/lex-schema/-/lex-schema-0.0.16.tgz#8362932e239b7eaa7c5d6982d06c3147e3afd138"
|
||||
integrity sha512-O+IorivZHJPeV3kU3NDD2yI8ATfckOphgvDfeiyKHRTxRUKS+lHMCpGUiSTC3fJrfMvYITrruUVViUHVEScrbA==
|
||||
dependencies:
|
||||
"@atproto/lex-data" "^0.0.14"
|
||||
"@atproto/syntax" "^0.5.1"
|
||||
"@standard-schema/spec" "^1.1.0"
|
||||
iso-datestring-validator "^2.2.2"
|
||||
tslib "^2.8.1"
|
||||
|
||||
"@atproto/lexicon@^0.6.0", "@atproto/lexicon@^0.6.2":
|
||||
version "0.6.2"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/lexicon/-/lexicon-0.6.2.tgz#f6152a2119df953236ca127c4b30e332265e81e7"
|
||||
@@ -450,28 +382,28 @@
|
||||
optionalDependencies:
|
||||
"@atproto/oauth-provider-api" "0.3.7"
|
||||
|
||||
"@atproto/oauth-provider@^0.15.14":
|
||||
version "0.15.14"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/oauth-provider/-/oauth-provider-0.15.14.tgz#d969018b4ad5c0dd5863150cb8c1b65458738589"
|
||||
integrity sha512-arA3O+Ye1YBhoIUnZtn8wfatnVnwiZrGyNkxhH0nqGbh/RRfwA5W0tgnSDq0VMclLkrPY/OnZ4v3oo9N81yWGg==
|
||||
"@atproto/oauth-provider@^0.15.12":
|
||||
version "0.15.12"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/oauth-provider/-/oauth-provider-0.15.12.tgz#9dbbfdd6808399d9d7ff8993ac888938fbf4c515"
|
||||
integrity sha512-Ri4aVx2I4lOKxViB92jwPhAs/NctWEwV0tgYSHcaRpvqr2SVlC2LxTVjUq14ohdbVfv4VFRzj0vZypEX+mclHg==
|
||||
dependencies:
|
||||
"@atproto-labs/fetch" "^0.2.3"
|
||||
"@atproto-labs/fetch-node" "^0.2.0"
|
||||
"@atproto-labs/pipe" "^0.1.1"
|
||||
"@atproto-labs/simple-store" "^0.3.0"
|
||||
"@atproto-labs/simple-store-memory" "^0.1.4"
|
||||
"@atproto/common" "^0.5.15"
|
||||
"@atproto/common" "^0.5.14"
|
||||
"@atproto/did" "^0.3.0"
|
||||
"@atproto/jwk" "^0.6.0"
|
||||
"@atproto/jwk-jose" "^0.1.11"
|
||||
"@atproto/lex-document" "^0.0.17"
|
||||
"@atproto/lex-resolver" "^0.0.19"
|
||||
"@atproto/lex-document" "^0.0.15"
|
||||
"@atproto/lex-resolver" "^0.0.17"
|
||||
"@atproto/oauth-provider-api" "0.3.7"
|
||||
"@atproto/oauth-provider-frontend" "0.2.9"
|
||||
"@atproto/oauth-provider-ui" "0.4.3"
|
||||
"@atproto/oauth-scopes" "^0.3.2"
|
||||
"@atproto/oauth-types" "^0.6.3"
|
||||
"@atproto/syntax" "^0.5.1"
|
||||
"@atproto/syntax" "^0.5.0"
|
||||
"@hapi/accept" "^6.0.3"
|
||||
"@hapi/address" "^5.1.1"
|
||||
"@hapi/bourne" "^3.0.0"
|
||||
@@ -510,20 +442,20 @@
|
||||
"@atproto/jwk" "^0.6.0"
|
||||
zod "^3.23.8"
|
||||
|
||||
"@atproto/ozone@^0.1.167":
|
||||
version "0.1.167"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/ozone/-/ozone-0.1.167.tgz#c3974bbe06f0926f165f90d5ba51ba9ce2032fff"
|
||||
integrity sha512-AFquyND8zsskjkDc3WrQObUnZlEky05pFo0YYLy5JoqQN0WXIePLgGY0SC0EIokfF8iYXJE1ZM1u/dgA7DHqGQ==
|
||||
"@atproto/ozone@^0.1.166":
|
||||
version "0.1.166"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/ozone/-/ozone-0.1.166.tgz#9e65d6f67ef1fe285d0880e5f5a1b282dc63bd70"
|
||||
integrity sha512-XZ77P/V/tt3SqTQYRsi5nM3P2h+QaNT7Nz3GTf+TMLVolACufRHPoDgp/PoTuS2FaLj1ndHnGyIPvZi/o8he6g==
|
||||
dependencies:
|
||||
"@atproto/api" "^0.19.4"
|
||||
"@atproto/api" "^0.19.2"
|
||||
"@atproto/common" "^0.5.14"
|
||||
"@atproto/crypto" "^0.4.5"
|
||||
"@atproto/identity" "^0.4.12"
|
||||
"@atproto/lexicon" "^0.6.2"
|
||||
"@atproto/syntax" "^0.5.1"
|
||||
"@atproto/syntax" "^0.5.0"
|
||||
"@atproto/ws-client" "^0.0.4"
|
||||
"@atproto/xrpc" "^0.7.7"
|
||||
"@atproto/xrpc-server" "^0.10.16"
|
||||
"@atproto/xrpc-server" "^0.10.15"
|
||||
"@did-plc/lib" "^0.0.1"
|
||||
compression "^1.7.4"
|
||||
cors "^2.8.5"
|
||||
@@ -541,30 +473,30 @@
|
||||
undici "^6.14.1"
|
||||
ws "^8.12.0"
|
||||
|
||||
"@atproto/pds@^0.4.216":
|
||||
version "0.4.216"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/pds/-/pds-0.4.216.tgz#4d9c73a529bd00893753aba1e7bb33f99b9aaaf4"
|
||||
integrity sha512-yPNatCb2kvudRp5DMbPemN1+uMsLOJDydw2PCBMxuzdimOf10PsekOhhZxtfGGRvk2NbvKoyfUvkROoFmTr+ew==
|
||||
"@atproto/pds@^0.4.214":
|
||||
version "0.4.214"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/pds/-/pds-0.4.214.tgz#c68d55ec0b00a4e35f4801c826d44b65cd62f984"
|
||||
integrity sha512-bTWeWg3H0TlELfE2eI2ySQuC6ojsCBSSmPtCXBh3Td9TFNpIoZQ/tYLUJMtMXaiVUi6HxzXQL1/iuYfC4Y+ZYQ==
|
||||
dependencies:
|
||||
"@atproto-labs/fetch-node" "^0.2.0"
|
||||
"@atproto-labs/simple-store" "^0.3.0"
|
||||
"@atproto-labs/simple-store-memory" "^0.1.4"
|
||||
"@atproto-labs/simple-store-redis" "^0.0.1"
|
||||
"@atproto-labs/xrpc-utils" "^0.0.24"
|
||||
"@atproto/api" "^0.19.4"
|
||||
"@atproto/api" "^0.19.2"
|
||||
"@atproto/aws" "^0.2.31"
|
||||
"@atproto/common" "^0.5.15"
|
||||
"@atproto/common" "^0.5.14"
|
||||
"@atproto/crypto" "^0.4.5"
|
||||
"@atproto/identity" "^0.4.12"
|
||||
"@atproto/lex-cbor" "^0.0.15"
|
||||
"@atproto/lex-data" "^0.0.14"
|
||||
"@atproto/lex-cbor" "^0.0.14"
|
||||
"@atproto/lex-data" "^0.0.13"
|
||||
"@atproto/lexicon" "^0.6.2"
|
||||
"@atproto/oauth-provider" "^0.15.14"
|
||||
"@atproto/oauth-provider" "^0.15.12"
|
||||
"@atproto/oauth-scopes" "^0.3.2"
|
||||
"@atproto/repo" "^0.8.13"
|
||||
"@atproto/syntax" "^0.5.1"
|
||||
"@atproto/repo" "^0.8.12"
|
||||
"@atproto/syntax" "^0.5.0"
|
||||
"@atproto/xrpc" "^0.7.7"
|
||||
"@atproto/xrpc-server" "^0.10.17"
|
||||
"@atproto/xrpc-server" "^0.10.15"
|
||||
"@did-plc/lib" "^0.0.4"
|
||||
"@hapi/address" "^5.1.1"
|
||||
better-sqlite3 "^10.0.0"
|
||||
@@ -608,21 +540,6 @@
|
||||
varint "^6.0.0"
|
||||
zod "^3.23.8"
|
||||
|
||||
"@atproto/repo@^0.8.13":
|
||||
version "0.8.13"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/repo/-/repo-0.8.13.tgz#70160b8b3f78b6addcba7cf3e3ae06306e6b6641"
|
||||
integrity sha512-VS8XHaBMGdq60xwRI5zQmXzsMF1hU7NKPjmkdr65tJdrv2z0VW77mG01Ui19Xh9O0mUc/LG6GEhwVrabB9Txow==
|
||||
dependencies:
|
||||
"@atproto/common" "^0.5.14"
|
||||
"@atproto/common-web" "^0.4.18"
|
||||
"@atproto/crypto" "^0.4.5"
|
||||
"@atproto/lexicon" "^0.6.2"
|
||||
"@ipld/dag-cbor" "^7.0.0"
|
||||
multiformats "^9.9.0"
|
||||
uint8arrays "3.0.0"
|
||||
varint "^6.0.0"
|
||||
zod "^3.23.8"
|
||||
|
||||
"@atproto/sync@^0.1.40":
|
||||
version "0.1.40"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/sync/-/sync-0.1.40.tgz#b8b467ac4fbf2e682d36cd5697508f993e9e645a"
|
||||
@@ -645,13 +562,6 @@
|
||||
dependencies:
|
||||
tslib "^2.8.1"
|
||||
|
||||
"@atproto/syntax@^0.5.1":
|
||||
version "0.5.1"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/syntax/-/syntax-0.5.1.tgz#78257b903a0723720dca32110379208791ac3c24"
|
||||
integrity sha512-J8DJjgKgACIyCTbpfvoTnf7+ofTx1kxTGO7KAftkC+jczaMdQhKdgIBAg2DaYy+80cvYGTHy5q/HI9qMAwGbWw==
|
||||
dependencies:
|
||||
tslib "^2.8.1"
|
||||
|
||||
"@atproto/ws-client@^0.0.4":
|
||||
version "0.0.4"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/ws-client/-/ws-client-0.0.4.tgz#9e436c0e72abea5da0d5a7e8ec862cec0fdb10cd"
|
||||
@@ -681,27 +591,6 @@
|
||||
rate-limiter-flexible "^2.4.1"
|
||||
ws "^8.12.0"
|
||||
|
||||
"@atproto/xrpc-server@^0.10.16", "@atproto/xrpc-server@^0.10.17":
|
||||
version "0.10.17"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/xrpc-server/-/xrpc-server-0.10.17.tgz#1016d4a6d97966a3f80e8785e5d5cba7b68c1c60"
|
||||
integrity sha512-FjexO6P/LRTx6/FdiWTzycFF4TgACW9npsFOitnocydTCOLYouP0OvUwdkxkreFC7qerT4+ARKpqrxRzyj0MNA==
|
||||
dependencies:
|
||||
"@atproto/common" "^0.5.15"
|
||||
"@atproto/crypto" "^0.4.5"
|
||||
"@atproto/lex-cbor" "^0.0.15"
|
||||
"@atproto/lex-client" "^0.0.17"
|
||||
"@atproto/lex-data" "^0.0.14"
|
||||
"@atproto/lex-json" "^0.0.14"
|
||||
"@atproto/lex-schema" "^0.0.16"
|
||||
"@atproto/lexicon" "^0.6.2"
|
||||
"@atproto/ws-client" "^0.0.4"
|
||||
"@atproto/xrpc" "^0.7.7"
|
||||
express "^4.17.2"
|
||||
http-errors "^2.0.0"
|
||||
mime-types "^2.1.35"
|
||||
rate-limiter-flexible "^2.4.1"
|
||||
ws "^8.12.0"
|
||||
|
||||
"@atproto/xrpc@^0.7.6", "@atproto/xrpc@^0.7.7":
|
||||
version "0.7.7"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.7.7.tgz#c0e3106c854cb9bc7d3129de2f31b8256eb0ed11"
|
||||
@@ -2257,11 +2146,6 @@
|
||||
dependencies:
|
||||
tslib "^2.6.2"
|
||||
|
||||
"@standard-schema/spec@^1.1.0":
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/@standard-schema/spec/-/spec-1.1.0.tgz#a79b55dbaf8604812f52d140b2c9ab41bc150bb8"
|
||||
integrity sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==
|
||||
|
||||
"@tokenizer/token@^0.3.0":
|
||||
version "0.3.0"
|
||||
resolved "https://registry.yarnpkg.com/@tokenizer/token/-/token-0.3.0.tgz#fe98a93fe789247e998c75e74e9c7c63217aa276"
|
||||
@@ -4262,10 +4146,10 @@ typed-emitter@^2.1.0:
|
||||
optionalDependencies:
|
||||
rxjs "^7.5.2"
|
||||
|
||||
typescript@^6.0.2:
|
||||
version "6.0.2"
|
||||
resolved "https://registry.yarnpkg.com/typescript/-/typescript-6.0.2.tgz#0b1bfb15f68c64b97032f3d78abbf98bdbba501f"
|
||||
integrity sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==
|
||||
typescript@^5.9.3:
|
||||
version "5.9.3"
|
||||
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.9.3.tgz#5b4f59e15310ab17a216f5d6cf53ee476ede670f"
|
||||
integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==
|
||||
|
||||
uglify-js@^3.1.4:
|
||||
version "3.19.3"
|
||||
|
||||
@@ -47,7 +47,8 @@ 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: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. 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. 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
-15
@@ -37,7 +37,6 @@ export default defineConfig(
|
||||
'*.e2e.ts',
|
||||
'*.e2e.tsx',
|
||||
'eslint.config.mjs',
|
||||
'.jscodeshift/**',
|
||||
],
|
||||
},
|
||||
|
||||
@@ -47,6 +46,7 @@ export default defineConfig(
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
reactHooks.configs.flat.recommended,
|
||||
// @ts-expect-error https://github.com/un-ts/eslint-plugin-import-x/issues/439
|
||||
importX.flatConfigs.recommended,
|
||||
importX.flatConfigs.typescript,
|
||||
importX.flatConfigs['react-native'],
|
||||
@@ -61,7 +61,6 @@ export default defineConfig(
|
||||
'react-native': reactNative,
|
||||
'react-native-a11y': reactNativeA11y,
|
||||
'simple-import-sort': simpleImportSort,
|
||||
// @ts-expect-error - not sure why
|
||||
lingui,
|
||||
'react-compiler': reactCompiler,
|
||||
'bsky-internal': bskyInternal,
|
||||
@@ -127,7 +126,6 @@ export default defineConfig(
|
||||
*/
|
||||
...react.configs.recommended.rules,
|
||||
...react.configs['jsx-runtime'].rules,
|
||||
'react/hook-use-state': 'warn',
|
||||
'react/no-unescaped-entities': 'off',
|
||||
'react/prop-types': 'off',
|
||||
'react-native/no-inline-styles': 'off',
|
||||
@@ -190,18 +188,6 @@ export default defineConfig(
|
||||
*/
|
||||
ignore: ['^#\/locale\/locales\/.+\/messages'],
|
||||
}],
|
||||
'import-x/no-extraneous-dependencies': ['error', {
|
||||
'whitelist': [
|
||||
// test files only
|
||||
'@jest/globals',
|
||||
// we only use a really simple util from this, and we know it will be present
|
||||
'expo-modules-core',
|
||||
// this is a dep for @atproto/api, but we absolutely need them in sync, so just
|
||||
// rely on the transient version
|
||||
'@atproto/common-web',
|
||||
]
|
||||
}],
|
||||
'import-x/no-nodejs-modules': 'error',
|
||||
|
||||
/**
|
||||
* TypeScript-specific rules
|
||||
|
||||
@@ -29,7 +29,6 @@ function getTagName(node) {
|
||||
return reversedIdentifiers.reverse().join('.')
|
||||
}
|
||||
|
||||
/** @type {import('eslint').Rule.RuleModule} */
|
||||
module.exports = {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
|
||||
@@ -3,7 +3,6 @@ const BANNED_IMPORTS = [
|
||||
'@fortawesome/free-solid-svg-icons',
|
||||
]
|
||||
|
||||
/** @type {import('eslint').Rule.RuleModule} */
|
||||
module.exports = {
|
||||
meta: {
|
||||
type: 'suggestion',
|
||||
|
||||
@@ -10,7 +10,6 @@ const BANNED_IMPORT_PREFIXES = [
|
||||
'view/',
|
||||
]
|
||||
|
||||
/** @type {import('eslint').Rule.RuleModule} */
|
||||
module.exports = {
|
||||
meta: {
|
||||
type: 'suggestion',
|
||||
|
||||
@@ -9,7 +9,6 @@ jest.mock('@react-native-async-storage/async-storage', () =>
|
||||
require('@react-native-async-storage/async-storage/jest/async-storage-mock'),
|
||||
)
|
||||
jest.mock('react-native/Libraries/EventEmitter/NativeEventEmitter', () => {
|
||||
// eslint-disable-next-line import-x/no-nodejs-modules
|
||||
const {EventEmitter} = require('events')
|
||||
return {
|
||||
__esModule: true,
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import {GestureHandlerRootView} from 'react-native-gesture-handler'
|
||||
import {SafeAreaProvider} from 'react-native-safe-area-context'
|
||||
import {render} from '@testing-library/react-native'
|
||||
|
||||
import {ThemeProvider} from '../src/lib/ThemeContext'
|
||||
import {type RootStoreModel, RootStoreProvider} from '../src/state'
|
||||
|
||||
const customRender = (ui: any, rootStore: RootStoreModel) =>
|
||||
render(
|
||||
<GestureHandlerRootView style={{flex: 1}}>
|
||||
<RootStoreProvider value={rootStore}>
|
||||
<ThemeProvider theme="light">
|
||||
<SafeAreaProvider>{ui}</SafeAreaProvider>
|
||||
</ThemeProvider>
|
||||
</RootStoreProvider>
|
||||
</GestureHandlerRootView>,
|
||||
)
|
||||
|
||||
// re-export everything
|
||||
export * from '@testing-library/react-native'
|
||||
|
||||
// override render method
|
||||
export {customRender as render}
|
||||
+3
-13
@@ -33,27 +33,17 @@ class BottomSheetView(
|
||||
private var eventDispatcher: EventDispatcher? = null
|
||||
|
||||
// Native content height observation (eliminates JS bridge round-trip)
|
||||
private var contentLayoutListener: OnLayoutChangeListener? = null
|
||||
private var contentLayoutListener: View.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 if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R) {
|
||||
// API 30-34: heightPixels may exclude nav bar, use currentWindowMetrics
|
||||
} else {
|
||||
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 {
|
||||
@@ -365,7 +355,7 @@ class BottomSheetView(
|
||||
|
||||
val innerViewGroup = this.innerView as? ViewGroup ?: return
|
||||
|
||||
val listener = OnLayoutChangeListener { _, _, top, _, bottom, _, _, oldTop, oldBottom ->
|
||||
val listener = View.OnLayoutChangeListener { _, _, top, _, bottom, _, _, oldTop, oldBottom ->
|
||||
val newHeight = bottom - top
|
||||
val oldHeight = oldBottom - oldTop
|
||||
if (newHeight != oldHeight) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as React from 'react'
|
||||
import {Component, createRef} from 'react'
|
||||
import {type ComponentType, type ContextType, type RefObject} from 'react'
|
||||
import {
|
||||
Dimensions,
|
||||
type LayoutChangeEvent,
|
||||
@@ -21,9 +22,9 @@ import {
|
||||
Context as PortalContext,
|
||||
} from './BottomSheetPortal'
|
||||
|
||||
const NativeView: React.ComponentType<
|
||||
const NativeView: ComponentType<
|
||||
BottomSheetViewProps & {
|
||||
ref: React.RefObject<any>
|
||||
ref: RefObject<any>
|
||||
style: StyleProp<ViewStyle>
|
||||
}
|
||||
> = requireNativeViewManager('BottomSheet')
|
||||
@@ -39,14 +40,14 @@ const IS_IOS15 =
|
||||
const IS_NON_E2E_ANDROID =
|
||||
Platform.OS === 'android' && Number(Platform.Version) < 35
|
||||
|
||||
export class BottomSheetNativeComponent extends React.Component<
|
||||
export class BottomSheetNativeComponent extends Component<
|
||||
BottomSheetViewProps,
|
||||
{
|
||||
open: boolean
|
||||
viewHeight?: number
|
||||
}
|
||||
> {
|
||||
ref = React.createRef<any>()
|
||||
ref = createRef<any>()
|
||||
|
||||
static contextType = PortalContext
|
||||
|
||||
@@ -79,7 +80,7 @@ export class BottomSheetNativeComponent extends React.Component<
|
||||
}
|
||||
|
||||
render() {
|
||||
const Portal = this.context as React.ContextType<typeof PortalContext>
|
||||
const Portal = this.context as ContextType<typeof PortalContext>
|
||||
if (!Portal) {
|
||||
throw new Error(
|
||||
'BottomSheet: You need to wrap your component tree with a <BottomSheetPortalProvider> to use the bottom sheet.',
|
||||
@@ -139,7 +140,7 @@ function BottomSheetNativeComponentInner({
|
||||
onStateChange: (
|
||||
event: NativeSyntheticEvent<{state: BottomSheetState}>,
|
||||
) => void
|
||||
nativeViewRef: React.RefObject<View>
|
||||
nativeViewRef: RefObject<View>
|
||||
onLayout?: (event: LayoutChangeEvent) => void
|
||||
}) {
|
||||
const insets = useSafeAreaInsets()
|
||||
|
||||
@@ -1,20 +1,17 @@
|
||||
import React from 'react'
|
||||
import {createContext, useContext, useMemo} from 'react'
|
||||
import {type ElementType, type ReactNode} from 'react'
|
||||
|
||||
import {createPortalGroup_INTERNAL} from './lib/Portal'
|
||||
|
||||
type PortalContext = React.ElementType<{children: React.ReactNode}>
|
||||
type PortalContext = ElementType<{children: ReactNode}>
|
||||
|
||||
export const Context = React.createContext({} as PortalContext)
|
||||
export const Context = createContext({} as PortalContext)
|
||||
Context.displayName = 'BottomSheetPortalContext'
|
||||
|
||||
export const useBottomSheetPortal_INTERNAL = () => React.useContext(Context)
|
||||
export const useBottomSheetPortal_INTERNAL = () => useContext(Context)
|
||||
|
||||
export function BottomSheetPortalProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const portal = React.useMemo(() => {
|
||||
export function BottomSheetPortalProvider({children}: {children: ReactNode}) {
|
||||
const portal = useMemo(() => {
|
||||
return createPortalGroup_INTERNAL()
|
||||
}, [])
|
||||
|
||||
@@ -32,7 +29,7 @@ const defaultPortal = createPortalGroup_INTERNAL()
|
||||
|
||||
export const BottomSheetOutlet = defaultPortal.Outlet
|
||||
|
||||
export function BottomSheetProvider({children}: {children: React.ReactNode}) {
|
||||
export function BottomSheetProvider({children}: {children: ReactNode}) {
|
||||
return (
|
||||
<Context.Provider value={defaultPortal.Portal}>
|
||||
<defaultPortal.Provider>{children}</defaultPortal.Provider>
|
||||
|
||||
+9
-9
@@ -1,6 +1,7 @@
|
||||
import React from 'react'
|
||||
import {createContext, useContext, useEffect, useMemo, useState} from 'react'
|
||||
import {type ReactNode} from 'react'
|
||||
|
||||
import {BackgroundNotificationHandlerPreferences} from './ExpoBackgroundNotificationHandler.types'
|
||||
import {type BackgroundNotificationHandlerPreferences} from './ExpoBackgroundNotificationHandler.types'
|
||||
import {BackgroundNotificationHandler} from './ExpoBackgroundNotificationHandlerModule'
|
||||
|
||||
interface BackgroundNotificationPreferencesContext {
|
||||
@@ -11,30 +12,29 @@ interface BackgroundNotificationPreferencesContext {
|
||||
) => void
|
||||
}
|
||||
|
||||
const Context = React.createContext<BackgroundNotificationPreferencesContext>(
|
||||
const Context = createContext<BackgroundNotificationPreferencesContext>(
|
||||
{} as BackgroundNotificationPreferencesContext,
|
||||
)
|
||||
export const useBackgroundNotificationPreferences = () =>
|
||||
React.useContext(Context)
|
||||
export const useBackgroundNotificationPreferences = () => useContext(Context)
|
||||
|
||||
export function BackgroundNotificationPreferencesProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
children: ReactNode
|
||||
}) {
|
||||
const [preferences, setPreferences] =
|
||||
React.useState<BackgroundNotificationHandlerPreferences>({
|
||||
useState<BackgroundNotificationHandlerPreferences>({
|
||||
playSoundChat: true,
|
||||
})
|
||||
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
;(async () => {
|
||||
const prefs = await BackgroundNotificationHandler.getAllPrefsAsync()
|
||||
setPreferences(prefs)
|
||||
})()
|
||||
}, [])
|
||||
|
||||
const value = React.useMemo(
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
preferences,
|
||||
setPref: async <
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import React from 'react'
|
||||
import {createRef, PureComponent} from 'react'
|
||||
import {type ComponentType, type RefObject} from 'react'
|
||||
import {requireNativeModule} from 'expo'
|
||||
import {requireNativeViewManager} from 'expo-modules-core'
|
||||
|
||||
import {GifViewProps} from './GifView.types'
|
||||
import {type GifViewProps} from './GifView.types'
|
||||
|
||||
const NativeModule = requireNativeModule('ExpoBlueskyGifView')
|
||||
const NativeView: React.ComponentType<
|
||||
GifViewProps & {ref: React.RefObject<any>}
|
||||
> = requireNativeViewManager('ExpoBlueskyGifView')
|
||||
const NativeView: ComponentType<GifViewProps & {ref: RefObject<any>}> =
|
||||
requireNativeViewManager('ExpoBlueskyGifView')
|
||||
|
||||
export class GifView extends React.PureComponent<GifViewProps> {
|
||||
export class GifView extends PureComponent<GifViewProps> {
|
||||
// TODO native types, should all be the same as those in this class
|
||||
private nativeRef: React.RefObject<any> = React.createRef()
|
||||
private nativeRef: RefObject<any> = createRef()
|
||||
|
||||
constructor(props: GifViewProps | Readonly<GifViewProps>) {
|
||||
super(props)
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import * as React from 'react'
|
||||
import {createRef, PureComponent} from 'react'
|
||||
import {type RefObject} from 'react'
|
||||
import {StyleSheet} from 'react-native'
|
||||
|
||||
import {GifViewProps} from './GifView.types'
|
||||
import {type GifViewProps} from './GifView.types'
|
||||
|
||||
export class GifView extends React.PureComponent<GifViewProps> {
|
||||
private readonly videoPlayerRef: React.RefObject<HTMLMediaElement> =
|
||||
React.createRef()
|
||||
export class GifView extends PureComponent<GifViewProps> {
|
||||
private readonly videoPlayerRef: RefObject<HTMLMediaElement> = createRef()
|
||||
private isLoaded = false
|
||||
|
||||
constructor(props: GifViewProps | Readonly<GifViewProps>) {
|
||||
|
||||
+32
-40
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bsky.app",
|
||||
"version": "1.120.0",
|
||||
"version": "1.119.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 start",
|
||||
"e2e:mock-server": "cd dev-env && yarn e2e:mock-server",
|
||||
"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,7 +70,6 @@
|
||||
"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",
|
||||
@@ -81,15 +80,13 @@
|
||||
"icons:optimize": "svgo -f ./assets/icons"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atproto/api": "^0.19.6",
|
||||
"@atproto/api": "^0.19.3",
|
||||
"@bitdrift/react-native": "^0.6.8",
|
||||
"@braintree/sanitize-url": "^6.0.2",
|
||||
"@bsky.app/alf": "^0.1.7",
|
||||
"@bsky.app/expo-image-crop-tool": "^0.5.0",
|
||||
"@bsky.app/expo-translate-text": "^0.2.9",
|
||||
"@bsky.app/expo-translate-text": "^0.2.7",
|
||||
"@bsky.app/react-native-mmkv": "2.12.5",
|
||||
"@bsky.app/sift": "^0.3.1",
|
||||
"@bsky.app/tapper": "^0.5.0",
|
||||
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
|
||||
"@emoji-mart/data": "^1.2.1",
|
||||
"@emoji-mart/react": "^1.1.1",
|
||||
@@ -111,6 +108,7 @@
|
||||
"@ipld/dag-cbor": "^9.2.0",
|
||||
"@lingui/core": "^5.9.2",
|
||||
"@lingui/react": "^5.9.2",
|
||||
"@mattermost/react-native-paste-input": "mattermost/react-native-paste-input",
|
||||
"@miblanchard/react-native-slider": "^2.6.0",
|
||||
"@mozzius/expo-dynamic-app-icon": "^1.8.0",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
@@ -118,9 +116,9 @@
|
||||
"@react-navigation/native": "^7.1.33",
|
||||
"@react-navigation/native-stack": "^7.14.4",
|
||||
"@sentry/react-native": "~6.20.0",
|
||||
"@tanstack/query-async-storage-persister": "^5.96.2",
|
||||
"@tanstack/react-query": "^5.96.2",
|
||||
"@tanstack/react-query-persist-client": "^5.96.2",
|
||||
"@tanstack/query-async-storage-persister": "^5.25.0",
|
||||
"@tanstack/react-query": "5.25.0",
|
||||
"@tanstack/react-query-persist-client": "^5.25.0",
|
||||
"@tiptap/core": "^2.9.1",
|
||||
"@tiptap/extension-document": "^2.9.1",
|
||||
"@tiptap/extension-hard-break": "^2.9.1",
|
||||
@@ -145,7 +143,7 @@
|
||||
"emoji-mart": "^5.6.0",
|
||||
"emoji-regex": "^10.4.0",
|
||||
"eventemitter3": "^5.0.1",
|
||||
"expo": "^54.0.33",
|
||||
"expo": "^54.0.27",
|
||||
"expo-application": "~7.0.8",
|
||||
"expo-blur": "~15.0.8",
|
||||
"expo-build-properties": "~1.0.10",
|
||||
@@ -154,34 +152,32 @@
|
||||
"expo-contacts": "^15.0.10",
|
||||
"expo-dev-client": "~6.0.20",
|
||||
"expo-device": "~8.0.10",
|
||||
"expo-file-system": "~19.0.21",
|
||||
"expo-font": "~14.0.11",
|
||||
"expo-file-system": "~19.0.20",
|
||||
"expo-font": "~14.0.10",
|
||||
"expo-haptics": "~15.0.8",
|
||||
"expo-image": "~3.0.11",
|
||||
"expo-image-manipulator": "~14.0.8",
|
||||
"expo-image-picker": "~17.0.10",
|
||||
"expo-image-picker": "~17.0.9",
|
||||
"expo-intent-launcher": "~13.0.8",
|
||||
"expo-keep-awake": "~15.0.8",
|
||||
"expo-linear-gradient": "~15.0.8",
|
||||
"expo-linking": "~8.0.11",
|
||||
"expo-linking": "~8.0.10",
|
||||
"expo-localization": "~17.0.8",
|
||||
"expo-location": "~19.0.8",
|
||||
"expo-media-library": "~18.2.1",
|
||||
"expo-notifications": "~0.32.16",
|
||||
"expo-paste-input": "^0.1.12",
|
||||
"expo-notifications": "~0.32.14",
|
||||
"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.13",
|
||||
"expo-splash-screen": "~31.0.12",
|
||||
"expo-system-ui": "~6.0.9",
|
||||
"expo-updates": "~29.0.16",
|
||||
"expo-video": "~3.0.16",
|
||||
"expo-updates": "~29.0.14",
|
||||
"expo-video": "~3.0.15",
|
||||
"expo-video-thumbnails": "^10.0.8",
|
||||
"expo-web-browser": "~15.0.10",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-text-encoding": "^1.0.6",
|
||||
"fuse.js": "^7.1.0",
|
||||
"hls.js": "^1.6.2",
|
||||
"idb-keyval": "^6.2.2",
|
||||
"js-sha256": "^0.9.0",
|
||||
@@ -202,7 +198,6 @@
|
||||
"react": "19.1.0",
|
||||
"react-compiler-runtime": "^19.1.0-rc.1",
|
||||
"react-dom": "19.1.0",
|
||||
"react-hotkeys-hook": "5.2.4",
|
||||
"react-image-crop": "^11.0.7",
|
||||
"react-is": "19",
|
||||
"react-keyed-flatten-children": "^5.0.0",
|
||||
@@ -213,7 +208,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.21.0",
|
||||
"react-native-keyboard-controller": "^1.20.7",
|
||||
"react-native-pager-view": "6.8.0",
|
||||
"react-native-progress": "bluesky-social/react-native-progress",
|
||||
"react-native-qrcode-styled": "^0.3.3",
|
||||
@@ -243,13 +238,13 @@
|
||||
"@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.4",
|
||||
"@expo/config-plugins": "~54.0.1",
|
||||
"@lingui/babel-plugin-lingui-macro": "^5.9.2",
|
||||
"@lingui/cli": "^5.9.2",
|
||||
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.15",
|
||||
"@react-native/babel-preset": "0.81.5",
|
||||
"@react-native/eslint-config": "^0.81.5",
|
||||
"@react-native/typescript-config": "^0.81.5",
|
||||
"@sentry/webpack-plugin": "^3.2.2",
|
||||
"@testing-library/react-native": "^13.2.0",
|
||||
@@ -263,12 +258,12 @@
|
||||
"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.10",
|
||||
"babel-preset-expo": "~54.0.0",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-import-resolver-typescript": "^4.4.4",
|
||||
"eslint-plugin-bsky-internal": "link:./eslint",
|
||||
"eslint-plugin-import-x": "^4.16.2",
|
||||
"eslint-plugin-lingui": "^0.12.0",
|
||||
"eslint-plugin-import-x": "^4.16.1",
|
||||
"eslint-plugin-lingui": "^0.11.0",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-compiler": "^19.1.0-rc.2",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
@@ -280,7 +275,7 @@
|
||||
"husky": "^8.0.3",
|
||||
"is-ci": "^3.0.1",
|
||||
"jest": "^29.7.0",
|
||||
"jest-expo": "~54.0.17",
|
||||
"jest-expo": "~54.0.14",
|
||||
"jest-junit": "^16.0.0",
|
||||
"lint-staged": "^13.2.3",
|
||||
"lockfile-lint": "^4.14.0",
|
||||
@@ -289,22 +284,20 @@
|
||||
"react-refresh": "^0.14.0",
|
||||
"svgo": "^3.3.2",
|
||||
"ts-plugin-sort-import-suggestions": "^1.0.4",
|
||||
"typescript": "^6.0.2",
|
||||
"typescript-eslint": "^8.58.0",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.56.0",
|
||||
"webpack-bundle-analyzer": "^4.10.1"
|
||||
},
|
||||
"resolutions": {
|
||||
"@react-native/babel-preset": "0.81.5",
|
||||
"@react-native/normalize-colors": "0.81.5",
|
||||
"**/@expo/image-utils": "0.8.12",
|
||||
"**/@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",
|
||||
"**/multiformats": "9.9.0",
|
||||
"unicode-segmenter": "0.14.5",
|
||||
"@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"
|
||||
"@types/estree": "1.0.6"
|
||||
},
|
||||
"jest": {
|
||||
"preset": "jest-expo/ios",
|
||||
@@ -327,8 +320,7 @@
|
||||
],
|
||||
"modulePathIgnorePatterns": [
|
||||
"__tests__/.*/__mocks__",
|
||||
"__e2e__/.*",
|
||||
"bskylink/.*"
|
||||
"__e2e__/.*"
|
||||
],
|
||||
"coveragePathIgnorePatterns": [
|
||||
"<rootDir>/node_modules/",
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
diff --git a/node_modules/@mattermost/react-native-paste-input/android/src/main/java/com/mattermost/pasteinputtext/PasteTextInputManager.kt b/node_modules/@mattermost/react-native-paste-input/android/src/main/java/com/mattermost/pasteinputtext/PasteTextInputManager.kt
|
||||
index 4ed2307..ede1181 100644
|
||||
--- a/node_modules/@mattermost/react-native-paste-input/android/src/main/java/com/mattermost/pasteinputtext/PasteTextInputManager.kt
|
||||
+++ b/node_modules/@mattermost/react-native-paste-input/android/src/main/java/com/mattermost/pasteinputtext/PasteTextInputManager.kt
|
||||
@@ -54,7 +54,7 @@ class PasteTextInputManager(context: ReactApplicationContext) : ReactTextInputMa
|
||||
}
|
||||
|
||||
override fun getExportedCustomBubblingEventTypeConstants(): MutableMap<String, Any> {
|
||||
- val map = super.getExportedCustomBubblingEventTypeConstants()!!
|
||||
+ val map = super.getExportedCustomBubblingEventTypeConstants().toMutableMap()
|
||||
map["onPaste"] = MapBuilder.of(
|
||||
"phasedRegistrationNames",
|
||||
MapBuilder.of("bubbled", "onPaste")
|
||||
@@ -0,0 +1,264 @@
|
||||
diff --git a/node_modules/@mattermost/react-native-paste-input/ios/PasteInputView.m b/node_modules/@mattermost/react-native-paste-input/ios/PasteInputView.m
|
||||
index e916023..5049c33 100644
|
||||
--- a/node_modules/@mattermost/react-native-paste-input/ios/PasteInputView.m
|
||||
+++ b/node_modules/@mattermost/react-native-paste-input/ios/PasteInputView.m
|
||||
@@ -4,6 +4,7 @@
|
||||
//
|
||||
// Created by Elias Nahum on 04-11-20.
|
||||
// Copyright © 2020 Facebook. All rights reserved.
|
||||
+// Updated to remove parent’s default text view
|
||||
//
|
||||
|
||||
#import "PasteInputView.h"
|
||||
@@ -12,49 +13,78 @@
|
||||
|
||||
@implementation PasteInputView
|
||||
{
|
||||
- PasteInputTextView *_backedTextInputView;
|
||||
+ // We'll store the custom text view in this ivar
|
||||
+ PasteInputTextView *_customBackedTextView;
|
||||
}
|
||||
|
||||
- (instancetype)initWithBridge:(RCTBridge *)bridge
|
||||
{
|
||||
+ // Must call the super’s designated initializer
|
||||
if (self = [super initWithBridge:bridge]) {
|
||||
- _backedTextInputView = [[PasteInputTextView alloc] initWithFrame:self.bounds];
|
||||
- _backedTextInputView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
|
||||
- _backedTextInputView.textInputDelegate = self;
|
||||
+ // 1. The parent (RCTMultilineTextInputView) has already created
|
||||
+ // its own _backedTextInputView = [RCTUITextView new] in super init.
|
||||
+ // We can remove that subview:
|
||||
|
||||
- [self addSubview:_backedTextInputView];
|
||||
- }
|
||||
+ id<RCTBackedTextInputViewProtocol> parentInputView = super.backedTextInputView;
|
||||
+ if ([parentInputView isKindOfClass:[UIView class]]) {
|
||||
+ UIView *parentSubview = (UIView *)parentInputView;
|
||||
+ if (parentSubview.superview == self) {
|
||||
+ [parentSubview removeFromSuperview];
|
||||
+ }
|
||||
+ }
|
||||
|
||||
+ // 2. Now create our custom PasteInputTextView
|
||||
+ _customBackedTextView = [[PasteInputTextView alloc] initWithFrame:self.bounds];
|
||||
+ _customBackedTextView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
|
||||
+ _customBackedTextView.textInputDelegate = self;
|
||||
+
|
||||
+ // Optional: disable inline predictions for iOS 17+
|
||||
+ if (@available(iOS 17.0, *)) {
|
||||
+ _customBackedTextView.inlinePredictionType = UITextInlinePredictionTypeNo;
|
||||
+ }
|
||||
+
|
||||
+ // 3. Add your custom text view as the only subview
|
||||
+ [self addSubview:_customBackedTextView];
|
||||
+ }
|
||||
return self;
|
||||
}
|
||||
|
||||
+/**
|
||||
+ * Override the parent's accessor so that anywhere in RN that calls
|
||||
+ * `self.backedTextInputView` will get the custom PasteInputTextView.
|
||||
+ */
|
||||
- (id<RCTBackedTextInputViewProtocol>)backedTextInputView
|
||||
{
|
||||
- return _backedTextInputView;
|
||||
+ return _customBackedTextView;
|
||||
}
|
||||
|
||||
-- (void)setDisableCopyPaste:(BOOL)disableCopyPaste {
|
||||
- _backedTextInputView.disableCopyPaste = disableCopyPaste;
|
||||
+#pragma mark - Setters for React Props
|
||||
+
|
||||
+- (void)setDisableCopyPaste:(BOOL)disableCopyPaste
|
||||
+{
|
||||
+ _customBackedTextView.disableCopyPaste = disableCopyPaste;
|
||||
}
|
||||
|
||||
-- (void)setOnPaste:(RCTDirectEventBlock)onPaste {
|
||||
- _backedTextInputView.onPaste = onPaste;
|
||||
+- (void)setOnPaste:(RCTDirectEventBlock)onPaste
|
||||
+{
|
||||
+ _customBackedTextView.onPaste = onPaste;
|
||||
}
|
||||
|
||||
-- (void)setSmartPunctuation:(NSString *)smartPunctuation {
|
||||
- if ([smartPunctuation isEqualToString:@"enable"]) {
|
||||
- [_backedTextInputView setSmartDashesType:UITextSmartDashesTypeYes];
|
||||
- [_backedTextInputView setSmartQuotesType:UITextSmartQuotesTypeYes];
|
||||
- [_backedTextInputView setSmartInsertDeleteType:UITextSmartInsertDeleteTypeYes];
|
||||
- } else if ([smartPunctuation isEqualToString:@"disable"]) {
|
||||
- [_backedTextInputView setSmartDashesType:UITextSmartDashesTypeNo];
|
||||
- [_backedTextInputView setSmartQuotesType:UITextSmartQuotesTypeNo];
|
||||
- [_backedTextInputView setSmartInsertDeleteType:UITextSmartInsertDeleteTypeNo];
|
||||
- } else {
|
||||
- [_backedTextInputView setSmartDashesType:UITextSmartDashesTypeDefault];
|
||||
- [_backedTextInputView setSmartQuotesType:UITextSmartQuotesTypeDefault];
|
||||
- [_backedTextInputView setSmartInsertDeleteType:UITextSmartInsertDeleteTypeDefault];
|
||||
- }
|
||||
+- (void)setSmartPunctuation:(NSString *)smartPunctuation
|
||||
+{
|
||||
+ if ([smartPunctuation isEqualToString:@"enable"]) {
|
||||
+ [_customBackedTextView setSmartDashesType:UITextSmartDashesTypeYes];
|
||||
+ [_customBackedTextView setSmartQuotesType:UITextSmartQuotesTypeYes];
|
||||
+ [_customBackedTextView setSmartInsertDeleteType:UITextSmartInsertDeleteTypeYes];
|
||||
+ } else if ([smartPunctuation isEqualToString:@"disable"]) {
|
||||
+ [_customBackedTextView setSmartDashesType:UITextSmartDashesTypeNo];
|
||||
+ [_customBackedTextView setSmartQuotesType:UITextSmartQuotesTypeNo];
|
||||
+ [_customBackedTextView setSmartInsertDeleteType:UITextSmartInsertDeleteTypeNo];
|
||||
+ } else {
|
||||
+ [_customBackedTextView setSmartDashesType:UITextSmartDashesTypeDefault];
|
||||
+ [_customBackedTextView setSmartQuotesType:UITextSmartQuotesTypeDefault];
|
||||
+ [_customBackedTextView setSmartInsertDeleteType:UITextSmartInsertDeleteTypeDefault];
|
||||
+ }
|
||||
}
|
||||
|
||||
#pragma mark - UIScrollViewDelegate
|
||||
@@ -62,7 +92,6 @@ - (void)setSmartPunctuation:(NSString *)smartPunctuation {
|
||||
- (void)scrollViewDidScroll:(UIScrollView *)scrollView
|
||||
{
|
||||
RCTDirectEventBlock onScroll = self.onScroll;
|
||||
-
|
||||
if (onScroll) {
|
||||
CGPoint contentOffset = scrollView.contentOffset;
|
||||
CGSize contentSize = scrollView.contentSize;
|
||||
@@ -71,22 +100,22 @@ - (void)scrollViewDidScroll:(UIScrollView *)scrollView
|
||||
|
||||
onScroll(@{
|
||||
@"contentOffset": @{
|
||||
- @"x": @(contentOffset.x),
|
||||
- @"y": @(contentOffset.y)
|
||||
+ @"x": @(contentOffset.x),
|
||||
+ @"y": @(contentOffset.y)
|
||||
},
|
||||
@"contentInset": @{
|
||||
- @"top": @(contentInset.top),
|
||||
- @"left": @(contentInset.left),
|
||||
- @"bottom": @(contentInset.bottom),
|
||||
- @"right": @(contentInset.right)
|
||||
+ @"top": @(contentInset.top),
|
||||
+ @"left": @(contentInset.left),
|
||||
+ @"bottom": @(contentInset.bottom),
|
||||
+ @"right": @(contentInset.right)
|
||||
},
|
||||
@"contentSize": @{
|
||||
- @"width": @(contentSize.width),
|
||||
- @"height": @(contentSize.height)
|
||||
+ @"width": @(contentSize.width),
|
||||
+ @"height": @(contentSize.height)
|
||||
},
|
||||
@"layoutMeasurement": @{
|
||||
- @"width": @(size.width),
|
||||
- @"height": @(size.height)
|
||||
+ @"width": @(size.width),
|
||||
+ @"height": @(size.height)
|
||||
},
|
||||
@"zoomScale": @(scrollView.zoomScale ?: 1),
|
||||
});
|
||||
diff --git a/node_modules/@mattermost/react-native-paste-input/ios/PasteTextInput.mm b/node_modules/@mattermost/react-native-paste-input/ios/PasteTextInput.mm
|
||||
index dd50053..2ed7017 100644
|
||||
--- a/node_modules/@mattermost/react-native-paste-input/ios/PasteTextInput.mm
|
||||
+++ b/node_modules/@mattermost/react-native-paste-input/ios/PasteTextInput.mm
|
||||
@@ -122,8 +122,8 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared &
|
||||
const auto &newTextInputProps = static_cast<const PasteTextInputProps &>(*props);
|
||||
|
||||
// Traits:
|
||||
- if (newTextInputProps.traits.multiline != oldTextInputProps.traits.multiline) {
|
||||
- [self _setMultiline:newTextInputProps.traits.multiline];
|
||||
+ if (newTextInputProps.multiline != oldTextInputProps.multiline) {
|
||||
+ [self _setMultiline:newTextInputProps.multiline];
|
||||
}
|
||||
|
||||
if (newTextInputProps.traits.autocapitalizationType != oldTextInputProps.traits.autocapitalizationType) {
|
||||
@@ -421,7 +421,7 @@ - (void)textInputDidChangeSelection
|
||||
return;
|
||||
}
|
||||
const auto &props = static_cast<const PasteTextInputProps &>(*_props);
|
||||
- if (props.traits.multiline && ![_lastStringStateWasUpdatedWith isEqual:_backedTextInputView.attributedText]) {
|
||||
+ if (props.multiline && ![_lastStringStateWasUpdatedWith isEqual:_backedTextInputView.attributedText]) {
|
||||
[self textInputDidChange];
|
||||
_ignoreNextTextInputCall = YES;
|
||||
}
|
||||
@@ -708,11 +708,11 @@ - (BOOL)_textOf:(NSAttributedString *)newText equals:(NSAttributedString *)oldTe
|
||||
- (SubmitBehavior)getSubmitBehavior
|
||||
{
|
||||
const auto &props = static_cast<const PasteTextInputProps &>(*_props);
|
||||
- const SubmitBehavior submitBehaviorDefaultable = props.traits.submitBehavior;
|
||||
+ const SubmitBehavior submitBehaviorDefaultable = props.submitBehavior;
|
||||
|
||||
// We should always have a non-default `submitBehavior`, but in case we don't, set it based on multiline.
|
||||
if (submitBehaviorDefaultable == SubmitBehavior::Default) {
|
||||
- return props.traits.multiline ? SubmitBehavior::Newline : SubmitBehavior::BlurAndSubmit;
|
||||
+ return props.multiline ? SubmitBehavior::Newline : SubmitBehavior::BlurAndSubmit;
|
||||
}
|
||||
|
||||
return submitBehaviorDefaultable;
|
||||
diff --git a/node_modules/@mattermost/react-native-paste-input/ios/PasteTextInputSpecs/Props.cpp b/node_modules/@mattermost/react-native-paste-input/ios/PasteTextInputSpecs/Props.cpp
|
||||
index 29e094f..7ef519a 100644
|
||||
--- a/node_modules/@mattermost/react-native-paste-input/ios/PasteTextInputSpecs/Props.cpp
|
||||
+++ b/node_modules/@mattermost/react-native-paste-input/ios/PasteTextInputSpecs/Props.cpp
|
||||
@@ -22,8 +22,7 @@ PasteTextInputProps::PasteTextInputProps(
|
||||
const PropsParserContext &context,
|
||||
const PasteTextInputProps &sourceProps,
|
||||
const RawProps& rawProps)
|
||||
- : ViewProps(context, sourceProps, rawProps),
|
||||
- BaseTextProps(context, sourceProps, rawProps),
|
||||
+ : BaseTextInputProps(context, sourceProps, rawProps),
|
||||
traits(convertRawProp(context, rawProps, sourceProps.traits, {})),
|
||||
smartPunctuation(convertRawProp(context, rawProps, "smartPunctuation", sourceProps.smartPunctuation, {})),
|
||||
disableCopyPaste(convertRawProp(context, rawProps, "disableCopyPaste", sourceProps.disableCopyPaste, {false})),
|
||||
@@ -133,7 +132,7 @@ TextAttributes PasteTextInputProps::getEffectiveTextAttributes(Float fontSizeMul
|
||||
ParagraphAttributes PasteTextInputProps::getEffectiveParagraphAttributes() const {
|
||||
auto result = paragraphAttributes;
|
||||
|
||||
- if (!traits.multiline) {
|
||||
+ if (!multiline) {
|
||||
result.maximumNumberOfLines = 1;
|
||||
}
|
||||
|
||||
diff --git a/node_modules/@mattermost/react-native-paste-input/ios/PasteTextInputSpecs/Props.h b/node_modules/@mattermost/react-native-paste-input/ios/PasteTextInputSpecs/Props.h
|
||||
index 723d00c..31cfe66 100644
|
||||
--- a/node_modules/@mattermost/react-native-paste-input/ios/PasteTextInputSpecs/Props.h
|
||||
+++ b/node_modules/@mattermost/react-native-paste-input/ios/PasteTextInputSpecs/Props.h
|
||||
@@ -15,6 +15,7 @@
|
||||
#include <react/renderer/components/iostextinput/conversions.h>
|
||||
#include <react/renderer/components/iostextinput/primitives.h>
|
||||
#include <react/renderer/components/text/BaseTextProps.h>
|
||||
+#include <react/renderer/components/textinput/BaseTextInputProps.h>
|
||||
#include <react/renderer/components/view/ViewProps.h>
|
||||
#include <react/renderer/core/Props.h>
|
||||
#include <react/renderer/core/PropsParserContext.h>
|
||||
@@ -25,7 +26,7 @@
|
||||
|
||||
namespace facebook::react {
|
||||
|
||||
-class PasteTextInputProps final : public ViewProps, public BaseTextProps {
|
||||
+class PasteTextInputProps final : public BaseTextInputProps {
|
||||
public:
|
||||
PasteTextInputProps() = default;
|
||||
PasteTextInputProps(const PropsParserContext& context, const PasteTextInputProps& sourceProps, const RawProps& rawProps);
|
||||
diff --git a/node_modules/@mattermost/react-native-paste-input/ios/PasteTextInputSpecs/ShadowNodes.cpp b/node_modules/@mattermost/react-native-paste-input/ios/PasteTextInputSpecs/ShadowNodes.cpp
|
||||
index 31e07e3..7f0ebfb 100644
|
||||
--- a/node_modules/@mattermost/react-native-paste-input/ios/PasteTextInputSpecs/ShadowNodes.cpp
|
||||
+++ b/node_modules/@mattermost/react-native-paste-input/ios/PasteTextInputSpecs/ShadowNodes.cpp
|
||||
@@ -91,20 +91,11 @@ void PasteTextInputShadowNode::updateStateIfNeeded(
|
||||
const auto& state = getStateData();
|
||||
|
||||
react_native_assert(textLayoutManager_);
|
||||
- react_native_assert(
|
||||
- (!state.layoutManager || state.layoutManager == textLayoutManager_) &&
|
||||
- "`StateData` refers to a different `TextLayoutManager`");
|
||||
-
|
||||
- if (state.reactTreeAttributedString == reactTreeAttributedString &&
|
||||
- state.layoutManager == textLayoutManager_) {
|
||||
- return;
|
||||
- }
|
||||
|
||||
auto newState = TextInputState{};
|
||||
newState.attributedStringBox = AttributedStringBox{reactTreeAttributedString};
|
||||
newState.paragraphAttributes = getConcreteProps().paragraphAttributes;
|
||||
newState.reactTreeAttributedString = reactTreeAttributedString;
|
||||
- newState.layoutManager = textLayoutManager_;
|
||||
newState.mostRecentEventCount = getConcreteProps().mostRecentEventCount;
|
||||
setStateData(std::move(newState));
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
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
|
||||
@@ -1,15 +0,0 @@
|
||||
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
|
||||
@@ -0,0 +1,992 @@
|
||||
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 {
|
||||
@@ -1,170 +0,0 @@
|
||||
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 {
|
||||
@@ -1,16 +0,0 @@
|
||||
diff --git a/node_modules/react-native/third-party-podspecs/fmt.podspec b/node_modules/react-native/third-party-podspecs/fmt.podspec
|
||||
index 2f38990..9b02e48 100644
|
||||
--- a/node_modules/react-native/third-party-podspecs/fmt.podspec
|
||||
+++ b/node_modules/react-native/third-party-podspecs/fmt.podspec
|
||||
@@ -26,4 +26,11 @@ Pod::Spec.new do |spec|
|
||||
spec.public_header_files = "include/fmt/*.h"
|
||||
spec.header_mappings_dir = "include"
|
||||
spec.source_files = ["include/fmt/*.h", "src/format.cc"]
|
||||
+
|
||||
+ # TODO: Remove after upgrading React Native past 0.83.x
|
||||
+ # Fix fmt 11.0.2 consteval build error with Xcode 26.4 (facebook/react-native#55601)
|
||||
+ # Fixed in RN 0.84+ which bumps fmt to a compatible version.
|
||||
+ spec.prepare_command = <<~SCRIPT
|
||||
+ perl -i -pe 's/^# define FMT_USE_CONSTEVAL 1$/# define FMT_USE_CONSTEVAL 0/' include/fmt/base.h
|
||||
+ SCRIPT
|
||||
end
|
||||
@@ -0,0 +1,27 @@
|
||||
// Based on https://github.com/expo/expo/pull/33957
|
||||
// Could be removed once the app has been updated to Expo 53
|
||||
const {withAndroidStyles} = require('@expo/config-plugins')
|
||||
|
||||
module.exports = function withAndroidDayNightThemePlugin(appConfig) {
|
||||
const cleanupList = new Set([
|
||||
'colorPrimary',
|
||||
'android:editTextBackground',
|
||||
'android:textColor',
|
||||
'android:editTextStyle',
|
||||
])
|
||||
|
||||
return withAndroidStyles(appConfig, config => {
|
||||
config.modResults.resources.style = config.modResults.resources.style
|
||||
?.map(style => {
|
||||
if (style.$.name === 'AppTheme' && style.item != null) {
|
||||
style.item = style.item.filter(item => !cleanupList.has(item.$.name))
|
||||
}
|
||||
return style
|
||||
})
|
||||
.filter(style => {
|
||||
return style.$.name !== 'ResetEditText'
|
||||
})
|
||||
|
||||
return config
|
||||
})
|
||||
}
|
||||
+27
-23
@@ -1,7 +1,8 @@
|
||||
import '#/logger/sentry/setup'
|
||||
import '#/view/icons'
|
||||
|
||||
import {Fragment, useEffect, useState} from 'react'
|
||||
import {useEffect, useState} from 'react'
|
||||
import * as React from 'react'
|
||||
import {GestureHandlerRootView} from 'react-native-gesture-handler'
|
||||
import {KeyboardProvider as KeyboardControllerProvider} from 'react-native-keyboard-controller'
|
||||
import {
|
||||
@@ -11,11 +12,13 @@ import {
|
||||
import * as ScreenOrientation from 'expo-screen-orientation'
|
||||
import * as SplashScreen from 'expo-splash-screen'
|
||||
import * as SystemUI from 'expo-system-ui'
|
||||
import {useLingui} from '@lingui/react/macro'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import * as Sentry from '@sentry/react-native'
|
||||
|
||||
import {Provider as HideBottomBarBorderProvider} from '#/lib/hooks/useHideBottomBarBorder'
|
||||
import {QueryProvider} from '#/lib/react-query'
|
||||
import {s} from '#/lib/styles'
|
||||
import {ThemeProvider} from '#/lib/ThemeContext'
|
||||
import {Provider as TranslateOnDeviceProvider} from '#/lib/translation'
|
||||
import I18nProvider from '#/locale/i18nProvider'
|
||||
@@ -56,8 +59,9 @@ 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 {atoms as a, ThemeProvider as Alf} from '#/alf'
|
||||
import {ThemeProvider as Alf} from '#/alf'
|
||||
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
|
||||
import {Provider as ContextMenuProvider} from '#/components/ContextMenu'
|
||||
import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
|
||||
@@ -65,7 +69,6 @@ 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,
|
||||
@@ -87,9 +90,9 @@ import {Splash} from '#/Splash'
|
||||
import {BottomSheetProvider} from '../modules/bottom-sheet'
|
||||
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
|
||||
|
||||
void SplashScreen.preventAutoHideAsync()
|
||||
SplashScreen.preventAutoHideAsync()
|
||||
if (IS_IOS) {
|
||||
void SystemUI.setBackgroundColorAsync('black')
|
||||
SystemUI.setBackgroundColorAsync('black')
|
||||
}
|
||||
if (IS_ANDROID) {
|
||||
// iOS is handled by the config plugin -sfn
|
||||
@@ -103,17 +106,17 @@ if (IS_ANDROID) {
|
||||
/**
|
||||
* Begin geolocation ASAP
|
||||
*/
|
||||
void Geo.resolve()
|
||||
void prefetchAgeAssuranceConfig()
|
||||
void prefetchLiveEvents()
|
||||
void prefetchAppConfig()
|
||||
Geo.resolve()
|
||||
prefetchAgeAssuranceConfig()
|
||||
prefetchLiveEvents()
|
||||
prefetchAppConfig()
|
||||
|
||||
function InnerApp() {
|
||||
const [isReady, setIsReady] = useState(false)
|
||||
const [isReady, setIsReady] = React.useState(false)
|
||||
const {currentAccount} = useSession()
|
||||
const {resumeSession} = useSessionApi()
|
||||
const theme = useColorModeTheme()
|
||||
const {t: l} = useLingui()
|
||||
const {_} = useLingui()
|
||||
const hasCheckedReferrer = useStarterPackEntry()
|
||||
|
||||
// init
|
||||
@@ -132,16 +135,17 @@ function InnerApp() {
|
||||
}
|
||||
}
|
||||
const account = readLastActiveAccount()
|
||||
void onLaunch(account)
|
||||
onLaunch(account)
|
||||
}, [resumeSession])
|
||||
|
||||
useEffect(() => {
|
||||
return listenSessionDropped(() => {
|
||||
Toast.show(l`Sorry! Your session expired. Please sign in again.`, {
|
||||
type: 'info',
|
||||
})
|
||||
Toast.show(
|
||||
_(msg`Sorry! Your session expired. Please sign in again.`),
|
||||
'info',
|
||||
)
|
||||
})
|
||||
}, [l])
|
||||
}, [_])
|
||||
|
||||
return (
|
||||
<Alf theme={theme}>
|
||||
@@ -149,7 +153,7 @@ function InnerApp() {
|
||||
<ContextMenuProvider>
|
||||
<Splash isReady={isReady && hasCheckedReferrer}>
|
||||
<VideoVolumeProvider>
|
||||
<Fragment
|
||||
<React.Fragment
|
||||
// Resets the entire tree below when it changes:
|
||||
key={currentAccount?.did}>
|
||||
<AnalyticsFeaturesContext>
|
||||
@@ -174,7 +178,7 @@ function InnerApp() {
|
||||
<EmailVerificationProvider>
|
||||
<HideBottomBarBorderProvider>
|
||||
<GestureHandlerRootView
|
||||
style={a.h_full}>
|
||||
style={s.h100pct}>
|
||||
<GlobalGestureEventsProvider>
|
||||
<IntentDialogProvider>
|
||||
<TranslateOnDeviceProvider>
|
||||
@@ -205,7 +209,7 @@ function InnerApp() {
|
||||
</PolicyUpdateOverlayProvider>
|
||||
</QueryProvider>
|
||||
</AnalyticsFeaturesContext>
|
||||
</Fragment>
|
||||
</React.Fragment>
|
||||
</VideoVolumeProvider>
|
||||
</Splash>
|
||||
</ContextMenuProvider>
|
||||
@@ -217,9 +221,9 @@ function InnerApp() {
|
||||
function App() {
|
||||
const [isReady, setReady] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
void Promise.all([initPersistedState(), Geo.resolve(), setupDeviceId]).then(
|
||||
() => setReady(true),
|
||||
React.useEffect(() => {
|
||||
Promise.all([initPersistedState(), Geo.resolve(), setupDeviceId]).then(() =>
|
||||
setReady(true),
|
||||
)
|
||||
}, [])
|
||||
|
||||
|
||||
+42
-46
@@ -3,12 +3,11 @@ 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 {useLingui} from '@lingui/react/macro'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import * as Sentry from '@sentry/react-native'
|
||||
|
||||
import {Provider as HotkeysProvider} from '#/lib/hotkeys'
|
||||
import {QueryProvider} from '#/lib/react-query'
|
||||
import {ThemeProvider} from '#/lib/ThemeContext'
|
||||
import {Provider as TranslateOnDeviceProvider} from '#/lib/translation'
|
||||
@@ -48,6 +47,7 @@ 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,7 +58,6 @@ 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,
|
||||
@@ -82,17 +81,17 @@ import {Provider as HideBottomBarBorderProvider} from './lib/hooks/useHideBottom
|
||||
/**
|
||||
* Begin geolocation ASAP
|
||||
*/
|
||||
void Geo.resolve()
|
||||
void prefetchAgeAssuranceConfig()
|
||||
void prefetchLiveEvents()
|
||||
void prefetchAppConfig()
|
||||
Geo.resolve()
|
||||
prefetchAgeAssuranceConfig()
|
||||
prefetchLiveEvents()
|
||||
prefetchAppConfig()
|
||||
|
||||
function InnerApp() {
|
||||
const [isReady, setIsReady] = useState(false)
|
||||
const {currentAccount} = useSession()
|
||||
const {resumeSession} = useSessionApi()
|
||||
const theme = useColorModeTheme()
|
||||
const {t: l} = useLingui()
|
||||
const {_} = useLingui()
|
||||
const hasCheckedReferrer = useStarterPackEntry()
|
||||
|
||||
// init
|
||||
@@ -105,22 +104,23 @@ function InnerApp() {
|
||||
await features.init
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('session: resumeSession failed', {message: e})
|
||||
logger.error(`session: resumeSession failed`, {message: e})
|
||||
} finally {
|
||||
setIsReady(true)
|
||||
}
|
||||
}
|
||||
const account = readLastActiveAccount()
|
||||
void onLaunch(account)
|
||||
onLaunch(account)
|
||||
}, [resumeSession])
|
||||
|
||||
useEffect(() => {
|
||||
return listenSessionDropped(() => {
|
||||
Toast.show(l`Sorry! Your session expired. Please sign in again.`, {
|
||||
type: 'info',
|
||||
})
|
||||
Toast.show(
|
||||
_(msg`Sorry! Your session expired. Please sign in again.`),
|
||||
'info',
|
||||
)
|
||||
})
|
||||
}, [l])
|
||||
}, [_])
|
||||
|
||||
return (
|
||||
<Alf theme={theme}>
|
||||
@@ -156,10 +156,8 @@ function InnerApp() {
|
||||
<HideBottomBarBorderProvider>
|
||||
<IntentDialogProvider>
|
||||
<TranslateOnDeviceProvider>
|
||||
<HotkeysProvider>
|
||||
<Shell />
|
||||
<ToastOutlet />
|
||||
</HotkeysProvider>
|
||||
<Shell />
|
||||
<ToastOutlet />
|
||||
</TranslateOnDeviceProvider>
|
||||
</IntentDialogProvider>
|
||||
</HideBottomBarBorderProvider>
|
||||
@@ -197,8 +195,8 @@ function App() {
|
||||
const [isReady, setReady] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
void Promise.all([initPersistedState(), Geo.resolve(), setupDeviceId]).then(
|
||||
() => setReady(true),
|
||||
Promise.all([initPersistedState(), Geo.resolve(), setupDeviceId]).then(() =>
|
||||
setReady(true),
|
||||
)
|
||||
}, [])
|
||||
|
||||
@@ -214,31 +212,29 @@ function App() {
|
||||
<Geo.Provider>
|
||||
<AppConfigProvider>
|
||||
<A11yProvider>
|
||||
<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>
|
||||
<OnboardingProvider>
|
||||
<AnalyticsContext>
|
||||
<SessionProvider>
|
||||
<PrefsStateProvider>
|
||||
<I18nProvider>
|
||||
<ShellStateProvider>
|
||||
<ModalStateProvider>
|
||||
<DialogStateProvider>
|
||||
<LightboxStateProvider>
|
||||
<PortalProvider>
|
||||
<StarterPackProvider>
|
||||
<InnerApp />
|
||||
</StarterPackProvider>
|
||||
</PortalProvider>
|
||||
</LightboxStateProvider>
|
||||
</DialogStateProvider>
|
||||
</ModalStateProvider>
|
||||
</ShellStateProvider>
|
||||
</I18nProvider>
|
||||
</PrefsStateProvider>
|
||||
</SessionProvider>
|
||||
</AnalyticsContext>
|
||||
</OnboardingProvider>
|
||||
</A11yProvider>
|
||||
</AppConfigProvider>
|
||||
</Geo.Provider>
|
||||
|
||||
+2
-1
@@ -1,4 +1,5 @@
|
||||
import {forwardRef, useCallback, useEffect, useState} from 'react'
|
||||
import {type PropsWithChildren} from 'react'
|
||||
import {
|
||||
AccessibilityInfo,
|
||||
Image as RNImage,
|
||||
@@ -51,7 +52,7 @@ type Props = {
|
||||
isReady: boolean
|
||||
}
|
||||
|
||||
export function Splash(props: React.PropsWithChildren<Props>) {
|
||||
export function Splash(props: PropsWithChildren<Props>) {
|
||||
'use no memo'
|
||||
const insets = useSafeAreaInsets()
|
||||
const intro = useSharedValue(0)
|
||||
|
||||
@@ -12,8 +12,6 @@ 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'
|
||||
@@ -51,8 +49,6 @@ 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()
|
||||
@@ -75,7 +71,6 @@ 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
|
||||
}, [])
|
||||
|
||||
@@ -239,38 +234,18 @@ export function NoAccessScreen() {
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={[a.pt_lg, a.gap_xl, {maxWidth: 280}]}>
|
||||
<View style={[a.pt_lg, a.gap_xl]}>
|
||||
<Logo width={120} textFill={t.atoms.text.color} />
|
||||
<Text
|
||||
style={[
|
||||
a.text_sm,
|
||||
a.italic,
|
||||
a.leading_snug,
|
||||
t.atoms.text_contrast_medium,
|
||||
]}>
|
||||
<Text style={[a.text_sm, a.italic, 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 you’d 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>
|
||||
@@ -280,11 +255,6 @@ 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
|
||||
|
||||
@@ -57,7 +57,7 @@ export const otherRequiredData: OtherRequiredData = {
|
||||
birthdate: new Date(2000, 1, 1).toISOString(),
|
||||
}
|
||||
|
||||
const serverStateEnabled = false || IS_E2E
|
||||
const serverStateEnabled = false
|
||||
export const serverState: AppBskyAgeassuranceGetState.OutputSchema | undefined =
|
||||
serverStateEnabled
|
||||
? {
|
||||
|
||||
@@ -2,10 +2,8 @@ import {createContext, useCallback, useContext, useEffect, useMemo} from 'react'
|
||||
|
||||
import {useGetAndRegisterPushToken} from '#/lib/notifications/notifications'
|
||||
import {Provider as RedirectOverlayProvider} from '#/ageAssurance/components/RedirectOverlay'
|
||||
import {
|
||||
AgeAssuranceDataProvider,
|
||||
useAgeAssuranceDataContext,
|
||||
} from '#/ageAssurance/data'
|
||||
import {AgeAssuranceDataProvider} from '#/ageAssurance/data'
|
||||
import {useAgeAssuranceDataContext} from '#/ageAssurance/data'
|
||||
import {logger} from '#/ageAssurance/logger'
|
||||
import {
|
||||
useAgeAssuranceState,
|
||||
|
||||
+2
-10
@@ -77,18 +77,10 @@ export function applyFonts(style: TextStyle, fontFamily: 'system' | 'theme') {
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable contextual alternates and emoji overrides in Inter
|
||||
* Disable contextual alternates in Inter
|
||||
* {@link https://developer.mozilla.org/en-US/docs/Web/CSS/font-variant}
|
||||
*/
|
||||
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')
|
||||
}
|
||||
style.fontVariant = (style.fontVariant || []).concat('no-contextual')
|
||||
} else {
|
||||
// fallback families only supported on web
|
||||
if (IS_WEB) {
|
||||
|
||||
+2
-1
@@ -1,4 +1,5 @@
|
||||
import {createContext, useCallback, useContext, useMemo, useState} from 'react'
|
||||
import {type PropsWithChildren} from 'react'
|
||||
import {type Theme, type ThemeName} from '@bsky.app/alf'
|
||||
|
||||
import {
|
||||
@@ -64,7 +65,7 @@ Context.displayName = 'AlfContext'
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
theme: themeName,
|
||||
}: React.PropsWithChildren<{theme: ThemeName}>) {
|
||||
}: PropsWithChildren<{theme: ThemeName}>) {
|
||||
const [fontScale, setFontScale] = useState<Alf['fonts']['scale']>(() =>
|
||||
getFontScale(),
|
||||
)
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import {Children} from 'react'
|
||||
import {
|
||||
type StyleProp,
|
||||
type TextProps as RNTextProps,
|
||||
type TextStyle,
|
||||
} from 'react-native'
|
||||
import {type TextProps as RNTextProps} from 'react-native'
|
||||
import {type StyleProp, type TextStyle} from 'react-native'
|
||||
import {UITextView} from 'react-native-uitextview'
|
||||
import createEmojiRegex from 'emoji-regex'
|
||||
|
||||
import {type Alf, applyFonts, atoms, flatten} from '#/alf'
|
||||
import {IS_IOS, IS_NATIVE} from '#/env'
|
||||
import {IS_NATIVE} from '#/env'
|
||||
import {IS_IOS} from '#/env'
|
||||
|
||||
/**
|
||||
* Ensures that `lineHeight` defaults to a relative value of `1`, or applies
|
||||
@@ -109,8 +107,7 @@ export function renderChildrenWithEmoji(
|
||||
})
|
||||
}
|
||||
|
||||
const SINGLE_EMOJI_RE =
|
||||
/^[\p{Emoji_Presentation}\p{Extended_Pictographic}\uFE0F\u200D]+$/u
|
||||
const SINGLE_EMOJI_RE = /^[\p{Emoji_Presentation}\p{Extended_Pictographic}]+$/u
|
||||
export function isOnlyEmoji(text: string) {
|
||||
return text.length <= 15 && SINGLE_EMOJI_RE.test(text)
|
||||
}
|
||||
|
||||
+1
-37
@@ -1,39 +1,3 @@
|
||||
import {type DimensionValue, StyleSheet} from 'react-native'
|
||||
import {StyleSheet} from 'react-native'
|
||||
|
||||
export const flatten = StyleSheet.flatten
|
||||
|
||||
/**
|
||||
* Coerce a style value to a number. Padding values are typed as
|
||||
* `DimensionValue` (numbers, percentages, "auto", etc.) but our ALF atoms
|
||||
* are always plain numbers. Non-numeric values are treated as 0.
|
||||
*/
|
||||
function num(v: unknown): number {
|
||||
return typeof v === 'number' ? v : 0
|
||||
}
|
||||
|
||||
interface PaddingStyle {
|
||||
padding?: DimensionValue
|
||||
paddingHorizontal?: DimensionValue
|
||||
paddingVertical?: DimensionValue
|
||||
paddingTop?: DimensionValue
|
||||
paddingBottom?: DimensionValue
|
||||
paddingLeft?: DimensionValue
|
||||
paddingRight?: DimensionValue
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract resolved padding values from a style object. Returns numbers for
|
||||
* each side, resolving shorthand properties (padding → paddingVertical →
|
||||
* paddingTop/paddingBottom, etc.). Values are expected to be numbers — any
|
||||
* non-numeric `DimensionValue` (e.g. percentages) is treated as 0.
|
||||
*/
|
||||
export function extractPadding(style: PaddingStyle | PaddingStyle[]) {
|
||||
const s = flatten(style as any) ?? {}
|
||||
const base = num(s.padding)
|
||||
return {
|
||||
paddingTop: num(s.paddingTop) || num(s.paddingVertical) || base,
|
||||
paddingBottom: num(s.paddingBottom) || num(s.paddingVertical) || base,
|
||||
paddingLeft: num(s.paddingLeft) || num(s.paddingHorizontal) || base,
|
||||
paddingRight: num(s.paddingRight) || num(s.paddingHorizontal) || base,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,9 +9,6 @@ export enum Features {
|
||||
ImportContactsOnboardingDisable = 'import_contacts:onboarding:disable',
|
||||
ImportContactsSettingsDisable = 'import_contacts:settings:disable',
|
||||
LiveNowBetaDisable = 'live_now_beta:disable',
|
||||
ImageUploadsHighResolution = 'image_uploads:high_resolution',
|
||||
GroupChatsEnable = 'group_chats:enable',
|
||||
DmsNewMessageComposerEnable = 'dms:new_message_composer:enable',
|
||||
|
||||
AATest = 'aa-test',
|
||||
}
|
||||
|
||||
@@ -110,7 +110,6 @@ const Context = createContext<AnalyticsBaseContextType>({
|
||||
},
|
||||
},
|
||||
})
|
||||
Context.displayName = 'AnalyticsContext'
|
||||
|
||||
/**
|
||||
* Ensures that deviceId is set and migrated from legacy storage. Handled on
|
||||
|
||||
+19
-128
@@ -230,9 +230,6 @@ export type Events = {
|
||||
|
||||
'composer:gif:open': {}
|
||||
'composer:gif:select': {}
|
||||
'composer:image:edit': {
|
||||
platform: Platform['OS']
|
||||
}
|
||||
'composerPrompt:press': {}
|
||||
'composerPrompt:camera:press': {}
|
||||
'composerPrompt:gallery:press': {}
|
||||
@@ -473,19 +470,13 @@ export type Events = {
|
||||
profileDid: string
|
||||
position?: number
|
||||
}
|
||||
'profile:mute': {}
|
||||
'profile:unmute': {}
|
||||
'profile:block': {}
|
||||
'profile:unblock': {}
|
||||
'suggestedUser:follow': {
|
||||
logContext:
|
||||
| 'Explore'
|
||||
| 'DiscoverInterstitial'
|
||||
| 'ProfileInterstitial'
|
||||
| 'ProfileHeader'
|
||||
| 'InterstitialDiscover'
|
||||
| 'InterstitialProfile'
|
||||
| 'Profile'
|
||||
| 'Onboarding'
|
||||
| 'SeeMoreSuggestedUsers'
|
||||
| 'ProgressGuide'
|
||||
location: 'Card' | 'Profile' | 'FollowAll'
|
||||
recId?: number | string
|
||||
position: number
|
||||
@@ -495,11 +486,9 @@ export type Events = {
|
||||
'suggestedUser:press': {
|
||||
logContext:
|
||||
| 'Explore'
|
||||
| 'DiscoverInterstitial'
|
||||
| 'ProfileInterstitial'
|
||||
| 'ProfileHeader'
|
||||
| 'InterstitialDiscover'
|
||||
| 'InterstitialProfile'
|
||||
| 'Onboarding'
|
||||
| 'SeeMoreSuggestedUsers'
|
||||
recId?: number | string
|
||||
position: number
|
||||
suggestedDid: string
|
||||
@@ -508,11 +497,10 @@ export type Events = {
|
||||
'suggestedUser:seen': {
|
||||
logContext:
|
||||
| 'Explore'
|
||||
| 'DiscoverInterstitial'
|
||||
| 'ProfileInterstitial'
|
||||
| 'ProfileHeader'
|
||||
| 'InterstitialDiscover'
|
||||
| 'InterstitialProfile'
|
||||
| 'Profile'
|
||||
| 'Onboarding'
|
||||
| 'SeeMoreSuggestedUsers'
|
||||
| 'ProgressGuide'
|
||||
recId?: number | string
|
||||
position: number
|
||||
@@ -522,14 +510,13 @@ export type Events = {
|
||||
'suggestedUser:seeMore': {
|
||||
logContext:
|
||||
| 'Explore'
|
||||
| 'DiscoverInterstitial'
|
||||
| 'ProfileInterstitial'
|
||||
| 'ProfileHeader'
|
||||
| 'InterstitialDiscover'
|
||||
| 'InterstitialProfile'
|
||||
| 'Profile'
|
||||
| 'Onboarding'
|
||||
recId?: number | string
|
||||
}
|
||||
'suggestedUser:dismiss': {
|
||||
logContext: 'DiscoverInterstitial' | 'ProfileInterstitial' | 'ProfileHeader'
|
||||
logContext: 'InterstitialDiscover' | 'InterstitialProfile'
|
||||
recId?: number | string
|
||||
position: number
|
||||
suggestedDid: string
|
||||
@@ -716,115 +703,20 @@ export type Events = {
|
||||
'reportDialog:failure': {}
|
||||
|
||||
translate: {
|
||||
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.
|
||||
*/
|
||||
sourceLanguages: string[]
|
||||
targetLanguage: string
|
||||
textLength: number
|
||||
googleTranslate: boolean
|
||||
}
|
||||
'translate:result': {
|
||||
success: boolean
|
||||
method: 'on-device' | 'google-translate' | 'fallback-alert'
|
||||
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
|
||||
/**
|
||||
* 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
|
||||
sourceLanguage: string | null
|
||||
targetLanguage: string
|
||||
}
|
||||
'translate:override': {
|
||||
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
|
||||
/**
|
||||
* 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
|
||||
sourceLanguage: string
|
||||
targetLanguage: string
|
||||
}
|
||||
|
||||
'verification:create': {}
|
||||
@@ -920,7 +812,6 @@ export type Events = {
|
||||
canUpdateBirthday: boolean
|
||||
}
|
||||
'ageAssurance:noAccessScreen:openBirthdateDialog': {}
|
||||
'ageAssurance:noAccessScreen:openDeleteAccountDialog': {}
|
||||
|
||||
/*
|
||||
* Specifically for the `BlockedGeoOverlay`
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
import {useCallback} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {Sift, type UseSiftReturn} from '@bsky.app/sift'
|
||||
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {type AutocompleteItem} from '#/components/Autocomplete/types'
|
||||
import {useOnKeyboard} from '#/components/hooks/useOnKeyboard'
|
||||
import {Portal} from '#/components/Portal'
|
||||
import {IS_WEB} from '#/env'
|
||||
import {AutocompleteItemEmoji} from './AutocompleteItemEmoji'
|
||||
import {AutocompleteItemProfile} from './AutocompleteItemProfile'
|
||||
import {AutocompleteItemSearch} from './AutocompleteItemSearch'
|
||||
|
||||
function renderItem(
|
||||
item: Parameters<Parameters<typeof Sift<AutocompleteItem>>[0]['render']>[0],
|
||||
) {
|
||||
switch (item.item.type) {
|
||||
case 'profile':
|
||||
return <AutocompleteItemProfile {...item} />
|
||||
case 'emoji':
|
||||
return <AutocompleteItemEmoji {...item} />
|
||||
case 'search':
|
||||
return <AutocompleteItemSearch {...item} />
|
||||
default:
|
||||
return <View />
|
||||
}
|
||||
}
|
||||
|
||||
export function Autocomplete({
|
||||
inverted,
|
||||
sift,
|
||||
data,
|
||||
render = renderItem,
|
||||
onSelect,
|
||||
onDismiss,
|
||||
}: {
|
||||
inverted?: boolean
|
||||
sift: UseSiftReturn
|
||||
data: AutocompleteItem[]
|
||||
render?: Parameters<typeof Sift<AutocompleteItem>>[0]['render']
|
||||
onSelect: (item: AutocompleteItem) => void
|
||||
onDismiss: () => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
|
||||
const updatePosition = useCallback(() => {
|
||||
sift.updatePosition()
|
||||
}, [sift])
|
||||
|
||||
useOnKeyboard('keyboardDidShow', updatePosition)
|
||||
useOnKeyboard('keyboardDidHide', updatePosition)
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<Sift
|
||||
inverted={inverted}
|
||||
sift={sift}
|
||||
data={data}
|
||||
onSelect={onSelect}
|
||||
onDismiss={onDismiss}
|
||||
style={[
|
||||
a.overflow_hidden,
|
||||
a.rounded_md,
|
||||
a.border,
|
||||
t.atoms.border_contrast_low,
|
||||
t.atoms.bg,
|
||||
a.w_full,
|
||||
IS_WEB
|
||||
? {
|
||||
maxWidth: 300,
|
||||
}
|
||||
: {},
|
||||
]}
|
||||
render={render}
|
||||
/>
|
||||
</Portal>
|
||||
)
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import {SiftItem} from '@bsky.app/sift'
|
||||
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {type AutocompleteItemProps} from './types'
|
||||
|
||||
export function AutocompleteItemEmoji({
|
||||
active,
|
||||
props,
|
||||
item,
|
||||
}: AutocompleteItemProps) {
|
||||
const t = useTheme()
|
||||
|
||||
if (item.type !== 'emoji') return null
|
||||
|
||||
return (
|
||||
<SiftItem
|
||||
{...props}
|
||||
style={s => [
|
||||
{paddingVertical: 6, paddingHorizontal: 10},
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.gap_sm,
|
||||
active || s.hovered || s.pressed ? [t.atoms.bg_contrast_25] : [],
|
||||
]}>
|
||||
<Text style={[a.text_xl, a.leading_tight]}>{item.value}</Text>
|
||||
<Text style={[a.text_md, a.leading_tight]}>:{item.emoji.id}:</Text>
|
||||
</SiftItem>
|
||||
)
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import {SiftItem} from '@bsky.app/sift'
|
||||
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import * as ProfileCard from '#/components/ProfileCard'
|
||||
import {type AutocompleteItemProps} from './types'
|
||||
|
||||
export function AutocompleteItemProfile({
|
||||
active,
|
||||
isFirst,
|
||||
isLast,
|
||||
props,
|
||||
item,
|
||||
}: AutocompleteItemProps) {
|
||||
const t = useTheme()
|
||||
const moderationOpts = useModerationOpts()
|
||||
|
||||
if (item.type !== 'profile' || !moderationOpts) return null
|
||||
|
||||
return (
|
||||
<SiftItem
|
||||
{...props}
|
||||
style={s => [
|
||||
a.py_sm,
|
||||
a.px_md,
|
||||
active || s.hovered || s.pressed ? [t.atoms.bg_contrast_25] : [],
|
||||
isFirst && {
|
||||
paddingTop: a.py_sm.paddingTop * 1.2,
|
||||
},
|
||||
isLast && {
|
||||
paddingBottom: a.py_sm.paddingTop * 1.2,
|
||||
},
|
||||
]}>
|
||||
<ProfileCard.Header>
|
||||
<ProfileCard.Avatar
|
||||
disabledPreview
|
||||
profile={item.profile}
|
||||
moderationOpts={moderationOpts}
|
||||
/>
|
||||
<ProfileCard.NameAndHandle
|
||||
profile={item.profile}
|
||||
moderationOpts={moderationOpts}
|
||||
/>
|
||||
</ProfileCard.Header>
|
||||
</SiftItem>
|
||||
)
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import {View} from 'react-native'
|
||||
import {SiftItem} from '@bsky.app/sift'
|
||||
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {MagnifyingGlass_Stroke2_Corner0_Rounded as MagnifyingGlassIcon} from '#/components/icons/MagnifyingGlass'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {type AutocompleteItemProps} from './types'
|
||||
|
||||
export function AutocompleteItemSearch({
|
||||
active,
|
||||
isFirst,
|
||||
isLast,
|
||||
props,
|
||||
item,
|
||||
}: AutocompleteItemProps) {
|
||||
const t = useTheme()
|
||||
|
||||
if (item.type !== 'search') return null
|
||||
|
||||
return (
|
||||
<SiftItem
|
||||
{...props}
|
||||
style={s => [
|
||||
a.py_sm,
|
||||
a.px_md,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.gap_sm,
|
||||
active || s.hovered || s.pressed ? [t.atoms.bg_contrast_25] : [],
|
||||
isFirst && {
|
||||
paddingTop: a.py_sm.paddingTop * 1.2,
|
||||
},
|
||||
isLast && {
|
||||
paddingBottom: a.py_sm.paddingTop * 1.2,
|
||||
},
|
||||
]}>
|
||||
<View
|
||||
style={[
|
||||
a.align_center,
|
||||
{
|
||||
width: 40,
|
||||
},
|
||||
]}>
|
||||
<MagnifyingGlassIcon fill={t.atoms.text_contrast_low.color} size="xl" />
|
||||
</View>
|
||||
<Text style={[a.text_md, a.leading_snug]}>{item.value}</Text>
|
||||
</SiftItem>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
export * from './Autocomplete'
|
||||
export * from './AutocompleteItemEmoji'
|
||||
export * from './AutocompleteItemProfile'
|
||||
export * from './types'
|
||||
export * from './useAutocomplete'
|
||||
export * from './util'
|
||||
@@ -1,48 +0,0 @@
|
||||
import {type Sift} from '@bsky.app/sift'
|
||||
import {type Emoji} from '@emoji-mart/data'
|
||||
|
||||
import type * as bsky from '#/types/bsky'
|
||||
|
||||
export type AutocompleteProfile = {
|
||||
key: string
|
||||
type: 'profile'
|
||||
value: string
|
||||
profile: bsky.profile.AnyProfileView
|
||||
}
|
||||
|
||||
export type AutocompleteTag = {
|
||||
key: string
|
||||
type: 'tag'
|
||||
value: string
|
||||
tag: string
|
||||
}
|
||||
|
||||
export type AutocompleteEmoji = {
|
||||
key: string
|
||||
type: 'emoji'
|
||||
value: string
|
||||
emoji: Emoji
|
||||
}
|
||||
|
||||
export type AutocompleteSearch = {
|
||||
key: string
|
||||
type: 'search'
|
||||
value: string
|
||||
}
|
||||
|
||||
export type AutocompleteItem =
|
||||
| AutocompleteProfile
|
||||
| AutocompleteTag
|
||||
| AutocompleteEmoji
|
||||
| AutocompleteSearch
|
||||
|
||||
export type AutocompleteItemType = AutocompleteItem['type']
|
||||
|
||||
export type AutocompleteItemProps = Parameters<
|
||||
Parameters<typeof Sift<AutocompleteItem>>[0]['render']
|
||||
>[0]
|
||||
|
||||
export type AutocompleteApi = {
|
||||
query: string
|
||||
items: AutocompleteItem[]
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
import {useCallback} from 'react'
|
||||
import {moderateProfile, type ModerationOpts} from '@atproto/api'
|
||||
import {keepPreviousData, useQuery} from '@tanstack/react-query'
|
||||
|
||||
import {isJustAMute, moduiContainsHideableOffense} from '#/lib/moderation'
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {STALE} from '#/state/queries'
|
||||
import {DEFAULT_LOGGED_OUT_PREFERENCES} from '#/state/queries/preferences'
|
||||
import {useAgent} from '#/state/session'
|
||||
import {
|
||||
type AutocompleteApi,
|
||||
type AutocompleteItem,
|
||||
type AutocompleteItemType,
|
||||
type AutocompleteProfile,
|
||||
} from '#/components/Autocomplete/types'
|
||||
import {useEmojiSearch} from './useEmojiSearch'
|
||||
|
||||
const DEFAULT_MOD_OPTS = {
|
||||
userDid: undefined,
|
||||
prefs: DEFAULT_LOGGED_OUT_PREFERENCES.moderationPrefs,
|
||||
}
|
||||
|
||||
export function useAutocomplete({
|
||||
type,
|
||||
query: q,
|
||||
limit,
|
||||
showSearchFallback = false,
|
||||
}: {
|
||||
type: AutocompleteItemType
|
||||
query: string
|
||||
limit?: number
|
||||
showSearchFallback?: boolean
|
||||
}): AutocompleteApi {
|
||||
const agent = useAgent()
|
||||
const moderationOpts = useModerationOpts()
|
||||
const emojiSearch = useEmojiSearch()
|
||||
|
||||
const query = useQuery({
|
||||
staleTime: STALE.MINUTES.ONE,
|
||||
queryKey: [
|
||||
'autocomplete',
|
||||
{
|
||||
type,
|
||||
query: q,
|
||||
},
|
||||
],
|
||||
async queryFn() {
|
||||
if (type === 'profile') {
|
||||
// TODO return recents
|
||||
if (!q) return []
|
||||
|
||||
// Going from "foo" to "foo." should not clear matches.
|
||||
q = q.toLowerCase().trim().replace(/\.$/, '')
|
||||
|
||||
const res = await agent.searchActorsTypeahead({
|
||||
q,
|
||||
limit: limit || 8,
|
||||
})
|
||||
|
||||
return (res?.data.actors || []).map(profile => ({
|
||||
key: profile.did,
|
||||
type: 'profile' as const,
|
||||
value: '@' + profile.handle,
|
||||
profile,
|
||||
}))
|
||||
} else if (type === 'emoji') {
|
||||
return emojiSearch(q, limit || 8)
|
||||
}
|
||||
|
||||
return []
|
||||
},
|
||||
select: useCallback(
|
||||
(items: AutocompleteItem[]) => {
|
||||
const seen = new Set<string>()
|
||||
let results: AutocompleteItem[] = []
|
||||
|
||||
for (const item of items) {
|
||||
if (seen.has(item.key)) continue
|
||||
seen.add(item.key)
|
||||
|
||||
if (item.type === 'profile') {
|
||||
const moderated = moderateProfileItem({
|
||||
query: q,
|
||||
item,
|
||||
moderationOpts: moderationOpts || DEFAULT_MOD_OPTS,
|
||||
})
|
||||
if (moderated) results.push(moderated)
|
||||
} else {
|
||||
results.push(item)
|
||||
}
|
||||
}
|
||||
|
||||
if (showSearchFallback && q) {
|
||||
results.unshift({
|
||||
key: `search-${q}`,
|
||||
type: 'search' as const,
|
||||
value: q,
|
||||
})
|
||||
}
|
||||
|
||||
return results
|
||||
},
|
||||
[q, showSearchFallback, moderationOpts],
|
||||
),
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
|
||||
return {
|
||||
query: q,
|
||||
items: query.data || [],
|
||||
}
|
||||
}
|
||||
|
||||
function moderateProfileItem({
|
||||
query,
|
||||
item,
|
||||
moderationOpts,
|
||||
}: {
|
||||
query: string
|
||||
item: AutocompleteProfile
|
||||
moderationOpts: ModerationOpts
|
||||
}) {
|
||||
const modui = moderateProfile(item.profile, moderationOpts).ui('profileList')
|
||||
const isExactMatch = query && item.profile.handle.toLowerCase() === query
|
||||
|
||||
if (
|
||||
(isExactMatch && !moduiContainsHideableOffense(modui)) ||
|
||||
!modui.filter ||
|
||||
isJustAMute(modui)
|
||||
) {
|
||||
return item
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import {useCallback} from 'react'
|
||||
import {type Emoji} from '@emoji-mart/data'
|
||||
import Fuse from 'fuse.js'
|
||||
|
||||
import {useGetEmojis} from '#/lib/useGetEmojis'
|
||||
import {type AutocompleteEmoji} from '#/components/Autocomplete/types'
|
||||
|
||||
/*
|
||||
* Lazily loaded Fuse instance for emoji search. Built once on first search,
|
||||
* then reused for all subsequent searches.
|
||||
*/
|
||||
let emojiFuseInstance: Fuse<Emoji> | null = null
|
||||
|
||||
export function useEmojiSearch(): (
|
||||
query: string,
|
||||
limit?: number,
|
||||
) => Promise<AutocompleteEmoji[]> {
|
||||
const getEmojis = useGetEmojis()
|
||||
|
||||
return useCallback(
|
||||
async (query: string, limit: number = 8) => {
|
||||
if (!emojiFuseInstance) {
|
||||
const data = await getEmojis()
|
||||
emojiFuseInstance = new Fuse(Object.values(data.emojis), {
|
||||
keys: ['search'],
|
||||
threshold: 0.3,
|
||||
})
|
||||
}
|
||||
|
||||
const results = emojiFuseInstance.search(query, {limit})
|
||||
return results.map(result => ({
|
||||
key: result.item.id,
|
||||
type: 'emoji' as const,
|
||||
value: result.item.skins[0].native,
|
||||
emoji: result.item,
|
||||
}))
|
||||
},
|
||||
[getEmojis],
|
||||
)
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
export function parseAutocompleteItemType(type: string) {
|
||||
switch (type) {
|
||||
case 'mention':
|
||||
return 'profile'
|
||||
case 'tag':
|
||||
return 'tag'
|
||||
case 'emoji':
|
||||
return 'emoji'
|
||||
default:
|
||||
throw new Error(`Unknown autocomplete item type: ${type}`)
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react'
|
||||
import {type ComponentType, type ReactElement, type ReactNode} from 'react'
|
||||
import {
|
||||
type AccessibilityProps,
|
||||
type GestureResponderEvent,
|
||||
@@ -82,8 +83,8 @@ export type ButtonState = {
|
||||
export type ButtonContext = VariantProps & ButtonState
|
||||
|
||||
type NonTextElements =
|
||||
| React.ReactElement<any>
|
||||
| Iterable<React.ReactElement<any> | null | undefined | boolean>
|
||||
| ReactElement<any>
|
||||
| Iterable<ReactElement<any> | null | undefined | boolean>
|
||||
|
||||
export type ButtonProps = Pick<
|
||||
PressableProps,
|
||||
@@ -109,7 +110,7 @@ export type ButtonProps = Pick<
|
||||
style?: StyleProp<ViewStyle>
|
||||
hoverStyle?: StyleProp<ViewStyle>
|
||||
children: NonTextElements | ((context: ButtonContext) => NonTextElements)
|
||||
PressableComponent?: React.ComponentType<PressableProps>
|
||||
PressableComponent?: ComponentType<PressableProps>
|
||||
}
|
||||
|
||||
export type ButtonTextProps = TextProps &
|
||||
@@ -776,7 +777,7 @@ export function ButtonIcon({
|
||||
icon: Comp,
|
||||
size,
|
||||
}: {
|
||||
icon: React.ComponentType<SVGIconProps>
|
||||
icon: ComponentType<SVGIconProps>
|
||||
/**
|
||||
* @deprecated no longer needed
|
||||
*/
|
||||
@@ -894,8 +895,8 @@ export type StackedButtonProps = Omit<
|
||||
keyof VariantProps | 'children'
|
||||
> &
|
||||
Pick<VariantProps, 'color'> & {
|
||||
children: React.ReactNode
|
||||
icon: React.ComponentType<SVGIconProps>
|
||||
children: ReactNode
|
||||
icon: ComponentType<SVGIconProps>
|
||||
}
|
||||
|
||||
export function StackedButton({children, ...props}: StackedButtonProps) {
|
||||
|
||||
@@ -1,432 +0,0 @@
|
||||
import {useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react'
|
||||
import {
|
||||
type TextInput,
|
||||
type TextInputSubmitEditingEvent,
|
||||
View,
|
||||
} from 'react-native'
|
||||
import Animated, {
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
} from 'react-native-reanimated'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import {useSift, type UseSiftReturn} from '@bsky.app/sift'
|
||||
import {
|
||||
facets,
|
||||
type TapperActiveFacet,
|
||||
type TapperFacet,
|
||||
useTapper,
|
||||
} from '@bsky.app/tapper'
|
||||
|
||||
import {mergeRefs} from '#/lib/merge-refs'
|
||||
import {
|
||||
atoms as a,
|
||||
type TextStyleProp,
|
||||
useAlf,
|
||||
type ViewStyleProp,
|
||||
web,
|
||||
} from '#/alf'
|
||||
import {normalizeTextStyles} from '#/alf/typography'
|
||||
import {
|
||||
Autocomplete as AutocompleteBase,
|
||||
AutocompleteItemEmoji,
|
||||
AutocompleteItemProfile,
|
||||
parseAutocompleteItemType,
|
||||
useAutocomplete,
|
||||
} from '#/components/Autocomplete'
|
||||
import {
|
||||
AutosizedTextarea,
|
||||
type AutosizedTextareaProps,
|
||||
} from '#/components/forms/AutosizedTextarea'
|
||||
import {Span, Text} from '#/components/Typography'
|
||||
import {IS_IOS, IS_WEB, IS_WEB_TOUCH_DEVICE} from '#/env'
|
||||
|
||||
export type SubmitRequest =
|
||||
| {
|
||||
platform: 'web'
|
||||
shiftKey: boolean
|
||||
metaKey: boolean
|
||||
nativeEvent: KeyboardEvent
|
||||
}
|
||||
| {
|
||||
platform: 'native'
|
||||
nativeEvent: TextInputSubmitEditingEvent
|
||||
}
|
||||
|
||||
/**
|
||||
* Imperative API exposed via `internalApiRef` prop for parent components that
|
||||
* need to control the composer programmatically, e.g. to clear the input or
|
||||
* insert text at the current cursor position.
|
||||
*/
|
||||
export type ComposerInternalApi = {
|
||||
input?: ReturnType<typeof useTapper>['input']
|
||||
clear: () => void
|
||||
insert(text: string): void
|
||||
setAutocompleteAnchor: (node: View | null) => void
|
||||
}
|
||||
|
||||
export function useComposerInternalApiRef() {
|
||||
return useRef<ComposerInternalApi>(null)
|
||||
}
|
||||
|
||||
/*
|
||||
* ─── Composer ─────────────────────────────────────────────────────────────────
|
||||
*/
|
||||
|
||||
export type ComposerProps = Omit<
|
||||
AutosizedTextareaProps,
|
||||
| 'value'
|
||||
| 'onChange'
|
||||
| 'onChangeText'
|
||||
| 'onSelectionChange'
|
||||
| 'selection'
|
||||
| 'style'
|
||||
| 'onSubmitEditing'
|
||||
> & {
|
||||
label: string
|
||||
ref?: React.RefObject<TextInput>
|
||||
internalApiRef?: React.Ref<ComposerInternalApi>
|
||||
outerStyle?: ViewStyleProp['style']
|
||||
contentTextStyle?: TextStyleProp['style']
|
||||
contentPaddingStyle?: {
|
||||
paddingTop?: number
|
||||
paddingBottom?: number
|
||||
paddingLeft?: number
|
||||
paddingRight?: number
|
||||
}
|
||||
onChange?: (text: string) => void
|
||||
onActiveFacet?: (activeFacet: TapperActiveFacet | null) => void
|
||||
onFacetCommitted?: (facet: TapperFacet) => void
|
||||
onRequestSubmit?: (request: SubmitRequest) => void
|
||||
autocompletePlacement?: Exclude<
|
||||
Parameters<typeof useSift>[0],
|
||||
undefined
|
||||
>['placement']
|
||||
disableEmojiFacets?: boolean
|
||||
}
|
||||
|
||||
export function Composer({
|
||||
label,
|
||||
ref,
|
||||
internalApiRef,
|
||||
outerStyle,
|
||||
contentTextStyle,
|
||||
contentPaddingStyle,
|
||||
onChange: onChangeOuter,
|
||||
onActiveFacet: onActiveFacetOuter,
|
||||
onFacetCommitted: onFacetCommittedOuter,
|
||||
onRequestSubmit,
|
||||
autocompletePlacement,
|
||||
defaultValue,
|
||||
disableEmojiFacets = !IS_WEB,
|
||||
...rest
|
||||
}: ComposerProps) {
|
||||
const {theme: t, fonts} = useAlf()
|
||||
const insets = useSafeAreaInsets()
|
||||
|
||||
/*
|
||||
* Meat and potatoes
|
||||
*/
|
||||
const tapper = useTapper({
|
||||
initialText: defaultValue ?? '',
|
||||
facets: disableEmojiFacets
|
||||
? {
|
||||
mention: facets.mention,
|
||||
tag: facets.tag,
|
||||
url: facets.url,
|
||||
}
|
||||
: facets,
|
||||
})
|
||||
const sift = useSift({
|
||||
offset: a.p_sm.padding,
|
||||
placement: autocompletePlacement,
|
||||
dynamicWidth: IS_WEB,
|
||||
insets,
|
||||
})
|
||||
|
||||
/*
|
||||
* Active facet state for controlling the visibility of the Autocomplete.
|
||||
*/
|
||||
const [activeFacet, setActiveFacet] = useState<TapperActiveFacet | null>(null)
|
||||
|
||||
/*
|
||||
* Reanimated shared value for syncing scroll on all platforms.
|
||||
*/
|
||||
const inputScrollSharedValue = useSharedValue(0)
|
||||
|
||||
/*
|
||||
* Expose imperative internal API
|
||||
*/
|
||||
useImperativeHandle(
|
||||
internalApiRef,
|
||||
() => ({
|
||||
input: tapper.input,
|
||||
clear: () => {
|
||||
tapper.inputProps.onChangeText('')
|
||||
inputScrollSharedValue.value = 0
|
||||
},
|
||||
insert: tapper.insert,
|
||||
setAutocompleteAnchor: sift.refs.setAnchor,
|
||||
}),
|
||||
[tapper.input, tapper.insert, inputScrollSharedValue, sift.refs.setAnchor],
|
||||
)
|
||||
|
||||
/*
|
||||
* Skip the initial mount to avoid an unnecessary re-render — the parent
|
||||
* already knows the initial value since it passed `initialText`.
|
||||
*/
|
||||
const isFirstRender = useRef(true)
|
||||
useEffect(() => {
|
||||
if (isFirstRender.current) {
|
||||
isFirstRender.current = false
|
||||
return
|
||||
}
|
||||
onChangeOuter?.(tapper.state.text)
|
||||
}, [tapper.state.text, onChangeOuter])
|
||||
|
||||
/*
|
||||
* Tapper callbacks
|
||||
*/
|
||||
const callbackRefs = useRef({
|
||||
onActiveFacetOuter,
|
||||
onFacetCommittedOuter,
|
||||
})
|
||||
callbackRefs.current = {
|
||||
onActiveFacetOuter,
|
||||
onFacetCommittedOuter,
|
||||
}
|
||||
useEffect(() => {
|
||||
const offActiveFacet = tapper.on('activeFacet', facet => {
|
||||
setActiveFacet(facet)
|
||||
callbackRefs.current.onActiveFacetOuter?.(facet)
|
||||
})
|
||||
const offFacetCommitted = tapper.on('facetCommitted', facet => {
|
||||
callbackRefs.current.onFacetCommittedOuter?.(facet)
|
||||
})
|
||||
const offAfterInsert = tapper.on('afterInsert', () => {
|
||||
tapper.input.focus()
|
||||
})
|
||||
return () => {
|
||||
offActiveFacet()
|
||||
offFacetCommitted()
|
||||
offAfterInsert()
|
||||
}
|
||||
}, [tapper.on, tapper.input])
|
||||
|
||||
/*
|
||||
* Styles
|
||||
*/
|
||||
const previewScrollStyle = useAnimatedStyle(() => ({
|
||||
transform: [{translateY: -inputScrollSharedValue.value}],
|
||||
}))
|
||||
const textStyle = useMemo(() => {
|
||||
const ts = normalizeTextStyles(
|
||||
[a.leading_snug, t.atoms.text, contentTextStyle],
|
||||
{
|
||||
fontScale: fonts.scaleMultiplier,
|
||||
fontFamily: fonts.family,
|
||||
flags: {},
|
||||
},
|
||||
)
|
||||
/**
|
||||
* On iOS, having a lineHeight on the Text component causes the text to be
|
||||
* vertically misaligned with the TextInput.
|
||||
*
|
||||
* This only seems to be an issue on iOS, and not on Android or web. It's
|
||||
* possible that this is a bug in React Native's Text component on iOS,
|
||||
* but in the meantime, we'll just remove the lineHeight on iOS to ensure
|
||||
* the text is properly aligned.
|
||||
*/
|
||||
if (IS_IOS) {
|
||||
delete ts.lineHeight
|
||||
}
|
||||
return ts
|
||||
}, [contentTextStyle, fonts])
|
||||
|
||||
/*
|
||||
* Web keyboard handling
|
||||
*/
|
||||
const isComposing = useRef(false)
|
||||
const onKeyPressWeb = (e: React.KeyboardEvent | any) => {
|
||||
if (IS_WEB_TOUCH_DEVICE) return
|
||||
if (isComposing.current) return
|
||||
|
||||
/*
|
||||
* On Safari, the final keydown to dismiss an IME is also "Enter" with
|
||||
* keyCode 229. Chrome/Firefox don't have this problem.
|
||||
*
|
||||
* @see https://github.com/bluesky-social/social-app/issues/4178
|
||||
*/
|
||||
if (e.key === 'Enter' && e.keyCode === 229) return
|
||||
|
||||
if (e.key === 'Enter') {
|
||||
onRequestSubmit?.({
|
||||
platform: 'web',
|
||||
shiftKey: e.shiftKey,
|
||||
metaKey: e.metaKey,
|
||||
nativeEvent: e.nativeEvent,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Sift popover positioning
|
||||
*/
|
||||
const updateAutocompletePosition = () => {
|
||||
sift.updatePosition()
|
||||
}
|
||||
|
||||
const textContent = (
|
||||
<Text style={[textStyle, web({whiteSpace: 'pre-wrap'})]}>
|
||||
{tapper.state.nodes.map((node, i) => {
|
||||
switch (node.type) {
|
||||
case 'text':
|
||||
return <Span key={i}>{node.value}</Span>
|
||||
case 'trigger':
|
||||
case 'facet':
|
||||
return (
|
||||
<Span
|
||||
key={i}
|
||||
ref={IS_WEB ? sift.refs.setAnchor : undefined}
|
||||
style={
|
||||
node.type === 'facet' && {
|
||||
color: t.palette.primary_500,
|
||||
}
|
||||
}>
|
||||
{node.raw}
|
||||
</Span>
|
||||
)
|
||||
}
|
||||
})}
|
||||
</Text>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<View style={[a.relative, outerStyle]}>
|
||||
{IS_WEB && (
|
||||
<View
|
||||
pointerEvents="none"
|
||||
style={[a.absolute, a.inset_0, a.z_10, {overflow: 'hidden'}]}>
|
||||
<Animated.View
|
||||
style={[
|
||||
contentPaddingStyle,
|
||||
{position: 'absolute', left: 0, right: 0},
|
||||
previewScrollStyle,
|
||||
]}>
|
||||
{textContent}
|
||||
</Animated.View>
|
||||
</View>
|
||||
)}
|
||||
<AutosizedTextarea
|
||||
placeholderTextColor={t.palette.contrast_500}
|
||||
accessibilityLabel={label}
|
||||
accessibilityHint={label}
|
||||
onSubmitEditing={e => {
|
||||
onRequestSubmit?.({platform: 'native', nativeEvent: e})
|
||||
}}
|
||||
style={[
|
||||
textStyle,
|
||||
contentPaddingStyle,
|
||||
a.z_20,
|
||||
{
|
||||
color: 'transparent',
|
||||
background: 'transparent',
|
||||
},
|
||||
web({
|
||||
caretColor: textStyle.color ?? 'black',
|
||||
overscrollBehavior: 'none',
|
||||
}),
|
||||
]}
|
||||
{...rest}
|
||||
{...tapper.inputProps}
|
||||
{...sift.targetProps}
|
||||
ref={mergeRefs([ref, tapper.inputProps.ref, sift.targetProps.ref])}
|
||||
onBlur={e => {
|
||||
rest.onBlur?.(e)
|
||||
setActiveFacet(null)
|
||||
}}
|
||||
onKeyPress={IS_WEB ? onKeyPressWeb : undefined}
|
||||
onScroll={e => {
|
||||
if (IS_WEB) {
|
||||
inputScrollSharedValue.value = (e.target as any).scrollTop
|
||||
} else {
|
||||
inputScrollSharedValue.value = e.nativeEvent.contentOffset.y
|
||||
}
|
||||
}}
|
||||
// @ts-ignore web only
|
||||
onCompositionStart={() => {
|
||||
isComposing.current = true
|
||||
}}
|
||||
// @ts-ignore web only
|
||||
onCompositionEnd={() => {
|
||||
isComposing.current = false
|
||||
}}
|
||||
onUpdateHeight={updateAutocompletePosition}>
|
||||
{IS_WEB ? null : textContent}
|
||||
</AutosizedTextarea>
|
||||
</View>
|
||||
|
||||
{activeFacet && activeFacet.type !== 'url' && (
|
||||
<AutocompleteInner
|
||||
sift={sift}
|
||||
activeFacet={activeFacet}
|
||||
onDismiss={() => setActiveFacet(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/*
|
||||
* ─── Autocomplete (private) ───────────────────────────────────────────────────
|
||||
*/
|
||||
|
||||
function AutocompleteInner({
|
||||
sift,
|
||||
activeFacet,
|
||||
onDismiss,
|
||||
}: {
|
||||
sift: UseSiftReturn
|
||||
activeFacet: TapperActiveFacet
|
||||
onDismiss: () => void
|
||||
}) {
|
||||
const {items} = useAutocomplete({
|
||||
type: parseAutocompleteItemType(activeFacet.type),
|
||||
query: activeFacet.value,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
activeFacet?.type === 'emoji' &&
|
||||
!!activeFacet.value.length &&
|
||||
activeFacet.raw.endsWith(':')
|
||||
) {
|
||||
if (items?.[0]) {
|
||||
activeFacet.replace(items[0].value, {noTrailingSpace: true})
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
}, [items, activeFacet])
|
||||
|
||||
return items && items.length ? (
|
||||
<AutocompleteBase
|
||||
inverted={!IS_WEB}
|
||||
sift={sift}
|
||||
data={items}
|
||||
render={props => {
|
||||
if (props.item.type === 'profile') {
|
||||
return <AutocompleteItemProfile {...props} />
|
||||
}
|
||||
if (props.item.type === 'emoji') {
|
||||
return <AutocompleteItemEmoji {...props} />
|
||||
}
|
||||
return <View />
|
||||
}}
|
||||
onSelect={item => {
|
||||
activeFacet.replace(item.value)
|
||||
onDismiss()
|
||||
}}
|
||||
onDismiss={onDismiss}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import {type ReactNode} from 'react'
|
||||
import {
|
||||
BackHandler,
|
||||
Keyboard,
|
||||
@@ -102,7 +103,7 @@ const SPRING_OUT: WithSpringConfig = {
|
||||
/**
|
||||
* Needs placing near the top of the provider stack, but BELOW the theme provider.
|
||||
*/
|
||||
export function Provider({children}: {children: React.ReactNode}) {
|
||||
export function Provider({children}: {children: ReactNode}) {
|
||||
return (
|
||||
<PortalProvider>
|
||||
{children}
|
||||
@@ -111,7 +112,7 @@ export function Provider({children}: {children: React.ReactNode}) {
|
||||
)
|
||||
}
|
||||
|
||||
export function Root({children}: {children: React.ReactNode}) {
|
||||
export function Root({children}: {children: ReactNode}) {
|
||||
const playHaptic = useHaptics()
|
||||
const [mode, setMode] = useState<'full' | 'auxiliary-only'>('full')
|
||||
const [measurement, setMeasurement] = useState<Measurement | null>(null)
|
||||
@@ -572,7 +573,7 @@ export function Outer({
|
||||
style,
|
||||
align = 'left',
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
children: ReactNode
|
||||
style?: StyleProp<ViewStyle>
|
||||
align?: 'left' | 'right'
|
||||
}) {
|
||||
@@ -895,7 +896,7 @@ export function ItemRadio({selected}: {selected: boolean}) {
|
||||
)
|
||||
}
|
||||
|
||||
export function LabelText({children}: {children: React.ReactNode}) {
|
||||
export function LabelText({children}: {children: ReactNode}) {
|
||||
const t = useTheme()
|
||||
return (
|
||||
<Text
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react'
|
||||
import {type PropsWithChildren, type ReactNode} from 'react'
|
||||
import {
|
||||
FlatList,
|
||||
type FlatListProps,
|
||||
@@ -52,7 +53,7 @@ export function Outer({
|
||||
control,
|
||||
onClose,
|
||||
webOptions,
|
||||
}: React.PropsWithChildren<DialogOuterProps>) {
|
||||
}: PropsWithChildren<DialogOuterProps>) {
|
||||
const {_} = useLingui()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
@@ -265,7 +266,7 @@ export function FlatListFooter({
|
||||
children,
|
||||
onLayout,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
children: ReactNode
|
||||
onLayout?: (event: LayoutChangeEvent) => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
|
||||
@@ -18,6 +18,7 @@ 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 {
|
||||
@@ -32,7 +33,6 @@ 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,9 +313,7 @@ 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`, {
|
||||
type: 'error',
|
||||
})
|
||||
Toast.show(l`Failed to update feeds`, 'xmark')
|
||||
}
|
||||
},
|
||||
[l, pin, saveFeeds, removeFeed, uri, savedFeedConfig, type],
|
||||
|
||||
@@ -7,17 +7,24 @@ import Animated, {
|
||||
LayoutAnimationConfig,
|
||||
LinearTransition,
|
||||
} from 'react-native-reanimated'
|
||||
import {type AppBskyFeedDefs} from '@atproto/api'
|
||||
import {Trans, useLingui} from '@lingui/react/macro'
|
||||
import {type AppBskyFeedDefs, AtUri} from '@atproto/api'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {Trans} from '@lingui/react/macro'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
|
||||
import {type NavigationProp} from '#/lib/routes/types'
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {useGetPopularFeedsQuery} from '#/state/queries/feed'
|
||||
import {type FeedDescriptor} from '#/state/queries/post-feed'
|
||||
import {useSuggestedFollowsByActorWithDismiss} from '#/state/queries/suggested-follows'
|
||||
import {useGetSuggestedUsersForDiscoverQuery} from '#/state/queries/trending/useGetSuggestedUsersForDiscoverQuery'
|
||||
import {useProfilesQuery} from '#/state/queries/profile'
|
||||
import {
|
||||
useSuggestedFollowsByActorQuery,
|
||||
useSuggestedFollowsQuery,
|
||||
} from '#/state/queries/suggested-follows'
|
||||
import {useSession} from '#/state/session'
|
||||
import * as userActionHistory from '#/state/userActionHistory'
|
||||
import {type SeenPost} from '#/state/userActionHistory'
|
||||
import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture'
|
||||
import {
|
||||
atoms as a,
|
||||
@@ -35,12 +42,12 @@ import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Has
|
||||
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
|
||||
import {InlineLinkText} from '#/components/Link'
|
||||
import * as ProfileCard from '#/components/ProfileCard'
|
||||
import {ProgressGuideList} from '#/components/ProgressGuide/List'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {type Metrics, useAnalytics} from '#/analytics'
|
||||
import {IS_IOS} from '#/env'
|
||||
import type * as bsky from '#/types/bsky'
|
||||
import {FollowDialogWithoutGuide} from './ProgressGuide/FollowDialog'
|
||||
import {ProgressGuideList} from './ProgressGuide/List'
|
||||
|
||||
const DISMISS_ANIMATION_DURATION = 200
|
||||
|
||||
@@ -107,6 +114,93 @@ export function SuggestedFeedsCardPlaceholder() {
|
||||
)
|
||||
}
|
||||
|
||||
function getRank(seenPost: SeenPost): string {
|
||||
let tier: string
|
||||
if (seenPost.feedContext === 'popfriends') {
|
||||
tier = 'a'
|
||||
} else if (seenPost.feedContext?.startsWith('cluster')) {
|
||||
tier = 'b'
|
||||
} else if (seenPost.feedContext === 'popcluster') {
|
||||
tier = 'c'
|
||||
} else if (seenPost.feedContext?.startsWith('ntpc')) {
|
||||
tier = 'd'
|
||||
} else if (seenPost.feedContext?.startsWith('t-')) {
|
||||
tier = 'e'
|
||||
} else if (seenPost.feedContext === 'nettop') {
|
||||
tier = 'f'
|
||||
} else {
|
||||
tier = 'g'
|
||||
}
|
||||
let score = Math.round(
|
||||
Math.log(
|
||||
1 + seenPost.likeCount + seenPost.repostCount + seenPost.replyCount,
|
||||
),
|
||||
)
|
||||
if (seenPost.isFollowedBy || Math.random() > 0.9) {
|
||||
score *= 2
|
||||
}
|
||||
const rank = 100 - score
|
||||
return `${tier}-${rank}`
|
||||
}
|
||||
|
||||
function sortSeenPosts(postA: SeenPost, postB: SeenPost): 0 | 1 | -1 {
|
||||
const rankA = getRank(postA)
|
||||
const rankB = getRank(postB)
|
||||
// Yes, we're comparing strings here.
|
||||
// The "larger" string means a worse rank.
|
||||
if (rankA > rankB) {
|
||||
return 1
|
||||
} else if (rankA < rankB) {
|
||||
return -1
|
||||
} else {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
function useExperimentalSuggestedUsersQuery() {
|
||||
const {currentAccount} = useSession()
|
||||
const userActionSnapshot = userActionHistory.useActionHistorySnapshot()
|
||||
const dids = useMemo(() => {
|
||||
const {likes, follows, followSuggestions, seen} = userActionSnapshot
|
||||
const likeDids = likes
|
||||
.map(l => new AtUri(l))
|
||||
.map(uri => uri.host)
|
||||
.filter(did => !follows.includes(did))
|
||||
let suggestedDids: string[] = []
|
||||
if (followSuggestions.length > 0) {
|
||||
suggestedDids = [
|
||||
// It's ok if these will pick the same item (weighed by its frequency)
|
||||
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)],
|
||||
]
|
||||
}
|
||||
const seenDids = seen
|
||||
.sort(sortSeenPosts)
|
||||
.map(l => new AtUri(l.uri))
|
||||
.map(uri => uri.host)
|
||||
return [...new Set([...suggestedDids, ...likeDids, ...seenDids])].filter(
|
||||
did => did !== currentAccount?.did,
|
||||
)
|
||||
}, [userActionSnapshot, currentAccount])
|
||||
const {data, isLoading, error} = useProfilesQuery({
|
||||
handles: dids.slice(0, 16),
|
||||
})
|
||||
|
||||
const profiles = data
|
||||
? data.profiles.filter(profile => {
|
||||
return !profile.viewer?.following
|
||||
})
|
||||
: []
|
||||
|
||||
return {
|
||||
isLoading,
|
||||
error,
|
||||
profiles: profiles.slice(0, 6),
|
||||
}
|
||||
}
|
||||
|
||||
export function SuggestedFollows({feed}: {feed: FeedDescriptor}) {
|
||||
const {currentAccount} = useSession()
|
||||
const [feedType, feedUriOrDid] = feed.split('|')
|
||||
@@ -122,14 +216,86 @@ export function SuggestedFollows({feed}: {feed: FeedDescriptor}) {
|
||||
}
|
||||
|
||||
export function SuggestedFollowsProfile({did}: {did: string}) {
|
||||
const {profiles, recId, onDismiss, isLoading, error} =
|
||||
useSuggestedFollowsByActorWithDismiss({did})
|
||||
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,
|
||||
])
|
||||
|
||||
return (
|
||||
<ProfileGrid
|
||||
isSuggestionsLoading={isLoading}
|
||||
profiles={profiles}
|
||||
recId={recId}
|
||||
isSuggestionsLoading={isSuggestionsLoading}
|
||||
profiles={filteredProfiles}
|
||||
totalProfileCount={allProfiles.length}
|
||||
error={error}
|
||||
viewContext="profile"
|
||||
onDismiss={onDismiss}
|
||||
@@ -138,9 +304,21 @@ export function SuggestedFollowsProfile({did}: {did: string}) {
|
||||
}
|
||||
|
||||
export function SuggestedFollowsHome() {
|
||||
const {isLoading, data, error} = useGetSuggestedUsersForDiscoverQuery()
|
||||
|
||||
const profiles = data?.actors
|
||||
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())
|
||||
|
||||
@@ -148,29 +326,66 @@ export function SuggestedFollowsHome() {
|
||||
setDismissedDids(prev => new Set(prev).add(did))
|
||||
}, [])
|
||||
|
||||
// Combine profiles from experimental query with paginated suggestions
|
||||
const allProfiles = useMemo(() => {
|
||||
const result: Array<{
|
||||
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<{
|
||||
actor: bsky.profile.AnyProfileView
|
||||
recId?: string
|
||||
recId?: number
|
||||
}> = []
|
||||
|
||||
for (const profile of profiles ?? []) {
|
||||
result.push({actor: profile, recId: data?.recId})
|
||||
for (const profile of experimentalProfiles) {
|
||||
if (!seen.has(profile.did)) {
|
||||
seen.add(profile.did)
|
||||
combined.push({actor: profile, recId: undefined})
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}, [data?.recId, profiles])
|
||||
for (const profile of fallbackProfiles) {
|
||||
if (!seen.has(profile.actor.did)) {
|
||||
seen.add(profile.actor.did)
|
||||
combined.push(profile)
|
||||
}
|
||||
}
|
||||
|
||||
return combined
|
||||
}, [experimentalProfiles, moreSuggestions?.pages])
|
||||
|
||||
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={isLoading}
|
||||
isSuggestionsLoading={isSuggestionsLoading}
|
||||
profiles={filteredProfiles}
|
||||
totalProfileCount={allProfiles.length}
|
||||
error={error}
|
||||
error={experimentalError || suggestionsError}
|
||||
viewContext="feed"
|
||||
onDismiss={onDismiss}
|
||||
/>
|
||||
@@ -181,26 +396,22 @@ export function ProfileGrid({
|
||||
isSuggestionsLoading,
|
||||
error,
|
||||
profiles,
|
||||
recId,
|
||||
totalProfileCount,
|
||||
viewContext = 'feed',
|
||||
onDismiss,
|
||||
isVisible = true,
|
||||
onRequestHide,
|
||||
}: {
|
||||
isSuggestionsLoading: boolean
|
||||
profiles: {actor: bsky.profile.AnyProfileView; recId?: string}[]
|
||||
recId?: string
|
||||
profiles: {actor: bsky.profile.AnyProfileView; recId?: number}[]
|
||||
totalProfileCount?: number
|
||||
error: Error | null
|
||||
viewContext: 'profile' | 'profileHeader' | 'feed'
|
||||
onDismiss?: (did: string) => void
|
||||
isVisible?: boolean
|
||||
onRequestHide?: () => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const ax = useAnalytics()
|
||||
const {t: l} = useLingui()
|
||||
const {_} = useLingui()
|
||||
const moderationOpts = useModerationOpts()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const followDialogControl = useDialogControl()
|
||||
@@ -217,10 +428,10 @@ export function ProfileGrid({
|
||||
const containerRef = useRef<View>(null)
|
||||
const hasTrackedRef = useRef(false)
|
||||
const logContext: Metrics['suggestedUser:seen']['logContext'] = isFeedContext
|
||||
? 'DiscoverInterstitial'
|
||||
? 'InterstitialDiscover'
|
||||
: isProfileHeaderContext
|
||||
? 'ProfileHeader'
|
||||
: 'ProfileInterstitial'
|
||||
? 'Profile'
|
||||
: 'InterstitialProfile'
|
||||
|
||||
// Callback to fire seen events
|
||||
const fireSeen = useCallback(() => {
|
||||
@@ -241,7 +452,7 @@ export function ProfileGrid({
|
||||
})
|
||||
}
|
||||
})
|
||||
}, [isLoading, error, profiles, maxLength, ax, logContext])
|
||||
}, [ax, isLoading, error, profiles, maxLength, logContext])
|
||||
|
||||
// For profile header, fire when isVisible becomes true
|
||||
useEffect(() => {
|
||||
@@ -329,7 +540,9 @@ export function ProfileGrid({
|
||||
profile={profile.actor}
|
||||
onPress={() => {
|
||||
ax.metric('suggestedUser:press', {
|
||||
logContext,
|
||||
logContext: isFeedContext
|
||||
? 'InterstitialDiscover'
|
||||
: 'InterstitialProfile',
|
||||
recId: profile.recId,
|
||||
position: index,
|
||||
suggestedDid: profile.actor.did,
|
||||
@@ -345,12 +558,14 @@ export function ProfileGrid({
|
||||
<ProfileCard.Outer>
|
||||
{onDismiss && (
|
||||
<Button
|
||||
label={l`Dismiss this suggestion`}
|
||||
label={_(msg`Dismiss this suggestion`)}
|
||||
onPress={e => {
|
||||
e.preventDefault()
|
||||
onDismiss(profile.actor.did)
|
||||
ax.metric('suggestedUser:dismiss', {
|
||||
logContext,
|
||||
logContext: isFeedContext
|
||||
? 'InterstitialDiscover'
|
||||
: 'InterstitialProfile',
|
||||
position: index,
|
||||
suggestedDid: profile.actor.did,
|
||||
recId: profile.recId,
|
||||
@@ -416,8 +631,10 @@ export function ProfileGrid({
|
||||
style={[a.rounded_sm]}
|
||||
onFollow={() => {
|
||||
ax.metric('suggestedUser:follow', {
|
||||
logContext,
|
||||
location: 'Profile',
|
||||
logContext: isFeedContext
|
||||
? 'InterstitialDiscover'
|
||||
: 'InterstitialProfile',
|
||||
location: 'Card',
|
||||
recId: profile.recId,
|
||||
position: index,
|
||||
suggestedDid: profile.actor.did,
|
||||
@@ -434,13 +651,6 @@ 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
|
||||
@@ -467,32 +677,35 @@ export function ProfileGrid({
|
||||
<Text style={[a.text_sm, a.font_semi_bold, t.atoms.text]}>
|
||||
<Trans>Suggested for you</Trans>
|
||||
</Text>
|
||||
<Button
|
||||
label={l`See more suggested profiles`}
|
||||
onPress={() => {
|
||||
followDialogControl.open()
|
||||
ax.metric('suggestedUser:seeMore', {
|
||||
logContext,
|
||||
recId,
|
||||
})
|
||||
}}>
|
||||
{({hovered}) => (
|
||||
<Text
|
||||
style={[
|
||||
a.text_sm,
|
||||
{color: t.palette.primary_500},
|
||||
hovered &&
|
||||
web({
|
||||
textDecorationLine: 'underline',
|
||||
textDecorationColor: t.palette.primary_500,
|
||||
}),
|
||||
]}>
|
||||
<Trans>See more</Trans>
|
||||
</Text>
|
||||
)}
|
||||
</Button>
|
||||
{!isProfileHeaderContext && (
|
||||
<Button
|
||||
label={_(msg`See more suggested profiles`)}
|
||||
onPress={() => {
|
||||
followDialogControl.open()
|
||||
ax.metric('suggestedUser:seeMore', {
|
||||
logContext: isFeedContext ? 'Explore' : 'Profile',
|
||||
})
|
||||
}}>
|
||||
{({hovered}) => (
|
||||
<Text
|
||||
style={[
|
||||
a.text_sm,
|
||||
{color: t.palette.primary_500},
|
||||
hovered &&
|
||||
web({
|
||||
textDecorationLine: 'underline',
|
||||
textDecorationColor: t.palette.primary_500,
|
||||
}),
|
||||
]}>
|
||||
<Trans>See more</Trans>
|
||||
</Text>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<FollowDialogWithoutGuide control={followDialogControl} />
|
||||
|
||||
<LayoutAnimationConfig skipExiting skipEntering>
|
||||
{gtMobile ? (
|
||||
<View style={[a.p_lg, a.pt_md]}>
|
||||
@@ -510,14 +723,16 @@ export function ProfileGrid({
|
||||
decelerationRate="fast">
|
||||
{content}
|
||||
|
||||
<SeeMoreSuggestedProfilesCard
|
||||
onPress={() => {
|
||||
followDialogControl.open()
|
||||
ax.metric('suggestedUser:seeMore', {
|
||||
logContext,
|
||||
})
|
||||
}}
|
||||
/>
|
||||
{!isProfileHeaderContext && (
|
||||
<SeeMoreSuggestedProfilesCard
|
||||
onPress={() => {
|
||||
followDialogControl.open()
|
||||
ax.metric('suggestedUser:seeMore', {
|
||||
logContext: 'Explore',
|
||||
})
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</ScrollView>
|
||||
</BlockDrawerGesture>
|
||||
)}
|
||||
@@ -527,11 +742,11 @@ export function ProfileGrid({
|
||||
}
|
||||
|
||||
function SeeMoreSuggestedProfilesCard({onPress}: {onPress: () => void}) {
|
||||
const {t: l} = useLingui()
|
||||
const {_} = useLingui()
|
||||
|
||||
return (
|
||||
<Button
|
||||
label={l`Browse more accounts`}
|
||||
label={_(msg`Browse more accounts`)}
|
||||
onPress={onPress}
|
||||
style={[
|
||||
a.flex_col,
|
||||
@@ -555,7 +770,7 @@ const numFeedsToDisplay = 3
|
||||
export function SuggestedFeeds() {
|
||||
const t = useTheme()
|
||||
const ax = useAnalytics()
|
||||
const {t: l} = useLingui()
|
||||
const {_} = useLingui()
|
||||
const {data, isLoading, error} = useGetPopularFeedsQuery({
|
||||
limit: numFeedsToDisplay,
|
||||
})
|
||||
@@ -642,7 +857,7 @@ export function SuggestedFeeds() {
|
||||
a.gap_md,
|
||||
]}>
|
||||
<InlineLinkText
|
||||
label={l`Browse more suggestions`}
|
||||
label={_(msg`Browse more suggestions`)}
|
||||
to="/search"
|
||||
style={[t.atoms.text_contrast_medium]}>
|
||||
<Trans>Browse more suggestions</Trans>
|
||||
@@ -661,7 +876,7 @@ export function SuggestedFeeds() {
|
||||
{content}
|
||||
|
||||
<Button
|
||||
label={l`Browse more feeds on the Explore page`}
|
||||
label={_(msg`Browse more feeds on the Explore page`)}
|
||||
onPress={() => {
|
||||
navigation.navigate('SearchTab')
|
||||
}}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {View} from 'react-native'
|
||||
import type React from 'react'
|
||||
|
||||
import {atoms as a, type ViewStyleProp} from '#/alf'
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ 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,5 +1,6 @@
|
||||
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,4 +1,5 @@
|
||||
import {useCallback, useMemo} from 'react'
|
||||
import {type PropsWithChildren} from 'react'
|
||||
import {type GestureResponderEvent, Linking} from 'react-native'
|
||||
import {sanitizeUrl} from '@braintree/sanitize-url'
|
||||
import {
|
||||
@@ -318,7 +319,7 @@ export function Link({
|
||||
)
|
||||
}
|
||||
|
||||
export type InlineLinkProps = React.PropsWithChildren<
|
||||
export type InlineLinkProps = PropsWithChildren<
|
||||
BaseLinkProps &
|
||||
TextStyleProp &
|
||||
Pick<TextProps, 'selectable' | 'numberOfLines' | 'emoji'> &
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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'
|
||||
|
||||
@@ -4,6 +4,7 @@ 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'
|
||||
|
||||
@@ -27,7 +27,6 @@ export function NewskieDialog({
|
||||
profile: AppBskyActorDefs.ProfileViewDetailed
|
||||
disabled?: boolean
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const control = useDialogControl()
|
||||
|
||||
@@ -53,7 +52,7 @@ export function NewskieDialog({
|
||||
{({hovered, pressed}) => (
|
||||
<Newskie
|
||||
size="lg"
|
||||
fill={t.palette.yellow}
|
||||
fill="#FFC404"
|
||||
style={{
|
||||
opacity: hovered || pressed ? 0.5 : 1,
|
||||
}}
|
||||
@@ -133,7 +132,7 @@ function DialogInner({
|
||||
<Newskie
|
||||
width={64}
|
||||
height={64}
|
||||
fill={t.palette.yellow}
|
||||
fill="#FFC404"
|
||||
style={[a.absolute, a.inset_0]}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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'
|
||||
@@ -30,8 +31,7 @@ export function Row({
|
||||
children,
|
||||
style,
|
||||
size = 'sm',
|
||||
}: {children: React.ReactNode | React.ReactNode[]} & CommonProps &
|
||||
ViewStyleProp) {
|
||||
}: {children: ReactNode | ReactNode[]} & CommonProps & ViewStyleProp) {
|
||||
const styles = useMemo(() => {
|
||||
switch (size) {
|
||||
case 'lg':
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {useCallback, useRef, useState} from 'react'
|
||||
import React 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] = useState(false)
|
||||
const [isPlayerActive, setIsPlayerActive] = React.useState(false)
|
||||
// Tracking whether the gif has been loaded yet
|
||||
const [isPrefetched, setIsPrefetched] = useState(false)
|
||||
const [isPrefetched, setIsPrefetched] = React.useState(false)
|
||||
// Tracking whether the image is animating
|
||||
const [isAnimating, setIsAnimating] = useState(true)
|
||||
const [isAnimating, setIsAnimating] = React.useState(true)
|
||||
|
||||
// Used for controlling animation
|
||||
const imageRef = useRef<Image>(null)
|
||||
const imageRef = React.useRef<Image>(null)
|
||||
|
||||
const load = useCallback(() => {
|
||||
const load = React.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 = useCallback(
|
||||
const onPlayPress = React.useCallback(
|
||||
(event: GestureResponderEvent) => {
|
||||
// Don't propagate on web
|
||||
event.preventDefault()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {useCallback, useEffect, useMemo, useState} from 'react'
|
||||
import React 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 = useCallback(
|
||||
const onShouldStartLoadWithRequest = React.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] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isPlayerActive, setPlayerActive] = React.useState(false)
|
||||
const [isLoading, setIsLoading] = React.useState(true)
|
||||
|
||||
const aspect = useMemo(() => {
|
||||
const aspect = React.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
|
||||
useEffect(() => {
|
||||
React.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 = useCallback(() => {
|
||||
const onLoad = React.useCallback(() => {
|
||||
setIsLoading(false)
|
||||
}, [])
|
||||
|
||||
const onPlayPress = useCallback(
|
||||
const onPlayPress = React.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 = useCallback(() => {
|
||||
const onAcceptConsent = React.useCallback(() => {
|
||||
setPlayerActive(true)
|
||||
}, [])
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {useCallback, useMemo} from 'react'
|
||||
import React, {useCallback} 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 = useMemo(() => {
|
||||
const embedPlayerParams = React.useMemo(() => {
|
||||
const params = parseEmbedPlayerFromUrl(link.uri)
|
||||
|
||||
if (params && externalEmbedPrefs?.[params.source] !== 'hide') {
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import {
|
||||
createContext,
|
||||
import React, {
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useId,
|
||||
useMemo,
|
||||
@@ -12,7 +10,7 @@ import {useWindowDimensions} from 'react-native'
|
||||
|
||||
import {IS_NATIVE, IS_WEB} from '#/env'
|
||||
|
||||
const Context = createContext<{
|
||||
const Context = React.createContext<{
|
||||
activeViewId: string | null
|
||||
setActiveView: (viewId: string) => void
|
||||
sendViewPosition: (viewId: string, y: number) => void
|
||||
@@ -96,7 +94,7 @@ export function Provider({children}: {children: React.ReactNode}) {
|
||||
}
|
||||
|
||||
export function useActiveVideoWeb() {
|
||||
const context = useContext(Context)
|
||||
const context = React.useContext(Context)
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
'useActiveVideoWeb must be used within a ActiveVideoWebProvider',
|
||||
|
||||
@@ -13,7 +13,6 @@ import {Mute_Stroke2_Corner0_Rounded as MuteIcon} from '#/components/icons/Mute'
|
||||
import {Pause_Filled_Corner0_Rounded as PauseIcon} from '#/components/icons/Pause'
|
||||
import {Play_Filled_Corner0_Rounded as PlayIcon} from '#/components/icons/Play'
|
||||
import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as UnmuteIcon} from '#/components/icons/Speaker'
|
||||
import {KeepAwake} from '#/components/KeepAwake'
|
||||
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
|
||||
import {useVideoMuteState} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext'
|
||||
import {GifPresentationControls} from '../GifPresentationControls'
|
||||
@@ -113,7 +112,6 @@ export function VideoEmbedInnerNative({
|
||||
/>
|
||||
)}
|
||||
<MediaInsetBorder />
|
||||
<KeepAwake enabled={isPlaying} />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {useCallback, useEffect, useId, useRef, useState} from 'react'
|
||||
import {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, updateCuePositions} = useHLS({
|
||||
const {hlsRef, loop} = useHLS({
|
||||
playlist: embed.playlist,
|
||||
setHasSubtitleTrack,
|
||||
setError,
|
||||
@@ -90,7 +90,6 @@ export function VideoEmbedInnerWeb({
|
||||
hasSubtitleTrack={hasSubtitleTrack}
|
||||
isGif={embed.presentation === 'gif'}
|
||||
altText={embed.alt}
|
||||
updateCuePositions={updateCuePositions}
|
||||
/>
|
||||
</div>
|
||||
</View>
|
||||
@@ -146,47 +145,6 @@ 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[]
|
||||
>([])
|
||||
@@ -262,10 +220,6 @@ 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])
|
||||
@@ -353,6 +307,5 @@ function useHLS({
|
||||
return {
|
||||
hlsRef,
|
||||
loop: !hasLowQualityFragmentAtStart,
|
||||
updateCuePositions,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,7 +48,6 @@ export function Controls({
|
||||
hasSubtitleTrack,
|
||||
isGif,
|
||||
altText,
|
||||
updateCuePositions,
|
||||
}: {
|
||||
videoRef: React.RefObject<HTMLVideoElement | null>
|
||||
hlsRef: React.RefObject<Hls | undefined | null>
|
||||
@@ -62,7 +61,6 @@ export function Controls({
|
||||
hasSubtitleTrack: boolean
|
||||
isGif: boolean
|
||||
altText?: string
|
||||
updateCuePositions: (controlsVisible?: boolean) => void
|
||||
}) {
|
||||
const {
|
||||
play,
|
||||
@@ -296,13 +294,6 @@ 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,6 +1,7 @@
|
||||
import {createContext, useContext, useMemo, useState} from 'react'
|
||||
import React from 'react'
|
||||
|
||||
const Context = createContext<{
|
||||
const Context = React.createContext<{
|
||||
// native
|
||||
muted: boolean
|
||||
setMuted: React.Dispatch<React.SetStateAction<boolean>>
|
||||
// web
|
||||
@@ -10,10 +11,10 @@ const Context = createContext<{
|
||||
Context.displayName = 'VideoVolumeContext'
|
||||
|
||||
export function Provider({children}: {children: React.ReactNode}) {
|
||||
const [muted, setMuted] = useState(true)
|
||||
const [volume, setVolume] = useState(1)
|
||||
const [muted, setMuted] = React.useState(true)
|
||||
const [volume, setVolume] = React.useState(1)
|
||||
|
||||
const value = useMemo(
|
||||
const value = React.useMemo(
|
||||
() => ({
|
||||
muted,
|
||||
setMuted,
|
||||
@@ -27,7 +28,7 @@ export function Provider({children}: {children: React.ReactNode}) {
|
||||
}
|
||||
|
||||
export function useVideoVolumeState() {
|
||||
const context = useContext(Context)
|
||||
const context = React.useContext(Context)
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
'useVideoVolumeState must be used within a VideoVolumeProvider',
|
||||
@@ -37,7 +38,7 @@ export function useVideoVolumeState() {
|
||||
}
|
||||
|
||||
export function useVideoMuteState() {
|
||||
const context = useContext(Context)
|
||||
const context = React.useContext(Context)
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
'useVideoMuteState must be used within a VideoVolumeProvider',
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
import {useCallback, useMemo} from 'react'
|
||||
import {Platform, type StyleProp, type TextStyle, View} from 'react-native'
|
||||
import {type AppBskyFeedDefs, AppBskyFeedPost} from '@atproto/api'
|
||||
import {type AppBskyFeedDefs} 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,
|
||||
type TranslationFunctionParams,
|
||||
} from '#/lib/translation'
|
||||
import {type TranslationFunction} from '#/lib/translation'
|
||||
import {
|
||||
codeToLanguageName,
|
||||
getPostLanguageTags,
|
||||
isPostInLanguage,
|
||||
languageName,
|
||||
} from '#/locale/helpers'
|
||||
@@ -28,17 +25,18 @@ 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()
|
||||
@@ -46,21 +44,6 @@ 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])
|
||||
@@ -72,11 +55,11 @@ export function TranslatedPost({
|
||||
case 'success':
|
||||
return (
|
||||
<TranslationResult
|
||||
translate={translate}
|
||||
clearTranslation={clearTranslation}
|
||||
initialTranslationParams={initialTranslationParams}
|
||||
translate={translate}
|
||||
postText={postText}
|
||||
postTextStyle={postTextStyle}
|
||||
resultSourceLanguage={
|
||||
sourceLanguage={
|
||||
translationState.sourceLanguage ?? null // Fallback primarily for iOS
|
||||
}
|
||||
translatedText={translationState.translatedText}
|
||||
@@ -85,18 +68,19 @@ export function TranslatedPost({
|
||||
case 'error':
|
||||
return (
|
||||
<TranslationError
|
||||
translate={translate}
|
||||
clearTranslation={clearTranslation}
|
||||
message={translationState.message}
|
||||
initialTranslationParams={initialTranslationParams}
|
||||
postText={postText}
|
||||
primaryLanguage={langPrefs.primaryLanguage}
|
||||
/>
|
||||
)
|
||||
default:
|
||||
return (
|
||||
needsTranslation && (
|
||||
<TranslationLink
|
||||
postText={postText}
|
||||
primaryLanguage={langPrefs.primaryLanguage}
|
||||
translate={translate}
|
||||
initialTranslationParams={initialTranslationParams}
|
||||
/>
|
||||
)
|
||||
)
|
||||
@@ -119,18 +103,30 @@ 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(initialTranslationParams)
|
||||
}, [initialTranslationParams, translate])
|
||||
void translate({
|
||||
text: postText,
|
||||
targetLangCode: primaryLanguage,
|
||||
})
|
||||
|
||||
ax.metric('translate', {
|
||||
sourceLanguages: [], // todo: get from post maybe?
|
||||
targetLanguage: primaryLanguage,
|
||||
textLength: postText.length,
|
||||
})
|
||||
}, [ax, postText, primaryLanguage, translate])
|
||||
|
||||
return (
|
||||
<View
|
||||
@@ -162,24 +158,22 @@ function TranslationLink({
|
||||
}
|
||||
|
||||
function TranslationError({
|
||||
translate,
|
||||
clearTranslation,
|
||||
message,
|
||||
initialTranslationParams,
|
||||
postText,
|
||||
primaryLanguage,
|
||||
}: {
|
||||
translate: TranslationFunction
|
||||
clearTranslation: () => void
|
||||
message: string
|
||||
initialTranslationParams: TranslationFunctionParams
|
||||
postText: string
|
||||
primaryLanguage: string
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {t: l} = useLingui()
|
||||
const translate = useGoogleTranslate()
|
||||
|
||||
const handleFallback = () => {
|
||||
void translate({
|
||||
...initialTranslationParams,
|
||||
forceGoogleTranslate: true,
|
||||
})
|
||||
void translate(postText, primaryLanguage)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -250,24 +244,24 @@ function TranslationError({
|
||||
function TranslationResult({
|
||||
clearTranslation,
|
||||
translate,
|
||||
postText,
|
||||
postTextStyle,
|
||||
resultSourceLanguage,
|
||||
sourceLanguage,
|
||||
translatedText,
|
||||
initialTranslationParams,
|
||||
}: {
|
||||
clearTranslation: () => void
|
||||
translate: TranslationFunction
|
||||
postText: string
|
||||
postTextStyle?: StyleProp<TextStyle>
|
||||
resultSourceLanguage: string | null
|
||||
sourceLanguage: string | null
|
||||
translatedText: string
|
||||
initialTranslationParams: TranslationFunctionParams
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const langPrefs = useLanguagePrefs()
|
||||
const {i18n, t: l} = useLingui()
|
||||
|
||||
const langName = resultSourceLanguage
|
||||
? codeToLanguageName(resultSourceLanguage, i18n.locale)
|
||||
const langName = sourceLanguage
|
||||
? codeToLanguageName(sourceLanguage, i18n.locale)
|
||||
: undefined
|
||||
|
||||
const flattenedStyle = flatten(postTextStyle) ?? {}
|
||||
@@ -326,7 +320,7 @@ function TranslationResult({
|
||||
<Trans>Translated</Trans>
|
||||
</Text>
|
||||
)}
|
||||
{resultSourceLanguage != null && (
|
||||
{sourceLanguage != null && (
|
||||
<>
|
||||
<Text
|
||||
style={[
|
||||
@@ -339,9 +333,9 @@ function TranslationResult({
|
||||
·{' '}
|
||||
</Text>
|
||||
<TranslationLanguageSelect
|
||||
resultSourceLanguage={resultSourceLanguage}
|
||||
sourceLanguage={sourceLanguage}
|
||||
translate={translate}
|
||||
initialTranslationParams={initialTranslationParams}
|
||||
postText={postText}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
@@ -365,12 +359,12 @@ function TranslationResult({
|
||||
|
||||
function TranslationLanguageSelect({
|
||||
translate,
|
||||
resultSourceLanguage,
|
||||
initialTranslationParams,
|
||||
postText,
|
||||
sourceLanguage,
|
||||
}: {
|
||||
translate: TranslationFunction
|
||||
resultSourceLanguage: string
|
||||
initialTranslationParams: TranslationFunctionParams
|
||||
postText: string
|
||||
sourceLanguage: string
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const ax = useAnalytics()
|
||||
@@ -386,8 +380,8 @@ function TranslationLanguageSelect({
|
||||
)
|
||||
.sort((a, b) => {
|
||||
// Prioritize sourceLanguage at the top
|
||||
if (a.code2 === resultSourceLanguage) return -1
|
||||
if (b.code2 === resultSourceLanguage) return 1
|
||||
if (a.code2 === sourceLanguage) return -1
|
||||
if (b.code2 === sourceLanguage) return 1
|
||||
// Localized sort
|
||||
return languageName(a, langPrefs.appLanguage).localeCompare(
|
||||
languageName(b, langPrefs.appLanguage),
|
||||
@@ -398,28 +392,25 @@ 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, resultSourceLanguage],
|
||||
[langPrefs, sourceLanguage],
|
||||
)
|
||||
|
||||
const handleChangeTranslationLanguage = (sourceLangCode: string) => {
|
||||
ax.metric('translate:override', {
|
||||
os: Platform.OS,
|
||||
possibleSourceLanguages: initialTranslationParams.possibleSourceLanguages,
|
||||
expectedSourceLanguage: sourceLangCode,
|
||||
expectedTargetLanguage: initialTranslationParams.expectedTargetLanguage,
|
||||
resultSourceLanguage,
|
||||
sourceLanguage: sourceLangCode,
|
||||
targetLanguage: langPrefs.primaryLanguage,
|
||||
})
|
||||
void translate({
|
||||
text: initialTranslationParams.text,
|
||||
expectedTargetLanguage: initialTranslationParams.expectedTargetLanguage,
|
||||
expectedSourceLanguage: sourceLangCode,
|
||||
possibleSourceLanguages: initialTranslationParams.possibleSourceLanguages,
|
||||
text: postText,
|
||||
targetLangCode: langPrefs.primaryLanguage,
|
||||
sourceLangCode,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Select.Root
|
||||
value={resultSourceLanguage}
|
||||
value={sourceLanguage}
|
||||
onValueChange={handleChangeTranslationLanguage}>
|
||||
<Select.Trigger label={l`Change the source language`}>
|
||||
{({props}) => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user